从 JS 里的 MD5 转换踩坑开始说起
写 JS 代码的同学们不知道有没有注意过,后台接口通过 JSON 处理汉字字符、emoji 时,返回的是像 \u00ff
这样转义处理的字符,而不是它们的明文原文。这是为什么呢?
1 |
|
1 | import json |
以及,这个问题和今天要说的 MD5 转换编码的踩坑又有什么关系呢?
踩坑
最近在小程序项目中,对于一些请求数据的防篡改处理,与后台约定了在表单内增加一个校验字段,为原始数据进行 MD5 换算后的结果。最初调试走通后没发现什么问题,就发布上线了。
之后陆续有部分用户反馈,偶尔发现部分页面无法正常显示,与后台同学定位发现这些用户请求时无法通过防篡改校验,导致被拦截策略误伤。只好对这些情况作了一些白名单的临时处理,待有时间时再彻底定位处理。
后来逐渐发现,出现 MD5 处理结果异常的用户,往往名字里有 emoji 或者生僻汉字出现,莫非问题和这些字符的编码方式有关——
通过与后台对比 emoji 的编码结果,发现两端确实出现了不一致的情况,看来这些字符确实是问题的充分条件了。
阅读与学习
于是,在某次版本之后得以稍微喘口气的某个周末,开始阅读之前同事从网上找到的纯 JavaScript 实现的 MD5 模块源码——发现并看不懂,还得先找找 MD5 算法的原理,结合着参考对照阅读,终于大致明白了各个函数的作用。
经过阅读对比,发现目前项目里使用的这套算法中分组、线性函数 F(x,y,z)/G(x,y,z)/H(x,y,z)/I(x,y,z)
和16个分组的4轮运算过程看起来都没什么问题。
而在开始这些处理之前,有个对于输入字符串的处理函数,目前是这样写的:
1 | function encodeUTF8(string) { |
很显然,这个函数的作用自然就是“编码为UTF8”,顾名思义嘛——
不过这时候有同学可能就要问了:等等,我们平时在项目中写代码时,用的已经是 UTF-8 编码了啊?为什么还要再编码成 UTF-8?
编码方式
这里就涉及到文件编码与 JS 引擎内部编码的区别了,有兴趣的同学可以阅读一下相关文章:
《Unicode 编码及 UTF-32, UTF-16 和 UTF-8》
没有时间自己详细阅读前因后果的同学,也可以参考一下我的理解(不保证绝对准确无误):
- Unicode 是一套字符集,为各种语言的每个字符定义了对应的编号——码点/码位(Code Point),但不代表其最终的表示和存储形式。
- 实际计算机系统中根据不同的需求,对其有不同的编码方式实现,如今依然常见的有 UTF-8、UTF-16。
- JavaScript 因历史原因,使用的是近似于 UTF-16 的 UCS-2 方案(编码中操作处理时表现为 UCS-2,浏览渲染显示时基于 UTF-16 重新显示为 Unicode 字符)。
所以,这个模块在 JavaScript 的字符串进行 MD5 计算前,“尝试”将 JS 引擎内的 UTF-16/UCS-2 格式的字符串先转换成了基于 UTF-8 格式表示的 Unicode 字符,再将其对应编码值进行 MD5 计算处理。
例子说明
这里举几个例子:
1. 英文字母:“A”
作为基础拉丁字母的 A,它的 Unicode 码点为 65(转换成16进制为 0x41
)。
其各种编码的16进制书写和在内存、硬盘等介质中2进制表现形式为:
编码 | 16进制 | 2进制 |
---|---|---|
UTF-8 | 41 |
01000001 |
UTF-16BE | 00 41 |
00000000 01000001 |
UTF-16LE | 41 00 |
01000001 00000000 |
UTF-32BE | 00 00 00 41 |
00000000 00000000 00000000 01000001 |
UTF-32LE | 41 00 00 00 |
01000001 00000000 00000000 00000000 |
可以看出,对于码点较小的字符,位于几种编码的第一段区域时,表现格式其实差别不大:
- UTF-8 在第一段与 ASCII 码、Unicode 码点一致,占用1个字节;
- 而 UTF-16 和 UTF-32 相当于在高位补 0,分别占用2个和4个字节。
2. 汉字:“我”
作为常用汉字的“我”字,码点 25105(0x6211
):
- 由于
0x6211
没有超过两个字节,所以使用 UTF-16 相当于直接对 Unicode 码点做16进制编码转换,仍只占用2字节; - 而相对于 UTF-8,它已经进入第三段范围(
0x800
~0xffff
),已经需要3个字节来进行表示了。
编码 | 16进制 | 2进制 |
---|---|---|
UTF-8 | E6 88 91 |
11100110 10001000 10010001 |
UTF-16BE | 62 11 |
01100010 00010001 |
UTF-16LE | 11 62 |
00010001 01100010 |
UTF-32BE | 00 00 62 11 |
00000000 00000000 01100010 00010001 |
UTF-32LE | 11 62 00 00 |
00010001 01100010 00000000 00000000 |
3. 音乐符号:“𝅘𝅥𝅮”
而对于音乐符号,码点 119136(0x1D160
):
- 它进入了 UTF-8 的第四段范围(
0x10000
~0x10ffff
),就算是使用 UTF-8 也需要4个字节来编码表示了; - 使用 UTF-16 编码时,超过
0xffff
的它需要借助 低位代理&高位代理 进行辅助表示,也使用了4个字节。
编码 | 16进制 | 2进制 |
---|---|---|
UTF-8 | F0 9D 85 A0 |
11110000 10011101 10000101 10100000 |
UTF-16BE | D8 34 DD 60 |
11011000 00110100 11011101 01100000 |
UTF-16LE | 34 D8 60 DD |
00110100 11011000 01100000 11011101 |
UTF-32BE | 00 01 D1 60 |
00000000 00000001 11010001 01100000 |
UTF-32LE | 60 D1 01 00 |
01100000 11010001 00000001 00000000 |
4. 扩展B区汉字和 emoji:
汉字“𠀾”(这个字怎么读?我也不会读……查了一下,似乎是吴越方言,读“pēi”),码点 131134(0x2003E
):
编码 | 16进制 | 2进制 |
---|---|---|
UTF-8 | F0 A0 80 BE |
11110000 10100000 10000000 10111110 |
UTF-16BE | D8 40 DC 3E |
11011000 01000000 11011100 00111110 |
UTF-16LE | 40 D8 3E DC |
01000000 11011000 00111110 11011100 |
UTF-32BE | 00 02 00 3E |
00000000 00000010 00000000 00111110 |
UTF-32LE | 3E 00 02 00 |
00111110 00000000 00000010 00000000 |
中日韩常用的统一汉字文字区——基本多文种平面 内的
0x4E00
~0x9FFF
段落,其中基本含括了日常生活需要用到的常用汉字。在2000年、2001年、2003年、2010年、2015年、2017年、2020年扩充了 A、B、C、D、E、F、G 这7个扩充区,包括各种古籍、生僻字、日语汉字、喃字、急用字等更多汉字。顺便一提,“biang biang 面”的“biang”于2020年被收录到了扩充区 G 中(码点
0x30EDD
/0x30EDE
),不过目前大部分字体和客户端都还无法支持渲染显示。
Emoji(绘文字)“🐟”,码点 128031(0x1F41F
):
编码 | 16进制 | 2进制 |
---|---|---|
UTF-8 | F0 9F 90 9F |
11110000 10011111 10010000 10011111 |
UTF-16BE | D8 3D DC 1F |
11011000 00111101 11011100 00011111 |
UTF-16LE | 3D D8 1F DC |
00111101 11011000 00011111 11011100 |
UTF-32BE | 00 01 F4 1F |
00000000 00000001 11110100 00011111 |
UTF-32LE | 1F F4 01 00 |
00011111 11110100 00000001 00000000 |
分析问题
现在,我们回过头来看最初那个 UTF-8 编码转换函数:
1 | function encodeUTF8(string) { |
先不论其中的位运算是否有问题,已经可以看出,这个算法的转换结果只有1~3字节这三种情况,不会输出4字节的结果——
所以,问题很明显了:它能处理常见的英文、汉字字符,但是无法处理结果为4字节的 0x10000
~ 0x10ffff
范围内的字符,其中就包括上面的音符特殊字符、汉字扩展B区、emoji 的几种情况。
特殊解决方案
找到问题原因后,就可以调整搜寻方向,比如找找其它的 UTF-8 转码的 JavaScript 实现方案。
其中就找到一个很神奇的方法,看起来真是意外的简单,甚至完全没有需要用到任何位运算处理: unescape(encodeURIComponent(string))
。
它真的有用吗,我们来试一试:
1 | const encoded = unescape(encodeURIComponent('🐟')); |
通过它处理“🐟”之后,得到的正是这个 emoji 的 UTF-8 表示形式:0xF09F909F
。
为什么会这样呢?其实原因不复杂,因为 encodeURIComponent
的转换是基于 UTF-8 进行计算的(估计是为了网络传输效率和常见服务器支持格式考虑而设计实现的),再将结果直接按字节地一一转换回 JavaScript 字符串,其中每个字符的 charCode 就变成对应 UTF-8 位置的结果了。
这个方案的问题在于 unescape
作为非标准函数,因为各种问题已经在很早时被宣布弃用(deprecated),我们最好还是找更为标准的解决方案,以免以后浏览器不再支持而无法正常工作。
改进解决方案
1. 基于 Unicode 码点操作
原本的 encodeUTF8
函数中通过 String.prototype.charCodeAt()
操作原始字符串,得到的是根据 UCS-2 计算的字长、相当于 UTF-16 的编码,基于这个结果再转换成 UTF-8 最好是先还原成 Unicode 码点再操作。
而好在 ES6 中增加了新的基于 Unicode 码点的处理方式 String.prototype.codePointAt()
;而对于字长的处理则可以使用 Array.from()
,它将会正确按照每个字符进行拆分:
1 | '钓🐟'.length; |
所以转换处理的循环部分可以改写一下,这里主要是要注意:需要基于 Array.from()
的结果进行循环,以及通过 codePointAt
进行码点获取时,只需要传入每个字符的下标0位置即可:
1 | function encodeUTF8(string) { |
2. 4字节 UTF-8 的转换支持
Unicode 码点段落 | UTF-8 编码 |
---|---|
0x0 ~ 0x7f |
0xxxxxxx |
0x80 ~ 0x7ff |
110xxxxx 10xxxxxx |
0x800 ~ 0xffff |
1110xxxx 10xxxxxx 10xxxxxx |
0x10000 ~ ... |
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
通过上面的格式可以看出,Unicode 码点大于 0xffff
的字符转换成 4字节的 UTF-8 时,处理方式大致分以下几步:
- 将字符的所有二进制位分为最高3位
xxx
和之后三组6位yyyyyy
这样的4组二进制位; - 最高3位
xxx
与11110000
(0xF0
) 进行按位或运算,得到11110xxx
的结果; - 最后3组
yyyyyy
与10000000
(0x80
) 进行按位或运算,得到10yyyyyy
的结果。
顺便把相关位运算的 <<
/>>
位移运算符改为 <<<
/>>>
无符号位移运算符。
最终算法如下:
1 | function encodeUTF8(string) { |
返回结果从字符串变成了更方便运算的类 byte[] 的数组,调用的位置也记得需要做相应调整。
拓展
几种 JavaScript 字符串内特殊字符的转义写法
1. 单字节字符
在常见编程语言里,我们经常通过 x
的前缀来书写16进制编码,JavaScript 看起来也支持这个写法:
1 | console.log('\x41'); |
可以看出,\x
后面固定为两位16进制格式,即只支持表示单个字节。那么对于汉字这样多字节字符,比如“谢”字(UTF-8: 0xE8B0A2
, UTF-16BE: 0x8C22
),该怎样书写呢?
2. 多字节字符
在 PHP 里,我们可以直接按字节顺序写出,最终打印出来的就是完整的汉字:
1 | echo "\xE8\xB0\xA2\xE8\xB0\xA2"; |
但是在 JavaScript 中,如果你按照这样的方式书写,就会发现打印出来的并不是你预想中的“谢谢”:
1 | console.log('\x8C\x22\x8C\x22'); |
为什么会这样呢?
通过输出结果我们可以看出,其中 \x8C
和 \x22
都被当成单个的字符进行处理了,而 JavaScript 中 UCS-2/UTF-16 的实现下,一个字符是以2或4个字节进行存储的。
所以,这里最终得到的字符串其实是: 0x008C
0x0022
,而不是 0x8C22
。
这种多字节字符的情况,就需要使用 JavaScript 提供的 \u
进行转义书写了:
1 | console.log('\u8C22\u8C22'); |
不过 \u
有着和 \x
类似的毛病,\x
后面固定为两位16进制,\u
后面默认则是固定4位16进制。
对于 “🐟”(UTF-16: 0xD83DDC1F
, Code Point: 0x1F41F
)就需要写成 \uD83D\uDC1F
,或者使用 ES6 提供的码点写法整个写入 \u{1F41F}
:
1 | console.log('\uD83D\uDC1F'); |
这种格式下,一个码点就是一个字符,各个字符的边界更直观,更便于阅读和调整。
回顾
最后,我们回到最开始的问题:服务器为什么以 \uXXXX
的形式返回汉字和emoji?
服务器对于诸如汉字和 emoji 这些多字节字符,返回 JSON 字符串的时候如果直接返回明文,其实返回的是自己运行环境下的编码实现。比如 PHP 返回 谢谢
时,发出的将会是 0xE8 0xB0 0xA2 0xE8 0xB0 0xA2
,对于 UCS-2/UTF-16 的 JavaScript 来说就变成乱码了。
而服务端实现 JSON 序列化时可能考虑到了这点,提前将其变为基于 UTF-16 的转义书写字符串字面量,这样浏览器内的 JavaScript 反序列化后就可以正常得到预期的 谢谢
了。
我的博客即将同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=ortdhu8zxomu