JSON のキー順序問題と、決定論的シリアライズが必要な場面

約7分

JSON は柔軟で扱いやすい形式ですが、「同じデータを毎回同じ文字列にシリアライズできるか」という問いには、実は意外な落とし穴があります。本記事では JSON のキー順序が仕様上どう扱われ、どんな場面で順序の固定が必要になるかを整理します。

RFC 8259:キー順序は無保証

JSON の仕様(RFC 8259)は、オブジェクトのキー順序について:

An object is an unordered collection of zero or more name/value pairs

と明記しています。JSON オブジェクトは順序を持たないのが正規の定義で、{"a":1,"b":2}{"b":2,"a":1} は仕様上同じデータを表します。

この前提に立つと、シリアライズした2つの文字列が一致しなくても、論理的には同じデータです。

現実は順序を保つ実装が多い

ところが現実の JavaScript では、JSON.stringify でシリアライズすると オブジェクトの挿入順でキーが出力されます:

const obj = {};
obj.b = 2;
obj.a = 1;
JSON.stringify(obj); // → '{"b":2,"a":1}'

これは ES2015 以降で整数キー以外は挿入順を保つことが言語仕様で定められたためで、ブラウザ間でも統一されています。Python の dict も 3.7 から挿入順を保証するようになりました。

つまり「JSON 仕様は順序を定めないが、実装はだいたい順序を保つ」という状態で、これがある種の罠を生みます:

  • 小規模なテストでは動く:開発中はオブジェクトの作り方が同じなので順序が一致する
  • 本番でたまに壊れる:別の経路で生成されたオブジェクトと比較すると順序が違って一致しない

決定論的シリアライズが必要な場面

「同じデータは必ず同じ JSON 文字列になる」性質を決定論的シリアライズ(canonical / deterministic JSON)と呼び、以下のユースケースで必要になります。

1. 署名・ハッシュ計算

JSON を署名する場合、署名対象の文字列が一意に決まらないと、検証側で署名が一致しません。

// ❌ 順序が保証されないとマズい
const data = { name: 'Alice', age: 30 };
const signature = sign(JSON.stringify(data), key);

// 受信側
JSON.stringify(receivedData) === '{"name":"Alice","age":30}' ? 検証OK : 検証NG;

JWT がクレームをそのまま JSON 文字列にして署名対象にしているため、JWT を発行する側と検証する側で同じシリアライズ規則を使う必要があります。実用上は両方が同じライブラリを使うので問題が起きにくいですが、サードパーティ実装を混ぜると噛み合わなくなることがあります。

JWS の仕様(RFC 7515)は「JSON のシリアライズは送り手側が決めて構わない」とする立場で、署名対象は送られてくるバイト列そのものです。そのためサーバーは受け取った JSON をそのまま署名対象にして、再シリアライズはしません。

2. キャッシュキーの生成

オブジェクトをハッシュしてキャッシュキーにする場合、同じデータが毎回同じハッシュになる必要があります。

// ❌ 同じデータで違うキャッシュキーが生まれる
const k1 = hash(JSON.stringify({ a: 1, b: 2 })); // → "x..."
const k2 = hash(JSON.stringify({ b: 2, a: 1 })); // → "y..."(異なる)

これを防ぐには、シリアライズ前にキーをソートする必要があります:

function canonicalStringify(obj) {
	if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
	if (Array.isArray(obj)) return `[${obj.map(canonicalStringify).join(',')}]`;
	const keys = Object.keys(obj).sort();
	return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalStringify(obj[k])}`).join(',')}}`;
}

3. 設定ファイルの差分

Git で JSON 設定ファイルを管理するとき、保存のたびにキー順序が入れ替わると、ノイズな diff が大量に発生します。

CI で自動整形するなら、キーをアルファベット順にソートするルールを入れておくと、編集者によらず安定した diff が出せます。prettier --order-keys のようなオプションは標準では無いので、自前のフォーマッタを用意するか、jq -S . でソートしてから保存するパターンが使われます。

4. ブロックチェーンのトランザクション

ブロックチェーン系では「同じトランザクションが必ず同じハッシュになる」必要があり、JSON ベースのプロトコルは決定論的シリアライズを必須にしています。Bitcoin のトランザクションはバイナリですが、Ethereum のJSON-RPC や IPLD(Filecoin など)は決定論的 JSON を使います。

業界標準:JCS(RFC 8785)

決定論的 JSON のための業界標準として、JCS(JSON Canonicalization Scheme、RFC 8785) が 2020 年に公開されました。

JCS のルール:

  1. キーは UTF-16 のコードポイント順にソート
  2. 空白なし(カンマ・コロンの周りに空白を入れない)
  3. 数値は ECMAScript の Number.toString() 互換でシリアライズ
  4. 文字列は UTF-8 で、必要最小限のエスケープのみ

JCS 準拠のライブラリは Java / Python / JavaScript など主要言語にあり、暗号署名や検証で使われます。

どこまで気にするべきか

実用上、決定論的シリアライズを意識すべきかは用途次第:

用途決定論性が必要
設定ファイル◎(diff のノイズを減らすため)
API レスポンス×
署名対象
キャッシュキー
ログ出力△(読みやすさ優先)
データ保存(DB)×

「JSON.stringify でデータを比較する」コードを見たら、決定論性を担保しているかを確認するくせをつけると、運用上のバグを減らせます。

まとめ

  • JSON 仕様上、オブジェクトのキー順序は無保証
  • 多くの実装は挿入順を保つので「だいたい動く」が、それに依存する設計はバグの温床
  • 署名・キャッシュキー・設定差分など、同じデータを同じ文字列に固定したい場面では決定論的シリアライズを使う
  • JCS(RFC 8785)が業界標準

JSON の整形やキー順を変えた結果を試したいときは、本サイトの JSON フォーマッタでキーソートのオプションを試せます。{"b":2,"a":1} を入れて整形すると、ソート前後でハッシュが変わることが直感的に確認できます。