API 响应、配置文件、翻译文件等 JSON 格式的数据,经常需要比较。可是如果直接对两段 JSON 做文本比较,常常会出现数据明明相同却报出一大堆差异的情况。本文说明其原因和解决方法。
为什么会出现假差异
JSON 标准(RFC 8259)把对象定义为“无序的名称/值对集合”。也就是说,{"a":1,"b":2} 和 {"b":2,"a":1} 表示的是同一份数据。但作为文本,两者的行顺序不同,diff 就会把它当作差异。此外,下列差异也与数据本身无关:
- 缩进宽度(2 个空格、4 个空格、制表符)以及压缩成一行的 JSON
- 冒号后是否有空格(
"a":1与"a": 1) - 数字写法(
1.0与1,1e2与100) - 字符串转义(
"\u00e9"与"é")
解决办法:先解析,再按同样的格式重新输出
最可靠的方法是把两段 JSON 解析成数据,再按同样的规则重新序列化:
- 把两段文本解析为 JSON。如有语法错误,先修正。
- 递归地对所有对象的键进行排序。
- 用相同的缩进(如 2 个空格)重新输出。
- 按行比较输出结果。
例如,下面两段 JSON 的键顺序和数组顺序都不同。
{"name":"kim","age":30,"tags":["a","b"]}
{"age":30,"name":"kim","tags":["b","a"]}
排序并重新输出后,键顺序的差异消失了,只剩下真正的差异——数组顺序。
@@ -2,7 +2,7 @@ "age": 30, "name": "kim", "tags": [- "a",- "b"+ "b",+ "a" ] }
不要对数组排序
与对象不同,数组的顺序是有意义的。 ["a","b"] 和 ["b","a"] 是不同的数据。因此,规范化时原则上不对数组排序。不过,如果是标签列表这类在业务上顺序并不重要的数组,比较前自行排序会让结果更易读。哪种做法正确,取决于数据的含义。
解析会改变的东西
先解析再重新输出的方法有一些值得注意的副作用:
- 数字写法会被统一。
1.0和1都会变成1,看起来相同。这通常是好事,但如果写法本身很重要,也请比较一下原始文本。 - 大整数的精度。 JavaScript 用 64 位浮点数表示数字,因此大于
Number.MAX_SAFE_INTEGER(2^53 − 1 = 9007199254740991)的整数无法精确表示。例如9007199254740993解析后会变成9007199254740992。当 JSON 中用数字存放很长的 ID 时,请注意这一点。 - 重复的键。 RFC 8259 只建议(SHOULD)对象内的名称保持唯一,出现重复键时如何处理因实现而异。JavaScript 的
JSON.parse会保留最后一个值。 - 注释和末尾逗号。
// 注释这样的注释和[1, 2,]这样的末尾逗号都不属于标准 JSON(JSON5、JSONC 等扩展格式允许)。标准解析器会报错。
在命令行中操作
如果安装了 jq,可以用 -S(--sort-keys)选项输出按键排序后的结果。
jq -S . before.json > a.json
jq -S . after.json > b.json
diff -u a.json b.json
与 JSON Patch 的区别
diff 的结果是供人阅读的、按行列出的变更清单。而 JSON Patch(RFC 6902)是用 JSON 结构中的路径来表示变更的标准,例如 {"op":"replace","path":"/age","value":31};JSON Merge Patch(RFC 7396)则是把只含变更部分的 JSON 覆盖到原数据上的方式。程序之间交换变更时适合用这些标准,人工审查时则适合用排序后的文本 diff。
在本工具中试一试
在 文本对比工具 的选项中开启 JSON 键排序,就会先解析两侧内容、对键排序、以 2 个空格缩进重新输出,然后再比较。数组顺序保持不变;解析失败时,会提示是哪一侧、第几个字符出错。也可以一次拖入两个 .json 文件。把结果当作 patch 阅读的方法,请参阅 unified diff 阅读指南。