Versionner les contrats JSON : compatibilité, dépréciation et migration de données
L’essentiel : Ajouter un champ n'est pas automatiquement sûr. Couvre les frontières de compatibilité, les champs de version, les fenêtres de dépréciation, la migration à double écriture, les tests de contrat et les métriques de canari.

L'essentiel : faites l'évolution de version en étendre, remplir, puis contracter, et persistez la version dans le document ; confirmez que les consommateurs tolèrent les champs et valeurs d'énumération inconnus avant de livrer quoi que ce soit, et menez la dépréciation par geler, annoncer, observer et retirer, dans cet ordre.
La partie difficile du versionnement n'est pas de changer le code ; c'est que vous ne pouvez pas savoir qui lit encore l'ancien champ. La compatibilité n'est jamais une promesse à sens unique — c'est un alignement continu entre l'éditeur et des consommateurs inconnus.
1. Frontières de compatibilité : l'ajout n'est pas automatiquement sûr
Les manuels appellent un nouveau champ facultatif « rétrocompatible », mais cela ne tient que si les consommateurs tolèrent les champs inconnus. Ajouter un champ casse encore les clients dans au moins trois situations :
- le consommateur valide avec un schéma qui définit
additionalProperties: false; - le consommateur parcourt l'objet dans un ordre fixe, ou lit les valeurs par position plutôt que par clé ;
- le nouveau champ réutilise un ancien nom mais change son sens, par exemple élargir
statusde « statut de commande » à « statut de traitement ».
Un ajout sûr exige trois préconditions : le champ est facultatif, son sens est indépendant, et les consommateurs ignorent les clés inconnues. Confirmez les trois avant même de discuter d'un numéro de version.
{
"schemaVersion": 2,
"orderId": "ord-8842",
"status": "paid",
"refundDeadline": "2026-11-30T00:00:00+08:00",
"channel": "wechat"
}
Ici refundDeadline et channel sont des ajouts facultatifs de v2, tandis que orderId et status conservent leur sémantique d'origine. schemaVersion est persisté avec le document plutôt que de vivre uniquement dans un en-tête HTTP.
2. Changements cassants que vous ne pouvez pas livrer
Traitez les changements suivants comme des lignes rouges exigeant une nouvelle version ou une transition à double écriture. Mettre à jour la documentation ne vous sauvera pas :
| Changement | Pourquoi il casse la compatibilité | Alternative acceptable |
|---|---|---|
| Supprimer un champ | Les consommateurs lisent undefined immédiatement |
Déprécier, observer, puis retirer après la fenêtre |
| Changer le type d'un champ | "1" devient 1 ; les clients fortement typés échouent d'emblée et les faiblement typés échouent souvent à la validation |
Ajouter un nouveau nom de champ et écrire les deux pendant la transition |
| Ajouter un champ obligatoire | Les anciens appelants ne l'envoient pas, donc la validation échoue toujours | Fournir une valeur par défaut, ou le rendre obligatoire seulement dans la nouvelle version |
| Changer l'ensemble des valeurs d'énumération | Les instructions switch des consommateurs n'ont pas de branche de repli |
Apprendre aux clients à accepter des valeurs inconnues d'abord, puis élargir l'énumération |
| Changer le sens d'un champ | Les types restent valides tandis que le comportement change — le cas le plus difficile à déboguer | Le traiter comme un nouveau champ et geler l'ancienne sémantique |
| Changer la profondeur d'imbrication | Chaque chemin d'accès doit changer | Garder l'ancienne forme et ajouter des champs plats ou un paramètre expand |
Le changement de sens est la ligne la plus dangereuse : la validation de schéma reste verte, la surveillance ne rapporte aucune erreur, et seules les métriques métier dérivent. Tout changement de sens devrait être livré comme un nouveau champ.
3. Où vit la version : document, URL, type de média
Les trois emplacements courants servent des objectifs différents :
| Emplacement | Exemple | Bon pour | Limite |
|---|---|---|---|
| Champ du document | "schemaVersion": 2 |
Bases de données, files de messages, sauvegardes hors ligne | La couche transport ne peut pas router dessus |
| Chemin d'URL | /v2/orders/8842 |
API publiques | Lie les adresses de ressources aux versions et rend la migration coûteuse |
| Type de média | application/vnd.acme.order.v2+json |
Services internes ayant besoin de négociation de contenu | Les appelants doivent définir correctement Accept |
Une répartition pratique du travail : utilisez la négociation de type de média sur le fil, et persistez toujours un champ de version dans le document. Les anciens messages dans une file ne se mettront pas à niveau parce que vous avez changé une URL, et les fichiers de sauvegarde non plus. Seul un schemaVersion dans le document donne aux rejeux, remplissages et audits une base.
GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json
Si vous versionnez l'URL, gardez au moins l'ancien chemin renvoyant l'ancienne forme pendant toute la fenêtre de dépréciation au lieu de le pointer discrètement vers la nouvelle implémentation. Pour des décisions plus larges de contrat d'API, voir Points clés pour concevoir des contrats de données d'API JSON.
4. Ouvrir une fenêtre de dépréciation
La dépréciation n'est pas une simple annonce ; c'est une fenêtre avec un début, une période d'observation et une fin. Un rythme viable :
- Geler : arrêter d'écrire l'ancien champ, mais continuer à le lire.
- Annoncer : marquer la date de retrait dans la doc, le changelog et un en-tête de réponse en même temps.
- Observer : compter les lectures de l'ancien champ par appelant jusqu'à ce qu'elles restent à zéro pendant deux périodes consécutives.
- Retirer : seulement après la fermeture de la fenêtre, des lectures à zéro et des tests de régression réussis, supprimer le champ et son code de compatibilité.
L'en-tête de réponse Sunset (RFC 8594) donne aux clients une date de retrait lisible par machine. Deprecation est encore un brouillon IETF : utilisable, mais ne supposez pas que chaque client le comprend. Associez-le à un en-tête Link pointant vers le guide de migration :
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 longueur de la fenêtre est une convention d'équipe. Un plancher courant est un cycle de livraison ou 90 jours, selon le plus long. Les API publiques devraient en avoir plus, les services internes moins, mais jamais moins que le temps nécessaire à chaque appelant pour se mettre à niveau.
5. Trouver les consommateurs avant de déprécier
Avant de déprécier, vous devez pouvoir répondre à « qui lit encore ceci ? » Sinon la fenêtre est une auto-illusion. Classez les preuves par fiabilité :
- Journaux de passerelle : agrégez l'accès à l'ancien champ par
appId,User-Agentet version de client. - Traces distribuées : taguez l'ancien champ dans la couche de sérialisation pour qu'une trace montre directement le nom du service appelant.
- Registre de consommateurs de file : la liste des groupes de consommateurs est plus fiable qu'une recherche de code.
- Recherche dans le dépôt :
rgne couvre que votre propre code ; les appelants externes exigent encore les journaux.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats
Un résultat de recherche prouve « quelqu'un dans ce dépôt le lit », pas « personne d'autre ne le fait ». Exportez les journaux de passerelle jour par jour et confirmez que les lectures de l'ancien champ sont restées à zéro avant de retirer quoi que ce soit.
6. Migration de données : étendre, remplir, contracter
Le Parallel Change de Martin Fowler décrit trois étapes : étendre (ancien et nouveau champs coexistent et les deux versions peuvent être lues), remplir (déplacer les enregistrements historiques vers la nouvelle forme), et contracter (supprimer l'ancien champ et les branches de compatibilité). N'inversez pas l'ordre, sinon vous n'aurez pas de chemin de retour arrière.
// 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 };
}
Détails faciles à manquer :
- Idempotence : incluez l'ancien numéro de version dans le
filter, pour qu'une réexécution ne réécrive jamais un enregistrement déjà migré. - Lots et points de contrôle : avancez par curseur
_id, stockez un point de contrôle, et reprenez à partir de là après un échec. - Répétition : exécutez le script sur un snapshot de production et comparez nombres d'enregistrements et sommes de contrôle avant et après.
- Retour arrière : gardez l'ancien champ jusqu'à ce que le nouveau passe la validation ; basculez les chemins de lecture avec un indicateur et ne revenez jamais en arrière en modifiant
schemaVersionseul. - Validation : exécutez les contraintes métier après migration (montants non négatifs, transitions d'état légales), pas seulement une vérification de parsing JSON.
Les données de configuration suivent le même processus ; voir Gérer JSON pour la configuration, les fixtures et les données locales pour l'organisation de ces fichiers.
7. Tests de contrat : mettre la compatibilité en CI
La compatibilité ne peut pas dépendre de la mémoire de la revue de code ; elle a besoin de tests. Deux couches suffisent :
- Tests de forme : validez les réponses avec JSON Schema, mais ne définissez pas
additionalProperties: falsesur les schémas utilisés par les consommateurs. - Tests de sous-ensemble consommateur : extrayez les champs dont l'ancien consommateur dépend réellement dans un sous-schéma et assertez que les nouvelles réponses le satisfont encore.
// 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));
});
Le sous-schéma ne déclare que les champs et types que l'ancien consommateur lit et laisse passer chaque nouveau champ. Cela attrape les champs supprimés et les changements de type sans fausses alertes sur les ajouts. Gardez les données de cas dans un répertoire de fixtures, ordonnées et diffables ; pour les conflits de fusion dans ces fichiers, voir Conflits Git des fichiers JSON : union écrit du JSON cassé, fusionnez par clé à la place.
8. Déploiement canari : élargir par dimension, arrêter sur métriques
Les problèmes de compatibilité apparaissent rarement dans les tests unitaires ; ils apparaissent dans un canari. Élargissez l'exposition du plus étroit au plus large :
| Dimension | Pratique | Ce que cela valide |
|---|---|---|
| Liste blanche de tenants | Activer le nouveau chemin de lecture d'abord pour les clients internes ou amis | Différences réelles de forme des données |
| Version de client | Activer seulement pour les clients déjà mis à niveau vers la nouvelle forme | Anciens et nouveaux clients fonctionnant côte à côte |
| Pourcentage de trafic | 5 % → 25 % → 100 %, en observant un cycle métier complet par étape | Appels de longue traîne et comportement du cache |
Surveillez au moins trois métriques : taux d'erreur API, taux d'échec de validation de contrat, et ratio de lecture entre ancien et nouveau champs. Définissez des conditions d'arrêt automatique, comme des 5xx au-dessus de 1 % ou un rebond soudain des lectures de l'ancien champ, et basculez l'indicateur en retour. Gardez les doubles écritures actives pendant le canari pour que le retour arrière n'exige que de basculer le chemin de lecture, pas de réparer des données.
9. Une liste de vérification de compatibilité avant livraison
- [ ] Les nouveaux champs sont facultatifs, sémantiquement indépendants et ne réutilisent pas d'anciens noms.
- [ ] Les consommateurs gèrent les champs inconnus et les valeurs d'énumération inconnues avec un repli.
- [ ] Le champ de version est persisté avec le document et négociable sur le fil.
- [ ] La fenêtre de dépréciation a une annonce, un en-tête
Sunsetet une observation. - [ ] Les lectures de l'ancien champ peuvent être tracées jusqu'à des appelants précis via journaux ou traces.
- [ ] Le script de migration est idempotent, loti, reprenable et a un chemin de retour arrière.
- [ ] Les tests de contrat couvrent le sous-ensemble de l'ancien consommateur et tournent en CI.
- [ ] Le canari a des dimensions d'exposition et des métriques d'arrêt explicites.
FAQ
Ajouter un champ facultatif est-il toujours rétrocompatible ?
Non. Ce n'est compatible que lorsque les consommateurs ignorent les champs inconnus et évitent la validation de schéma stricte. Un consommateur utilisant additionalProperties: false ou lisant par position échouera encore. Vérifiez leur tolérance avec un test de contrat avant de livrer.
schemaVersion doit-il vivre dans l'URL ou dans le corps de réponse ?
Le fil peut négocier les versions via des types de média ou des chemins d'URL, mais les enregistrements stockés doivent porter schemaVersion dans le document. Les files, les sauvegardes hors ligne et les audits ne passent jamais par une URL, donc seul une version dans le document peut soutenir le rejeu et le remplissage.
Un champ déprécié peut-il être supprimé immédiatement ?
Non. Arrêtez de l'écrire, annoncez la date de retrait, et comptez les lectures par appelant. Supprimez seulement après la fermeture de la fenêtre et des lectures restées à zéro. Avant la suppression, lancez une régression complète pour confirmer qu'aucune tâche cron cachée ou rapport ne le lit encore.
Que faire si un script de migration échoue à mi-chemin ?
Concevez-le comme une tâche idempotente : mises à jour conditionnelles, curseur par clé primaire et points de contrôle. Reprenez depuis le point de contrôle après un échec, et les exécutions répétées ne doivent pas écrire deux fois. Pour le retour arrière, gardez l'ancien champ et un snapshot ; ne vous contentez pas d'annuler schemaVersion.
Comment séquencer une migration de données ?
Suivez Parallel Change : étendez d'abord pour que ancien et nouveau champs coexistent et que les deux versions soient lisibles, remplissez les enregistrements historiques vers la nouvelle forme, puis contractez en supprimant l'ancien champ et les branches de compatibilité. N'inversez pas l'ordre, sinon vous perdez le chemin de retour arrière, et rendez le script de migration idempotent, loti et reprenable.