JSON 对比只有在比较契约明确后才有意义。文本 diff 回答的是“哪些字符发生了移动”,结构化 diff 可以回答“哪个对象成员新增了、哪个值改变了、哪个数组元素移动了”。这两类问题不同,API 契约、配置审查和数据迁移也不一定需要同一种答案。
核心要点
- 先解析两个文档再比较;空白和对象成员顺序不是 JSON 数据本身。
- 把
null、缺失成员、false、0和空字符串视为不同状态。 - JSON 对象是无序的名称集合,数组默认是有序序列。“忽略数组顺序”是业务策略,不是普遍真理。
- 使用 JSON Pointer 风格路径,并转义成员名中的
~和/;点号路径存在歧义。 - 在生成 diff 前决定数字、重复键、Unicode 规范化、时间戳、易变 ID 和敏感字段的处理方式。
- 在线处理是数据流决策。除非核实实现和网络路径,否则不能推断一定是纯客户端或不留存。
结构化 Diff 比较什么
JSON 数据模型包含对象、数组、字符串、数字、布尔值和 null。结构化比较器通常报告:
| 变化 | 含义 |
|---|---|
add |
右侧文档存在而左侧不存在的成员或数组值 |
remove |
左侧文档存在而右侧不存在的成员或数组值 |
replace |
两侧位置都存在但值不同 |
move/copy |
可选操作,需要明确身份和数组策略 |
对象成员顺序通常不影响语义相等。数组默认是序列,因此顺序会影响结果。如果应用把数组当成带 id 的集合,应根据有文档记录的 schema 规则规范化后再比较,不要给任意数组排序后就宣称等价。
先定义比较契约
选择界面或库之前先写清楚:
- 根类型: 两侧必须都是对象,还是允许比较任意 JSON 值?
- 对象策略: 键是否按集合比较,重复名称是否拒绝?
- 数组策略: 按顺序、按集合,还是按稳定身份字段匹配?
- 数字策略: 比较 JSON 数字文本、解析后的 IEEE-754 值,还是业务十进制定点值?
- 规范化: 是否转换或忽略 Unicode、换行、时间戳、自动生成 ID 和密钥字段?
- 输出: 人类可读树、JSON Pointer 操作、JSON Patch(RFC 6902)还是领域报告?
- 限制: 最大字节数、深度、成员数、数组长度和 diff 输出大小是多少?
JSON 语法允许不同实现对重复成员名有不同处理;许多解析器只保留最后一个值。应拒绝重复键,或使用明确支持该策略的解析器,否则比较可能静默丢失输入。
在线、本地与托管流程
在线比较器对非敏感片段很方便,但必须核实隐私声明。确认输入是否发送到服务器,是否进入日志、分析、缓存、第三方检查或崩溃报告;同时检查输入上限,以及粘贴的密钥是否留在浏览器历史、存储或剪贴板中。
对于凭据、个人信息、生产配置或受监管记录,优先使用本地流程或获批准的受控服务。比较前脱敏或令牌化,注意 diff 输出本身可能复制敏感值。纯浏览器实现仍然有供应链和页面权限边界。
支持 JSON Pointer 的 JavaScript 示例
下面的示例可以比较任意 JSON 值:对象键按无序集合处理,数组按顺序处理,并区分缺失属性和 JSON null。路径采用 JSON Pointer 转义:~ 转为 ~0,/ 转为 ~1。
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 或支持显式存在性语义的库。下面提供一个文本无关的基线:对象键排序后序列化,但不决定数组策略。
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 时,排序对象键可以去除展示层面的差异:
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。如果签名、哈希或互操作性依赖规范字节,应使用明确指定的规范化方案和版本。
数组需要业务语义
考虑:
{"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) 是由
add、remove、replace、move、copy和test等操作组成的有序序列,使用 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 应用于源文档,并验证结果与目标文档一致。
一手来源
- RFC 8259:JSON 数据交换语法
- RFC 6901:JSON Pointer
- RFC 6902:JSON Patch
- RFC 7396:JSON Merge Patch
- RFC 8785:JSON Canonicalization Scheme
- OWASP:Secrets Management Cheat Sheet
总结
可靠的 JSON 对比从语义开始,而不是从按钮开始。先解析,明确数组和数字策略,保留缺失与 null 的区别,输出无歧义路径,限制不可信输入,并根据数据敏感度选择本地或托管流程。好的 diff 应解释真实变化,而不是悄悄改变文档含义。