← 記事一覧

2 つの JSON ファイルを比較する方法: アルゴリズムとツール

プレーンテキスト diff はキー並び替えや空白ノイズを見落とす。適切な JSON diff の仕組み: LCS 行 diff、意味的なツリー比較、キー正規化、各アプローチのトレードオフを学ぶ。

二つの JSON レスポンスはほとんど同じに見えるのに、デプロイの合間に何かが変わってテストが落ちる。あるいは大きな設定ファイルに触れる PR をレビューしていて、どの値が動いたのか正確に知りたい。JSON ファイルの比較は単純に聞こえますが —— 素朴なテキスト diff はキーの並び替えも変更扱いしますし、深く入れ子になった構造にはもっと賢いアプローチが必要だと気づくまでは。本記事では、まともな JSON diff を成立させているアルゴリズムを説明します。

なぜ素のテキスト diff では足りないか

二つのファイルを比較する最も簡単な方法は diff を走らせるか行単位で比べることです。プレーンテキストにはこれで十分。JSON では少なくとも二つの点で壊れます。

問題 1:キーの順序

JSON のオブジェクトは仕様上順不同です。次の二つのドキュメントは意味的に同一です:

// ドキュメント A
{ "name": "Ada", "plan": "pro", "active": true }

// ドキュメント B
{ "active": true, "name": "Ada", "plan": "pro" }

行単位のテキスト diff は全行を変更として報告します。まともな JSON diff は差分ゼロを報告します。

問題 2:書式のノイズ

インデント、空白、長い配列を 1 行で書くか複数行で書くか —— これらはどれもデータには無関係です。テキスト diff は空白の差をすべて変更として扱います。

両方の問題への解決策は同じです:先にパースし、構造を diff する。テキストを diff するのではない。

ステップ 1 —— パースと正規化

比較に入る前に、両方のドキュメントを JSON.parse() で JavaScript オブジェクトにパースし、キーをソートして一貫したインデントで再シリアライズします:

function normalise(json) {
  const value = JSON.parse(json);
  return JSON.stringify(sortKeys(value), null, 2);
}

function sortKeys(value) {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value !== null && typeof value === 'object') {
    return Object.keys(value)
      .sort((a, b) => a.localeCompare(b))
      .reduce((acc, key) => {
        acc[key] = sortKeys(value[key]);
        return acc;
      }, {});
  }
  return value;
}

正規化後、上のドキュメント A と B はどちらも同じ文字列を生成します:

{
  "active": true,
  "name": "Ada",
  "plan": "pro"
}

これで二つの正規化済み文字列のテキスト diff は、書式やキー順序のノイズではなく、実際のデータ差だけをハイライトします。

ステップ 2 —— LCS による行レベル diff

正規化済みテキストで問題はこうなります:二つの行列が与えられたとき、一方をもう一方に変えるための最小の変更集合(挿入と削除)を見つける。これは古典的な最長共通部分列(LCS)問題です。

LCS とは

二つの列の LCS は、両方に同じ順序で現れる最長の部分列のことで、連続している必要はありません。例:

Before: ["  active: true", "  name: Ada",   "  plan: pro" ]
After:  ["  active: true", "  name: Ada",   "  plan: team"]

LCS:    ["  active: true", "  name: Ada"]   // 共通の 2 行

// 結果:
//   same:    "  active: true"
//   same:    "  name: Ada"
//   deleted: "  plan: pro"
//   added:   "  plan: team"

LCS は欲しい diff そのものを与えてくれます:同じだった行、削除された行、追加された行。

DP アルゴリズム

LCS は動的計画法で解きます。長さ mn の二つの配列に対し、二次元表を作り、dp[i][j] を「前の配列の最初の i 要素」と「後ろの配列の最初の j 要素」の LCS 長とします:

// ボトムアップで DP 表を埋める
for (let i = m - 1; i >= 0; i--) {
  for (let j = n - 1; j >= 0; j--) {
    dp[i][j] = before[i] === after[j]
      ? dp[i + 1][j + 1] + 1                    // 行が一致
      : Math.max(dp[i + 1][j], dp[i][j + 1]);   // 良い方の分岐を取る
  }
}

// 表をたどって diff 操作を復元
let i = 0, j = 0;
while (i < m && j < n) {
  if (before[i] === after[j]) {
    ops.push({ type: 'same', line: before[i++] }); j++;
  } else if (dp[i + 1][j] >= dp[i][j + 1]) {
    ops.push({ type: 'del', line: before[i++] });
  } else {
    ops.push({ type: 'add', line: after[j++] });
  }
}

