JSON 对比只有在比较契约明确后才有意义。文本 diff 回答的是“哪些字符发生了移动”,结构化 diff 可以回答“哪个对象成员新增了、哪个值改变了、哪个数组元素移动了”。这两类问题不同,API 契约、配置审查和数据迁移也不一定需要同一种答案。

核心要点

  • 先解析两个文档再比较;空白和对象成员顺序不是 JSON 数据本身。
  • null、缺失成员、false0 和空字符串视为不同状态。
  • JSON 对象是无序的名称集合,数组默认是有序序列。“忽略数组顺序”是业务策略,不是普遍真理。
  • 使用 JSON Pointer 风格路径,并转义成员名中的 ~/;点号路径存在歧义。
  • 在生成 diff 前决定数字、重复键、Unicode 规范化、时间戳、易变 ID 和敏感字段的处理方式。
  • 在线处理是数据流决策。除非核实实现和网络路径,否则不能推断一定是纯客户端或不留存。

结构化 Diff 比较什么

JSON 数据模型包含对象、数组、字符串、数字、布尔值和 null。结构化比较器通常报告:

变化 含义
add 右侧文档存在而左侧不存在的成员或数组值
remove 左侧文档存在而右侧不存在的成员或数组值
replace 两侧位置都存在但值不同
move/copy 可选操作,需要明确身份和数组策略

对象成员顺序通常不影响语义相等。数组默认是序列,因此顺序会影响结果。如果应用把数组当成带 id 的集合,应根据有文档记录的 schema 规则规范化后再比较,不要给任意数组排序后就宣称等价。

先定义比较契约

选择界面或库之前先写清楚:

  1. 根类型: 两侧必须都是对象,还是允许比较任意 JSON 值?
  2. 对象策略: 键是否按集合比较,重复名称是否拒绝?
  3. 数组策略: 按顺序、按集合,还是按稳定身份字段匹配?
  4. 数字策略: 比较 JSON 数字文本、解析后的 IEEE-754 值,还是业务十进制定点值?
  5. 规范化: 是否转换或忽略 Unicode、换行、时间戳、自动生成 ID 和密钥字段?
  6. 输出: 人类可读树、JSON Pointer 操作、JSON Patch(RFC 6902)还是领域报告?
  7. 限制: 最大字节数、深度、成员数、数组长度和 diff 输出大小是多少?

JSON 语法允许不同实现对重复成员名有不同处理;许多解析器只保留最后一个值。应拒绝重复键,或使用明确支持该策略的解析器,否则比较可能静默丢失输入。

在线、本地与托管流程

在线比较器对非敏感片段很方便,但必须核实隐私声明。确认输入是否发送到服务器,是否进入日志、分析、缓存、第三方检查或崩溃报告;同时检查输入上限,以及粘贴的密钥是否留在浏览器历史、存储或剪贴板中。

对于凭据、个人信息、生产配置或受监管记录,优先使用本地流程或获批准的受控服务。比较前脱敏或令牌化,注意 diff 输出本身可能复制敏感值。纯浏览器实现仍然有供应链和页面权限边界。

支持 JSON Pointer 的 JavaScript 示例

下面的示例可以比较任意 JSON 值:对象键按无序集合处理,数组按顺序处理,并区分缺失属性和 JSON null。路径采用 JSON Pointer 转义:~ 转为 ~0/ 转为 ~1

javascript
const hasOwn = (value, key) =>
  Object.prototype.hasOwnProperty.call(value, key);

function pointer(parent, token) {
  const escaped = String(token).replaceAll("~", "~0").replaceAll("/", "~1");
  return parent === "" ? `/${escaped}` : `${parent}/${escaped}`;
}

function compareJson(left, right, path = "") {
  const changes = [];

  if (Object.is(left, right)) return changes;
  if (left === null || right === null ||
      typeof left !== "object" || typeof right !== "object") {
    return [{ op: "replace", path, from: left, to: right }];
  }

  if (Array.isArray(left) || Array.isArray(right)) {
    if (!Array.isArray(left) || !Array.isArray(right)) {
      return [{ op: "replace", path, from: left, to: right }];
    }
    const length = Math.max(left.length, right.length);
    for (let index = 0; index < length; index += 1) {
      const itemPath = pointer(path, index);
      if (index >= left.length) {
        changes.push({ op: "add", path: itemPath, value: right[index] });
      } else if (index >= right.length) {
        changes.push({ op: "remove", path: itemPath, value: left[index] });
      } else {
        changes.push(...compareJson(left[index], right[index], itemPath));
      }
    }
    return changes;
  }

  const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
  for (const key of [...keys].sort()) {
    const itemPath = pointer(path, key);
    if (!hasOwn(left, key)) {
      changes.push({ op: "add", path: itemPath, value: right[key] });
    } else if (!hasOwn(right, key)) {
      changes.push({ op: "remove", path: itemPath, value: left[key] });
    } else {
      changes.push(...compareJson(left[key], right[key], itemPath));
    }
  }
  return changes;
}

