发布于 更新于
格式化只是简单的那一半。真正有用的是:知道 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 解析就会被舍入到最近的可表示值,然后以这个错误的数字重新序列化。整条链路上没有任何环节报告问题,只是这条记录指向了另一行数据。修复要在上游做:把标识符序列化成字符串。如果你只是要看文档,那就看原始文本,不要看解析结果。
输入
{"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;一份排版完美的文档,照样可能被它本来要发往的接口拒绝。