API のレスポンス、設定ファイル、翻訳ファイルなど、JSON 形式のデータを比較する機会はよくあります。ところが2つの JSON をそのままテキスト比較すると、データは同じなのに差分が大量に出ることが少なくありません。この記事では、その理由と解決方法を説明します。
なぜ見かけだけの差分が出るのか
JSON の標準(RFC 8259)は、オブジェクトを「順序のない名前/値のペアの集まり」と定義しています。つまり {"a":1,"b":2} と {"b":2,"a":1} は同じデータを表します。しかしテキストとしては行の順序が違うため、diff は差分とみなします。ほかにも、データとは関係なく次のような違いが生じます。
- インデントの幅(2スペース、4スペース、タブ)や、1行に圧縮された JSON
- コロンの後の空白の有無(
"a":1と"a": 1) - 数値の表記(
1.0と1、1e2と100) - 文字列のエスケープ(
"\u00e9"と"é")
解決策:パースしてから同じ形で書き直す
最も確実な方法は、2つの JSON をパースしてデータにしてから、同じ規則で再びシリアライズすることです。
- 2つのテキストを JSON としてパースします。ここで文法エラーがあれば先に直します。
- すべてのオブジェクトのキーを再帰的にソートします。
- 同じインデント(例:2スペース)で書き直します。
- その結果を行単位で比較します。
たとえば次の2つの JSON は、キーの順序と配列の順序が異なります。
{"name":"kim","age":30,"tags":["a","b"]}
{"age":30,"name":"kim","tags":["b","a"]}
ソートして書き直すと、キー順の違いは消え、本当の違いである配列の順序だけが残ります。
@@ -2,7 +2,7 @@ "age": 30, "name": "kim", "tags": [- "a",- "b"+ "b",+ "a" ] }
配列の順序はソートしない
オブジェクトと違い、配列は順序に意味があります。 ["a","b"] と ["b","a"] は別のデータです。そのため、正規化の際に配列はソートしないのが原則です。ただし、タグの一覧のように業務上順序が重要でない配列なら、比較の前に自分でソートしておくと結果が読みやすくなります。どちらが正しいかはデータの意味しだいです。
パースによって変わってしまうもの
パースしてから書き直す方法には、知っておきたい副作用があります。
- 数値の表記が統一されます。
1.0と1はどちらも1になり、同じに見えます。たいていは望ましいことですが、表記そのものが重要な場合は元のテキストでも比較してみてください。 - 大きな整数の精度。 JavaScript は数値を64ビット浮動小数点数として扱うため、
Number.MAX_SAFE_INTEGER(2^53 − 1 = 9007199254740991)より大きい整数は正確に表現できません。たとえば9007199254740993はパースすると9007199254740992になります。長い ID が数値として入っている JSON では注意してください。 - 重複したキー。 RFC 8259 はオブジェクト内の名前が一意であるべきだと推奨(SHOULD)しているだけで、重複キーの扱いは実装によって異なります。JavaScript の
JSON.parseは最後の値を残します。 - コメントと末尾のカンマ。
// コメントや[1, 2,]のような末尾のカンマは標準の JSON ではありません(JSON5、JSONC といった拡張形式では許されます)。標準のパーサーはエラーを出します。
コマンドラインで行う方法
jq がインストールされていれば、-S(--sort-keys)オプションでキーをソートして出力できます。
jq -S . before.json > a.json
jq -S . after.json > b.json
diff -u a.json b.json
JSON Patch との違い
diff の結果は、人が読むための行単位の変更リストです。一方、JSON Patch(RFC 6902)は {"op":"replace","path":"/age","value":31} のように JSON 構造のパスで変更を表す標準で、JSON Merge Patch(RFC 7396)は変更部分だけを含む JSON を元のデータに上書きする方式です。プログラムどうしで変更をやり取りするならこれらの標準が、人がレビューするならソート後のテキスト diff が適しています。
このツールで試す
テキスト比較ツールのオプションで JSON キーをソートをオンにすると、両方をパースしてキーをソートし、2スペースのインデントで書き直してから比較します。配列の順序は変えず、パースに失敗した場合はどちら側の何文字目でエラーが起きたかを知らせます。.json ファイルを2つまとめてドロップしてもかまいません。結果を patch として読む方法は unified diff の読み方を参照してください。