API レスポンスの新旧を比べたいとき、テキスト差分ツールに貼ると地獄を見る。インデントが違う・キーの順番が違う・改行位置が違う、それだけで全行が「差分あり」になる。本当に知りたいのは「どのキーが増えて、どの値が変わったか」という構造の差分なのに。
JSON を構造レベルで比較する差分ツールをぱんだツールズの1機能として作った。整形やキー順の違いは無視して、キーと値の意味的な差分だけを出す。
この記事では、JSON の構造 diff を再帰で実装する方法と、その過程で出てくる「キー順」「配列」の扱いという2つの判断ポイントを解説する。
テキスト diff と構造 diff の違い
まず前提。テキスト diff(diff コマンドや Git の差分)は行単位の文字列比較。だから次の2つは「全然違う」と判定される。
{"name":"taro","age":20}
{
"age": 20,
"name": "taro"
}
人間にとっては同じ内容だが、テキストとしては別物。構造 diff は、両方を JSON.parse してからキーと値で比較するので、この2つを「差分なし」と正しく判定できる。整形スタイルやキーの並び順に左右されない、というのが存在意義。
キーの和集合を取って分類する
オブジェクト同士の比較は、「左右どちらかに存在する全キー」を和集合で集めて、各キーを5種類に分類していく。
type DiffType = 'added' | 'removed' | 'changed' | 'unchanged' | 'nested'
function computeJsonDiff(left: JsonValue, right: JsonValue, depth = 0): DiffEntry[] {
if (isObject(left) && isObject(right)) {
const allKeys = [...new Set([...Object.keys(left), ...Object.keys(right)])]
const entries: DiffEntry[] = []
for (const key of allKeys) {
const hasLeft = key in left
const hasRight = key in right
if (!hasLeft) {
entries.push({ key, type: 'added', rightValue: right[key], depth }) // 右だけ → 追加
} else if (!hasRight) {
entries.push({ key, type: 'removed', leftValue: left[key], depth }) // 左だけ → 削除
} else if (JSON.stringify(left[key]) === JSON.stringify(right[key])) {
entries.push({ key, type: 'unchanged', leftValue: left[key], depth }) // 同値 → 変更なし
} else if (isObject(left[key]) && isObject(right[key])) {
entries.push({ key, type: 'nested', // 両方オブジェクト → 再帰
children: computeJsonDiff(left[key], right[key], depth + 1), depth })
} else if (isArray(left[key]) && isArray(right[key])) {
/* 配列の扱い(後述) */
} else {
entries.push({ key, type: 'changed', leftValue: left[key], rightValue: right[key], depth })
}
}
return entries
}
// トップレベルがプリミティブ/配列のときの比較は省略
}
new Set([...keys(left), ...keys(right)]) で重複を除いた全キーを得て、key in left / key in right の有無で「追加」「削除」を判定。両方にあれば値を比べる。depth を再帰のたびに +1 して持ち回ることで、後段の表示でネストのインデントを付けられる。
判断1:深い等価判定は JSON.stringify で済ませる(キー順の罠)
「値が同じか」をどう判定するか。オブジェクトや配列をネストまで含めて厳密に比較する deep equal を自前で書いてもいいが、ここでは JSON.stringify(a) === JSON.stringify(b) で済ませている。手軽で、ネストの深さに関係なく一発で比較できる。
ただし JSON.stringify にはキー順に敏感という弱点がある。{"a":1,"b":2} と {"b":2,"a":1} は内容が同じでも、stringify すると文字列が変わって「不一致」と出る。
これが問題にならないのは、分類の順序のおかげ。あるキーの値同士を比べるとき、unchanged 判定(stringify 比較)で外れても、次の「両方オブジェクトなら nested で再帰」のブランチに落ちる。再帰した先では、また和集合でキーを突き合わせるので、キーの順番が違っても各キーは正しくマッチする。たとえば {"user":{"a":1,"b":2}} と {"user":{"b":2,"a":1}} を比べると、user の値は「stringify では不一致 → nested 再帰 → 中の各キーは unchanged」となり、最終的に差分ゼロに落ち着く。
つまり JSON.stringify の雑な等価判定を、再帰がセーフティネットとして補正している。トップで stringify が一致すれば即 unchanged(速い)、外れても再帰で正しく評価される(正確)、という二段構え。手軽さと正しさを両立させた割り切り。
判断2:配列は「インデックスをキーにしたオブジェクト」として扱う
配列の diff は本質的に難しい。要素が1つ挿入されただけで以降が全部ズレるので、厳密にやるなら LCS(最長共通部分列)のようなアルゴリズムが要る。このツールは、そこは割り切ってインデックスをキーにしたオブジェクトに変換し、同じ位置同士で比較している。
} else if (isArray(left[key]) && isArray(right[key])) {
// [a, b, c] を {"0":a, "1":b, "2":c} に変換して位置で比較
const leftObj: JsonObject = {}
const rightObj: JsonObject = {}
const leftArr = left[key] as JsonArray
const rightArr = right[key] as JsonArray
const maxLen = Math.max(leftArr.length, rightArr.length)
for (let i = 0; i < maxLen; i++) {
if (i < leftArr.length) leftObj[String(i)] = leftArr[i]
if (i < rightArr.length) rightObj[String(i)] = rightArr[i]
}
entries.push({ key, type: 'nested',
children: computeJsonDiff(leftObj, rightObj, depth + 1), depth })
}
配列を {"0": ..., "1": ...} というオブジェクトに変換してしまえば、あとはオブジェクト比較の再帰にそのまま乗せられる。短い方を超えたインデックスは「片方にしか無いキー」になるので、自動的に added / removed として扱われる。コードが増えないのが利点。
トレードオフは明確で、これは位置ベースの比較。配列の先頭に要素を挿入すると、以降の全要素が「変更」と判定される。「同じ要素が移動した」は検出できない。だが API レスポンスや設定ファイルの比較という用途では、位置ベースで十分実用になる。LCS を実装する複雑さに見合わない、という判断。割り切りを明示しておくのが大事で、「配列はインデックス対応で比較」と FAQ にも書いてある。
集計も再帰で
差分の件数(追加 N・削除 M・変更 K)も、ツリーを再帰でたどって数える。nested に当たったら子の集計を足し込むだけ。
function countDiff(entries: DiffEntry[]): DiffSummary {
const summary = { added: 0, removed: 0, changed: 0 }
for (const entry of entries) {
if (entry.type === 'added') summary.added++
else if (entry.type === 'removed') summary.removed++
else if (entry.type === 'changed') summary.changed++
else if (entry.type === 'nested' && entry.children) {
const child = countDiff(entry.children) // 子を再帰集計して合算
summary.added += child.added
summary.removed += child.removed
summary.changed += child.changed
}
}
return summary
}
diff のデータ構造をツリーで持っておくと、表示も集計も同じ再帰パターンで書けるので、ロジックがきれいに揃う。
まとめ
構造的な JSON diff は、再帰さえ押さえればコンパクトに書ける。ただし2つの判断が要る。
- テキスト diff と違い、
JSON.parseしてキー・値で比較するので整形・キー順に左右されない - オブジェクトはキーの和集合を取り、added / removed / unchanged / nested / changed に分類
- 深い等価判定は
JSON.stringifyで手軽に。キー順に弱いが、外れたらnested再帰が補正する二段構え - 配列はインデックスをキーにしたオブジェクト化してオブジェクト比較に合流。位置ベースの割り切り(LCS はやらない)と明示する
- 表示も集計も同じツリーを再帰でたどる
「テキストの差分」と「構造の差分」は別問題で、JSON のように構造を持つデータは後者で比べるべき、という当たり前を、再帰で素直に実装できた題材だった。
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理など、開発者向けのツールを多数公開している。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。