時間計算量:O(m × n)。空間計算量:O(m × n)。一般的な JSON ドキュメントには十分速い。超大規模なドキュメント(例えば各 2000 行を超えるなど)では、この表は相当のメモリを消費します —— その規模では Myers の diff アルゴリズム(O(n + d²)d は差の数)に切り替える価値があります。

メモリ最適化:型付き配列

普通の JavaScript の 2 次元数値配列は要素ごとのオーバーヘッドが大きい。フラットな Int32Array にするとメモリはおよそ 8 倍少なくなり、キャッシュ局所性も向上します:

const W  = n + 1;
const dp = new Int32Array((m + 1) * W); // フラットなバッファ

// dp[i][j] には dp[i * W + j] でアクセス
dp[i * W + j] = before[i] === after[j]
  ? dp[(i + 1) * W + (j + 1)] + 1
  : Math.max(dp[(i + 1) * W + j], dp[i * W + (j + 1)]);

500 行のドキュメントなら、この表はヒープオブジェクト約 2 MB から連続した型付きメモリ約 1 MB に下がります。

ステップ 3 —— 隣接する変更を「修正」行にペアリング

生の LCS 出力は削除と追加しか知りません。しかし並列 diff ビューでは、置き換えた追加行と組にした削除行を「修正」行として表示するほうが見た目が良くなります:

// LCS の生の操作:
del  "  plan: pro"
add  "  plan: team"

// ペアリング後:
modified  left: "  plan: pro"   right: "  plan: team"

ペアリングのアルゴリズムは連続する deladd 操作のブロックを集めて互いに紐付けます。組にならない削除は右側に空のプレースホルダ、組にならない追加は左側に空のプレースホルダ:

// 連続する del/add ブロックを集める
const dels = [], adds = [];
while (ops[i].type !== 'same') {
  if (ops[i].type === 'del') dels.push(ops[i].line);
  else                        adds.push(ops[i].line);
  i++;
}

// 修正ペアに zip する
const pairs = Math.min(dels.length, adds.length);
for (let k = 0; k < pairs; k++)
  rows.push({ type: 'modified', left: dels[k], right: adds[k] });

// 残った削除
for (let k = pairs; k < dels.length; k++)
  rows.push({ type: 'deleted', left: dels[k] });

// 残った追加
for (let k = pairs; k < adds.length; k++)
  rows.push({ type: 'added', right: adds[k] });

ステップ 4 —— サマリーのための意味 diff

行 diff は「どのテキストが変わったか」を教えてくれます。意味 diff は「どのフィールドがどう変わったか」を教えてくれます —— 「3 件追加、1 件削除、2 件変更」のようなサマリーに有用です。

意味 diff はパース済みの両オブジェクトを再帰的に同時に歩きます:

function diffValue(key, path, before, after) {
  if (before === undefined) return { status: 'added',   after  };
  if (after  === undefined) return { status: 'removed', before };

  if (isObject(before) && isObject(after)) {
    const keys     = union(Object.keys(before), Object.keys(after)).sort();
    const children = keys.map(k =>
      diffValue(k, path + '.' + k, before[k], after[k])
    );
    return {
      status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
      children,
    };
  }

  if (Array.isArray(before) && Array.isArray(after)) {
    const len      = Math.max(before.length, after.length);
    const children = Array.from({ length: len }, (_, i) =>
      diffValue(i, path + '[' + i + ']', before[i], after[i])
    );
    return {
      status: children.every(c => c.status === 'unchanged') ? 'unchanged' : 'changed',
      children,
    };
  }

  return deepEqual(before, after)
    ? { status: 'unchanged', before, after }
    : { status: 'changed',   before, after };
}

得られた木を歩いて状態別に葉ノードを数えると、サマリー数値が得られます。行 diff と違って、意味 diff は構造上キー順序とは無関係 —— JSON 内での出現順に関係なく、同じキーパスでの値を比較します。

全部つなげる

完全な JSON diff ツールは次のステップをつなぎます:

