近期是业务的流量高峰,生产环境的服务每天会出现几次 URIError: URI malformed
导致的 500 错误
触发该错误的条件只有一种:用 encodeURI
等编码含有不合法字符的字符串,导致编解码失败
从日志上查看,触发该错误的用户数据形如下:
“日子过的像流水一般。它静静的从我们身边缓缓流过,不带半分声响。未来的日子要加油,不让它变成一种负担�”
[“在中秋节来临之际,我们预祝大家节日快乐�”, “�??”]
“彼岸花花开彼岸,断肠草草断肝肠??�”
探索过程
乍一看,第一反应会怀疑 emoji 是幕后黑手,但事实上完全可以正常编码
> encodeURI('?')
< '%F0%9F%8C%B8'
复制代码
甚至
> encodeURI('�')
< '%EF%BF%BD'
复制代码
既然这些数据都能正常 encode,那么出错的来源就是这些 � 乱码生前的真实模样了,因此得弄清楚乱码是怎么造成的
再回头看看 encodeURL
的错误情况,官方示例 如下
众所周知,Unicode 包含了基本字符和扩展字符
大部分的常用字符都在 Unicode 的基本平面内(65536 个),在 UTF-16 中用 2 个字节即可表示(\uXXXX
)
而在基本平面以外(补充平面)的特殊字符,如象形文字(?)、楔形文字(?)、emoji(?)等等,则需要用 4 个字节来表示(\uXXXX
\uXXXX
)
如:? => \ud83c\udf38
并且补充字符的 Unicode 是专门有被分配范围的,对应到 UTF-16 编码,前两个字节范围是 U+D800 到 U+DBFF,后两个字节范围是 U+DC00 到 U+DFFF
因此高低位的编码单独拆分开,也不会被视为一个基本字符
如:\ud83c
=> �,也就有了我们上述的报错
了解到这,光秃秃的石头开始透出水面,日志中触发报错的用户数据中的乱码就是 emoji 的残缺编码
但用户为什么会输入只有一半的 emoji 编码呢?如此频繁的触发,显然不太可能
水落石出
前面说到,Unicode 的基本字符在 UTF-16 中用 2 个字节表示,补充字符需要用 4 个字节表示
然而,Javascript 的诞生时间比 UTF-16 的发布时间早了一年,因此 Javascript 只能使用已经被淘汰的 UCS-2 进行编码
这导致它认为所有字符在这门语言中都是 2 个字节,对于补充平面的 Unicode 字符,它只会作为 2 个字符处理,因此会有如下情况:
> "\ud83c\udf38"
< "?"
> "?".length
< 2
复制代码
这时回头再看日志数据,发现出现问题的字符串都恰好是 20、50 等整十的长度
再回归到相关的业务代码中,果然发现这些用户输入的数据都进行了长度限制
此时答案终于明了:
某个文本输入框限制了最多输入 100 个字符,而用户恰好输入到第 99 个字后,第 100 个字符输入了 emoji
这时经过 js 的截断处理后,用户会看到字数统计已经满了,但最后输入的 emoji 表情并没有显示(但其实 emoji 的高位编码此时仍然处在字符串中),所以用户接着点击提交便会收到报错
如下动图所示(注意看字数的变化)
解决方案
对用户输入文本进行长度限制的需求在业务中太常见了,不方便在各处业务中的字符串截断处理中改进,因此选择在进行 encodeURI
的地方之前统一进行不完整编码的过滤
前面说过,补充平面字符的编码范围:前两个字节范围是 [\uD800-\uDBFF]
~ [\uDC00-\uDFFF]
所以正则表达式需要囊括的情况有四种(未考虑高低位颠倒的情况):
- 高位字符单独出现:
[\uD800-\uDBFF][^\uDC00-\uDFFF]
、[\uD800-\uDBFF]$
- 低位字符单独出现:
[^\uD800-\uDBFF][\uDC00-\uDFFF]
、^[\uDC00-\uDFFF]
/**
* 查找高低位不完整的字符编码位置
* @param {string} str
* @returns {number}
*/
findInvalidUnicode: function(str) {
const reg = /([\uD800-\uDBFF])[^\uDC00-\uDFFF]|^([\uDC00-\uDFFF])|([\uD800-\uDBFF])$|[^\uD800-\uDBFF]([\uDC00-\uDFFF])/g.exec(str);
if (reg) {
let index = reg.index;
if (reg[4]) {
return index + 1;
}
return index;
}
return -1;
}
复制代码