JSON Diff 是变化模型,不只是高亮界面。它必须决定对象成员顺序是否重要、数组如何匹配、路径如何编码、哪些值可以展示,以及结果只是报告还是可执行补丁。不同领域可以有不同的合理答案。

核心要点

  • 先解析 JSON;空白和对象成员顺序不应制造语义变化。
  • 使用 JSON Pointer 路径并转义成员名。键名含点号、斜杠或方括号时,点号路径会产生歧义。
  • 数组必须选择策略:按位置、基于 LCS 的编辑匹配,或按领域键匹配。“智能匹配”不是通用语义真理。
  • Diff 报告不自动等于有效 JSON Patch,必须测试补丁应用并验证结果文档。
  • LCS 和嵌套匹配可能成本很高;对不可信输入限制深度、节点数、数组长度和操作输出。
  • 对 diff 输出中的密钥脱敏,为动态字段定义规范化,并保留足够溯源信息以复现比较。

结构化 Diff 产生什么

解析后,比较器遍历两份 JSON 值并输出:

操作 含义
add 右侧存在、左侧不存在的值
remove 左侧存在、右侧不存在的值
replace 两侧位置都存在但值不同
move 已有数组或对象值改变位置
copy 在另一个位置复制值
test 应用补丁前必须满足的前置条件

后三项是 JSON Patch 操作,并非所有人类可读 diff 都必须使用。报告可以有更简单的词汇。

比较契约

选择算法前先记录:

  1. 对象键是否无序,解析时是否拒绝重复键?
  2. 数组按顺序、按集合,还是按唯一字段(如 id)匹配?
  3. 数字按解析值、词法表示,还是业务十进制类型比较?
  4. null、缺失、空字符串和默认值是否不同?
  5. 时间戳、生成 ID、签名或密钥值是否需要规范化或脱敏?
  6. 输出是给人审查、审查系统,还是自动补丁消费者?
  7. 输入和输出的资源限制是什么?

没有这份契约,不同工具可能对同一文档产生不同但内部一致的结果。

对象比较

对象可以通过合并两侧键集合、按稳定顺序遍历并递归比较匹配键的值来处理。应使用 own-property 检查,避免运行时原型链伪装成 JSON 数据。

路径格式很重要。JSON Pointer(RFC 6901)把 ~ 转为 ~0/ 转为 ~1,并使用 / 作为分隔符,因此含 a/ba.b 的键都能无歧义表达。

数组匹配算法

按位置比较

比较左右两侧相同索引的元素,结果可预测且成本低,但开头插入一个元素可能让后续所有元素看起来都改变。它适合元组、有序优先级列表和时间序列。

最长公共子序列

LCS 可以识别有序序列中的插入和删除。经典动态规划实现对长度为 nm 的数组需要 O(n × m) 时间和空间;优化版本会用内存换时间,且不同的平局处理可能产生不同结果。LCS 需要相等或相似判断,而外观相同的对象不一定拥有相同身份。

按键匹配

用稳定的领域键(如 id)匹配对象,再递归比较匹配记录。必须定义重复键、缺失键、类型变化以及记录移动是否有意义。不要猜测键,也不要把任意对象字符串化后当作身份证明。

集合式比较

把数组当集合会丢弃顺序,也可能丢弃重复次数。只有业务契约明确如此并定义重复处理时才成立。按序列化文本排序不是通用集合算法。

有界 JavaScript Diff 示例

下面的示例执行确定性的对象比较、按位置比较数组、JSON Pointer 转义和节点预算。它有意不是最小 LCS 或按键匹配实现,生产代码必须明确选择策略。

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

const escapePointer = (token) =>
  String(token).replaceAll("~", "~0").replaceAll("/", "~1");

const childPath = (path, token) =>
  `${path}/${escapePointer(token)}`;

function diffJson(left, right, path = "", state = { nodes: 0, limit: 10000 }) {
  state.nodes += 1;
  if (state.nodes > state.limit) throw new RangeError("diff node limit exceeded");
  if (Object.is(left, right)) return [];

  const leftObject = left !== null && typeof left === "object";
  const rightObject = right !== null && typeof right === "object";
  if (!leftObject || !rightObject || Array.isArray(left) !== Array.isArray(right)) {
    return [{ op: "replace", path, from: left, to: right }];
  }

  if (Array.isArray(left)) {
    const changes = [];
    const length = Math.max(left.length, right.length);
    for (let index = 0; index < length; index += 1) {
      const location = childPath(path, index);
      if (index >= left.length) {
        changes.push({ op: "add", path: location, value: right[index] });
      } else if (index >= right.length) {
        changes.push({ op: "remove", path: location, value: left[index] });
      } else {
        changes.push(...diffJson(left[index], right[index], location, state));
      }
    }
    return changes;
  }

  const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort();
  const changes = [];
  for (const key of keys) {
    const location = childPath(path, key);
    if (!own(left, key)) {
      changes.push({ op: "add", path: location, value: right[key] });
    } else if (!own(right, key)) {
      changes.push({ op: "remove", path: location, value: left[key] });
    } else {
      changes.push(...diffJson(left[key], right[key], location, state));
    }
  }
  return changes;
}