function jsonDiff(leftText, rightText) {
  // 1. パース
  const leftVal  = JSON.parse(leftText);
  const rightVal = JSON.parse(rightText);

  // 2. 意味 diff → サマリー数値
  const summary = summarise(diffValue('root', '
#x27;, leftVal, rightVal)); // 3. 正規化(キーをソート、一貫したインデント) const leftNorm = JSON.stringify(sortKeys(leftVal), null, 2); const rightNorm = JSON.stringify(sortKeys(rightVal), null, 2); // 4. LCS 行 diff const rows = buildLineDiff(leftNorm, rightNorm); return { summary, rows }; }

Deep-Equal と構造 diff

よくある近道は二つの JSON ドキュメントを deepEqual(Node の assert.deepStrictEqual、Lodash の isEqual)で比べることです —— 速いが、真偽だけ。差があることは教えてくれますが、どこでどう 違うかは教えてくれません。

構造 diff は両方の木を歩き、レポート(件数、パス、前後値)を生成し、任意で JSON Patch も出せます。答えが Yes/No でいいときは deepEqual を使う —— テストやキャッシュ無効化など。人間が差に基づいて行動する必要があるときは構造 diff を使う。

diff から JSON Patch を生成

意味 diff の木があれば、追加/削除/変更のノードを数えるのと同じ走査で JSON PatchRFC 6902)を出力できます —— 「前」ドキュメントを「後」へ変える可搬な add / remove / replace 操作のリストです。それが diff を HTTP PATCH リクエストのボディとして出荷したり、後で別の場所で適用したりする方法です。

function toJsonPatch(node, pointer = '') {
  const ops = [];
  if (node.status === 'added')   ops.push({ op: 'add',     path: pointer, value: node.after });
  if (node.status === 'removed') ops.push({ op: 'remove',  path: pointer });
  if (node.status === 'changed' && !node.children) {
    ops.push({ op: 'replace', path: pointer, value: node.after });
  }
  for (const child of node.children ?? []) {
    const seg = String(child.key).replace(/~/g, '~0').replace(/\//g, '~1');
    ops.push(...toJsonPatch(child, pointer + '/' + seg));
  }
  return ops;
}

パスセグメントは JSON Pointer (RFC 6901) の規則に従います —— キー中の ~/ はそれぞれ ~0~1 にエスケープすることに注意。fast-json-patch のようなライブラリも同じものを生成してくれます。HTTP 越しに送るときは application/json-patch+json という content type を使ってください。null が削除を意味する、よりシンプルな「上書き」型の代替については JSON Patch と JSON Merge Patch を参照。

知っておくべきエッジケース

  • 配列要素の並び替え —— 意味 diff は配列を位置で比較するので([0][0])、バージョン間で配列の並びが違うと、データが同じでも全要素が「変更」に見えます。これをうまく扱うには配列レベルでの LCS が必要で、複雑さが大きく増します。
  • とても大きいドキュメント —— O(m × n) の LCS 表は数万行のドキュメントでメモリを使い果たす可能性があります。実用的なヒューリスティック: m × n が閾値(例:200 万)を超えたら、完全な diff を計算せず全行を変更として表示することにフォールバックする。
  • 数値精度 —— JSON.parse() は数値をすべて IEEE 754 倍精度に変換します。非常に大きな整数(2⁵³ 超)は静かに精度を失うので、大きな整数の末尾数桁だけ違う二つのドキュメントはパース後等価と比較される可能性があります。

よくある質問

二つの JSON ファイルをどう比較しますか?

両方をパースし、正規化(キーソート、一貫したインデント)してから、テキスト diff ではなく構造 diff を走らせる —— あるいは両方を JSON Diff に貼り付けてください。これがそれをやって、色分けされた並列ビューを見せてくれます。

なぜ素のテキスト diff は JSON で動かないのですか?

JSON オブジェクトは順不同で空白は無意味だからです。テキスト diff はキーの並び替えや書式変更を変更として扱いますが、JSON 対応の diff は両方を無視し、実際のデータ差だけを報告します。

意味 JSON diff とは何ですか?

パース済みの両構造を歩き、順序に関係なく同じキーパス上の値を比較し、「3 件追加、1 件削除、2 件変更」のような件数を出すもの。構成上キー順序に依存しません。

JSON の比較で数値精度を失う可能性は?

はい —— JSON.parse() は数値を IEEE 754 倍精度に変換するので、2⁵³ を超える整数は末尾数桁が異なっても等価と比較されることがあります。RFC 8785 の正規化規則が関連問題に対処します。

JSON Diff ツールを試す

JSON Diff は fixjson.org 上で上記すべてを実装しています:両ドキュメントをパースし、ソート済みキーで正規化、LCS 行 diff を走らせ、追加・削除・変更を色分けした並列ビューで表示 —— さらに追加・削除・変更・未変更のフィールド数を示すサマリー行も表示します。YAML ドキュメントもサポート。すべてブラウザ内で実行され、データは一切サーバーに送信されません。