今天的早些时候,Node.js发布了一个更新,它会影响到转化到缓冲区中的无效UTF-8字符串的处理。我又得去检查一遍websocket-driver的中UTF-8校验的代码了,并且我发现自己又忘记了如何使用正则去进行校验了。我先把它从网页上拷贝了下来,过了一会儿才终于彻底搞明白它的工作原理了。如果你写的程序是进行文本处理的,你很可能也需要了解这个,因此我觉得我应该把它给写下来。

首先你需要知道的是Unicode和UTF-8并不是一回事。Unicode是一个标准,它的目标是将有限的数字分配给全世界书写系统中的所有字符及文字。比如说,数字65,或者说U+0041,它对应的是大写字母’A’,90也就是U+005A对应的是大宝字母 ‘Z’,而32/U+0020是空格。U+02A4是字符‘ʤ’, U+046C是 ‘Ѭ’, U+0BF5 是‘௵’, 等等。总的说来,这些数字或者说’代码点(Code Point)’的范围会到U+10FFFF也就是1,114,111.

一个Unicode字符串,也就是一个字符序列,实际上就是从0到1,114.111这些数字的一个序列。这些数字是如何转化成你在屏幕上看到的字符的,这取决于你用什么字体去渲染它了。当我们通过一个TCP连接将文本发送出去,或者保存到磁盘中的时候,我们会将它存储成一个定长字节的序列。一个8比特的字节只能表示256个值,那我们如何去表示1,114,112个可能的代码点呢?这就是编码出场的时候了。

UTF-8是Unicode众多编码中的一种。编码定义了字节序列和代码点序列之间的映射关系,并告诉我们如何在它们之间进行转换。UTF-8是WEB上常用的编码,并被作为WebSocket协议的文本消息的编码。

那么UTF-8是如何工作的?首先需要知道的是我们不能将所有的代码点都映射到单个字节上:很多代码点的值都太大了。甚至我们都不能用它来表示00到FF,因为这样的话,更高的值就没法表示了。不过我们可以使用从00到7F这个范围(0到127),留下80到FF来表示其它的代码点。前128个代码点就通过单个字节的低7比特位来表示:

U+0000 to U+007F:

    00000000  00  --  7F  01111111

这就是UTF-8的独特之处:它并没有使用3个字节来表示所有的代码点(1,114,111是21比特),而是用了一个变长的字节,从1字节到4字节。前128个代码点每个都对应着一个字节,剩下的代码点都通过余下的128个字节的组合来表示(注:一个字节8比特有256个取值,单字节的UTF-8编码用了低7位的128个,剩下的用于其它代码点)。 这样做有两个好处,尽管有一个好处主要是针对程序员或者英语使用者的。第一个好处是UTF-8是向下兼容ASCII的:所有有效的ASCII文档都是一个有效的UTF-8文档,它们一一对应。第二个好处,这也是第一的结果,也就是说我们在传输英文文本的时候,不用使用2个或3个字节来表示。

单字节编码的区间内有7个比特是我们可以用的。为了表示更大的值,我们需要更多的字节,UTF-8定义的双字节由110xxxxx 10yyyyyy形式的字节对组成。x和y的比特是可变的,也就是有11个比特可以使用,加起来就到了U+07FF。

U+0080 to U+07FF:

    11000010  C2  --  DF  11011111
    10000000  80  --  BF  10111111

也就是说,代码点U+0080成了字节C2 80而代码点U+07FF是DF BF。需要注意的是,如果使用的空间超出实际所需的话则是错误的:C1 BF或者说11000001 10111111会被理解成U+007F,但你可以只用一个字节就能表示这个代码点,因此C1 BF不是一个合法的字节序列。

一般来说,多字节代码点由一个特殊比特位的字节(大于80的字节,也就是高位为1的)后面跟着一个或多个10xxxxxx形式的字节来组成。后面的字节可用的范围是80到BF。底于80的字节被用作单字节的代码点,如果在多字节编码中出现它们则是错误的。首字节的值会告诉我们它后面有多少个字节。

下面继续讲3字节的码点,它们是1110xxxx 10yyyyyy 10zzzzzz的形式,我们有16个比特的数据可用,这样我们的码点可以到达U+FFFF。然而,现在我们碰到了一个历史遗留问题。Unicode最早是在Unicode 88白皮书上描述的,上面是这么说的:

