JSON Toolkit

Versionare i contratti JSON: compatibilità, deprecazione e migrazione dei dati

In sintesi: Aggiungere un campo non è automaticamente sicuro. Copre i confini di compatibilità, i campi versione, le finestre di deprecazione, la migrazione a doppia scrittura, i contract test e le metriche di canary.

Avanzato~10 min di letturaPubblicato il 2026-10-31Aggiornato il 2026-10-31
Copertina del versionamento dei contratti JSON, compatibilità e migrazione dei dati

In sintesi: esegui l'evoluzione delle versioni come expand, backfill e contract, e persisti la versione dentro il documento; conferma che i consumatori tollerino campi sconosciuti e valori enum prima di spedire qualsiasi cosa, e porta la deprecazione attraverso freeze, announce, observe e remove in quest'ordine.

La parte difficile del versionamento non è cambiare il codice; è che non puoi sapere chi legge ancora il vecchio campo. La compatibilità non è mai una promessa unidirezionale — è un allineamento continuo tra il pubblicatore e consumatori sconosciuti.

1. Confini di compatibilità: additivo non è automaticamente sicuro

I libri di testo chiamano un nuovo campo opzionale "retrocompatibile", ma questo vale solo quando i consumatori tollerano i campi sconosciuti. Aggiungere un campo rompe comunque i client in almeno tre situazioni:

  • il consumatore valida con uno schema che imposta additionalProperties: false;
  • il consumatore percorre l'oggetto in un ordine fisso, o legge i valori per posizione invece che per chiave;
  • il nuovo campo riusa un vecchio nome ma ne cambia il significato, per esempio allargando status da "stato dell'ordine" a "stato di evasione".

Un'aggiunta sicura richiede tre precondizioni: il campo è opzionale, il suo significato è indipendente, e i consumatori ignorano le chiavi sconosciute. Confermale tutte e tre prima ancora di discutere un numero di versione.

{
  "schemaVersion": 2,
  "orderId": "ord-8842",
  "status": "paid",
  "refundDeadline": "2026-11-30T00:00:00+08:00",
  "channel": "wechat"
}

Qui refundDeadline e channel sono aggiunte opzionali della v2, mentre orderId e status mantengono la loro semantica originale. schemaVersion è persistito con il documento invece di vivere solo in un header HTTP.

2. Modifiche che rompono e che non puoi spedire

Tratta le seguenti modifiche come linee rosse che richiedono una nuova versione o una transizione a doppia scrittura. Aggiornare la documentazione non ti salverà:

Modifica Perché rompe la compatibilità Alternativa accettabile
Rimuovere un campo I consumatori leggono subito undefined Depreca, osserva, poi rimuovi dopo la finestra
Cambiare il tipo di un campo "1" diventa 1; i client fortemente tipizzati falliscono del tutto e quelli debolmente tipizzati spesso falliscono la validazione Aggiungi un nuovo nome di campo e scrivi in doppio durante la transizione
Aggiungere un campo obbligatorio I vecchi chiamanti non lo inviano, quindi la validazione fallisce sempre Fornisci un default, o rendilo obbligatorio solo nella nuova versione
Cambiare l'insieme dei valori enum Gli switch del consumatore mancano di un ramo di ripiego Insegna prima ai client ad accettare valori sconosciuti, poi allarga l'enum
Cambiare il significato di un campo I tipi restano validi mentre il comportamento cambia — il caso più difficile da debuggare Trattalo come un nuovo campo e congela la vecchia semantica
Cambiare la profondità di annidamento Ogni percorso di accesso deve cambiare Mantieni la vecchia forma e aggiungi campi piatti o un parametro expand

Cambiare il significato è la riga più pericolosa: la validazione dello schema resta verde, il monitoraggio non riporta errori, e solo le metriche di business vanno alla deriva. Qualsiasi cambio di significato dovrebbe essere spedito come un nuovo campo.

3. Dove vive la versione: documento, URL, media type

Le tre collocazioni comuni servono scopi diversi:

Posizione Esempio Adatta a Limitazione
Campo del documento "schemaVersion": 2 Database, code di messaggi, backup offline Lo strato di trasporto non può instradare su di esso
Percorso URL /v2/orders/8842 API pubbliche Accoppia gli indirizzi delle risorse alle versioni e rende la migrazione costosa
Media type application/vnd.acme.order.v2+json Servizi interni che necessitano negoziazione del contenuto I chiamanti devono impostare correttamente Accept

Una divisione pratica del lavoro: usa la negoziazione del media type sul filo, e persisti sempre un campo versione nel documento. I vecchi messaggi in una coda non si aggiornano perché hai cambiato un URL, e nemmeno i file di backup. Solo uno schemaVersion nel documento dà a replay, backfill e audit una base.

GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json

