如何比较两个 JSON 文件并查看改动内容

比较两个 JSON 文件最快的方法,是把两者都粘贴进一个并排显示的 diff 工具, 用同样的方式格式化,然后阅读它高亮出的行。难点通常不在比较本身, 而在噪音:重新排序的键、不同的缩进、多余的尾随逗号, 都会让两个几乎相同的文件看起来毫无共同之处。

本指南会讲解如何得到干净、可信的 diff。我们会看看 JSON 文件为什么在字面上 渐行渐远、语义上却完全一致,有哪几种值得了解的方法,以及一个你可以跟着做的 完整实例。如果你只想要工具,我们的 JSON 比较页面可以在浏览器里完成这一切。

为什么 JSON 文件比看上去更难比较

JSON 的语法小而严格(参见 json.org 上的规范), 但它在文本排布上给了书写者很大自由。两个文件可以描述完全相同的对象, 逐字节看却并不一致。纯文本 diff 对此一无所知,于是会尽职地把这些全部标记出来。

开始之前有一点需要先记牢:JSON 中的对象键没有顺序。规范 (RFC 8259) 把对象定义为一组无序的名称/值对。所以 {"name":"Ada","id":7}{"id":7,"name":"Ada"} 是相等的,尽管按行 diff 会把它们涂成红色和绿色。

看着像改动,其实通常不是
你在 diff 里看到的是真正的改动吗?该怎么做
键的顺序不同不是,对象是无序的对两侧都排序键
2 空格缩进 vs 4 空格缩进不是用同样方式格式化两侧
压缩版 vs 美化版不是格式化两侧
文件末尾的换行符不是忽略,或去除空白字符
某个值从 "7" 变成了 7是,字符串 vs 数字需要排查,这是真改动
数组元素顺序不同可能是,数组确实有序判断顺序在这里是否重要

最后一行常让人栽跟头。数组保持顺序,对象则不然。所以 [1, 2, 3][3, 2, 1] 是真的不同, 但对象内部的键可以随意调换位置。如果你想深入了解 JavaScript 如何解析这一切, MDN 上有一份扎实的 JSON 对象参考文档

比较 JSON 的四种方法,以及各自适用的场合

没有哪一种方法是绝对最好的。这取决于文件放在哪里,以及你想弄清楚什么。 下面是几种常见选项的对比。

