0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSONの差分を「テキスト」でなく「構造」で取る——JavaScriptの再帰で書くJSON diffとキー順・配列の罠

0
Posted at

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 にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?