Changements cassants Protobuf : ce qui survit sur le fil
7 min de lecture
Une API REST change quand une forme JSON change, et la majeure partie de cette forme est visible
dans la réponse qu’on peut lire dans un navigateur. Une API gRPC change quand un fichier .proto
change, et le format binaire sur le fil de Protocol Buffers a ses propres règles sur ce qu’un
client peut tolérer, qui n’ont rien à voir avec ce que disent les noms de champs. Deux
modifications qui paraissent également petites dans un diff, renuméroter un champ contre en
ajouter un, tombent des côtés opposés d’une ligne que changements cassants
trace en général : l’une est invisible pour chaque client existant, l’autre les casse tous d’un
coup. Distinguer les changements cassants Protobuf des changements sûrs veut dire lire les règles
propres du format sur le fil, pas deviner d’après l’apparence du changement dans un diff .proto.
Pourquoi la numérotation des champs compte-t-elle plus que le nom du champ dans Protobuf ?
Parce que le format sur le fil encode les champs par numéro, pas par nom. Le code généré dans
chaque langage lit et écrit ces numéros ; le nom de champ email dans votre fichier .proto est
une commodité pour les humains qui ne touche jamais les octets binaires envoyés sur le réseau.
Renommer un champ, email en email_address, est sûr sur le fil binaire tant que le numéro
reste le même, ce qui surprend les ingénieures habituées à REST, où une clé JSON renommée est
exactement le type de changement qui casse un client. L’exception est ce même cas REST : les
formats ProtoJSON et texte sérialisent le nom, donc
un renommage casse le transcodage JSON (un grpc-gateway, par exemple), les fichiers au format texte
et les field masks. Renuméroter ce même champ, en gardant le
nom mais en changeant 1 en 7, est exactement l’inverse : invisible dans une revue de code qui
ne montre que les noms, et ça corrompt chaque message qu’un client envoie ou reçoit à partir de ce
moment-là.
| Changement | Sûr sur le fil | Pourquoi |
|---|---|---|
| Renommer un champ, garder son numéro | Binaire oui, JSON et texte non | L’encodage binaire utilise le numéro ; ProtoJSON et le format texte utilisent le nom |
| Changer le numéro d’un champ | Non | Chaque message existant est maintenant lu comme le mauvais champ |
| Ajouter un nouveau champ avec un nouveau numéro | Oui | Les anciens clients ignorent les champs qu’ils ne reconnaissent pas |
| Supprimer un champ, réutiliser son ancien numéro pour autre chose | Non | Les anciennes données se décodent dans le mauvais nouveau champ |
Changer le type d’un champ de façon incompatible (ex. int32 en string) | Non | L’encodage sur le fil diffère selon le type |
Qu’est-ce qui rend la suppression d’un champ différente de celle dans une réponse JSON REST ?
Le numéro devient radioactif. Les directives de Protobuf elles-mêmes
recommandent de marquer le numéro d’un champ supprimé comme reserved plutôt que de le laisser
être réutilisé, parce que la
réutilisation est où le vrai dégât se produit : un client qui fait encore tourner du code généré
du mois dernier envoie un message utilisant l’ancien numéro du champ pour l’ancienne signification,
et le serveur, qui s’attend maintenant à ce que ce numéro signifie autre chose, mal-interprète les
données en silence au lieu de les rejeter carrément. REST n’a pas de piège équivalent, parce
qu’une clé JSON supprimée arrête simplement d’apparaître ; il n’y a aucun moyen pour la requête
d’un ancien client d’être silencieusement réinterprétée comme autre chose. Un fichier .proto
avec reserved 4, 9, 12; en haut d’un message est une cicatrice permanente, et c’est exactement le
but : ça empêche le numéro d’être attribué à un nouveau champ par quelqu’un qui n’en connaissait
pas l’histoire.
message Invoice {
reserved 4; // était `legacy_customer_id`, supprimé le 2026-06-01
reserved "legacy_customer_id"; // le nom aussi, pour JSON/texte
string customer_id = 5;
string status = 6;
}
Ajouter un champ nécessite-t-il jamais une entrée de changelog ?
Généralement pas une entrée de changement cassant, mais souvent une entrée normale, parce que « sûr sur le fil » et « invisible pour une lectrice à qui ça importe » sont deux affirmations différentes. Ajouter un champ à un message de réponse ne coûte rien structurellement, les anciens clients décodent le message et ignorent le nouveau champ automatiquement. Mais quelqu’un qui construit une nouvelle intégration contre ce service n’a aucun moyen de savoir que le champ existe à moins que quelqu’un le lui dise, parce que rien dans un build réussi ou un test qui passe ne rend visible un nouveau champ optionnel. Changelog d’API couvre en général ce qu’une entrée additive doit à quelqu’un qui la lit ; la raison spécifique à gRPC d’en écrire une quand même, c’est qu’il n’y a pas d’équivalent à parcourir une réponse REST dans un débogueur pour remarquer qu’une nouvelle clé est apparue.
En quoi est-ce différent de ce à quoi font face les appelants GraphQL ?
Les règles pour les ajouts sont les mêmes, mais l’exposition diffère. Dépréciation de schéma GraphQL couvre un modèle où un client ne reçoit que les champs qu’il demande explicitement, ce qui rend les changements additifs essentiellement sans risque et les suppressions le seul vrai danger. Les clients gRPC, à l’inverse, reçoivent tout ce que le serveur envoie et décodent tout contre leur propre copie compilée du schéma ; l’exposition d’un client n’est pas limitée par ce qu’il a demandé, seulement par ce que son code généré sait lire. Cette différence compte pour écrire des changelogs : une entrée GraphQL peut raisonnablement supposer que les clients sont protégés des champs qu’ils n’ont pas demandés, et une entrée gRPC ne peut pas du tout supposer ça.
Versionner un service gRPC fonctionne-t-il comme les /v1/, /v2/ de REST ?
Le mécanisme est différent même quand l’intention est la même. Que sont v1 et v2 dans une API
REST couvre le versionnage comme des chemins d’URL
parallèles servant des contrats différents ; les services gRPC versionnent typiquement via le nom
de package dans le fichier .proto lui-même, payments.v1.InvoiceService devenant
payments.v2.InvoiceService, ce qui change le nom de service complètement qualifié qu’un client
compose plutôt qu’un segment d’URL qu’il demande. Les deux approches résolvent le même problème,
laisser un ancien contrat continuer de fonctionner pendant qu’un nouveau existe, mais une équipe
venant d’un contexte REST cherche souvent un numéro de version au mauvais endroit et rate que la
déclaration de package fait ce travail.
Que devrait vraiment nommer une entrée de changelog gRPC ?
Le message, le numéro de champ, et si c’est additif ou une suppression nécessitant une migration,
dans cet ordre d’importance pour une lectrice décidant d’agir ou non. « Ajout de
shipping_address (champ 8) à Order » dit à une intégratrice tout ce dont elle a besoin pour
mettre à jour le code généré et commencer à l’utiliser. « Champ 4 réservé sur Invoice,
legacy_customer_id a disparu » lui dit de vérifier si quelque chose dans son code lit encore ce
champ, ce qu’une note style REST « champ supprimé de la réponse » ne communique pas avec la même
urgence, parce que les suppressions REST retournent simplement moins de données alors que la
réutilisation de champ Protobuf les corrompt activement.
FAQ
Le type d’un champ peut-il jamais être changé sans casser le format sur le fil ?
Seulement dans des groupes compatibles spécifiques que Protobuf documente, comme élargir int32
en int64 dans certains cas. Traitez tout changement de type comme cassant à moins de l’avoir
vérifié contre la table de compatibilité de Protobuf elle-même ; supposer une compatibilité par
analogie avec le système de types d’un langage est comment ça tourne mal.
Déprécier un champ dans Protobuf fonctionne-t-il comme la directive @deprecated de GraphQL ?
De façon similaire : Protobuf supporte une option de champ [deprecated = true] que l’outillage
peut afficher. Aucune des deux n’est appliquée : un serveur GraphQL répond toujours à une requête
sur un champ déprécié, et un client protobuf en encode toujours un. Les deux sont indicatives et
nécessitent le même appui de changelog.
Renuméroter est-il jamais sûr si vous contrôlez chaque client ? Dans un système entièrement fermé, en principe, mais ça élimine toute la propriété de sécurité pour laquelle les numéros de champ existent, et « on contrôle chaque client » est une affirmation qui cesse d’être vraie dès qu’un build est mis en cache, qu’un déploiement est retardé, ou qu’un client est ajouté dont personne ne se souvenait. Réservez le numéro plutôt que de le réutiliser, même en interne.
Les services gRPC ont-ils besoin d’une page de changelog comme une API REST publique ?
Seulement si des équipes externes les consomment sans lire directement les diffs .proto, le même
test « qui est de l’autre côté » que les changelogs d’API interne
applique en général. Un service gRPC consommé seulement par d’autres services de la même équipe
peut souvent se passer d’un changelog formel en faveur de l’historique des commits, parce que
quiconque le lit a déjà le schéma ouvert.
Les affirmations techniques de cet article n'ont pas été vérifiées de façon indépendante. Si quelque chose est faux, dis-le-nous et nous le corrigerons.