JSON,一个伟大的协议,前端工程师的卓越发明!相信 99% 的程序员都认识 JSON,它作为前后端交互的热门协议,因其易理解、简单、灵活和超强的可读性,得到了互联网的广泛欢迎,甚至很多微服务之间的传输协议中也得到应用。
但是笔者在开发一个 Go 的 JSON 编解码库的过程中,除了自己趟过各种奇奇怪怪的问题之外,也认识到广大程序员们对 JSON 各种奇奇怪怪的用法和使用姿势。在处理解决这些问题之后,笔者萌生了对 JSON 进行进一步科普和介绍的想法。
相信我,看完这篇文章,你就可以吃透这个可爱的 JSON 了。
这不是万字长文,所以答应我,不要 TLDR(too long, don't read)好吗?
JSON 是什么
这个问题似乎很容易回答:JavaScript Object Notation,直译就是 JavaScript 对象表示。
然而,这个命名中的 “JavaScript” 是个很大的误导,让人以为 JSON 是附属于 JavaScript 的。其实不然,JSON 是完全独立于任何语言之上的一个对象表示协议,甚至从我个人的角度来说,它非常的不 “JS”。
关于 JSON 的 “常识”
从大家的认知中,相信以下的几点是常识:
- JSON 可以是对象(object),使用
{...}
格式包起来 - JSON 可以是是数组(array),使用
[...]
格式包起来 - JSON 内的值可以是 string, boolean, number,也可以进一步嵌套 object 和 array
- JSON 也有特殊字符需要转义,最显而易见的就是双引号
"
、反斜杠\
、换行符\n
、\r
- JSON object 的键(key)必须是 string 格式
- JSON 可以通过 object 和 array 类型实现无限层级的嵌套
好了,懂了上面几点,其实也就弄懂了 JSON 90% 甚至是 99% 的应用场景了。程序员们也足以可以实现简单的 JSON 编码逻辑。
如果你想知道剩下那些让人掉大牙的 1%,欢迎你往下看;如果你想要自己开发一个 JSON 编解码库,以下内容也能够让你少走很多弯路:
JSON 标准规定了什么
在了解各种 JSON 的坑之前,我们先来了解一下 JSON 标准本身。
现行通行的 JSON 标准是 ECMA-404,这篇协议总共有14页,但除去封面、封底、目录、简介、版权声明,正文只有5页,并且其中3页大部分是图片。所以笔者推荐所有的程序员都把这篇文档通读一遍,恐怕这是大部分人唯一能完整读完的主流协议了(狗头)。
所以啊,“可爱” 的 JSON 可真不是标题党——试想这么短小的协议,怎不可谓可爱呢!
通读了文档之后我们可以发现,除了前文提及的几个常识之外,下面有几个知识点估计大家很少留意:
- JSON 是用来承载 unicode 字符的,这一点在标准中明确提及
- JSON 标准中其实并没有 boolean 这个类型,但是
true
和false
被并列为单独的两个类型 - 作为最外层的 JSON 类型,并不限定为 object 或 array,实际上 string, boolean, number, 甚至 null 也是可以的
- JSON 数字表示可以使用科学计数法,可能许多人在实际应用中没留意过
- JSON 明确说明不支持 +/-Inf 和 NaN 这两组在 IEEE 754 中规定的特殊数值
我们解读一下上面这些知识点带来的影响:
unicode 字符:
- JSON 传输的应当是可视化字符,而不应该也无法承载不可读的二进制数据
- 换句话说,请尽量不要用 JSON 来传输二进制数据
没有 boolean 类型
- 这个问题不大,主要是对各种库的使用上——有些库提供了 boolean 类型分类,而有些库则按照标准协议分为 true 和 false 两种类型,请注意区分
外层类型不限定
- 其实这影响不大,但是这使得 JSON 多了一个额外功能: 当我们要把包含换行符的文本压在一行内,但又要保持高可读性的时候,我们可以将文本序列化为 JSON
- 这个特性在打日志的时候特别有用
科学计数法:
- 这主要是在解析 JSON 数据时,需要注意兼容
特殊浮点值:
- 这个问题可大可小,大部分情况下不会遇到,但是一旦出现了,会导致整个序列化过程失败。开发者们需要谨慎处理浮点数,下文会进一步提及
JSON 没有规定什么?
- JSON 并没有严格限定文本的编码格式
- JSON 数字是十进制的,没有限制绝对值大小,也没有限制小数点后的位数
- JSON 没有明确规定 ASCII 的控制字符和不可见字符的传输格式
- JSON 没有限制 object 的 key 所使用的字符
- JSON 没有明确说明 object 的 key 之间是有序的还是无序的
为什么列出这几点?让我们继续往下看:
JSON 的常见 “坑位”
JSON 如此简单,但也正因为它的特性,我们会不知不觉地落入一些圈套中。下面我列几个常见的坑,读者看看能不能对号入座:
没搞明白编码格式导致解码出错
前面说到,JSON 明确声明自己是用于承载 unicode 的。但是,unicode 除了规定每个字符码的含义(码点)之外,还包含另外一个重要规范,那就是如何将这些字符串成字符流,这就是我们常说的 UTF-8、UTF-16BE、
UTF-16LE 等等概念。而 JSON 并没有对此作明确限定。这就导致了在 JSON 的编码与解码端,如果没有约定好,那么就会出现乱码。
笔者曾经与一个合作伙伴的开发工程师对接过 JSON,对方使用 Java 解码我发出的原始数据时出现乱码。我告诉对方,应该用 UTF-8 格式解码,但是对方不明白 UTF-8 是什么,只是不停的告诉我他使用的是哪一个 Java 函数。
我的解决方案不敢说万能,但应该即便是上古的解码器都能处理——这个方案就是指定各编码器在编码时,对大于 ASCII 范围的字符均作转义处理为 \uXXXX
格式。这么一通操作后,我的合作伙伴表示:程序通了。
其实在 JSON 规范中,列举了不少篇幅说明大于 U+00FF 的码点应该如何转义,包括大于 U+FFFF 的。所以从笔者的个人观点看来,如果我们严格按照 JSON 规范的话,什么 UTF-8,GB18030 等编码格式都是未被允许的,唯一严格允许的就是 \uXXXX
转义。但是在实际操作中,这种转义太浪费字节序列了,各种语言对 string 类型进行操作时,习惯性地按照本身的字符串在内存中的默认编码格式照搬到 JSON 序列化上了。
如果 JSON 的编码端无法确保或协调对端解码器的编码格式,那么请统一使用 \uXXXX
转义。
JSON 中的 UTF-16
如果读者不需要自行编码或解析 JSON 数据的话,可以跳过这一小节;否则,这一段是必修课。
对于编码值大于 127 的字符来说,如上文所示,我们可以转义为 \uXXXX
格式。那么是不是直接写 sprintf("\\u04X", aByte)
就可以呢?
如果你这么做,那么作为一个通用库来说……
<img src="https://ts1.cn.mm.bing.net/th/id/R-C.f1ca9cac65236fb8944862661d3a915c?rik=3iTDjVXAAsd2lw&riu=http%3a%2f%2fps3.tgbus.com%2fUploadFiles%2f201206%2f20120619174916338.jpg&ehk=wYBPVaiaHHF33Liake9fWy32femV5sBtaHR4BkfWcYs%3d&risl=&pid=ImgRaw&r=0&sres=1&sresct=1" width="25%" height="25%">
严格来说,\uXXXX
其实是对 UTF-16
编码的转写。这是一个比较少用的编码格式。我们都知道,UTF-8
是一个变长的编码格式,它编码的基本单位是 1 个字节。受早期 Windows 16位 wchar
的影响,有些人可能会误以为UTF-16
是定长的 2 个字节。其实并不然,对于大于 65535 的 unicode 码点,UTF-16
使用 4 个字节编码,而 JSON 只需要将编码后的两个半字(half world)按顺序使用 \uXXXX
转写出来就可以了。
对 JSON 具体需要转义的字符,以及 UTF-16
的相关内容,笔者之前也写过一篇文章专门说明,欢迎移步。
ASCII 控制字符
按理说,JSON 只应该承载可见字符。但是按照 JSON 的规范,JSON 承载的是 unicode,而 ASCII 控制字符也是 unicode 的一部分,所以 JSON 也是可以承载 ASCII 控制字符的。
其实这个问题并不大,即便把这些控制字符原封不动地包装在 JSON 序列化之后的数据流中,对端也是可以正确解析出来的。大家要注意的是,如果带控制字符的话,数据渲染到终端时,某些控制字符可能不会被渲染出来。如果此时你从终端复制一段数据,在粘贴到别处,这些字符可能就都丢失了。
老生常谈的浮点数
精度问题
众所周知,在许多语言的内部处理逻辑中,带小数部分的数字是使用浮点数来处理的。对于小数部分无法被 2 除尽的十进制数,系统(为了照顾 “你们人类”)而使用二进制浮点数的近似值来表示。
具体到 JSON 中,坑在哪里?其实吧这里不算是 JSON 的坑,而是一个通用的问题。我简单提一下吧:
首先我们知道,对很多强类型语言来说,浮点数往往可以细分为单精度和双精度两种,前者使用 4 个字节,后者使用 8 个字节。单精度在有效位数方面比双精度数小一大截,但是在具体实践中,考虑到数据传输、计算效率、数值范围,往往单精度就足矣。
这个时候,如果一个浮点数在系统内部经过各种不同精度的转换之后,在转换成 JSON 时会有什么问题呢?我们来考虑一下的过程:
- 一个十进制精确定点数值
2.1
- 使用单精度浮点数表示,
f = float32(2.1)
- 调用某些接口,可能接口本身是不支持单精度数,因此转成了双精度处理
d = float64(f)
- 将这个双精度数填入一个结构体并且格式化为 JSON 小数输出
此时,我们会得到什么数字呢?根据不同的语言,输出可能会不同。如果不指定精度的话,很多 JSON 编码库是支持根据浮点数的具体数值,猜测并且格式化为一个最接近的十进制小数。以 Go 为例子,我们会发现通过 JSON 输出的时候,这个 2.1
变成了 2.0999999046325684
。
这在本质上,是因为单精度数经过一次类型转换为双精度后,其二进制有效位数以零填充,转为十进制时,对于双精度浮点数,这就不再是双精度有效数字下的 2.1
了。
换句话说,开发者们在处理浮点数时,需要考虑不同精度浮点数的精度处理差异,特别是金融相关的数据计算和传输,一不小心就会造成大量的对账错误。
特殊浮点数
前文提及,JSON 明确说明不支持 +/-Inf 和 NaN 这两组在 IEEE 754 中规定的特殊数值。但有一些数学运算库,在计算之后会将奇点输出为 +/-Inf 或 NaN,对于很多 JSON 编码库来说,遇到这种数值会导致整个数据编码失败。因此开发者需要针对这种情况特殊处理。我开发的 jsonvalue 中就有这样的一个专题。毕竟是笔者在实际操作中趟过的坑……
有顺序的 K-V
在 JSON 规范中,明确强调 array 类型的子值顺序的重要性(这很好理解)。
但是针对 object 类型,key 的顺序则未提及。在实际操作中我发现不少应用场景中把 object 的 K-V 也当作有序数据来操作了——这在很多自己使用代码简单拼接 JSON 串的场景中,出乎意料地很常见。
还请各位明确注意:JSON 的 object,我们应当默认它是无序的。如果需要传递一系列有序的 KV 对,那么请务必使用 array 类型,不要再用 object 了,这绝对不是一个通用的做法。
在这一点上,我自己也犯过一个很低级的错误:
JSON 数据的幂等检查和数据校验
年少无知的我有一次设计过一个模块,接收上游发来的各种事件信息。为了确保事件都被处理,因此当下游响应不及时时,上游可能会将同一事件重复发出。此时我需要对事件进行幂等计算,确保同一事件不会被重复处理。
一开始我这是简单对上游数据进行 hash 计算。但是在实际运作了一段时间,出了 bug,而原因也很简单,我们看看下面两段数据:
{"time":1601539200,"event_id":10,"openid":"abcdefg"}
{"event_id":10,"time":1601539200,"openid":"abcdefg"}
这两段数据仅仅是 key 的顺序不同而已,但如果使用上面的逻辑,这数据根本就是一模一样的!我们永远要注意:如果我们没有明确地与上游约定好的话,那么请永远不要对上游做任何假设;即便使用文档约束,也依然要多多检查各种例外情况。
结果怎么解决?约束上游?这岂不是显得我能力不行嘛(狗头,主要是不想让上游知道我的 bug 这么 low),所以我在我自己这边简单对解析出来的 key 排序之后(反正 key 不多且无嵌套),再重新计算 hash 来解决。
结语
本文从 JSON 标准出发,结合自己的一些工作经验,整理了 JSON 编解码过程中的一些坑和注意点。如果本文有谬误,还请不吝指正;如果读者还遇到了其他的坑,也欢迎补充。
此外,如果读者中有 Go 开发者的话,也欢迎了解一下我的 jsonvalue 库,点个 star 或者给我提 issue 都非常欢迎~~
参考资料
JSON 相关资料:
- Python JSON模块解码中文的BUG
- 既然 GB18030 可以是 Unicode 的 UTF 转写,为什么中国境内不强制使用该字符集?
- Go json 踩坑记录
- axgle/mahonia
- golang:gbk/gb18030编码字符串与utf8字符串互转
- GB 18030 根上跟 Unicode 有关系吗?
- 细说:Unicode, UTF-8, UTF-16, UTF-32, UCS-2, UCS-4
- Unicode
- UTF-16
- GB 18030
本文章采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。
原作者: amc,原文发布于云+社区,也是本人的博客。欢迎转载,但请注明出处。
原文标题:《JSON 这么可爱,让我们用千字短文吃透它吧!》
发布日期:2022-10-21
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。