Kernpunkte für den Entwurf von JSON-API-Datenverträgen
Fazit: Sechs Dinge lassen sich einmal ausgeliefert sehr schwer ändern: Response-Hüllen, Fehlerformate, Paginierungsform, Darstellung von IDs und Zeit, die Semantik von null versus fehlend und die Versionsentwicklung.

Kurz gesagt: Legen Sie im Vertrag die Response-Hülle, das Fehlerformat, die Darstellung von IDs und Zeit sowie die Semantik von null versus fehlend fest, bevor Sie über Paginierung und Benennungsdetails streiten; wickeln Sie die oberste Ebene immer in eine data-Hülle, damit Paginierung und Tracing-IDs später ergänzt werden können, ohne bestehende Aufrufer zu brechen.
In der API-Entwurfsarbeit gibt es keine Standardantworten, aber es gibt einige „einmal gewählt, schwer zurückzunehmende“ Entscheidungen, die man besser durchdenkt, bevor man die erste Codezeile schreibt.
1. Response einhüllen oder nicht
Drei Stile:
// Style A: bare response
{ "id": 1, "name": "张三" }
// Style B: data envelope
{ "data": { "id": 1, "name": "张三" } }
// Style C: data + metadata
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
| Stil | Vorteile | Nachteile |
|---|---|---|
| Nackte Antwort | Am einfachsten, eine Ebene weniger fürs Frontend | Kann keine Metadaten anhängen; ein Array auf oberster Ebene birgt JSON-Hijacking- und Evolutionsrisiken |
| Datenhülle | Leicht meta / errors / links ergänzbar |
Eine Verschachtelungsebene mehr, das Frontend muss res.data.data schreiben |
| Daten + Metadaten | Beste Observability | Erfordert strikte Einigung, welche Felder in meta gehören |
Empfehlung: Einheitlich eine data-Hülle für Listen und Details verwenden und die oberste Ebene niemals zu einem Array machen. So bricht das spätere Ergänzen von Paginierung oder Tracing-IDs nie den Vertrag.
2. Fehlerformat: RFC 9457 kopieren
Eine eigene Fehlerstruktur zu erfinden ist eine riesige Quelle von Zusammenarbeitskosten. RFC 9457 (der RFC 7807 ersetzt) definiert application/problem+json:
{
"type": "https://bbdrug.com/problems/insufficient-balance",
"title": "余额不足",
"status": 422,
"detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
"instance": "/accounts/A1001/payments/p-889",
"balance": "12.00",
"required": "30.00"
}
| Feld | Bedeutung | Erforderlich |
|---|---|---|
type |
Stabile Problemtyp-URI | Empfohlen |
title |
Kurze menschenlesbare Zusammenfassung | Empfohlen |
status |
HTTP-Statuscode | Empfohlen |
detail |
Genaue Erklärung, was diesmal passiert ist | Optional |
instance |
Die Ressourceninstanz mit dem Problem | Optional |
| Erweiterungsfelder | Beliebige Geschäftsfelder | Optional |
Erweiterungsfelder (etwa balance / required oben) erlaubt die Spezifikation, und das Frontend kann sie für präzise Meldungen nutzen, statt den detail-Text zu parsen.
3. Paginierung: Cursor schlägt Offset
// Offset pagination
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// Cursor pagination
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| Dimension | Offset-Paginierung | Cursor-Paginierung |
|---|---|---|
| Implementierungsschwierigkeit | Niedrig (LIMIT/OFFSET) |
Mittel (erfordert einen stabilen Sortierschlüssel) |
| Performance auf tiefen Seiten | Schlecht (je größer der Offset, desto langsamer) | Gut |
| Wenn sich Daten ändern | Duplikate oder fehlende Einträge | Stabil |
| Zu einer Seite springen | Möglich | Nicht möglich |
| Braucht eine Gesamtzahl | Erfordert einen zusätzlichen Count | Nicht erforderlich |
Empfehlung: Offset-Paginierung für Admin-Tabellen (sie brauchen Seitensprünge und Summen) und Cursor-Paginierung für nutzerseitige Infinite Feeds.
4. IDs und Zeit
4.1 IDs immer als Strings
Die Begründung wurde im Artikel zu den Typen behandelt: Auf der JavaScript-Seite verlieren Werte oberhalb von 2^53 stillschweigend Präzision.
Selbst wenn die aktuelle ID eine kleine Auto-Increment-Ganzzahl ist, lassen Strings Raum für einen späteren Wechsel zu Snowflake-IDs.
{ "id": "1000000000000000001" }
4.2 RFC-3339-Strings für Zeit
{
"createdAt": "2026-10-30T10:41:45+08:00",
"updatedAt": "2026-10-30T02:41:45Z"
}
- Keine Millisekunden-Timestamp-Zahlen verwenden: Sie sind schwer lesbar, und die Einheit (Sekunden/Millisekunden) wird häufig verwechselt;
- Die Zeitzone ausdrücklich angeben: entweder mit
Zoder mit einem Offset; niemals „lokale Zeit ohne Zeitzone“ verwenden; - Felder einheitlich als
xxxAtbenennen, damit auf einen Blick klar ist, dass es ein Zeitpunkt ist.
5. null und fehlend
Sie müssen eine Variante wählen und sie in die Dokumentation schreiben:
| Ansatz | „Kein Wert“ ausdrücken | „Nicht ändern“ ausdrücken |
|---|---|---|
| Ansatz A | Der Feldwert ist null |
Das Feld in der Anfrage weglassen |
| Ansatz B | Das Feld existiert nicht | Das Feld in der Anfrage weglassen |
Das Problem von Ansatz A ist, dass „weggelassen“ und „als null gesendet“ in einem PATCH Unterschiedliches bedeuten, was leicht falsch interpretiert wird;
das Problem von Ansatz B ist, dass JSON.stringify im Frontend Felder mit dem Wert undefined verwirft, was zufällig zur Semantik passt.
Praktische Empfehlung:
- Ansatz A für Response-Bodies verwenden (
nullbedeutet klar keinen Wert, und das Frontend muss nichtundefinedunterscheiden); - Ansatz B für Request-Bodies (insbesondere
PATCH) verwenden, kombiniert mit Merge Patch oder JSON Patch, um die Semantik ausdrücklich zu machen.
6. Feldbenennung und Struktur
| Entscheidung | Übliche Wahl | Vorbehalt |
|---|---|---|
| Benennungsstil | camelCase (JS-Ökosystem) / snake_case (Python, Datenbanken) |
Projektweit einheitlich, nicht mischen |
| Enum-Werte | Kleinbuchstaben mit Unterstrichen, "pending_review" |
Keine Zahlen verwenden; sie sind schwer lesbar |
| Boolean-Felder | isActive / enabled |
Nicht status: 0/1 verwenden |
| Array-Felder | Plurale Substantive, items |
Für leere Arrays [] zurückgeben, nie null |
| Verschachtelungstiefe | Höchstens 3 Ebenen | Tiefer aufteilen oder einen expand-Parameter anbieten |
Für leere Arrays [] statt null zurückzugeben ist sehr wichtig: data.items.map(...) im Frontend stürzt dann nicht wegen eines null ab.
7. Evolutionsstrategie
| Änderung | Bricht Kompatibilität | Behandlung |
|---|---|---|
| Ein optionales Feld ergänzen | Nein | Direkt ausliefern |
| Ein Pflichtfeld ergänzen | Ja | Einen Standardwert ergänzen oder per Version ausliefern |
| Ein Feld entfernen | Ja | Zuerst als veraltet markieren, Aufrufer beobachten, dann entfernen |
| Den Typ eines Feldes ändern | Ja | Einen neuen Feldnamen ergänzen und während der Übergangszeit doppelt schreiben |
| Die Enum-Wertemenge ändern | Kommt darauf an | Der Client muss einen Fallback für unbekannte Werte haben |
| Die Feldbedeutung ändern | Ja | Wie ein neues Feld behandeln |
Drei Disziplinregeln:
- Clients müssen toleranter gegenüber unbekannten Feldern sein (nicht mit
additionalProperties: falsein die Enge treiben); - Clients müssen einen Fallback-Zweig für unbekannte Enum-Werte haben;
- Veraltete Felder mindestens eine Hauptversion lang behalten und mit
Deprecationin Response-Headern oder Dokumentation markieren.
8. Ein vollständiges Beispiel
{
"data": [
{
"id": "1001",
"name": "JSON 权威指南",
"price": "89.50",
"tags": ["json", "spec"],
"publishedAt": "2026-09-01T00:00:00+08:00",
"author": null
}
],
"page": { "nextCursor": null, "hasMore": false },
"meta": { "requestId": "req-7f3a", "tookMs": 12 }
}
Diese Antwort spiegelt alle obigen Konventionen wider: data-Hülle, String-IDs, Beträge als Strings, Zeitstempel mit Zeitzone,
null für fehlend, leeres Array, Cursor-Paginierung und meta für Observability-Informationen.
9. Checkliste
- [ ] Die oberste Ebene ist kein Array; einheitlich eine
data-Hülle verwenden; - [ ] Fehlerantworten folgen RFC 9457 und enthalten ein stabiles
type; - [ ] Die Paginierungsform entspricht dem Geschäftsszenario;
- [ ] IDs sind Strings, und Zeit verwendet RFC 3339;
- [ ] Die Semantik von
nullund fehlend ist in die Dokumentation geschrieben; - [ ] Der Benennungsstil ist projektweit einheitlich, und Enums verwenden Kleinschreibungs-Strings;
- [ ] Der Client hat Fallbacks für unbekannte Felder und unbekannte Enum-Werte;
- [ ] Breaking Changes laufen über Versionierung oder eine Doppelschreib-Übergangszeit.
FAQ
Wie sollte ich eine weiterentwickelbare JSON-API entwerfen?
Wickeln Sie Listen und Details einheitlich in eine data-Hülle und machen Sie die oberste Ebene niemals zu einem Array, damit Paginierung und Tracing-IDs ergänzt werden können, ohne den Vertrag zu brechen. Verwenden Sie String-IDs und RFC-3339-Zeitstempel und schreiben Sie die Semantik von null versus fehlend in die Dokumentation. Halten Sie neue Felder optional und semantisch unabhängig und geben Sie Clients Fallbacks für unbekannte Felder und Enum-Werte.
Warum kein eigenes Fehlerformat erfinden?
Eine eigene Fehlerstruktur zu erfinden ist eine riesige Quelle von Zusammenarbeitskosten. Kopieren Sie application/problem+json nach RFC 9457, identifizieren Sie Probleme mit einem stabilen type und erlauben Sie Erweiterungsfelder wie balance und required, sodass das Frontend präzise Meldungen bauen kann, ohne den detail-Text zu parsen.
Sollte jeder Endpunkt Cursor-Paginierung verwenden?
Nein. Admin-Tabellen brauchen Seitensprünge und Summen, Offset-Paginierung passt also besser; nutzerseitige Infinite Feeds sollten Cursor-Paginierung verwenden, die auf tiefen Seiten stabil bleibt und Duplikate oder Lücken bei Datenänderungen vermeidet. Entscheiden Sie danach, ob das Szenario Seitensprünge und Summen braucht.