JSONのIDが勝手に値を変えてしまう理由
JSONの数値に上限はありません。IEEE 754のdoubleにはあります。ほかのすべてはそこから導かれます。
このページの記述はすべて実測か出典付きです。そのどちらでもない場合は、そのことを明記しています。
ブラウザのコンソールにこれを貼り付けてみてください。
JSON.parse('{"id": 12345678901234567890}')
// { id: 12345678901234567000 }
末尾3桁が変わりました。例外も投げられず、警告も出ません。そしてもしこの値がTwitterのsnowflakeやデータベースの主キーだったなら、いまあなたが手にしているのは別のレコードです。これは、JSONがデータを静かに壊すもっとも一般的な経路であり、しかも人がいちばん疑わない場所で起きます。信頼しているパーサーの中です。
境界は実際どこにあるのか
RFC 8259は、JSONの数値の大きさにも精度にも制限を設けていません。文法上、桁数は何桁でも構いません。ですから12345678901234567890123456789は完全に妥当なJSONの数値であり、小数点以下200桁の小数もまた妥当です。
JSONを扱ううえで、JavaScriptの数値型はひとつだけです。IEEE 754の倍精度浮動小数点数です。doubleの仮数部は53ビットなので、2^53-1までの整数はすべて正確に表現できますが、それより上の整数をすべて表現することはできません。その値は9007199254740991で、JavaScriptはNumber.MAX_SAFE_INTEGERとして公開しています。
それより上では、doubleはまばらになります。表現可能な整数どうしの間隔は2^54までが2、次が4、次が8と、順に倍になっていきます。したがって、
9007199254740992 === 9007199254740993 // true
この2つは同じdoubleです。奇数のほうに対応するビットパターンが存在しないため、偶数側の隣に丸められます。あなたのIDはバグで壊れたのではありません。数直線の穴に落ちたのです。
RFCはこれを見越しています。第6節では、数値がIEEE 754 binary64を「往復」できるなら相互運用可能であるとし、それを用いる実装は「一般に……数値の値について実装どうしが厳密に一致するという意味で相互運用可能になる」と述べています。ここで実質的な意味を担っているのは一般にという語です。
巨大な整数だけの話ではない
小数の精度は、もっと早い段階で、もっと目立たない形で失われます。
0.1 + 0.2 // 0.30000000000000004
JSON.parse('{"v": 1.005}') // { v: 1.005 } だが、1.005 * 100 は 100.49999999999999
金額にまつわる古典的なバグです。1.005として保存された価格はdoubleで正確に表現できないため、小数点以下2桁に丸めると1.01ではなく1.00になります。金融システムが金額を最小単位の整数で持つか、10進の文字列で持ち、決してJSONの浮動小数点数で持たないのは、このためです。
もうひとつ、より静かな例もあります。1.0を含むJSONドキュメントは、JavaScriptでは数値1になり、シリアライズし直すと1になります。ドキュメントが変わってしまったのです。たいていの用途では問題になりませんが、ハッシュを取る、署名する、差分を取るといったドキュメントでは問題になります。
各言語の挙動
挙動は多くの人が思う以上に食い違い、自分がどちら側にいるかが対処法を決めます。
| 言語 | 大きな整数に対する既定の挙動 | 桁を保持できるか |
|---|---|---|
| JavaScript | 黙って最も近いdoubleに丸める | できません。JSON.parseには元のテキストを見られるフックがありません |
| Python | 任意精度のintで正確 |
できます。自動で。parse_intとparse_floatは生のテキストを受け取ります |
| Go | 既定はfloat64 |
できます。Decoder.UseNumber()がテキストをjson.Numberとして保持します |
| Java(Jackson) | 必要に応じてInteger、Long、BigInteger |
できます。USE_BIG_INTEGER_FOR_INTSで強制もできます |
| Rust(serde_json) | u64 / i64 / f64 |
できます。arbitrary_precisionフィーチャを使います |
| PHP | PHP_INT_MAXまではint、その先はfloat |
部分的に。JSON_BIGINT_AS_STRINGで文字列として保持します |
| C# | パーサーによってlong、decimal、double |
できます。System.Text.Jsonは生のテキストを公開します |
危ういのはこの非対称性です。Pythonのサービスが19桁の整数を正確に書き出し、JavaScriptのクライアントが別の値として読み、両者はどちらもログに残していない値について食い違ったままになります。
JavaScript固有の問題
JavaScriptが特異なのは、JSON.parseが介入する手段をまったく与えないからです。reviver関数が動くのは、数値がすでに変換されたあとです。
JSON.parse(text, function (key, value) {
// ここでの `value` はすでにdoubleです。元の桁は失われています。
return value;
});
TC39には「JSON.parse source text access」という提案があり、まさにこれを追加します。reviverが値のソーステキストを持つコンテキストオブジェクトを受け取れるようになり、そこからBigIntを構築できます。まだどこでも使えるわけではないので、現時点での選択肢は次のとおりです。
- テキストを自前でトークナイズするライブラリでパースする。当サイトのパーサーがやっているのがこれです。
- パース前に正規表現でテキストを前処理し、大きな整数をクォートする。もろい方法です。正規表現には、文字列の中の数値と、値としての数値を区別できません。
- 発生源で直す。
現実的な4つの対処、望ましい順
大きなIDは文字列で送る。 {"id": "12345678901234567890"}。これが対処法です。値ごとに2バイト増えるだけで、どの言語でも設定なしに正しく動きます。Twitterは2010年にidの隣へid_strフィールドを追加してこれを実施し、以降の大規模プラットフォームはどこも同じことをしています。APIを設計しているなら、最初からそうしてください。識別子は数量ではありませんし、算術演算をすることもありません。数値型を与えても得るものは何もありません。
金額は最小単位で持つ。 10.05ではなく1005セントとして保存します。2^53未満の整数はどこでも正確なので、小数の問題を回避するのではなく消し去ったことになります。
精度そのものが本質であるものは10進の文字列にする。 価格、測定値、意味のある座標。"lat": "51.5074"は見た目こそ野暮ったいですが、値がずれません。
生成側を変えられないなら、自分のパーサーを設定する。 GoならUseNumber、Pythonならparse_int、Rustならarbitrary_precision、PHPならJSON_BIGINT_AS_STRING。有効ですが、守れるのは自分が制御できる受け取り側だけです。
BigIntが解決すること、しないこと
JavaScriptのBigIntは任意精度の整数を表現できるので、値そのものは保持できます。しかしパースの助けにはなりません。
JSON.parse('{"id": 12345678901234567890}') // この時点で精度はすでに失われている
BigInt("12345678901234567890") // 文字列さえあれば正確
シリアライズの助けにもなりません。JSON.stringifyはBigIntに対して、数値と文字列のどちらを意図したのかを推測せず、例外を投げます。
JSON.stringify({ id: 1n })
// TypeError: Do not know how to serialize a BigInt
replacerを使って自分で決める必要があります。
JSON.stringify({ id: 1n }, (k, v) => (typeof v === 'bigint' ? v.toString() : v));
そして結局、文字列として送るところに戻ってきます。最初からそれが正解でした。
自分がこの問題を抱えているか確かめる方法
実際のペイロードをバリデーターに貼り付けてください。安全な範囲を外れた整数はすべて、JSON.parseなら代わりに返してくる値とともに指摘され、float64の往復に耐えない小数は別枠で指摘されます。
件数がゼロでなければ、そのペイロードの受け取り側のどれかは、あなたが送ったのとは違う数値をすでに読んでいます。しかもそのフィールドが存在してからずっとです。
このサイトの整形ツールは、JSON.parseを経由しません。すべての数値についてソーステキストをそのまま出し直すので、19桁のIDを含むドキュメントを整形しても、同じ19桁が返ってきます。これは低いハードルです。そして、多くの整形ツールが越えられていないハードルでもあります。