JSON 指南

JSON 格式化、压缩与校验完全指南

一份可以直接查阅的 JSON 参考:语法边界、如何把解析器给出的偏移量还原成你打错的那个字符、数字和字符串在哪里悄悄丢信息,以及为什么合法的 JSON 仍然会被接口拒绝。

格式化只是简单的那一半。真正有用的是:知道 JSON 语法到底允许什么,能把解析器报的字节偏移量翻译成你打错的那个字符,以及清楚文档中哪些部分经过一次「解析再序列化」就回不来了。这三件事本文都会讲清楚,并附上你本来要去 RFC 8259 里翻的那几张表。

JSON 的语法比 JavaScript 小得多

JSON 大约在 2001 年从 JavaScript 的对象字面量语法中切出来,后来被标准化了两次:ECMA-404 和 RFC 8259。这个子集小得是有意为之的。值只有六种类型 —— 对象、数组、字符串、数字、`true`/`false`、`null` —— 字符串只有一种引号写法,除此之外什么都没有。没有注释,没有尾随逗号,没有不加引号的键,也没有 `undefined`、`NaN`、`Infinity`、日期和正则。

几乎每一份被报成「JSON 无效」的文档,都是 JavaScript 引擎会毫无怨言接受的东西。这是最值得内化的一点:如果你是从浏览器控制台、编辑器配置文件或者 Python 的 `repr` 里复制出来的结构,那它是 JavaScript 或 Python 的对象字面量,不一定是 JSON 文档。让它过一遍严格解析器,是一次真实的检查,不是走过场。

右列每一种写法都是合法的 JavaScript、非法的 JSON。这张表本身就能解决绝大多数解析失败。

语法元素合法 JSON严格解析器会拒绝的写法
对象键{"name": "Ada"}{name: "Ada"}
字符串引号{"city": "Oslo"}{"city": 'Oslo'}
尾随分隔符[1, 2, 3][1, 2, 3,]
注释根本没有这个语法{"a": 1} // note
前导零{"n": 7}{"n": 007}
裸小数点{"n": 0.5}{"n": .5} 或 {"n": 1.}
显式正号{"n": 1}{"n": +1}
非有限数字{"v": null}{"v": NaN} 或 {"v": Infinity}
缺失的值直接不写这个键{"v": undefined}
字节序标记第一个词法单元前不能有 BOM`{` 之前有一个 U+FEFF

格式化改变了什么,又没碰什么

美化输出会在词法单元之间插入换行和缩进,并在每个名值分隔符后加一个空格。压缩则删掉每一个无意义的空白字节。这两个操作的定义完全落在「词法单元之间的空白」上,所以格式化版本和压缩版本解析出的结构完全相同 —— 唯一的例外是数字,下面会讲。

有必要说清楚格式化不做什么。它不会给键排序,书写顺序会被原样保留;但规范并不要求消费方尊重这个顺序,所以永远不要把正确性建立在键序上。它不改变类型:写成 `"42"` 的值仍然是字符串。它不按 schema 校验文档,所以一份排版完美的文档,完全可能缺了接收方要求的每一个字段。它也不修复任何东西 —— 格式化器要么解析成功,要么直接拒绝。

看偏移量,不要看错误信息

解析器的诊断信息在结构上就有误导性,这不是因为解析器写得差。解析器报告的是「它遇到了一个不该出现在这里的东西」的位置。它无法报告你真正写错的位置,因为在你多敲那个逗号的瞬间,文档仍然与好几种合法的后续保持一致。

所以实用规则是:拿到报告的偏移量,然后去看它前面的那几个字符。下面的例子里解析器指向偏移量 35,也就是那个右方括号,但缺陷在偏移量 34 的逗号上。

剩下的失败基本上是两种形状。未闭合的字符串会让解析器把文档剩下的部分全部吞掉,于是错误出现在缺失引号的几百行之后 —— 在格式化视图里,这表现为一个长得离谱的高亮字符串。缺失的右花括号则会在输入末尾报错,或者在下一个不能继续构成对象的词法单元处报错。两种情况的做法一样:把文档格式化到最后一个看起来正常的块,然后从紧随其后的内容开始找。

从字节偏移量定位尾随逗号
输入(单行,偏移量从 0 开始计数):

{"id":42,"tags":["beta","internal",],"owner":{"name":"Ada"}}
 0123456789...                     ^34 ^35

严格解析器会在偏移量 35 处报错 —— 也就是 ']' 这个字符,
因为 ']' 不能作为一个值的开头,而那个位置必须出现一个值。

真正的错误在偏移量 34:"internal" 后面那个逗号。

不同运行时的措辞不同,偏移量是一致的:
  V8            Unexpected token ] in JSON at position 35
  Python        Expecting value: line 1 column 36 (char 35)
  Go            invalid character ']' looking for beginning of value

Python 和很多解析器报的列号是从 1 开始的,所以 column 36
和 offset 35 指的是同一个字符。数之前先确认错误信息用的是哪种约定。

数字是 JSON 唯一会悄悄丢数据的地方

JSON 的数字语法是带可选小数部分和指数部分的十进制文本,对量级和精度都不设限。规范明确说明:为了互操作,实现方不应期待超过 IEEE 754 双精度的能力;而实际上几乎所有解析器都直接产出 double。数据就是在这个落差里无声无息地消失的,全程不报任何错。

