DevTool 团队

两份 JSON 哪里不一样:从文本对比到结构对比的实践

为什么普通文本对比看 JSON 满屏假差异?同一个值在 JSON 里有多种写法——键序、空白、转义形态、数字书写形式,按行工作的 diff 工具会全部标红。这篇讲清结构化对比的思路:parse 成语法树后按 JSONPath 报告差异,以及在线工具、jq 规范化、各语言深比较三条路各自的适用边界与坑(数组顺序的语义、签名场景键序不能忽略、大文件的截断)。

先选路:按你的场景挑工具

两份 JSON 用普通文本对比一比就满屏标红,九成不是数据变了——是 JSON 的同一个值有多种合法写法。别再用文本 diff 比 JSON,按场景选路:

你的场景 用什么 为什么
偶尔比对两份配置、接口响应 在线结构化对比(如本站 JSON 对比工具) 贴进去即出带路径的差异清单;浏览器本地计算,敏感配置不用先脱敏
CI / 脚本里把门 jq -S 规范化 + diff -u 键序漂移被抹平,输出稳定可复现,直接贴 PR
自动化测试断言相等 语言标准库深比较 Node / Python / Java 全部键序无关,一行断言搞定;代价是只答 true/false

三条路的共同前提是「先 parse 再比较」。下面讲清楚为什么文本 diff 会骗你、结构化报告怎么读、每条路具体怎么走。

为什么文本 diff 会骗你

同一个 JSON 值至少有四类合法写法,每一类都足以让按行工作的文本对比整行标红:

假差异来源 例子 哪来的
键的顺序 {"a":1,"b":2} vs {"b":2,"a":1} 序列化器键序不稳定:Go 对 map 排序、对 struct 按声明序;Python 3.7+ dict 按插入序;不同版本 Jackson、MongoDB 导出配置都会漂移
空白与缩进 压缩一行 vs 缩进四格 接口返回无空白,人看过的文件被编辑器格式化过——一格式化每行都「变」
转义形态 "你好" vs "\u4f60\u597d" 有的库默认非 ASCII 全转义,有的直接输出原字符
数字书写形式 1000 / 1e3 / 1000.0 解析后同一个数,写法不同

反方向的坑同样存在:把压缩 JSON 格式化后再 diff,行数全变,「差异行数」彻底失去参考意义。

结构化对比报告怎么读

结构化对比先把两份文本 parse 成语法树,再递归比较节点,报告的是带路径的条目:

replace  $.version         "1.4.0" → "1.5.0"
replace  $.retries         3 → 5
add      $.features[2]     "giftcard"
remove   $.limits.maxItems 50

每条三件事:操作(新增/删除/替换)、路径(JSONPath 定位到字段)、两侧的值。四类写法差异在 parse 后天然消失。这种报告可以直接对着 API 文档或配置 schema 读——「version 升到 1.5.0、retries 从 3 改 5」——而不是「第 7 行和第 9 行不一样」。

三个对比选项对应三个真实决策,默认值都是对的,只在明确需要时改:

选项 默认 什么时候改
忽略键序 开 调试 webhook 签名这类按字节计算的协议时关掉,让键序差异显形
忽略数组顺序 关 只在确认数组是集合语义(权限列表、标签)时开——开了 [1,2] 与 [2,1] 判等,用错场景会掩盖真实回归
忽略大小写 关 比较大小写不敏感的配置体系时再开

三条路各自怎么走

在线工具(一次性比对最省事)

以本站 JSON 对比工具 为例:

  1. 左右各贴一份(或各上传文件);
  2. 选项按上表默认走,只在对应场景改;
  3. 结果默认只看差异,分栏左右对照,可切统一视图;
  4. 单条差异可复制,整体结果可下载留存。

两个工程细节:计算全程在浏览器里,数据不出本机——比带敏感字段的响应前不用先脱敏;输入必须是合法 JSON,解析失败会标出哪一侧在什么位置出错,不会把解析错误伪装成海量差异。

jq(CI 与脚本)

jq -S . old.json > /tmp/old.canonical.json
jq -S . new.json > /tmp/new.canonical.json
diff -u /tmp/old.canonical.json /tmp/new.canonical.json

-S 排序所有对象键,空白也由 jq 统一——diff 输出稳定可复现,贴 PR 描述正合适。它抹不掉数组顺序与大小写差异,也不报告「哪个路径上什么操作」:jq 回答「有没有实质变化」,不回答「变了什么」。这个边界正好和在线工具互补。

代码断言(自动化测试)

  • Node.js:util.isDeepStrictEqual(a, b);
  • Python:json.load 之后 a == b(dict 相等天然键序无关);
  • Java:objectMapper.readValue(json, Map.class) 再 assertEquals。

只答 true/false,失败时不告诉你差在哪个路径——所以常见组合拳是 CI 里标准库断言守门,失败时人来在线工具看差异定位。

边界与真话

浏览器结构对比有容量上限。 本站单侧 2MB、差异条目 2 万条封顶——不是随手定的:规范化会让压缩 JSON 膨胀 3~5 倍,内存与渲染按展开后算;渲染不虚拟化时几万行 diff 会拖垮页面。超限会明确提示「已截断」而非装作比完。几十 MB 的导出与全量转储,老实走 jq。

在线工具不是所有场景的最优解。 需要可复现的证据链(PR、CI 门禁、code review)时,jq 规范化输出的文本 diff 比截图或「我在线看过了」可信得多;测试断言场景里,标准库一行代码比打开网页快。在线工具的领地是人要读的差异报告——路径、两侧值、分栏对照。

数组顺序的判断责任在你。 工具不知道 ["read","write"] 是权限集合还是有序流水。顺序参与语义(时间线、优先级队列)就别开忽略;确认集合再开。

常见问题

为什么文本对比工具看 JSON 会报一堆假差异?

因为 JSON 的同一个值有多种合法写法:键的顺序、空白缩进、字符串转义形态(\u4f60 与「你」)、数字书写形式(1e3 与 1000)。文本对比按行工作,任何一类写法差异都让整行标红,哪怕行内的值一个都没变。结构化对比先 parse 成语法树再比较节点,这四类形态差异在解析后就消失了,报告出来的才是值的真实变化。

jq 怎么在命令行里对比两份 JSON?

先用 jq -S 把两份文件规范化(所有对象键按字母序输出),再用 diff -u 比较规范化结果:jq -S . old.json 与 jq -S . new.json 各存一份临时文件后 diff。键序漂移被抹平,输出适合贴进 PR 描述。局限是它本质仍是文本对比——数组顺序、大小写差异抹不掉,也只回答「有没有变」,不擅长报告「具体哪个路径变了」。

键的顺序什么时候不能忽略?

绝大多数场景键序无语义,但存在例外:对 JSON 序列化后的字节串做哈希或签名的协议(webhook 签名验证、部分 API 的请求签名)按实际字节计算——键序不同、值相同的两份 JSON,签名输入并不相同。调试这类问题时应该关闭「忽略键序」选项,让键序差异显形;日常比对配置与接口响应时则保持开启,消除序列化器键序漂移的噪音。

对比结果被截断是什么意思?

结构对比工具给差异条目设了渲染上限(本站为两万条),超过后明确提示已截断,而不是静默丢弃。被截断是一个信号:差异面已经大到逐条阅读没有意义——更有效的做法是先用格式化工具把两份输入规范化缩小差异范围,或者改走 jq 命令行路线看整体统计。