这是教学用的有序数组 diff,不是完整的最小编辑脚本。生产实现仍应校验输入限制,决定数字语义,限制递归深度,并避免在 from/to 字段中返回密钥。

Python 与命令行流程

Python 的 dict.get() 无法区分缺失键和取值为 None 的键,应使用 sentinel 或支持显式存在性语义的库。下面提供一个文本无关的基线:对象键排序后序列化,但不决定数组策略。

python
import json
from pathlib import Path


def canonical(value):
    return json.dumps(
        value,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    )


left = json.loads(Path("left.json").read_text(encoding="utf-8"))
right = json.loads(Path("right.json").read_text(encoding="utf-8"))
if canonical(left) == canonical(right):
    print("equal under object-key sorting and ordered arrays")
else:
    print("different; use a structural diff for paths and operations")

使用 jq 时,排序对象键可以去除展示层面的差异:

bash
jq -S . left.json > left.canonical.json
jq -S . right.json > right.canonical.json
diff -u left.canonical.json right.canonical.json

规范化不是安全证明,也不一定符合 RFC 8785 JSON Canonicalization Scheme。如果签名、哈希或互操作性依赖规范字节,应使用明确指定的规范化方案和版本。

数组需要业务语义

考虑:

json
{"items": [{"id": "a", "value": 1}, {"id": "b", "value": 2}]}

如果 items 是排序列表,两个元素交换就是变化。如果它是由唯一 id 标识的记录集合,比较器可以按 id 匹配,独立于顺序报告字段变化。策略还必须定义重复 ID、缺失 ID 和重复值是否有意义。按字符串化 JSON 排序不能安全替代身份匹配。

常见对比场景

API 响应验证

比较期望 schema 和选定字段,而不一定比较每个易变值。把契约失败(缺失字段或类型错误)与合法数据变化分开。存储失败 diff 前脱敏令牌、个人信息和密钥。

配置审查

如果服务实际消费的是继承和默认值合并后的配置,就比较生效配置;同时保留原文件 diff,因为规范化可能掩盖重要源代码变化。密钥字段应只标记发生变化,不打印内容。

数据迁移

除了原始结构 diff,还要比较数量、标识符、类型、可空性和领域不变量。大型数据应流式读取或比较分区摘要,不要一次把两份文档全部载入内存。

版本控制

使用稳定格式化器和有文档的数组策略减少审查噪声。JSON diff 不是合并策略,冲突仍需要领域知识解决并重新验证。

JSON Diff、JSON Patch 与 JSON Merge Patch

  • Diff 是变化报告,操作词汇和路径格式可能由工具自定义。
  • JSON Patch(RFC 6902) 是由 addremovereplacemovecopytest 等操作组成的有序序列,使用 JSON Pointer 定位。
  • JSON Merge Patch(RFC 7396) 使用部分对象,并把 null 作为删除信号,因此无法表达所有 JSON 值,也无法表达所有业务层面的 null 语义。

应根据消费者选择格式,并测试将结果应用到左侧文档后是否得到预期右侧文档。对人类清晰的 diff 不一定适合自动应用。

校验与资源限制

比较前验证两个输入。限制最大字节数、解码深度、对象成员数、数组长度、操作数和输出大小。拒绝 NaN 与 Infinity,因为它们不是标准 JSON 值。把解析错误、重复键、深度超限和截断流分别处理。

对于不可信或超大输入,使用流式或有界解析器,并尽量避免平方级算法。除非有明确允许列表,不要让 diff 接口抓取任意远程 URL,否则可能形成 SSRF 和数据外泄路径。

常见问题

JSON 对比时对象键顺序重要吗?

对 JSON 数据模型的相等性而言,对象成员顺序不重要。文本 diff 仍可能因为行移动显示变化。数组不同:除非有明确的业务规则,否则数组顺序重要。

null 和缺失字段相同吗?

不同。存在且为 null 表示生产者明确选择了一个值;缺失可能表示未知、不适用、兼容性省略或使用默认值。schema 或比较契约必须决定这些状态如何解释。

可以安全地忽略数组顺序吗?

只有当应用把数组当作无序集合,并且有稳定身份或重复项策略时才可以。给任意数组排序可能隐藏有意义的重排,也会让重复元素产生歧义。

在线 JSON diff 工具适合比较密钥吗?

不要默认适合。应核实实际数据流和留存政策;对于凭据和受监管数据,使用本地或获批准的受控处理。生成或存储 diff 前先脱敏。

结构化 diff 会自动生成有效 JSON Patch 吗?

不会自动生成。工具必须使用 JSON Pointer 路径、合法操作语义和可安全应用的顺序。应把 patch 应用于源文档,并验证结果与目标文档一致。

一手来源

总结

可靠的 JSON 对比从语义开始,而不是从按钮开始。先解析,明确数组和数字策略,保留缺失与 null 的区别,输出无歧义路径,限制不可信输入,并根据数据敏感度选择本地或托管流程。好的 diff 应解释真实变化,而不是悄悄改变文档含义。