将字符编码从8位扩展到16位是非常明智的,确实如此,以至于刚想到的时候还有点震住了。 16个字节可以提供最多65536个不同的码值,这足够对全世界的所有字符进行编码了吗?由于’字符‘本身的定义也是文本编码方案设计中的一部分,讨论这个问题是没有意义的,除非问题改成这样:有没有可能重新建立一种有效的字符的定义,使得全世界的字符的总数小于65536? 答案是肯定的。 – Joseph D. Becker PhD, ‘Unicode 88′

当然了,最终表明答案是否定的,你可能也猜到了现在的代码点一共有1,114,112个。在UTF-16设计 的时候——这是一个固定双字节的编码规范——人们发现16个比特无法编码所有的已知字符。因此,Unicode标准保留了一个特殊的代码点区间以便UTF-16用来编码大于FFFF的值。这些值会通过4个字节来进行编码,也就是两个标准的代码点,前两个字节的范围是D8 00 到DB FF,而后两个字节的范围是DC 00 到DF FF。U+D800 to U+DFFF范围内的代码点又被称作代理,UTF-16使用代理对(surrogate pairs)来表示更大的值。没有字符会被分配给这些代码点,也没有任何编码方式会去使用它们。

因此对于3字节的编码,我们实际上只能编码U+0800到U+D7FF以及U+E000到U+FFFF的范围。

 U+0800 to U+D7FF:

    11100000  E0  --  ED  11101101
    10100000  A0  --  9F  10011111
    10000000  80  --  BF  10111111

U+E000 to U+FFFF:

    11101110  EE  --  EF  11101111
    10000000  80  --  BF  10111111
    10000000  80  --  BF  10111111‘

现在终于了4字节的这部分,这些字节的格式是11110www 10xxxxxx 10yyyyyy 10zzzzzz,我们有21个比特位可用,这样我们可以最大达到U+10FFFF。这段区间是没有间隔的,不过要想覆盖剩下的这些代码点,我们用不着使用完这整个范围的值,因此最终的结果是这样的:

U+010000 to U+10FFFF:

    11110000  F0  --  F4  11110100
    10010000  90  --  8F  10001111
    10000000  80  --  BF  10111111
    10000000  80  --  BF  10111111

现在我们已经介绍完了所有表示UTF-8中单个字符的有效字节序列。它们是:

[00-7F]
[C2-DF] [80-BF]
E0 [A0-BF] [80-BF]
[E1-EC] [80-BF] [80-BF]
ED [80-9F] [80-BF]
[EE-EF] [80-BF] [80-BF]
F0 [90-BF] [80-BF] [80-BF]
[F1-F3] [80-BF] [80-BF] [80-BF]
F4 [80-8F] [80-BF] [80-BF]

这些可以用一个正则来进行匹配,不过记住了正则只能在字符上进行操作,而不是字节。在Node中,我们可以使用buffer.toString('binary')将一个缓冲区转化成一个字符串,里面的字符则是这些字节的代码点的字面量(比如从0到255),然后将这个字符串用正则来进行校验。

现在我们已经理解怎么是UTF-8了,我们也可以明白Node中到底修改了些什么。

// Prior to these releases:
new Buffer('ab\ud800cd', 'utf8');
// <Buffer 61 62 ed a0 80 63 64>

// After this release:
new Buffer('ab\ud800cd', 'utf8');
// <Buffer 61 62 ef bf bd 63 64>

字符\ud800是一个代理(surrogate),没有对应的编码,因此它是一个无效字符。然而,JavaScript允许这个字符串存在并且不会抛出错误,因此Node决定这个字符串转化成缓冲区的时候也不要报错。不过现在这个字符被替换成了'\ufffd',也就是未知字符。为了不让你的程序发送一个JS认为有效的字符串而对方却拒绝承认它是一个UTF-8串,Node将它替换成了一个非代理字符,以避免下游的程序出现错误。当碰到奇怪的输入的时候,我通常是建议不要去猜测程序员到底想表达什么,但既然Unicode提供了这样的一个代码点,它被“用来替换掉一个在Unicode中未知的或者无法表示的字符“,这看起来也算是个不错的选择。