Zum Inhalt springen
jsonbeautifiers
Deutsch

JSON-Antwortformen, die fünf Jahre überstehen

Fast jede schmerzhafte API-Migration führt auf eine Formentscheidung zurück, die an einem Nachmittag fiel und von der ersten Integration eingefroren wurde.

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

Hier ist eine Antwort, die in jemandes v1 ausgeliefert wurde und noch heute ausgeliefert wird:

[
  { "id": 8102, "name": "Ada" },
  { "id": 8103, "name": "Grace" }
]

Drei Jahre später ist die Sammlung groß genug, dass sie Paginierung braucht, und es gibt keinen Platz für einen Cursor. Die oberste Ebene ist ein Array. Es einzupacken ändert den Typ, den jeder Client bereits parst, also liefert das Team /v2/users aus und pflegt für immer zwei Codepfade. An diesem Array war nichts falsch, als es geschrieben wurde. Es hatte nur keinen Raum zu wachsen.

Darum geht es hier insgesamt. Antwortentwurf handelt nicht von Eleganz, sondern davon, welche Änderungen billig bleiben.

Umschlag oder nackter Wert

Ein Umschlag ist ein Objekt auf oberster Ebene mit der Nutzlast unter einem Schlüssel:

{
  "data": [ { "id": "8102", "name": "Ada" } ],
  "nextCursor": "eyJpZCI6ODEwM30",
  "hasMore": true
}

Das Gegenargument ist real: Es ist Rauschen, und jeder Client schreibt .data. Das Argument dafür: Ein Objekt ist erweiterbar, ein nacktes Array nicht. Sie können später einen Cursor, eine Gesamtzahl, einen Abkündigungshinweis oder eine Trace-ID ergänzen, ohne den Typ von irgendetwas Bestehendem zu ändern.

Was ich mache: Sammlungen einpacken, für eine einzelne Ressource das nackte Objekt zurückgeben. Eine einzelne Ressource ist bereits ein Objekt, hat also den Wachstumsraum, den ein Umschlag ihr gegeben hätte. Sammlungen bekommen den Umschlag, weil sie diejenigen sind, die irgendwann Metadaten brauchen.

Was Sie auch wählen, wählen Sie einmal. Die Hälfte Ihrer Endpunkte eingepackt und die andere Hälfte nackt ist schlimmer als beides. Und stecken Sie kein Feld namens data in ein Feld namens data.

Die Feldtypen, die man nicht zurücknimmt

IDs sind Zeichenketten. Immer, auch solange sie noch kleine ganze Zahlen sind. Eine JSON-Zahl ist in JavaScript ein IEEE-754-Double, jeder Bezeichner über 9007199254740991 wird also beim Eintreffen stillschweigend gerundet, und Sie sehen jetzt einen anderen Datensatz. Twitter lief 2010 beim Wechsel auf 64-Bit-Snowflake-IDs dagegen und lieferte id_str neben id aus; das Muster blieb. Die Mechanik steht in warum sich Ihre JSON-IDs im Wert ändern. Der Entwurfspunkt ist enger: Ein Bezeichner ist keine Menge. Sie addieren nie zu ihm, sortieren ihn nicht arithmetisch und mitteln ihn nicht, ein numerischer Typ bringt Ihnen also nichts und kostet Sie den Tag, an dem Sie auf UUIDs umstellen.

Geld ist eine Ganzzahl in Untereinheiten oder eine Dezimalzeichenkette. Nie ein Float.

{ "amountMinor": 1005, "currency": "GBP" }
{ "amount": "10.05", "currency": "GBP" }

1.005 ist als Double nicht exakt darstellbar, in JavaScript ergibt 1.005 * 100 also 100.49999999999999, was auf 100 statt auf 101 rundet. Wählen Sie eine Darstellung, führen Sie die Währung daneben, und lassen Sie nie ein nacktes price: 10.05 ins Schema, denn es später herauszunehmen heißt, jeden Konsumenten zu prüfen, der damit rechnet.

Datumsangaben sind RFC-3339-Zeichenketten mit ausdrücklichem Offset. "2026-09-05T14:30:00Z". Kein Unix-Zeitstempel, kein "05/09/2026", und vor allem keine Ortszeit ohne Offset, denn die parst wunderbar und ist um Stunden falsch. JSON hat keinen Datumstyp, diese Konvention existiert also nur, wenn das Code-Review sie durchsetzt. Datums- und Zeitformate in JSON deckt den Rest ab.