最具体的威胁是 64 位标识符。Snowflake ID、Twitter/X 的状态 ID、Discord 的 snowflake,以及大多数 `bigint` 主键,都超过 2^53 - 1(9007199254740991)。按 double 解析就会被舍入到最近的可表示值,然后以这个错误的数字重新序列化。整条链路上没有任何环节报告问题,只是这条记录指向了另一行数据。修复要在上游做:把标识符序列化成字符串。如果你只是要看文档,那就看原始文本,不要看解析结果。

一次经过 JavaScript 引擎的往返
输入
{"id": 9007199254740993, "ratio": 0.1, "big": 1e400, "zero": -0, "amount": 4.50}

经过 JSON.parse() 再 JSON.stringify() 之后
{"id":9007199254740992,"ratio":0.1,"big":null,"zero":0,"amount":4.5}

  id      被向下舍入:2^53 + 1 无法用 double 精确表示
  ratio   完好:会打印回最短的可往返十进制表示
  big     1e400 溢出为 Infinity,stringify 把它输出成 null
  zero    -0 是合法的 double,但 stringify 打印为 0
  amount  尾随零不属于值本身,只属于文本

这五项里,只有一项会抛出你能捕获的东西。

字符串、转义与编码

JSON 字符串是用双引号包起来的 Unicode 码点序列。有两个字符必须永远转义 —— 双引号和反斜杠 —— 并且 U+0020 以下的每一个控制字符都必须转义,用短形式或 `\u` 序列都可以。其余字符都可以字面出现,包括正斜杠:它有一个合法的 `\/` 转义,但从来不是必需的。

基本多文种平面之外的字符(比如 emoji)可以用 UTF-8 字面写出,也可以写成一对 `\u` 代理项转义。两种都合法,解析结果是同一个字符串。RFC 8259 要求系统间交换的 JSON 使用 UTF-8,而字节序标记不属于语法的一部分 —— 对开头带 U+FEFF 的字符串调用 `JSON.parse` 会抛异常,这在文件被 Windows 编辑器保存过之后是一个常见又让人困惑的故障。

字符转义是否必需
双引号"始终必需
反斜杠\\始终必需
正斜杠\/从不必需 —— 合法但可选
退格 U+0008\b必需(或写 `\u0008`)
换页 U+000C\f必需(或写 `\u000c`)
换行 U+000A\n必需 —— 字面换行是解析错误
回车 U+000D\r必需
制表符 U+0009\t必需
其他 U+0000–U+001F\u00XX必需
emoji、中日韩、任意 BMP 及以上字符UTF-8 字面或一对 `\u`从不必需

重复键和其他未定义行为

RFC 8259 说对象内的名字「应该」唯一 —— 是建议,不是要求 —— 接着又说,实现收到重复键时的行为是不可预测的。这段规范写得异常诚实,它的含义是:`{"a": 1, "a": 2}` 没有确定的结果。

实际上多数实现保留最后一次出现的值:JavaScript、Python 的 `json`、Go 的 `encoding/json` 都是这样。但也有解析器保留第一个,有的把所有值收集成列表,还有的直接拒绝文档。安全研究反复利用的正是这个缺口:代理和源站对「看到的是哪个值」产生分歧。如果你是用程序生成 JSON,永远不要指望后面的键覆盖前面的 —— 在序列化之前先去重。

合法不等于正确

格式化器能证明的只是「这份文档可以被解析」。它无法告诉你这份文档就是接收方想要的那一份。这是两个不同的问题,需要不同的工具。如果你的 JSON 格式化得干干净净、请求却返回 400,那问题几乎一定出在结构而不是语法:缺了必填属性、数字以字符串形式传来、枚举值拼错了,或者该传数组的地方传了嵌套对象。

结构问题的标准答案是 JSON Schema。它能表达必填属性、类型、枚举、字符串模式、数值范围和条件约束,每种主流语言都有校验器。如果接口发布了 OpenAPI 描述,里面的 schema 就是 JSON Schema,可以直接拿来校验你的载荷。这样得到的是对照契约的真实通过/失败,而不只是对照语法。

  • 语法问题:能不能解析?用格式化器或校验器。
  • 结构问题:符不符合契约?用 JSON Schema 或 OpenAPI 文档。
  • 抽取问题:这个路径上是什么?用 JSONPath 或 `jq`。
  • 对比问题:两份载荷差在哪?先各自格式化,再做 diff。

要点回顾

  • 凡是带注释、尾随逗号、单引号或不加引号键的内容,都应当当作 JavaScript 对象字面量而不是 JSON,要有意识地转换,而不是去猜解析器为什么报错。
  • 解析失败时先读报告的偏移量,再检查它前面的几个字符:解析器只能指出「本应出现值」的位置,永远指不出多余分隔符敲在了哪里。
  • 把 64 位标识符和金额序列化成字符串,因为 JSON 数字要经过 IEEE 754 双精度,大整数和小数尾随零都会在不报错的情况下丢失。
  • 序列化前先对对象键去重:规范把重复键的处理留成了未定义,同一条请求链路上的不同解析器可能对「哪个值生效」产生分歧。
  • 语法用格式化器,结构用 JSON Schema;一份排版完美的文档,照样可能被它本来要发往的接口拒绝。

继续了解相关检查与工具