Se versioni l'URL, almeno mantieni il vecchio percorso che restituisce la vecchia forma per l'intera finestra di deprecazione, invece di puntarlo silenziosamente alla nuova implementazione. Per decisioni più ampie sui contratti API, vedi Punti chiave per progettare contratti di dati API JSON.

4. Aprire una finestra di deprecazione

La deprecazione non è un singolo annuncio; è una finestra con un inizio, un periodo di osservazione e una fine. Un ritmo praticabile:

  1. Freeze: smetti di scrivere il vecchio campo, ma continua a leggerlo.
  2. Announce: segna la data di rimozione nella documentazione, nel changelog e in un header della risposta contemporaneamente.
  3. Observe: conta le letture del vecchio campo per chiamante finché non resta a zero per due periodi consecutivi.
  4. Remove: solo dopo che la finestra si chiude, le letture sono zero e i test di regressione passano, elimina il campo e il suo codice di compatibilità.

L'header di risposta Sunset (RFC 8594) dà ai client una data di rimozione leggibile dalla macchina. Deprecation è ancora una bozza IETF: usabile, ma non assumere che ogni client la capisca. Abbinala a un header Link che punta alla guida alla migrazione:

Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"

La lunghezza della finestra è una convenzione di squadra. Un pavimento comune è un ciclo di rilascio o 90 giorni, quello più lungo. Le API pubbliche dovrebbero averne di più, i servizi interni possono averne meno, ma mai meno del tempo di cui ogni chiamante ha bisogno per aggiornarsi.

5. Trovare i consumatori prima di deprecare

Prima di deprecare, devi essere in grado di rispondere a "chi legge ancora questo?" Altrimenti la finestra è autoinganno. Ordina le prove per affidabilità:

  • Log del gateway: aggrega gli accessi al vecchio campo per appId, User-Agent e versione del client.
  • Tracce distribuite: tagga il vecchio campo nello strato di serializzazione così una traccia mostra direttamente il nome del servizio chiamante.
  • Registro dei consumatori della coda: l'elenco dei gruppi di consumatori è più affidabile di una ricerca nel codice.
  • Ricerca nel repository: rg copre solo il tuo codice; i chiamanti esterni necessitano comunque dei log.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats

Un successo nella ricerca prova "qualcuno in questo repository lo legge", non "nessun altro lo fa". Esporta i log del gateway giorno per giorno e conferma che le letture del vecchio campo sono rimaste a zero prima di rimuovere qualsiasi cosa.

6. Migrazione dei dati: expand, backfill, contract

Il Parallel Change di Martin Fowler descrive tre passi: expand (vecchi e nuovi campi coesistono e entrambe le versioni possono essere lette), backfill (sposta i record storici nella nuova forma) e contract (rimuovi il vecchio campo e i rami di compatibilità). Non invertire l'ordine, o non avrai un percorso di rollback.

// Migration skeleton: cursor by primary key, conditional writes for idempotency
async function migrateBatch(collection, cursor, batchSize = 500) {
  const records = await collection
    .find({ _id: { $gt: cursor } })
    .limit(batchSize)
    .toArray();

  if (records.length === 0) {
    return { nextCursor: cursor, done: true };
  }

  const operations = records
    .filter((record) => record.schemaVersion < 2)
    .map((record) => ({
      updateOne: {
        filter: { _id: record._id, schemaVersion: record.schemaVersion },
        update: {
          $set: {
            schemaVersion: 2,
            status: record.legacyStatus ?? record.status,
            refundDeadline: record.refundDeadline ?? null,
          },
        },
      },
    }));

  if (operations.length > 0) {
    await collection.bulkWrite(operations);
  }

  return { nextCursor: records.at(-1)._id, done: false };
}

Dettagli facili da trascurare:

  • Idempotenza: includi il vecchio numero di versione nel filter, così una riesecuzione non riscrive mai un record già migrato.
  • Batch e checkpoint: avanza per cursore _id, memorizza un checkpoint, e riprendi da lì dopo un fallimento.
  • Prova generale: esegui lo script contro uno snapshot di produzione e confronta conteggi dei record e checksum prima e dopo.
  • Rollback: mantieni il vecchio campo finché il nuovo campo non supera la validazione; cambia i percorsi di lettura con un flag e non fare mai rollback modificando solo schemaVersion.
  • Validazione: esegui i vincoli di business dopo la migrazione (importi non negativi, transizioni di stato legali), non solo un controllo di parsing JSON.

I dati di configurazione seguono lo stesso processo; vedi Gestire JSON per configurazione, fixture e dati locali per come organizzare quei file.

7. Contract test: mettere la compatibilità in CI

La compatibilità non può dipendere dalla memoria della revisione del codice; necessita di test. Due strati bastano:

  1. Test di forma: valida le risposte con JSON Schema, ma non impostare additionalProperties: false sugli schemi usati dai consumatori.
  2. Test del sottoinsieme del consumatore: estrai i campi su cui il vecchio consumatore si basa davvero in un sotto-schema e asserisci che le nuove risposte lo soddisfino ancora.