方法适合投入理解 JSON 吗?
用肉眼看很小的文件,一两个字段不,你就是解析器
在线 diff 工具快速检查,随处粘贴配合格式化 + 排序键,可以
命令行(jqdiff磁盘上的文件、脚本、超大文件先排序的话,可以
IDE 或 git diff已在仓库中的文件已提交的话很低默认按行比较

对大多数人来说,浏览器工具在速度上胜出,因为无需安装, 而且你可以直接从日志或 API 调用里粘贴一段内容。代价是格式噪音, 我们接下来就处理它。如果你常驻终端, jq 是值得学的工具,我们会展示其中最关键的那个参数。

最快得到干净比较结果的步骤

每当有人扔给我两个配置文件并问"有什么不一样?"时,我就是这么做的。 大约十五秒就能完成。

  1. 打开 JSON 比较工具
  2. 把原始版本粘贴到左侧,新版本粘贴到右侧。
  3. 点击两侧的 Format,让它们使用相同的缩进。
  4. 开启 排序键(规范化),这样重新排序的键就不会再显示为改动。
  5. 阅读结果。绿色表示新增,红色表示删除,而改动的值会各显示一次。

第三步和第四步就是全部诀窍。一旦两个文件格式完全一致、键也排好序, 剩下被高亮出来的就只有真正的改动了。我们的 diff 引擎构建在 Google 的 diff-match-patch 之上,它会先逐行比较,所以即使文件很长也依然很快。

一个完整实例

假设你正在审查一处对用户记录的改动。这是改动前:

{
  "name": "Ada Lovelace",
  "role": "editor",
  "active": true,
  "seats": 3
}

这是同事交给你的改动后版本:

{
  "active": true,
  "name": "Ada Lovelace",
  "role": "admin",
  "seats": 5,
  "team": "platform"
}

把它们丢进原始的按行 diff,看起来几乎每一行都动过,因为键的顺序不同。 把两侧都格式化并排序之后,真实情况其实很简单:

真正改动的内容
字段改动前改动后改动
roleeditoradmin已修改
seats35已修改
teamplatform已新增
nameAda LovelaceAda Lovelace无改动
activetruetrue无改动(只是移动了位置)

三处真实修改:角色提升、席位数变化,以及一个新增的 team 字段。 重新排序只是噪音。从 editor 提升到 admin 正是你在审查时想要抓住的那类改动,而当它被埋在二十行误报之下时,很容易被漏掉。

在命令行上消除格式噪音

如果文件已经在磁盘上,同样的"格式化并排序"思路用两条简短的命令就能实现。 -S 参数让 jq 对对象键排序;用管道处理会把两个文件都归一化, 这样普通的 diff 才诚实:

jq -S . old.json > old.sorted.json
jq -S . new.json > new.sorted.json
diff old.sorted.json new.sorted.json

现在 diff 只会报告真正改变的值,因为两个文件有着相同的缩进 和相同的键顺序。这就是在浏览器里点击 Format 和排序键的终端等价操作。

文本 diff vs 结构化 diff

上面讲的全都是文本 diff:快速、直观,非常适合人来阅读一处改动。 结构化 diff 更进一步,它把改动描述成数据。这方面的标准是 JSON Patch, 定义于 RFC 6902, 它把编辑表达为在指定路径上的 replaceadd 之类的操作。当需要由程序来应用这处改动、而不只是人用眼睛看时,你才需要结构化 diff。 对日常审查来说,一个排好序键的文本 diff 已经绰绰有余。

需要留意的常见陷阱

陷阱为什么会出问题解决办法
数字精度大于 2^53 − 1 的整数在 JavaScript 中会丢失精度把大 ID 当作字符串比较;参见 MAX_SAFE_INTEGER
Unicode 转义"café""café" 是同一个字符串,字节却不同格式化两侧,这会归一化编码
重复的键大多数解析器会静默保留最后一个在信任 diff 之前先校验 JSON
尾随逗号不是合法的 JSON;其中一个文件可能根本无法解析先修正语法,校验器会标出问题
字符串 vs 数字"5"5 看着像,类型却不同这是真正的改动,别不当回事

相关工具

JSON 很少是你要打交道的唯一格式。如果你要比较不同环境之间的配置, YAML 比较把同样的思路用在了 YAML 上。 审查两次 API 调用之间的改动,正是 API 响应 diff 的用武之地, 而依赖版本升级则在 package.json diff 页面上最容易读懂。

常见问题

在线比较 JSON 文件会把它们上传到什么地方吗?
在 comparetext.org 上,diff 在你的浏览器里运行。两个 JSON 文件由你自己机器上的 JavaScript 完成比较,所以除非你明确点击保存或分享,否则不会有任何内容被发送到服务器。这让它可以安全地用于配置文件、API 响应,以及其他你不愿粘贴到某个每敲一次键就上传一次的随便什么网站上的数据。
为什么我的两个 JSON 文件每一行都显示为不同?
几乎总是格式问题,而不是真正的改动。可能一个文件是压缩过的、或用制表符缩进,另一个用两个空格;也可能是对象键的顺序不同。点击两侧的 Format 让它们使用相同缩进,然后排序键,让顺序不再有影响。这之后,diff 通常会缩小到少数几处真正改变的值。
我该如何在比较 JSON 时忽略键的顺序?
JSON 对象的键没有定义顺序,所以 {"a":1,"b":2}{"b":2,"a":1} 是相等的。要让文本 diff 也认同这一点,比较前先对两侧的键排序。在浏览器里,使用规范化(排序键)选项。在命令行上,jq 可以做到:jq -S . file.json。一旦两个文件的键都按相同顺序排好,就只有真正的值改动会显示出来。
我能比较很大的 JSON 文件而不让页面卡死吗?
可以,但有限度。按行模式的 diff 在数千行的文件上依然很快,因为它先比较整行而不是每个字符。非常大的文件(好几 MB)更适合用 jq 或 git diff 这类命令行工具处理,它们会流式读取数据。只要是你能在浏览器里舒服滚动浏览的内容,在线 diff 就是更快的选择。
JSON 的文本 diff 和结构化 diff 有什么区别?
文本 diff 逐行比较文件,就像比较两篇文章一样。结构化 diff 理解 JSON,所以它知道重新排序的键不算改动,也知道数组内部移动位置的值是一次移动,而不是一次删除加一次新增。文本 diff 更快,对大多数审查场景已经足够好。结构化 diff(例如遵循 RFC 6902 的 JSON Patch)则在你需要把改动描述成程序可以应用的数据时才重要。
我该如何比较两个 API 响应?
把每个响应保存成文件,或从浏览器的网络面板复制出来,然后把旧响应粘贴到左侧、新响应粘贴到右侧。格式化两者让缩进一致,如果该 API 返回的键顺序不稳定,再排序键。api-response-diff 工具正是为此调校的:找出被重命名的字段、变化的状态码,或者两次调用之间从字符串变成数字的值。

准备好试试了吗?把你的文件粘贴进 JSON 比较工具,看看改动了什么。