跳到主要内容
AZ Tools

Unicode 规范化与看不见的字符

看起来一样的文本,未必真的一样。Unicode 允许同一个可见字符用多种方式拼出来,允许什么都不渲染的字符存在,还包含在屏幕上无法区分、底层却不同的字符对。本文讲清这些差异的来源,以及在它们演变成登录失败、重复记录或仿冒域名之前该怎么处理。

码位、字节,以及「长度」到底指什么

一个字符串至少有三种说得通的长度,而且互不相同。"héllo" 在读者眼里是 5 个字,按码位算是 5 个还是 6 个取决于 é 的拼法,按 UTF-8 字节算则是 6 或 7。表情符号把差距拉得更大:一个家庭表情是由若干「人」通过不可见的连接符拼成的一张图,多数语言报告的长度介于 2 到 11 之间。

选择与问题匹配的单位。数据库列长限制和 HTTP 头衡量的是字节长度;多数字符串 API 给出的是码位数;而人们说「几个字」时指的是字素簇的数量——也就是光标一次移动的单位。截断能否落在这个边界上,决定了你得到的是干净的裁切还是一个破碎的表情。

同一段文本,两种编码:NFC 与 NFD

"é" 可以是单个码位(U+00E9),也可以是两个:一个普通的 "e" 后跟组合尖音符(U+0301)。二者渲染完全相同,但序列不同,于是朴素的相等判断会说它们是不同字符串,数据库唯一索引会把两者都收下,用其中一种去搜索则找不到另一种。规范化就是选定一种标准形式:NFC 合成为单个码位,NFD 分解为基字符加组合记号。

这并不是罕见情形。在一个平台上输入、从另一个平台粘贴过来的文本天天在打架——macOS 历来以分解形存储文件名,而网络上大多发送合成形——韩文谚文同样存在预组合音节与独立字母两种写法。存储与传输的默认选择应当是 NFC;关键在于选定一种并在边界处统一应用,而不是哪里比较失败就往哪里撒一把。

NFKC 与 NFKD:当「差不多一样」就该算一样

带 K 的形式除了标准差异,还会折叠兼容性差异。在 NFKC 下,连字 fi 变成 "fi",全角 A 变成 "A",上标 ² 变成 "2",不换行空格变成普通空格。在比较标识符、用户名或搜索词——也就是两种写法不该争夺同一个位置的场合——这正是你想要的行为。

而在展示或存储任意文本时,这恰恰是你不想要的,因为该转换有损且不可逆:数学样式字母会塌回普通 ASCII,作者选择的排版形式也随之消失。请用带 K 的形式派生比较用的键,并另行保留原文用于回显给用户。

你看不见的那些字符

不少码位什么都不渲染。文件开头的字节序标记(BOM)会毁掉 JSON 文档的第一个键或 CSV 的第一个表头。零宽空格与连接符能在从文档复制粘贴时存活下来,悄悄破坏精确匹配。不换行空格看着像空格,却不会在按空白切分时断开;软连字符只有在词尾折行时才现身。

有些远不止是碍眼。双向覆盖字符能在不改变编译器所读内容的前提下,重排源码的「显示」顺序——这就是「Trojan Source」手法:屏幕上是注释,底下却是会执行的代码。而同形字——西里尔字母 а、希腊字母 ο、全角字母——能让一个域名或用户名看起来与熟悉的那个一模一样。只要字符串跨越信任边界,就请检查它的码位,而不要相信自己的眼睛。

让文本保持安全的规则

在边界处规范化,且只做一次。文本进入系统时统一转成 NFC 并如此存储,之后内部比较就是同类与同类相比。凡是被当作身份使用的值——用户名、邮箱本地部分、slug、查找键——请用 NFKC 加大小写折叠另行派生一个比较键,并在这个键上而非展示值上施加唯一性约束。

然后明确决定你允许什么。剥掉 BOM;在没有理由包含它们的字段里拒绝或删除零宽字符与双向控制字符;把混合书写系统的标识符当作可疑而非巧妙。这些处理都不昂贵,加在一起也远比排查「某个用户明明密码输对了却登录不进去」要便宜得多。

  • 存储与传输用 NFC;比较键用 NFKC + 大小写折叠。
  • 在边界处规范化一次,而不是每次比较时都做。
  • 解析 JSON、CSV 或配置文件前先去掉 BOM。
  • 当字符串看着没问题却行为异常时,去查它的码位。

相关工具