null, fehlend und leer

Vier Formen, vier Bedeutungen:

Form Bedeutung
"middleName": "Jane" Bekannter Wert
"middleName": null Bekanntermaßen kein Wert vorhanden
Schlüssel fehlt Unbekannt, nicht geladen oder nicht erlaubt
"tags": [] Bekanntermaßen null Tags

Der Fehler ist nicht, die falsche Konvention zu wählen, sondern alle vier uneinheitlich zu benutzen, sodass ein Client „diese Nutzerin hat keinen zweiten Vornamen“ nicht von „Sie haben eine spärliche Projektion angefordert“ unterscheiden kann. Entscheiden Sie je Feld und halten Sie die Linie.

Zwei Fallen. JSON.stringify verwirft Schlüssel, deren Wert undefined ist, behält aber null, ein JavaScript-Erzeuger springt also zwischen fehlend und null, je nachdem, ob eine Variable zugewiesen wurde. Und das required von JSON Schema behauptet, dass ein Schlüssel vorhanden ist, nicht dass er nicht null ist: {"name": null} erfüllt required: ["name"]. Wenn Sie nicht-null meinen, schreiben Sie es in den Typ.

{
  "type": "object",
  "required": ["name", "middleName"],
  "properties": {
    "name":       { "type": "string" },
    "middleName": { "type": ["string", "null"] }
  }
}

Erzeugen Sie den ersten Entwurf mit dem Schema-Generator aus einem echten Payload und korrigieren Sie die Nullbarkeit dann von Hand, denn ein Generator sieht nur die Werte, die zufällig in Ihrer Stichprobe standen.

Benennung

Wählen Sie camelCase oder snake_case, wenden Sie es auf jeden Schlüssel an jedem Endpunkt an, und beenden Sie die Diskussion. Gemischte Schreibweisen in einem Dokument sind das deutlichste Zeichen dafür, dass zwei Teams zwei Hälften geschrieben und keines das andere gelesen hat, und sie zerstören den billigen Client-Trick, Schlüssel mechanisch auf Struktfelder abzubilden. created_at schlägt ts. Ein Schlüssel, der einen Kommentar braucht, braucht einen besseren Namen.

Fehler

Ein Fehlerkörper braucht drei getrennte Dinge, und die meisten liefern eines:

{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient funds",
  "status": 402,
  "detail": "Balance is 320 minor units, transfer requires 1005.",
  "code": "INSUFFICIENT_FUNDS",
  "pointer": "/transfer/amountMinor"
}

Einen stabilen Maschinencode, auf den der Client verzweigt und den Sie nie zu ändern versprechen. Eine menschliche Meldung, die Sie frei umformulieren oder übersetzen dürfen und auf die kein Client jemals matchen sollte. Einen Zeiger auf das schuldige Feld, idealerweise ein JSON Pointer nach RFC 6901, damit er sich mechanisch gegen den Anfragekörper auflöst.

RFC 9457, Problem Details for HTTP APIs, standardisiert type, title, status, detail und instance und erlaubt ausdrücklich Erweiterungsmitglieder, Sie können ihn also übernehmen und trotzdem Ihr eigenes code mitführen. Er hat RFC 7807 abgelöst, den Namen, unter dem die meisten bestehenden Implementierungen ihn kennen. Ihn zu nutzen gibt Ihnen eine Form, die fremdes Werkzeug bereits versteht, was {"error": "irgendwas ist schiefgegangen"} nie tun wird. Bei Validierungsfehlern geben Sie alle zurück statt nur den ersten.

Cursor schlagen Offsets

Offset-Paginierung wird still verlustbehaftet, sobald es nebenläufige Schreibvorgänge gibt. Seite eins liefert Zeile 1 bis 50. Oben wird eine Zeile eingefügt. Seite zwei, offset=50, beginnt nun bei dem, was Zeile 50 war, der Konsument sieht diesen Datensatz also zweimal. Löschungen tun es andersherum und überspringen Datensätze ganz. Nichts wirft einen Fehler; es taucht Wochen später als Abstimmungsdifferenz auf.

Ein Cursor kodiert eine Position in einer stabilen Sortierung, normalerweise den Sortierschlüssel plus eine ID zur Entscheidung von Gleichständen, sodass Einfügungen darüber irrelevant sind. Dokumentieren Sie den Cursor als undurchsichtig, damit Sie seine Kodierung später ändern können, und geben Sie ein ausdrückliches hasMore zurück, statt Clients das Ende aus einer kurzen Seite ableiten zu lassen. Lassen Sie totalCount weg, sofern es nicht wirklich jemand braucht und Sie bereit sind, die zweite Abfrage zu bezahlen.

