跳至內容

比對兩份 JSON 檔案:差異比較指南

為何文字 diff 對 JSON 不可靠,以及如何做結構性比對——鍵順序獨立、陣列識別、JSON Patch 輸出。

兩份在文字 diff 看起來不同的 JSON 可能語意完全相同;兩份看起來相同 的卻可能在某個陣列裡藏著差異。JSON 的樹狀結構意味著有用的 diff 必 須作用在 解析後的結構 上,而非文字。本文解釋為何文字 diff 失敗、 結構 diff 怎麼運作,以及陣列策略——這是即使結構 diff 也會分歧的地方。

文字 diff 為何失敗

git diffdiff -u 是行為基礎的文字 diff,告訴你兩串位元組不同。 對 JSON 它在兩處失敗:

鍵順序。JSON 物件無序,但 JSON.stringify 按插入順序輸出。兩份 正確、含相同資料的 payload 可能序列化為不同位元組:

{"a": 1, "b": 2}
{"b": 2, "a": 1}

文字 diff 會說兩行都變了;結構 diff 會說「相同」。

格式。不同縮排、結尾換行、非 ASCII 是否跳脫、冒號後是否有空格—— 都會改變位元組而不改變語意。空白面向請見 壓縮與美化 JSON

文字 diff 不算錯,它在回答 不同問題。「位元組相同嗎」與「JSON 語意相同嗎」不是同一個問題。

結構 diff:依鍵遞迴比對

結構 diff 平行走訪兩棵樹:

  1. 若兩值都是物件,比較鍵集合。對每個鍵遞迴比對對應值。只在 A 中的鍵記為 removed、只在 B 中的鍵記為 added
  2. 若兩值都是陣列,平行走訪(陣列策略見下節)。
  3. 若兩值都是純量,直接比對。
  4. 若任何節點型別不同,記為 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.01 在 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)是操作序列:addremovereplacemovecopytest。上面範例的 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。

延伸閱讀