这段代码不负责解析 JSON、脱敏值、按身份匹配数组,也不保证操作可以按顺序应用。这些是独立契约,不应藏在递归函数里。

JSON Patch 与应用安全

JSON Patch(RFC 6902)定义了使用 JSON Pointer 定位的有序操作序列。生成的补丁必须应用到左侧文档,再与目标右侧文档比较。数组索引尤其敏感:删除前面的索引会改变后续索引含义,因此操作顺序很重要。

需要前置条件时使用 test,并把补丁应用视为带授权、大小限制、审计记录和失败处理的状态变更。不能仅因为模型或界面把补丁标记为“安全”就直接应用生产配置。

JSON Merge Patch(RFC 7396)语义不同:对象补丁中的 null 可以表示删除,因此无法表达所有业务层面的 null 值。没有核对消费者契约时,不能用它替代 JSON Patch。

应用场景

API 回归测试

比较稳定投影或带 Schema 的响应,而不一定比较每个易变时间戳、请求 ID 或签名。保存脱敏 diff,并将变化分类为契约失败、预期数据变化或格式噪声。

配置审查

涉及默认值或继承时,同时比较源配置和生效配置。语义 diff 说明实际变化,源 diff 解释变化原因。密钥字段只标记发生变化,不复制到报告。

数据同步

Diff 可以减少同步载荷,但接收方仍需要版本检查、授权、幂等、冲突处理和拒绝过期补丁的能力。补丁更小不代表更安全。

版本控制与迁移

使用确定性遍历和有文档的数组策略减少审查噪声。迁移时验证不变量,并记录源版本、Schema 版本、Diff 策略和应用结果。

性能与资源控制

比较不可信文档前限制字节数、嵌套深度、对象成员、数组长度、节点访问、候选匹配、操作数量和序列化输出。平方级 LCS 或全对全对象匹配可能形成拒绝服务向量。

对于大型文档,尽可能流式处理,比较分区记录,或在明确规范化策略下使用哈希。哈希可以检测相同字节或相同规范化值,不能解释语义差异,也不能证明两个实体代表同一现实对象。

脱敏与溯源

Diff 往往同时暴露旧值和新值,即使源文件受保护,产物也可能敏感。持久化前执行字段级脱敏,避免记录完整 payload,并确保脱敏不会意外改变决策语义。记录:

  • 源标识和版本;
  • 解析器、比较器和策略版本;
  • 规范化与脱敏规则;
  • 限制和失败状态;
  • 输出哈希、审核人或操作者、时间和补丁应用结果。

常见问题

为什么 JSON Diff 会把整个数组标成变化?

比较器可能按位置比较,或没有稳定的身份策略。开头插入元素会移动后续索引。只有当领域假设适用时,才使用 LCS 或按键匹配。

LCS 总是最佳数组算法吗?

不是。LCS 适合有序序列,但可能成本高,也不了解业务身份。元组适合按位置比较,有唯一 ID 的记录通常更适合按键匹配。

JSON Patch 总能产生最小变化吗?

不能。最小性取决于 Diff 算法、数组策略、平局处理和操作成本模型。更短的补丁也可能更难审查或更危险。

Diff 工具能安全比较密钥吗?

只有在明确的数据流和脱敏策略下才可以。更稳妥的做法是比较指纹或存在性/类型元数据,不把原始值写入日志和持久化产物。

可以自动应用生成的 Diff 吗?

只有在验证补丁对应的源版本、授权目标、执行限制,并测试结果文档后才可以。人类可读 diff 不是自动副作用安全的证明。

一手来源

总结

严肃的 JSON Diff 实现必须明确语义、身份、路径、限制、脱敏和补丁应用。选择与领域匹配的最简单算法,测量最坏情况,并针对预期源版本验证每个可执行变化。Diff 应帮助理解和受控同步,而不是制造新的歧义或副作用。