JSON 这么可爱,让我们用千字短文吃透它吧!

2022-10-20 11:46:20 浏览数 (1)

JSON,一个伟大的协议,前端工程师的卓越发明!相信 99% 的程序员都认识 JSON,它作为前后端交互的热门协议,因其易理解、简单、灵活和超强的可读性,得到了互联网的广泛欢迎,甚至很多微服务之间的传输协议中也得到应用。

但是笔者在开发一个 Go 的 JSON 编解码库的过程中,除了自己趟过各种奇奇怪怪的问题之外,也认识到广大程序员们对 JSON 各种奇奇怪怪的用法和使用姿势。在处理解决这些问题之后,笔者萌生了对 JSON 进行进一步科普和介绍的想法。

相信我,看完这篇文章,你就可以吃透这个可爱的 JSON 了。

这不是万字长文,所以答应我,不要 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 也有特殊字符需要转义,最显而易见的就是双引号 "、反斜杠 、换行符 nr
  • 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 这个类型,但是 truefalse 被并列为单独的两个类型
  • 作为最外层的 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) 就可以呢?

如果你这么做,那么作为一个通用库来说……

严格来说,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 都非常欢迎~~


参考资料

  • 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-20

原文链接:https://cloud.tencent.com/developer/article/2137440

0 人点赞