从 JS 里的 MD5 转换踩坑开始说起

写 JS 代码的同学们不知道有没有注意过,后台接口通过 JSON 处理汉字字符、emoji 时,返回的是像 \u00ff 这样转义处理的字符,而不是它们的明文原文。这是为什么呢?

1
2
3
<?php
echo json_encode('再会,谢谢所有的🐟');
// -> "\u518d\u4f1a\uff0c\u8c22\u8c22\u6240\u6709\u7684\ud83d\udc1f"
1
2
3
4
import json

print(json.dumps('再会,谢谢所有的🐟'))
# -> "\u518d\u4f1a\uff0c\u8c22\u8c22\u6240\u6709\u7684\ud83d\udc1f"

以及,这个问题和今天要说的 MD5 转换编码的踩坑又有什么关系呢?

踩坑

最近在小程序项目中,对于一些请求数据的防篡改处理,与后台约定了在表单内增加一个校验字段,为原始数据进行 MD5 换算后的结果。最初调试走通后没发现什么问题,就发布上线了。

之后陆续有部分用户反馈,偶尔发现部分页面无法正常显示,与后台同学定位发现这些用户请求时无法通过防篡改校验,导致被拦截策略误伤。只好对这些情况作了一些白名单的临时处理,待有时间时再彻底定位处理。

后来逐渐发现,出现 MD5 处理结果异常的用户,往往名字里有 emoji 或者生僻汉字出现,莫非问题和这些字符的编码方式有关——

通过与后台对比 emoji 的编码结果,发现两端确实出现了不一致的情况,看来这些字符确实是问题的充分条件了。

阅读与学习

于是,在某次版本之后得以稍微喘口气的某个周末,开始阅读之前同事从网上找到的纯 JavaScript 实现的 MD5 模块源码——发现并看不懂,还得先找找 MD5 算法的原理,结合着参考对照阅读,终于大致明白了各个函数的作用。

《MD5算法原理及其实现》

经过阅读对比,发现目前项目里使用的这套算法中分组、线性函数 F(x,y,z)/G(x,y,z)/H(x,y,z)/I(x,y,z)和16个分组的4轮运算过程看起来都没什么问题。

而在开始这些处理之前,有个对于输入字符串的处理函数,目前是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function encodeUTF8(string) {
let output = '';
for (let n = 0; n < string.length; n++) {
const c = string.charCodeAt(n);
if (c < 128) {
output += String.fromCharCode(c);
} else if ((c > 127) && (c < 2048)) {
output += String.fromCharCode((c >> 6) | 192);
output += String.fromCharCode((c & 63) | 128);
} else {
output += String.fromCharCode((c >> 12) | 224);
output += String.fromCharCode(((c >> 6) & 63) | 128);
output += String.fromCharCode((c & 63) | 128);
}
}
return output;
};

很显然,这个函数的作用自然就是“编码为UTF8”,顾名思义嘛——

不过这时候有同学可能就要问了:等等,我们平时在项目中写代码时,用的已经是 UTF-8 编码了啊?为什么还要再编码成 UTF-8?

编码方式

这里就涉及到文件编码与 JS 引擎内部编码的区别了,有兴趣的同学可以阅读一下相关文章:

《Unicode 编码及 UTF-32, UTF-16 和 UTF-8》

《JavaScript 的内部字符编码是 UCS-2 还是 UTF-16》

《Unicode与JavaScript详解》

《Unicode 一览表》

没有时间自己详细阅读前因后果的同学,也可以参考一下我的理解(不保证绝对准确无误):

  • 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. 汉字:“我”

作为常用汉字的“我”字,码点 251050x6211):

  • 由于 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. 音乐符号:“𝅘𝅥𝅮”

而对于音乐符号,码点 1191360x1D160):

  • 它进入了 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”),码点 1311340x2003E):

编码 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),不过目前大部分字体和客户端都还无法支持渲染显示。

《中日韩统一表意文字扩展区》

《中日韓統一表意文字擴展區B》

Emoji(绘文字)“🐟”,码点 1280310x1F41F):

编码 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function encodeUTF8(string) {
let output = [];
for (let n = 0; n < string.length; n++) {
const c = string.charCodeAt(n);
if (c < 128) {
output += String.fromCharCode(c);
} else if ((c > 127) && (c < 2048)) {
output += String.fromCharCode((c >> 6) | 192);
output += String.fromCharCode((c & 63) | 128);
} else {
output += String.fromCharCode((c >> 12) | 224);
output += String.fromCharCode(((c >> 6) & 63) | 128);
output += String.fromCharCode((c & 63) | 128);
}
}
return output;
};

先不论其中的位运算是否有问题,已经可以看出,这个算法的转换结果只有1~3字节这三种情况,不会输出4字节的结果——

所以,问题很明显了:它能处理常见的英文、汉字字符,但是无法处理结果为4字节的 0x10000 ~ 0x10ffff 范围内的字符,其中就包括上面的音符特殊字符、汉字扩展B区、emoji 的几种情况。

特殊解决方案

找到问题原因后,就可以调整搜寻方向,比如找找其它的 UTF-8 转码的 JavaScript 实现方案。

其中就找到一个很神奇的方法,看起来真是意外的简单,甚至完全没有需要用到任何位运算处理: unescape(encodeURIComponent(string))

它真的有用吗,我们来试一试:

