也谈UTF-8编码
今天的早些时候,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中未知的或者 无法表示的字符“,这看起来也算是个不错的选择。
原创文章转载请注明出处:也谈UTF-8编码