JSON配置比较别只比字符串:递归实现与8个边界测试
JSON 配置比较:先确定语义,再写递归
部署前比较两份 JSON 配置时,直接比较原始文本会把缩进、换行和键顺序也算作变化。把文本解析后再逐个节点比较,可以把注意力放回真正的配置变更。本文给出一个不依赖第三方库的教学实现,并列出我实际运行的边界测试。示例与说明由 AI 辅助整理,参考实现已在本地 Node.js 执行验证。
1. 先把比较规则说清楚
- 对象忽略成员顺序;数组按索引比较,顺序变化属于变化。
- 缺失字段和字段值为 null 不等价;字符串 “1” 与数字 1 不等价。
- 根节点可以是对象、数组或原始值,类型改变时输出一次整体修改。
- 使用 JSON Pointer 表示位置:根节点用空字符串,名为 a/b 的键编码为 /a~1b,键中的 ~ 编码为 ~0。界面可将根节点另显示为“根节点”,不要把 / 误当成根,因为 / 表示名称为空字符串的成员。
JSON.stringify 的输出顺序稳定,不代表它会为不同插入顺序的对象自动做规范化。因而“两个 stringify 结果相等”不能替代这里的语义比较。
2. 一个可以直接运行的参考实现
将下面内容保存为 json-diff-reference.mjs,使用支持 ES Module 的 Node.js 运行:
node json-diff-reference.mjs
import assert from 'node:assert/strict';
export function compareJsonTexts(leftText, rightText) {
const left = JSON.parse(leftText);
const right = JSON.parse(rightText);
const changes = [];
const kind = v => v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v;
const own = (v, key) => Object.prototype.hasOwnProperty.call(v, key);
const escape = key => key.replace(/~/g, '~0').replace(/\//g, '~1');
function walk(a, b, path) {
if (a === b) return;
const type = kind(a);
if (type !== kind(b) || (type !== 'object' && type !== 'array')) {
changes.push({ type: 'modified', path, before: a, after: b });
return;
}
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
const next = path + '/' + escape(key);
if (!own(a, key)) changes.push({ type: 'added', path: next, after: b[key] });
else if (!own(b, key)) changes.push({ type: 'removed', path: next, before: a[key] });
else walk(a[key], b[key], next);
}
}
walk(left, right, '');
return changes;
}
assert.deepEqual(compareJsonTexts('{"a":1,"b":2}', '{"b":2,"a":1}'), []);
assert.deepEqual(compareJsonTexts('{"db":{"port":3306}}', '{"db":{"port":5432}}'), [{type:'modified',path:'/db/port',before:3306,after:5432}]);
assert.deepEqual(compareJsonTexts('[1,2]', '[1]'), [{type:'removed',path:'/1',before:2}]);
assert.deepEqual(compareJsonTexts('null','{}'), [{type:'modified',path:'',before:null,after:{}}]);
assert.deepEqual(compareJsonTexts('{}','{"x":null}'), [{type:'added',path:'/x',after:null}]);
assert.deepEqual(compareJsonTexts('{"a/b":{"~":1}}','{"a/b":{"~":2}}'), [{type:'modified',path:'/a~1b/~0',before:1,after:2}]);
assert.deepEqual(compareJsonTexts('{"__proto__":1}','{}'), [{type:'removed',path:'/__proto__',before:1}]);
assert.throws(()=>compareJsonTexts('{','{}'), SyntaxError);
console.log('8 semantic JSON diff checks passed');
这里用 Set 合并两侧成员名,再用自有属性检查区分新增和删除。不要使用 if (!value) 判断缺失,否则 0、false、空字符串和 null 都可能被误判。使用 Object.prototype.hasOwnProperty.call,也能处理配置中名为 __proto__ 或 hasOwnProperty 的成员。
3. 测试覆盖的八类边界
参考文件中的断言已全部通过,输出为 8 semantic JSON diff checks passed。测试覆盖对象键重排、嵌套值修改、数组尾部删除、根类型变化、缺失与 null、路径转义、特殊成员名,以及无效 JSON 的 SyntaxError。它们验证的是核心比较语义,不等于已经完成页面交互、性能或安全审计。
例如旧配置 {"db":{"port":3306}} 与新配置 {"db":{"port":5432}} 会产生 /db/port 的修改记录;数组 [1,2] 与 [1] 会产生 /1 的删除记录。数组中间插入会导致后续索引逐项变化,这是按索引比较的定义,不是最短编辑序列。若业务数组包含稳定 id,应另设计按 id 匹配的策略。
4. 做成网页时还需要补什么
输入必须在 try/catch 中解析,错误时保留原文本,并分别提示左侧、右侧的解析错误。输出用户提供的键和值时使用 textContent 或框架的转义绑定,不要拼接到 innerHTML。纯前端工具可把处理限制在本地,并检查日志、统计脚本和保存功能是否也会携带输入内容。
这段递归实现适合小中型配置;极深的嵌套会有调用栈风险,大文件应限制大小/深度,或改为显式栈并移到 Web Worker。JSON.parse 后的超大整数可能已失去精度,精确标识符应采用字符串或专门的无损解析方案。重复键在解析后也无法完整保留,不应把这个工具当作原始 JSON 文本审计器。输出只是差异报告,不是可以直接执行的 JSON Patch。
参考资料
- MDN Web Docs:JavaScript 参考文档,JSON.parse(包括解析错误与数字精度说明)
- MDN Web Docs:JavaScript 参考文档,JSON.stringify(属性遍历顺序说明)
- IETF RFC 6901:JavaScript Object Notation (JSON) Pointer,2013年4月,第3—4节(语法与求值)
- 点赞
- 收藏
- 关注作者
评论(0)