Additive Änderung ist die einzige kostenlose Änderung

Der Vertrag, der Weiterentwicklung möglich macht, lebt auf der Client-Seite: unbekannte Felder müssen ignoriert werden. Wenn das gilt, ist ein neues Feld nicht brechend und Sie können fortlaufend ausliefern. Wenn ein Konsument streng validiert oder Typen mit additionalProperties: false erzeugt, bricht jede Ergänzung jemandem etwas, und Sie bleiben für immer auf v1. Schreiben Sie es in den ersten Absatz Ihrer Dokumentation.

Alles andere ist eine Version: ein Feld entfernen, eines umbenennen, seinen Typ ändern, ändern, was ein Wert bedeutet, verschärfen, was Sie annehmen, oder ein nullbares Feld nicht-nullbar machen. Das Beispiel-Payload der letzten Auslieferung und das heutige durch ein JSON-Diff zu schicken, fängt die Typänderung, die niemand vorhatte.

Heterogene Arrays kosten den Konsumenten mehr, als sie Ihnen sparen

{ "items": [
  { "kind": "comment",  "body": "..." },
  { "kind": "reaction", "emoji": "..." },
  { "id": 7, "legacy": true }
] }

Jeder Konsument schreibt nun eine Verteilung, und jeder statisch typisierte schreibt eine getaggte Union von Hand. Wenn Sie Formen mischen müssen, unterscheiden Sie sie: ein verpflichtendes kind mit einer dokumentierten, geschlossenen Wertemenge, vorhanden auf jedem Mitglied. Dann ist die Union mechanisch. Die unverzeihliche Fassung ist das dritte Element, bei dem die Form ohne Tag variiert und Clients nach Schlüsseln schnüffeln. Dasselbe gilt für ein Feld, das mal eine Zeichenkette und mal ein Objekt ist: Es spart Ihnen einen Versionssprung und kostet jeden Client für immer eine Typprüfung.

Wenn die Antwort groß wird

Jede Engine hat eine harte Decke für die Zeichenkettenlänge, und sie liegt niedriger als erwartet: Auf 64-Bit-V8 (Chrome und Node) sind es 536.870.888 Zeichen, eine Antwort jenseits von etwa einem halben Gigabyte lässt sich also nicht einmal als Zeichenkette halten, geschweige denn parsen. Andere Engines liegen höher, aber alle haben eine Decke, und der geparste Objektbaum kostet ein Mehrfaches des Textes. Lange davor blockiert ein mehrsekündiger Parse den Hauptthread.

Drei Auswege, geordnet danach, wie sehr sie die API stören. Feiner paginieren, damit keine einzelne Antwort groß ist. Zeilenbegrenzte Datensätze streamen, damit der Konsument beim Empfangen arbeitet, statt auf eine schließende Klammer zu warten (NDJSON und JSON Lines). Oder den Massenexport ganz aus der synchronen API herausnehmen: eine Job-ID und eine signierte URL für die fertige Datei zurückgeben. Große JSON-Dateien deckt die Konsumentenseite ab.

Die Checkliste

  • Sammlungen einpacken, für einzelne Ressourcen nackte Objekte zurückgeben, und konsequent bleiben.
  • IDs sind Zeichenketten. Geld sind Untereinheiten oder eine Dezimalzeichenkette. Datumsangaben sind RFC 3339 mit Offset.
  • Definieren Sie je Feld, was null, fehlend und leer jeweils bedeuten.
  • Eine Schreibweisenkonvention über alle Endpunkte.
  • Fehler tragen einen stabilen Code, eine veränderliche Meldung und einen Feldzeiger. Ziehen Sie RFC 9457 in Betracht.
  • Cursor-Paginierung, undurchsichtige Cursor, ein ausdrückliches hasMore.
  • Sagen Sie Clients, unbekannte Felder zu ignorieren, und halten Sie jede andere Änderung hinter einer Version.
  • Unterscheiden Sie jedes heterogene Array mit einem verpflichtenden kind.

Nichts davon ist an Tag eins teuer. Alles davon ist an Tag tausend teuer, und das ist der einzige Grund, weshalb es sich lohnt, jetzt darüber zu streiten.