Zum Inhalt springen
jsonbeautifiers
Deutsch

Warum sich Ihre JSON-IDs im Wert verändern

JSON-Zahlen sind unbegrenzt. IEEE-754-Doubles nicht. Alles Weitere folgt daraus.

Jede Aussage auf dieser Seite ist entweder gemessen oder belegt. Wo sie keines von beidem ist, steht das dabei.

Fügen Sie das in eine Browser-Konsole ein:

JSON.parse('{"id": 12345678901234567890}')
// { id: 12345678901234567000 }

Die letzten drei Ziffern haben sich geändert. Es wurde nichts geworfen, nichts gewarnt, und wenn dieser Wert ein Twitter-Snowflake oder ein Primärschlüssel einer Datenbank war, haben Sie jetzt einen anderen Datensatz. Das ist mit Abstand die häufigste Art, wie JSON Daten lautlos beschädigt, und es passiert an der Stelle, an der es die wenigsten erwarten: im Parser, dem sie vertrauen.

Wo die Grenze tatsächlich liegt

RFC 8259 setzt der Größe und der Genauigkeit einer JSON-Zahl keine Grenze. Die Grammatik erlaubt beliebig viele Ziffern. 12345678901234567890123456789 ist also eine völlig gültige JSON-Zahl, und eine Dezimalzahl mit zweihundert Nachkommastellen ebenfalls.

JavaScript hat für JSON-Zwecke genau einen numerischen Typ: den IEEE-754-Double. Ein Double hat 53 Bit Mantisse, kann also jede Ganzzahl bis 2^53-1 exakt darstellen und nicht jede darüber. Dieser Wert ist 9007199254740991, und JavaScript stellt ihn als Number.MAX_SAFE_INTEGER bereit.

Darüber werden Doubles dünn gesät. Der Abstand zwischen darstellbaren Ganzzahlen beträgt 2 bis 2^54, dann 4, dann 8, und verdoppelt sich jeweils. Also:

9007199254740992 === 9007199254740993   // true

Beides ist derselbe Double. Für die ungerade Zahl gibt es kein Bitmuster, sie wird also auf ihren geraden Nachbarn gerundet. Ihre ID wurde nicht von einem Bug beschädigt; sie ist in ein Loch im Zahlenstrahl gefallen.

Die RFC nimmt das vorweg. Abschnitt 6 sagt, eine Zahl sei interoperabel, wenn sie den Hin- und Rückweg durch IEEE 754 binary64 übersteht, und merkt an, dass Implementierungen, die das verwenden, „im Allgemeinen … in dem Sinne interoperabel sind, dass Implementierungen sich exakt auf ihre numerischen Werte einigen“. Das Wort, das dort die Arbeit tut, ist im Allgemeinen.

Es geht nicht nur um riesige Ganzzahlen

Dezimalzahlen verlieren viel früher und viel unauffälliger an Genauigkeit:

0.1 + 0.2                    // 0.30000000000000004
JSON.parse('{"v": 1.005}')   // { v: 1.005 }, aber 1.005 * 100 ergibt 100.49999999999999

Der klassische Geldbetrag-Bug. Ein als 1.005 gespeicherter Preis lässt sich nicht exakt als Double darstellen, das Runden auf zwei Stellen ergibt also 1,00 statt 1,01. Deshalb speichern Finanzsysteme Geld als Ganzzahlen in kleinen Einheiten oder als Dezimalzeichenketten - und nie als JSON-Fließkommazahlen.

Es gibt noch einen leiseren Fall. Ein JSON-Dokument mit 1.0 wird in JavaScript zur Zahl 1, und beim Zurückserialisieren entsteht 1. Das Dokument hat sich geändert. Für die meisten Zwecke ist das egal; für ein Dokument, das Sie hashen, signieren oder diffen, nicht.

Was jede Sprache tut

Die Verhaltensweisen gehen weiter auseinander, als die meisten erwarten, und zu wissen, auf welcher Seite Sie stehen, entscheidet über Ihre Lösung.

Sprache Standard bei einer großen Ganzzahl Kann man die Ziffern behalten?
JavaScript Rundet stillschweigend auf den nächsten Double Nein. JSON.parse hat keinen Haken, der den Originaltext sieht
Python int mit beliebiger Genauigkeit, exakt Ja, automatisch. parse_int und parse_float bekommen den Rohtext
Go float64 als Standard Ja. Decoder.UseNumber() behält den Text als json.Number
Java (Jackson) Integer, Long oder BigInteger, je nach Bedarf Ja, und USE_BIG_INTEGER_FOR_INTS erzwingt es
Rust (serde_json) u64 / i64 / f64 Ja, mit dem Feature arbitrary_precision
PHP int bis PHP_INT_MAX, danach float Teilweise. JSON_BIGINT_AS_STRING behält sie als Zeichenketten
C# long, decimal oder double, je nach Parser Ja, System.Text.Json gibt den Rohtext heraus

