比對兩份 JSON 檔案:差異比較指南
為何文字 diff 對 JSON 不可靠,以及如何做結構性比對——鍵順序獨立、陣列識別、JSON Patch 輸出。
兩份在文字 diff 看起來不同的 JSON 可能語意完全相同;兩份看起來相同 的卻可能在某個陣列裡藏著差異。JSON 的樹狀結構意味著有用的 diff 必 須作用在 解析後的結構 上,而非文字。本文解釋為何文字 diff 失敗、 結構 diff 怎麼運作,以及陣列策略——這是即使結構 diff 也會分歧的地方。
文字 diff 為何失敗
git diff 與 diff -u 是行為基礎的文字 diff,告訴你兩串位元組不同。
對 JSON 它在兩處失敗:
鍵順序。JSON 物件無序,但 JSON.stringify 按插入順序輸出。兩份
正確、含相同資料的 payload 可能序列化為不同位元組:
{"a": 1, "b": 2}
{"b": 2, "a": 1}
文字 diff 會說兩行都變了;結構 diff 會說「相同」。
格式。不同縮排、結尾換行、非 ASCII 是否跳脫、冒號後是否有空格—— 都會改變位元組而不改變語意。空白面向請見 壓縮與美化 JSON。
文字 diff 不算錯,它在回答 不同問題。「位元組相同嗎」與「JSON 語意相同嗎」不是同一個問題。
結構 diff:依鍵遞迴比對
結構 diff 平行走訪兩棵樹:
- 若兩值都是物件,比較鍵集合。對每個鍵遞迴比對對應值。只在 A 中的鍵記為 removed、只在 B 中的鍵記為 added。
- 若兩值都是陣列,平行走訪(陣列策略見下節)。
- 若兩值都是純量,直接比對。
- 若任何節點型別不同,記為 type changed。
給定:
// A
{ "name": "Ada", "age": 36, "city": "London" }
// B
{ "name": "Ada", "age": 37, "country": "UK" }
有用的 diff 會說:
age: 36 → 37 (changed)
city: "London" (removed)
country: "UK" (added)
…而不是「文字輸出兩行不同」。
Added / Removed / Changed
三種變更類型是所有結構 JSON diff 的基礎,並對應到標準 diff 格式:
- Added——B 有、A 無。
- Removed——A 有、B 無。
- Changed——兩邊都有但值不同。(型別改變時有時細分為「type changed」。)
當變更值本身是物件或陣列,可選擇發一筆「changed」帶整個新子樹, 或遞迴進去發細項變更。細項幾乎都比較有用。
陣列:依索引或依身份
陣列是最難的情況,三種合理策略:
依索引(by index)。A[0] 對 B[0]、A[1] 對 B[1]。便宜。
陣列短且位置有意義時可用(矩陣、RGB 三元組)。一插入或刪除頭部元素
就災難——後續每個索引都「變了」。
[1, 2, 3] → [0, 1, 2, 3]
依索引:每個元素都變、加一個新元素。人類看則只是頭部插入一個。
依 LCS(最長共同子序列)。diff 處理文字用的演算法。找出最長
匹配子序列,其餘視為插入與刪除。能合理處理上面的情況。成本是 O(n·m)。
依身份鍵。以 id 欄位(或自選鍵)配對。當陣列是無序紀錄集時最佳。
需要呼叫者指定鍵:
// A
[{ "id": 1, "name": "Ada" }, { "id": 2, "name": "Alan" }]
// B
[{ "id": 2, "name": "Alan" }, { "id": 1, "name": "Ada Lovelace" }]
依索引:兩個元素都變了。依 id:id=1 的 name 變了、id=2 沒變。
依 id 的結果幾乎總是人類想要的。
JSON Diff 工具 允許每次 diff 選擇陣列策略; 預設 LCS,可選擇以指定 id 欄位配對。
diff 前的正規化
執行 diff 前先把兩邊正規化,免得意外差異被當成變更:
- 遞迴排序物件鍵。「語意是否改變」的 diff 視
{a,b}與{b,a}為相同。 - 美化。兩邊一致縮排。
- 要不要正規化字串內的空白? 不要——字串內容有意義,別動。
- 數字格式正規化。
1.0與1在 JavaScript 中是同值;有些 diff 視為相同(因字串形式不同)、有些視為不同。請決定要哪種。
格式化加 diff 的組合流程是 format(A) → format(B) → diff,正是
/zh-Hant/json/formatter + /zh-Hant/json/diff
這對工具做的事。
JSON Patch 與 JSON Merge Patch
程式化用途的兩種標準 diff 輸出:
JSON Patch(RFC 6902)是操作序列:add、remove、replace、
move、copy、test。上面範例的 patch:
[
{ "op": "replace", "path": "/age", "value": 37 },
{ "op": "remove", "path": "/city" },
{ "op": "add", "path": "/country", "value": "UK" }
]
這是 PATCH endpoint 該用的格式。
JSON Merge Patch(RFC 7396)較簡單:長得像目標文件,以 null
表示移除:
{
"age": 37,
"city": null,
"country": "UK"
}
易讀,但無法表達「把欄位設為 null」(因為 null 代表刪除)。給人類
編輯的 diff 選 Merge Patch、機器生成的選 JSON Patch。
動手比
把兩份 payload 貼進 JSON Diff 工具。它預設做 結構 diff(鍵順序獨立)、標示 added / removed / changed 節點,並可 匯出 JSON Patch。
延伸閱讀
- JSONPath 詳解——以 JSONPath 將 diff 範圍縮到某子樹。
- 常見 JSON 語法錯誤——當其中一份文件無法解析時。