// Contract test: new responses must still satisfy the old consumer subset
import Ajv from 'ajv';
import assert from 'node:assert/strict';
import { test } from 'node:test';

const ajv = new Ajv({ strict: false });
const validateLegacySubset = ajv.compile(legacyConsumerSubsetSchema);

test('a v2 response still satisfies the v1 consumer subset', () => {
  const response = buildOrderResponse({ schemaVersion: 2 });
  const { schemaVersion, ...legacyFields } = response;
  const isValid = validateLegacySubset(legacyFields);

  assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});

Il sotto-schema dichiara solo i campi e i tipi che il vecchio consumatore legge e lascia passare ogni nuovo campo. Questo intercetta campi rimossi e cambi di tipo senza falsi allarmi sulle aggiunte. Mantieni i dati dei casi in una directory di fixture, ordinati e diffabili; per i conflitti di merge in quei file, vedi Conflitti Git nei file JSON: union scrive JSON rotto, fai il merge per chiave.

8. Rollout canary: scala per dimensione, fermati sulle metriche

I problemi di compatibilità raramente emergono nei test unitari; emergono in un canary. Allarga l'esposizione da stretta ad ampia:

Dimensione Pratica Cosa valida
Allowlist di tenant Abilita prima il nuovo percorso di lettura per clienti interni o amichevoli Differenze di forma dei dati del mondo reale
Versione del client Abilita solo per i client già aggiornati alla nuova forma Client vecchi e nuovi in esecuzione fianco a fianco
Percentuale di traffico 5% → 25% → 100%, osservando un ciclo di business completo per passo Chiamate della coda lunga e comportamento della cache

Osserva almeno tre metriche: tasso di errore dell'API, tasso di fallimento della validazione del contratto, e rapporto di lettura tra vecchi e nuovi campi. Definisci condizioni di stop automatiche, come 5xx sopra l'1% o un improvviso rimbalzo delle letture del vecchio campo, e fai rollback del flag. Mantieni le doppie scritture durante il canary così il rollback richiede solo cambiare il percorso di lettura, non riparare i dati.

9. Una lista di controllo della compatibilità prima di spedire

  • [ ] I nuovi campi sono opzionali, semanticamente indipendenti, e non riusano vecchi nomi.
  • [ ] I consumatori gestiscono campi sconosciuti e valori enum sconosciuti con un ripiego.
  • [ ] Il campo versione è persistito con il documento ed è negoziabile sul filo.
  • [ ] La finestra di deprecazione ha un annuncio, un header Sunset e un'osservazione.
  • [ ] Le letture del vecchio campo possono essere ricondotte a specifici chiamanti tramite log o tracce.
  • [ ] Lo script di migrazione è idempotente, a batch, ripetibile e ha un percorso di rollback.
  • [ ] I contract test coprono il sottoinsieme del vecchio consumatore e girano in CI.
  • [ ] Il canary ha dimensioni di esposizione esplicite e metriche di stop.

FAQ

Aggiungere un campo opzionale è sempre retrocompatibile?

No. È compatibile solo quando i consumatori ignorano i campi sconosciuti ed evitano una validazione rigorosa dello schema. Un consumatore che usa additionalProperties: false o legge per posizione fallirà comunque. Verifica la loro tolleranza con un contract test prima di spedire.

schemaVersion dovrebbe stare nell'URL o nel corpo della risposta?

Il filo può negoziare le versioni tramite media type o percorsi URL, ma i record memorizzati devono portare schemaVersion dentro il documento. Code, backup offline e audit non passano mai attraverso un URL, quindi solo una versione nel documento può supportare replay e backfill.

Un campo deprecato può essere eliminato subito?

No. Smetti di scriverlo, annuncia la data di rimozione, e conta le letture per chiamante. Elimina solo dopo che la finestra si chiude e le letture restano a zero. Prima dell'eliminazione, esegui una regressione completa per confermare che nessun cron job o report nascosto lo legga ancora.

E se uno script di migrazione fallisce a metà?

Progettalo come un job idempotente: aggiornamenti condizionali, un cursore sulla chiave primaria e checkpoint. Riprendi dal checkpoint dopo un fallimento, e le esecuzioni ripetute non devono scrivere due volte. Per il rollback, mantieni il vecchio campo e uno snapshot; non limitarti a ripristinare schemaVersion.

Come devo sequenziare una migrazione dei dati?

Segui Parallel Change: prima expand così vecchi e nuovi campi coesistono ed entrambe le versioni possono essere lette, poi backfill dei record storici nella nuova forma, poi contract rimuovendo il vecchio campo e i rami di compatibilità. Non invertire l'ordine, o perdi il percorso di rollback, e rendi lo script di migrazione idempotente, a batch e ripetibile.