Die Asymmetrie ist der gefährliche Teil. Ein Python-Dienst schreibt eine exakte 19-stellige Ganzzahl, ein JavaScript-Client liest etwas anderes, und die beiden Systeme sind sich über einen Wert uneins, den keines von beiden je protokolliert hat.

Das JavaScript-Problem im Besonderen

JavaScript ist der Sonderfall, weil JSON.parse Ihnen keine Möglichkeit gibt einzugreifen. Die Reviver-Funktion läuft, nachdem die Zahl bereits umgewandelt wurde:

JSON.parse(text, function (key, value) {
  // Hier ist `value` bereits ein Double. Die ursprünglichen Ziffern sind weg.
  return value;
});

Es gibt einen TC39-Vorschlag, „JSON.parse source text access“, der genau das ergänzt: Der Reviver bekommt ein Kontextobjekt mit dem Quelltext des Werts, sodass Sie daraus einen BigInt bauen können. Verfügbar ist das noch nicht überall, also bleiben heute diese Möglichkeiten:

  • Mit einer Bibliothek parsen, die den Text selbst tokenisiert - genau das tut der Parser dieser Seite.
  • Den Text vor dem Parsen mit einem regulären Ausdruck vorbehandeln, um große Ganzzahlen zu quotieren. Fragil: Eine Regex kann eine Zahl in einer Zeichenkette nicht von einer Zahl unterscheiden, die ein Wert ist.
  • Es an der Quelle beheben.

Die vier echten Lösungen, nach Vorzug geordnet

Senden Sie große IDs als Zeichenketten. {"id": "12345678901234567890"}. Das ist die Lösung. Sie kostet zwei Bytes pro Wert und ist in jeder Sprache ohne jede Konfiguration korrekt. Twitter hat das 2010 getan, indem es neben id ein Feld id_str einführte, und jede große Plattform seither hat es genauso gemacht. Wenn Sie eine API entwerfen, machen Sie es von Anfang an: Ein Bezeichner ist keine Menge, Sie rechnen nie damit, und ihm einen numerischen Typ zu geben bringt nichts.

Nehmen Sie für Geld die kleinste Einheit. Speichern Sie 1005 Cent statt 10,05. Ganzzahlen unterhalb von 2^53 sind überall exakt, und Sie haben das Dezimalproblem beseitigt statt es zu umgehen.

Nehmen Sie eine Dezimalzeichenkette überall dort, wo es auf Genauigkeit ankommt. Preise, Messwerte, Koordinaten, die zählen. "lat": "51.5074" ist hässlicher und driftet nicht.

Konfigurieren Sie Ihren Parser, wenn Sie den Erzeuger nicht ändern können. UseNumber in Go, parse_int in Python, arbitrary_precision in Rust, JSON_BIGINT_AS_STRING in PHP. Das funktioniert, schützt aber nur die Konsumenten, die Sie kontrollieren.

Was BigInt löst und was nicht

JavaScripts BigInt stellt Ganzzahlen beliebiger Genauigkeit dar, kann den Wert also halten. Beim Parsen hilft es nicht:

JSON.parse('{"id": 12345678901234567890}')   // die Genauigkeit ist längst weg
BigInt("12345678901234567890")               // exakt, wenn Sie die Zeichenkette haben

Und beim Serialisieren hilft es auch nicht, denn JSON.stringify wirft bei einem BigInt, statt zu raten, ob Sie eine Zahl oder eine Zeichenkette wollten:

JSON.stringify({ id: 1n })
// TypeError: Do not know how to serialize a BigInt

Sie müssen sich entscheiden, mit einem Replacer:

JSON.stringify({ id: 1n }, (k, v) => (typeof v === 'bigint' ? v.toString() : v));

Womit Sie wieder dabei sind, es als Zeichenkette zu senden - was von Anfang an die richtige Antwort war.

So finden Sie heraus, ob Sie dieses Problem haben

Fügen Sie ein echtes Payload in den Validator ein. Jede Ganzzahl außerhalb des sicheren Bereichs wird mit dem Wert markiert, den JSON.parse Ihnen stattdessen gäbe, und jede Dezimalzahl, die eine float64-Rundreise nicht übersteht, wird gesondert markiert.

Ist die Zahl nicht null, liest irgendein Konsument dieses Payloads bereits andere Zahlen als die, die Sie gesendet haben - und zwar so lange, wie es das Feld gibt.

Die Formatierungswerkzeuge dieser Seite gehen nie über JSON.parse. Sie geben den exakten Quelltext jeder Zahl erneut aus, ein Dokument mit einer 19-stelligen ID zu formatieren liefert also dieselben 19 Ziffern zurück. Das ist eine niedrige Hürde. Und eine, die die meisten Formatierer nicht nehmen.