1
2
3
4
const encoded = unescape(encodeURIComponent('🐟'));
const hexArr = encoded.split('').map(c => c.charCodeAt(0).toString(16));
console.log(hexArr.join('');
// -> 'f09f909f'

通过它处理“🐟”之后,得到的正是这个 emoji 的 UTF-8 表示形式:0xF09F909F

为什么会这样呢?其实原因不复杂,因为 encodeURIComponent 的转换是基于 UTF-8 进行计算的(估计是为了网络传输效率和常见服务器支持格式考虑而设计实现的),再将结果直接按字节地一一转换回 JavaScript 字符串,其中每个字符的 charCode 就变成对应 UTF-8 位置的结果了。

这个方案的问题在于 unescape 作为非标准函数,因为各种问题已经在很早时被宣布弃用(deprecated),我们最好还是找更为标准的解决方案,以免以后浏览器不再支持而无法正常工作。

《escape, encodeURI, encodeURIComponent有什么区别?》

改进解决方案

1. 基于 Unicode 码点操作

原本的 encodeUTF8 函数中通过 String.prototype.charCodeAt() 操作原始字符串,得到的是根据 UCS-2 计算的字长、相当于 UTF-16 的编码,基于这个结果再转换成 UTF-8 最好是先还原成 Unicode 码点再操作。

而好在 ES6 中增加了新的基于 Unicode 码点的处理方式 String.prototype.codePointAt();而对于字长的处理则可以使用 Array.from(),它将会正确按照每个字符进行拆分:

1
2
3
4
5
6
7
8
9
10
11
'钓🐟'.length;
// -> 3

Array.from('钓🐟').length;
// -> 2

'钓🐟'.split('');
// -> ['钓', '\uD83D', '\uDC1F']

Array.from('钓🐟')
// -> ['钓', '🐟']

所以转换处理的循环部分可以改写一下,这里主要是要注意:需要基于 Array.from() 的结果进行循环,以及通过 codePointAt 进行码点获取时,只需要传入每个字符的下标0位置即可:

1
2
3
4
5
6
7
8
function encodeUTF8(string) {
let charArr = Array.from(string);
for (let n = 0; n < charArr.length; n++) {
const cp = charArr[n].codePointAt(0);
// ...
}
// ...
};

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位 xxx111100000xF0) 进行按位或运算,得到 11110xxx 的结果;
  • 最后3组 yyyyyy100000000x80) 进行按位或运算,得到 10yyyyyy 的结果。

顺便把相关位运算的 <</>> 位移运算符改为 <<</>>> 无符号位移运算符。

最终算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function encodeUTF8(string) {
const output = [];
const arr = Array.from(string);
for (let n = 0; n < arr.length; n++) {
const cp = arr[n].codePointAt(0);
if (cp < 0x80) {
output.push(cp);
} else if (cp < 0x800) {
output.push(
(cp >>> 6) | 0xC0,
cp & 0x3F | 0x80,
);
} else if (cp < 0x10000) {
output.push(
(cp >>> 12) | 0xE0,
((cp & 0xFC0) >>> 6) | 0x80,
cp & 0x3F | 0x80,
);
} else {
output.push(
(cp >>> 18) | 0xF0,
((cp & 0x3F000) >>> 12) | 0x80,
((cp & 0xFC0) >>> 6) | 0x80,
cp & 0x3F | 0x80,
);
}
}
return output;
}

返回结果从字符串变成了更方便运算的类 byte[] 的数组,调用的位置也记得需要做相应调整。

拓展

几种 JavaScript 字符串内特殊字符的转义写法

1. 单字节字符

在常见编程语言里,我们经常通过 x 的前缀来书写16进制编码,JavaScript 看起来也支持这个写法:

1
2
3
4
5
6
7
8
9
10
11
console.log('\x41');
// -> A

console.log('\x4142');
// -> A42

console.log('\x41\x42');
// -> AB

console.log('\xF');
// Uncaught SyntaxError: Invalid hexadecimal escape sequence

可以看出,\x 后面固定为两位16进制格式,即只支持表示单个字节。那么对于汉字这样多字节字符,比如“谢”字(UTF-8: 0xE8B0A2, UTF-16BE: 0x8C22),该怎样书写呢?

2. 多字节字符

在 PHP 里,我们可以直接按字节顺序写出,最终打印出来的就是完整的汉字:

1
2
<?php echo "\xE8\xB0\xA2\xE8\xB0\xA2";
// -> 谢谢

但是在 JavaScript 中,如果你按照这样的方式书写,就会发现打印出来的并不是你预想中的“谢谢”:

1
2
console.log('\x8C\x22\x8C\x22');
// -> Œ"Œ"

为什么会这样呢?

通过输出结果我们可以看出,其中 \x8C\x22 都被当成单个的字符进行处理了,而 JavaScript 中 UCS-2/UTF-16 的实现下,一个字符是以2或4个字节进行存储的。

所以,这里最终得到的字符串其实是: 0x008C 0x0022,而不是 0x8C22

这种多字节字符的情况,就需要使用 JavaScript 提供的 \u 进行转义书写了:

1
2
console.log('\u8C22\u8C22');
// -> 谢谢

不过 \u 有着和 \x 类似的毛病,\x 后面固定为两位16进制,\u 后面默认则是固定4位16进制。

对于 “🐟”(UTF-16: 0xD83DDC1F, Code Point: 0x1F41F)就需要写成 \uD83D\uDC1F,或者使用 ES6 提供的码点写法整个写入 \u{1F41F}

1
2
3
4
console.log('\uD83D\uDC1F');
// -> 🐟
console.log('\u{1F41F}');
// -> 🐟

这种格式下,一个码点就是一个字符,各个字符的边界更直观,更便于阅读和调整。

回顾

最后,我们回到最开始的问题:服务器为什么以 \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