JSON.parseは16桁から整数を黙って書き換える
JSON整形ツールを書いていて、17桁のIDを貼ったら出力の末尾が1つずれた。
入れたのは12345678901234567で、出てきたのは12345678901234568。
整形は入力をJSON.parseで読んでJSON.stringifyで書き直すだけなので、丸めはその1往復のあいだに起きている。例外も警告も出ない。
サイトには結局置かなかったツールなんだけど、書いている途中で踏んだ細部が面白かったので残しておく。以下の実測はぜんぶNode 24.19.0(V8 13.6)。
出力を見ても丸めには気づけない
整形ツールは「読んで書き直す」道具なので、書き直した結果しか手元に残らない。
const input = '{"id": 12345678901234567}'
JSON.stringify(JSON.parse(input), null, 2)
// '{\n "id": 12345678901234568\n}'
出力の12345678901234568はそれ自体としては何もおかしくない。整数だし、桁も合っている。入力と並べて末尾を突き合わせない限り、変わったことは分からない。
だから警告は出力側からは拾えない。JSON.parseを通した時点で情報は落ちているので、入力の文字列そのものを見にいくしかない。
何桁から危ないのか
境界は桁ではなく2の53乗、つまり9007199254740992にある。これが16桁なので、15桁以下(最大999999999999999)は必ず安全で、16桁の途中から壊れはじめる。往復して値が変わらない最大の整数が9007199254740992、変わる最小の整数が9007199254740993。
桁数ごとに、その桁の整数を等間隔に10万点取って往復させた結果。
const survives = (s) => String(JSON.parse(s)) === s
| 桁数 | 往復しても値が変わらない割合 |
|---|---|
| 14桁 | 100.0% |
| 15桁 | 100.0% |
| 16桁 | 94.5% |
| 17桁 | 16.4% |
| 18桁 | 1.7% |
| 19桁 | 0.2% |
| 20桁 | 0.0% |
16桁が94.5%なのは、16桁の約89%が2の53乗より下にあって、残りは刻み幅が2なので半分だけ生き残るから。以降は刻み幅が倍々になっていくので、割合も倍々で落ちていく。
19桁が0.2%というのは、要するにその桁のIDは実質ぜんぶ壊れるということ。IDを数値で持つAPIを叩くとき、JSONのまま素通しできない理由がこれ。
ツール側は16を閾値にした。2の53乗を直に比較するには先にNumberへ落とすことになって本末転倒なので、桁数で保守的に切っている。15桁以下を誤って警告することは無い。
警告は入力の文字列から拾う
入力から16桁以上の整数リテラルを探す。ここで/-?\d{16,}/gを1発掛けると、数字が並んでいるだけの場所に片っ端から当たる。
| 入力 | 正規表現1発 | 文字列を読み飛ばす走査 |
|---|---|---|
{"id": 12345678901234567} | 拾う | 拾う |
{"note": "注文番号 12345678901234567"} | 拾う | 拾わない |
{"12345678901234567": 1} | 拾う | 拾わない |
{"a": 0.12345678901234567} | 拾う | 拾わない |
壊れた例を8件並べて試したら、正規表現が誤爆したのは上の表の下3行と同じ形の3件。文字列の中身とキーは丸めと関係ないし、小数部の桁は倍精度の有効桁の話であって整数の丸めではない。
なので、文字列リテラルを読み飛ばしながら1文字ずつ走る。細部が2つある。
エスケープは2文字進める
文字列の中では、\を見たらindex += 2で次の1文字ごと飛ばす。飛ばさないと\"の"を文字列の終わりと読んでしまい、そこから先の内と外がぜんぶ入れ替わる。
// エスケープを飛ばさない版
findRoundingRiskNumbers('{"a": "\\"12345678901234567\\"", "b": 1}')
// => ['12345678901234567'] ← 文字列の中身なのに拾ってしまう
\\(バックスラッシュ2つ)でも同じで、2文字目を文字列の終わりと誤読しないために2文字進める必要がある。
数値の後ろは小数部と指数部ごと読み飛ばす
整数部を読み終えて、次が.かeかEならその数値は整数ではないので警告しない。ただしそこで走査を止めると、小数部の数字が次の数値として数え直される。
// 数値の残りを飛ばさない版
findRoundingRiskNumbers('{"ratio": 0.12345678901234567}')
// => ['12345678901234567'] ← 小数部を17桁の整数として拾う
findRoundingRiskNumbers('{"tiny": 1.5e-1234567890123456}')
// => ['-1234567890123456'] ← 指数部を負の整数として拾う
-を数値の一部として飛ばすので、指数の-も巻き込める。
parseエラーの位置は取れるときと取れないときがある
失敗したときは、どこで読めなくなったかを行と列で出したい。JSON.parseの例外メッセージにはat position 8と書いてあるので、そこから割り出せる……と思って/position (\d+)/を前提に書くと落ちる。
壊れたJSONを33種類食わせて分類した結果、位置が載ったのは18件で、載らなかったのが15件。
位置が載る側。
{"a" 1} Expected ':' after property name in JSON at position 5 (line 1 column 6)
"abc Unterminated string in JSON at position 4 (line 1 column 5)
{"a":1,} Expected double-quoted property name in JSON at position 7 (line 1 column 8)
01 Unexpected number in JSON at position 1 (line 1 column 2)
1e Exponent part is missing a number in JSON at position 2 (line 1 column 3)
載らない側。
(空) Unexpected end of JSON input
[ Unexpected end of JSON input
{"a":} Unexpected token '}', "{"a":}" is not valid JSON
[1,2,] Unexpected token ']', "[1,2,]" is not valid JSON
undefined "undefined" is not valid JSON
見た限りでは、V8が「何を期待していたか」を言えるエラーには位置が載って、「予期しないトークン」と「入力の終わり」には載らない。{はExpected property name or '}' in JSON at position 1で位置つき、[はUnexpected end of JSON inputで位置なし、という非対称もここから来ている。
末尾カンマも閉じ忘れも日常的な壊れ方なので、位置が無い側は例外的な経路ではない。取れないときは行と列を名乗らず、メッセージだけ出す。
export function describeParseFailure(input: string, message: string): JsonResult {
const matched = /position (\d+)/.exec(message)
if (matched === null) return { ok: false, message }
return { ok: false, message, ...toLineColumn(input, Number(matched[1])) }
}
位置が載らない側は、代わりに入力そのものを引用してくる。この引用も入力が21文字以上になると前後を...で削る。
["AAAAAAAAAAAAAAAAAAAA",XXX,"BBBBBBBBBBBBBBBBBBBB"]
→ Unexpected token 'X', ..."AAAAAAAA",XXX,"BBBBB"... is not valid JSON
抜粋の中の"とメッセージ側の引用符が混ざるうえ、抜粋のどこが問題の位置なのかは示されない。位置の代わりにはならない。
positionは「文字目」ではない
位置が取れた側にも罠がある。positionはUTF-16のコード単位で数えられているので、サロゲートペアが入ると人間の数える文字数からずれる。
JSON.parse('{"a":"🐣🐣🐣","b" 1}')
// Expected ':' after property name in JSON at position 18 (line 1 column 19)
// 見た目には16文字目
絵文字3つで3ずれる。input.slice(0, position)で切ってから行と列を数え直しても、切る単位が同じなので同じだけずれる。ここを合わせにいくなら[...input]で数え直すことになるけれど、そうすると今度はエディタの列表示とずれる。ツールでは合わせにいかないことにした。
なお(line 1 column 6)はV8が最近書き足したもので、自分で数え直さなくても読める。ただしブラウザのエンジンごとにメッセージの形は違うし、ここで測ったのはNodeのV8だけなので、フォールバックを外す気にはならなかった。
まとめ
JSON.parseは2の53乗を超えた整数を黙って丸める。境界は16桁の途中で、19桁のIDは実測で99.8%が壊れる- 整形ツールは読んで書き直すので、丸めは出力から検出できない。警告は入力の文字列を走査して拾う
- 走査は文字列リテラルを読み飛ばす。エスケープは2文字進める、数値の後ろは小数部と指数部ごと飛ばす
JSON.parseのエラーメッセージには位置が載る系統と載らない系統がある。positionが取れない前提を書いておく