URIError: URI malformed 错误原因排查、解决

6,044 阅读3分钟

近期是业务的流量高峰,生产环境的服务每天会出现几次 URIError: URI malformed 导致的 500 错误
触发该错误的条件只有一种:用 encodeURI 等编码含有不合法字符的字符串,导致编解码失败

从日志上查看,触发该错误的用户数据形如下:

"日子过的像流水一般。它静静的从我们身边缓缓流过,不带半分声响。未来的日子要加油,不让它变成一种负担�"

["在中秋节来临之际,我们预祝大家节日快乐�", "�🎉🎉"]

"彼岸花花开彼岸,断肠草草断肝肠🌺🌺�"

探索过程

乍一看,第一反应会怀疑 emoji 是幕后黑手,但事实上完全可以正常编码

> encodeURI('🌸')
< '%F0%9F%8C%B8'

甚至

> encodeURI('�')
< '%EF%BF%BD'

既然这些数据都能正常 encode,那么出错的来源就是这些 � 乱码生前的真实模样了,因此得弄清楚乱码是怎么造成的

再回头看看 encodeURL 的错误情况,官方示例 如下

image.png

众所周知,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 表情
但一个 emoji 占 2 个字符长度,所以经过 js 的截断处理后,第 100 个字符就只剩 emoji 的高位编码了

而从用户角度会看到,字数统计显示已经满了,但最后输入的 emoji 表情并没有显示(但其实 emoji 的高位编码此时仍然处在字符串中),所以用户接着点击提交便会收到报错

如下动图所示(注意看字数的变化)

Kapture 2021-09-11 at 15.09.52.gif

解决方案

对用户输入文本进行长度限制的需求在业务中太常见了,不方便在各处业务中的字符串截断处理中改进,因此选择在进行 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) {
    const index = reg.index;
    return reg[4] ? index + 1 : index;
  }
  return -1;
}

参考文章