Encodage & évolution des schémas
Sérialiser les données pour les faire durer et évoluer : JSON, Thrift, Protocol Buffers, Avro, et la compatibilité ascendante/descendante.
Une application ne cesse jamais de changer. On ajoute des fonctionnalités, on affine la compréhension des besoins, le contexte métier évolue — et presque toujours, un changement de fonctionnalité entraîne un changement dans les données stockées : un nouveau champ, un nouveau type d'enregistrement, une donnée existante présentée autrement. Le problème, c'est que ces changements ne se propagent pas instantanément. Côté serveur, on procède à un déploiement progressif (rolling upgrade) : on met à jour quelques nœuds à la fois pour vérifier que tout va bien. Côté client, on dépend du bon vouloir de l'utilisateur, qui n'installera peut-être jamais la mise à jour.
La conséquence est inévitable : anciennes et nouvelles versions du code, anciens et nouveaux formats de données coexistent dans le système au même moment. Pour que la machine continue de tourner, il faut maintenir la compatibilité dans les deux directions. La compatibilité ascendante (backward compatibility) signifie qu'un code récent sait lire des données écrites par un code plus ancien ; elle est généralement facile à obtenir, puisque l'auteur du nouveau code connaît l'ancien format. La compatibilité descendante (forward compatibility) signifie qu'un code ancien sait lire des données écrites par un code plus récent ; elle est plus délicate, car elle exige du vieux code qu'il ignore proprement les ajouts qu'une version future fera. Ce chapitre explore les formats d'encodage — JSON, XML, Thrift, Protocol Buffers, Avro — sous l'angle de cette double compatibilité, puis examine les trois grands modes de circulation des données.
Encoder, décoder : traduire entre deux mondes
Un programme manipule ses données sous (au moins) deux représentations. En mémoire, ce sont des objets, des structures, des listes, des tables de hachage, des arbres — optimisés pour un accès rapide par le processeur, typiquement à coups de pointeurs. Mais dès qu'on veut écrire ces données dans un fichier ou les envoyer sur le réseau, il faut les transformer en une séquence d'octets autonome : un pointeur n'aurait aucun sens pour un autre processus.
La traduction de la représentation en mémoire vers une séquence d'octets s'appelle l'encodage (encoding, aussi nommé sérialisation ou marshalling), et l'opération inverse le décodage (decoding, parsing, désérialisation). Kleppmann choisit délibérément le mot « encodage » dans tout le livre, car « sérialisation » désigne aussi une notion totalement différente dans le contexte des transactions.
Note
L'encodage n'a rien à voir avec le chiffrement (encryption). Il s'agit uniquement de représenter une structure de données comme une suite d'octets, pas de la protéger.
Les formats spécifiques à un langage : un piège commode
Beaucoup de langages offrent un encodage intégré : java.io.Serializable en Java, Marshal en Ruby, pickle en Python, ou des bibliothèques tierces comme Kryo. C'est terriblement pratique — on sauvegarde et restaure un objet en quelques lignes. Mais ces formats cachent plusieurs problèmes profonds qui les rendent inadaptés à autre chose qu'un usage très éphémère.
- Couplage au langage. L'encodage est lié à un langage donné ; relire les données dans un autre langage est très difficile. Stocker des données dans ce format, c'est s'enchaîner à son langage actuel pour très longtemps et se fermer à l'intégration avec d'autres organisations.
- Faille de sécurité. Pour restaurer les objets, le décodeur doit pouvoir instancier des classes arbitraires. Si un attaquant parvient à faire décoder une séquence d'octets de son choix, il peut instancier ce qu'il veut, ce qui mène souvent à l'exécution de code arbitraire à distance.
- Versionnage négligé. Conçus pour la rapidité, ces formats traitent la compatibilité ascendante et descendante comme une arrière-pensée.
- Inefficacité. La sérialisation native de Java est tristement célèbre pour ses mauvaises performances et son encodage boursouflé.
Formats texte standards : JSON, XML, CSV
Pour des encodages lisibles par de nombreux langages, JSON et XML sont les candidats évidents, « presque aussi détestés qu'ils sont répandus ». XML passe pour trop verbeux et inutilement compliqué ; JSON doit sa popularité à son support natif dans les navigateurs (étant un sous-ensemble de JavaScript) et à sa simplicité relative. CSV complète le tableau : indépendant du langage, mais moins puissant. Ces trois formats sont textuels, donc lisibles par un humain. Voici l'enregistrement que tout le chapitre va décliner :
{
"userName": "Martin",
"favoriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
} Au-delà des querelles de syntaxe, ces formats souffrent de problèmes subtils.
- Ambiguïté des nombres. En XML et CSV, on ne distingue pas un nombre d'une chaîne composée de chiffres (sauf via un schéma externe). JSON distingue chaînes et nombres, mais pas les entiers des flottants, et ne précise aucune précision. Or les entiers supérieurs à 2⁵³ ne tiennent pas exactement dans un flottant double précision IEEE 754 : un langage qui les parse en flottant (comme JavaScript) les corrompt. Twitter, qui identifie chaque tweet par un entier 64 bits, renvoie l'identifiant deux fois — une fois comme nombre JSON, une fois comme chaîne décimale — pour contourner ce défaut.
- Pas de données binaires. JSON et XML gèrent bien le texte Unicode mais pas les chaînes d'octets bruts. On contourne en encodant le binaire en Base64, ce qui gonfle la taille des données et reste bricolé.
- Schémas optionnels. XML et JSON disposent de langages de schéma, puissants mais compliqués. Beaucoup d'outils JSON s'en passent ; or, comme l'interprétation correcte des nombres et des binaires dépend du schéma, leur absence oblige à ajouter du code applicatif. CSV n'a aucun schéma, et reste un format vague (que faire d'une virgule ou d'un saut de ligne dans une valeur ?).
Astuce
Malgré leurs défauts, JSON, XML et CSV restent excellents comme formats d'échange entre organisations. Dans ce cas, peu importe l'élégance ou l'efficacité : la difficulté de mettre plusieurs organisations d'accord sur quoi que ce soit l'emporte sur tout le reste.
Formats binaires à schéma : Thrift et Protocol Buffers
Pour des données internes à l'organisation, la pression d'un format « plus petit dénominateur commun » disparaît. Sur de petits volumes le gain est négligeable, mais dès qu'on atteint les téraoctets, le choix du format pèse lourd. Il existe des variantes binaires de JSON (MessagePack, BSON…), mais comme elles ne prescrivent pas de schéma, elles doivent inclure les noms de champ dans chaque enregistrement : l'encodage MessagePack de l'exemple fait 66 octets, à peine moins que les 81 octets du JSON textuel sans espaces.
Apache Thrift (né chez Facebook) et Protocol Buffers (né chez Google), tous deux libérés en 2007-2008, vont beaucoup plus loin. Ils exigent un schéma pour toute donnée encodée. Le schéma Thrift, écrit dans son langage de définition d'interface (IDL), ressemble à ceci :
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
} Et l'équivalent en Protocol Buffers :
message Person {
required string user_name = 1;
optional int64 favorite_number = 2;
repeated string interests = 3;
} Chacun fournit un générateur de code qui produit, à partir du schéma, des classes dans le langage voulu. La différence clé avec MessagePack : les noms de champ disparaissent de l'encodage, remplacés par des numéros de tag (1, 2, 3 dans les schémas ci-dessus). Un tag est un alias compact qui dit de quel champ on parle sans épeler son nom. Chaque champ reste annoté de son type, ce qui permet au lecteur de connaître la longueur à lire. Avec son encodage CompactProtocol — qui empaquette type et tag dans un seul octet et code les entiers sur une longueur variable — Thrift descend à 34 octets ; Protocol Buffers fait 33 octets.
Note
Les marqueurs required et optional ne changent rien à l'encodage binaire : rien n'indique dans les octets si un champ était obligatoire. required n'active qu'une vérification à l'exécution, qui échoue si le champ est absent — utile pour attraper des bogues, dangereux pour l'évolution (voir plus bas).
Évolution des schémas avec les tags
Un enregistrement encodé n'est que la concaténation de ses champs, chacun identifié par son tag et annoté de son type ; un champ non renseigné est simplement omis. On peut donc en déduire toutes les règles d'évolution.
| Modification | Effet sur la compatibilité | Règle |
|---|---|---|
| Renommer un champ | Sans danger | L'encodage ne référence jamais les noms, seulement les tags. |
| Changer un tag | Casse tout | Toutes les données existantes deviennent invalides. |
| Ajouter un champ | Descendante préservée | Donnez-lui un nouveau tag ; le vieux code ignore le tag inconnu en sautant le nombre d'octets indiqué par le type. |
Ajouter un champ required | Casse l'ascendante | Le nouveau code échouera sur des données anciennes où ce champ manque. Tout ajout doit être optional ou avoir une valeur par défaut. |
| Supprimer un champ | Symétrique de l'ajout | On ne supprime qu'un champ optional, et on ne réutilise jamais son tag. |
Changer le type d'un champ est parfois possible, mais risque la perte de précision ou la troncature : passer d'un entier 32 bits à 64 bits laisse le vieux code lire dans une variable 32 bits une valeur qui peut déborder. Détail savoureux : Protocol Buffers n'a pas de type liste, mais un marqueur repeated où le même tag réapparaît plusieurs fois. On peut donc transformer un champ optional en repeated : le nouveau code lisant d'anciennes données voit une liste de zéro ou un élément, l'ancien code lisant les nouvelles ne voit que le dernier élément.
Apache Avro : le schéma de l'écrivain et celui du lecteur
Né en 2009 comme sous-projet de Hadoop (Thrift ne convenant pas à ses cas d'usage), Avro procède différemment. Son schéma — disponible en IDL pour l'humain ou en JSON pour la machine — ne contient aucun numéro de tag :
{
"type": "record",
"name": "Person",
"fields": [
{"name": "userName", "type": "string"},
{"name": "favoriteNumber", "type": ["null", "long"], "default": null},
{"name": "interests", "type": {"type": "array", "items": "string"}}
]
} Encodé avec ce schéma, l'enregistrement ne fait que 32 octets — le plus compact de tous. Et pour cause : les octets ne contiennent rien pour identifier les champs ou leurs types. Ce sont des valeurs concaténées. Une chaîne est un préfixe de longueur suivi d'octets UTF-8, mais rien ne dit que c'est une chaîne ; ce pourrait être un entier. Pour parser, on parcourt les champs dans l'ordre du schéma, qui donne le type de chacun. Conséquence redoutable : la donnée ne se décode correctement que si le lecteur utilise le bon schéma.
C'est ici qu'Avro introduit son idée maîtresse. Le schéma de l'écrivain (writer's schema) est la version compilée dans l'application qui encode. Le schéma du lecteur (reader's schema) est celui dont dépend l'application qui décode. Les deux n'ont pas besoin d'être identiques — seulement compatibles. À la lecture, la bibliothèque Avro place les deux schémas côte à côte et résout les différences :
- les champs sont appariés par leur nom, donc l'ordre peut différer sans incidence ;
- un champ présent côté écrivain mais absent côté lecteur est ignoré ;
- un champ présent côté lecteur mais absent côté écrivain est rempli par la valeur par défaut déclarée dans le schéma du lecteur.
Règles d'évolution Avro
| Direction | Définition Avro |
|---|---|
| Compatibilité descendante (forward) | Nouveau schéma comme écrivain, ancien comme lecteur. |
| Compatibilité ascendante (backward) | Nouveau schéma comme lecteur, ancien comme écrivain. |
La règle se résume en une phrase : on ne peut ajouter ou supprimer qu'un champ doté d'une valeur par défaut. Ajouter un champ sans défaut casse l'ascendante ; supprimer un champ sans défaut casse la descendante. Avro n'a donc ni required ni optional : pour autoriser null, on déclare explicitement un type union comme union { null, long }, et null ne peut servir de défaut que s'il figure parmi les branches. C'est un peu verbeux, mais cela évite des bogues en rendant explicite ce qui peut ou non valoir null. Renommer un champ est possible via des alias dans le schéma du lecteur (ascendant mais pas descendant) ; ajouter une branche à une union l'est aussi (ascendant, pas descendant).
Mais où est le schéma de l'écrivain ?
Question éludée jusqu'ici : comment le lecteur connaît-il le schéma ayant servi à l'écriture ? On ne peut pas joindre le schéma entier à chaque enregistrement, sinon il pèserait plus lourd que la donnée. La réponse dépend du contexte.
- Gros fichier, beaucoup d'enregistrements. Usage typique avec Hadoop : des millions d'enregistrements au même schéma. On écrit le schéma une seule fois en tête du fichier (format des object container files d'Avro).
- Base de données, enregistrements individuels. Des enregistrements écrits à des moments différents avec des schémas différents. On préfixe chaque enregistrement d'un numéro de version, et on garde une table des versions de schéma à interroger. C'est ainsi que fonctionne Espresso de LinkedIn.
- Connexion réseau bidirectionnelle. Les deux processus négocient la version à l'établissement de la connexion, puis la conservent pour toute sa durée. C'est ce que fait le protocole RPC d'Avro.
À retenir
L'absence de tags rend Avro bien plus adapté aux schémas générés dynamiquement. Pour exporter une base relationnelle, on génère un schéma Avro depuis le schéma relationnel ; si une colonne est ajoutée ou retirée, on régénère un schéma et on exporte — l'appariement par nom fait le reste, sans intervention. Avec Thrift ou Protocol Buffers, un administrateur devrait assigner les tags à la main en veillant à ne jamais réutiliser un ancien.
Les mérites d'un schéma binaire
Thrift, Protocol Buffers et Avro décrivent tous un encodage binaire par un schéma. Leurs langages sont bien plus simples que XML Schema ou JSON Schema (pas de validation par expression régulière ou de bornes numériques), ce qui les rend faciles à implémenter et à porter dans de nombreux langages. Leurs propriétés sont précieuses :
- ils sont bien plus compacts que les variantes « JSON binaire », car ils omettent les noms de champ ;
- le schéma est une documentation toujours à jour, puisqu'il est requis pour décoder ;
- une base de schémas permet de vérifier la compatibilité ascendante et descendante avant tout déploiement ;
- la génération de code offre un typage vérifié à la compilation dans les langages statiques (Java, C++, C#) — alors qu'en JavaScript, Ruby ou Python, où il n'y a pas de vérificateur à satisfaire, elle est souvent superflue, et Avro peut d'ailleurs s'utiliser sans génération de code grâce à ses fichiers auto-descriptifs.
En somme, l'évolution de schéma offre la même souplesse que les bases « sans schéma » (schema-on-read), tout en garantissant mieux la cohérence des données et un meilleur outillage.
Modes de circulation des données
La compatibilité est une relation entre un processus qui encode et un processus qui décode. Reste à savoir qui encode et qui décode : Kleppmann distingue trois grands modes de flux de données (dataflow).
Via les bases de données
Le processus qui écrit encode, celui qui lit décode. S'il n'y a qu'un processus, le lecteur n'est qu'une version future de lui-même : stocker en base, c'est envoyer un message à son futur soi, d'où la nécessité évidente de la compatibilité ascendante. Mais comme plusieurs processus accèdent souvent à la base — y compris pendant un déploiement progressif où coexistent ancien et nouveau code —, la compatibilité descendante est également requise : une valeur écrite par du code récent peut être lue par du code ancien encore en service.
Un piège supplémentaire guette. Si du code récent écrit un nouveau champ, qu'un code ancien (qui l'ignore) lit l'enregistrement, le modifie et le réécrit, le nouveau champ risque d'être perdu lors de l'aller-retour à travers les objets du modèle. Les formats vus ci-dessus savent préserver les champs inconnus, mais il faut y veiller au niveau applicatif.
Piège courant
Les données survivent au code (data outlives code). Une nouvelle version d'application remplace l'ancienne en quelques minutes ; les données, elles, restent dans leur encodage d'origine pendant des années si on ne les réécrit pas. Migrer un gros jeu de données vers un nouveau schéma est coûteux, donc la plupart des bases l'évitent : ajouter une colonne avec une valeur par défaut null ne réécrit pas les lignes existantes, et l'évolution de schéma donne l'illusion d'une base encodée avec un schéma unique.
Via les services : REST et RPC
Quand des processus communiquent par le réseau, l'arrangement le plus courant oppose clients et serveurs : un serveur expose une API — un service — que les clients appellent. Un serveur peut lui-même être client d'un autre service : c'est le principe de l'architecture orientée services (SOA), rebaptisée microservices. L'objectif est de rendre les services déployables et évolutifs indépendamment, donc d'attendre que d'anciennes et de nouvelles versions tournent ensemble.
Quand HTTP sert de transport, on parle de service web, avec deux philosophies opposées. REST n'est pas un protocole mais une approche qui s'appuie sur HTTP : formats simples, URLs pour identifier les ressources, usage du cache et de la négociation de contenu. SOAP est un protocole fondé sur XML, accompagné d'une myriade de standards WS-* ; son API se décrit en WSDL, qui permet la génération de code mais reste illisible et difficile à interopérer. REST domine désormais les API publiques.
Beaucoup de frameworks reposent sur l'idée d'appel de procédure distante (RPC, remote procedure call) : faire ressembler une requête réseau à un appel de fonction local — la transparence de localisation. L'approche est fondamentalement bancale, car un appel réseau diffère radicalement d'un appel local.
| Appel de fonction local | Requête réseau (RPC) |
|---|---|
| Prévisible : réussit ou échoue selon vos paramètres. | Imprévisible : requête ou réponse perdue, machine lente ou indisponible. |
| Retourne, lève une exception, ou ne revient jamais. | Issue supplémentaire : timeout sans résultat — on ignore si la requête a abouti. |
| Le réessai ne pose pas de problème. | Réessayer peut exécuter l'action plusieurs fois, sauf idempotence intégrée. |
| Durée d'exécution à peu près constante. | Latence très variable, de la milliseconde à plusieurs secondes. |
| Passe des pointeurs vers la mémoire locale. | Doit encoder tous les paramètres en octets ; problématique pour les gros objets. |
La nouvelle génération de RPC assume cette différence : Finagle et Rest.li emploient des futures (promesses) pour les actions asynchrones susceptibles d'échouer, gRPC (bâti sur Protocol Buffers) gère les flux (streams). Pour l'évolution, on peut faire une hypothèse simplificatrice : les serveurs sont mis à jour avant les clients. Il suffit donc de la compatibilité ascendante sur les requêtes et descendante sur les réponses, propriétés héritées de l'encodage sous-jacent. Comme les API traversent souvent les frontières d'organisations, le fournisseur ne peut forcer ses clients à se mettre à jour : la compatibilité doit tenir longtemps, et un changement cassant impose de maintenir plusieurs versions de l'API côte à côte. Il n'existe d'ailleurs aucun accord sur le versionnage : numéro dans l'URL, en-tête Accept, ou version stockée par client côté serveur.
Via le passage de messages asynchrone
Entre RPC et bases de données se situent les systèmes de passage de messages asynchrone (asynchronous message-passing). Comme RPC, le message est livré rapidement ; comme une base, il transite par un intermédiaire qui le stocke temporairement : un courtier de messages (message broker, ou file de messages), tel RabbitMQ, ActiveMQ, NATS ou Apache Kafka. Ce détour offre plusieurs avantages : il tamponne si le destinataire est surchargé, redélivre automatiquement après un crash, dispense l'émetteur de connaître l'adresse du destinataire, permet d'envoyer un même message à plusieurs destinataires, et découple logiquement émetteur et récepteur. La communication est généralement à sens unique : l'émetteur publie et oublie ; une éventuelle réponse passe par un canal séparé.
Un courtier n'impose aucun modèle de données : un message n'est qu'une séquence d'octets avec des métadonnées, donc n'importe quel encodage convient. Si cet encodage est compatible dans les deux sens, on peut faire évoluer producteurs et consommateurs indépendamment, dans n'importe quel ordre. Attention toutefois : si un consommateur republie vers un autre topic, il faut préserver les champs inconnus, comme pour les bases.
Le modèle d'acteurs (actor model) pousse cette logique plus loin. Au lieu de manipuler des threads, on encapsule la logique dans des acteurs qui ne communiquent que par messages asynchrones — la livraison n'étant pas garantie. Un framework d'acteurs distribués étend ce mécanisme à plusieurs nœuds : qu'émetteur et récepteur soient sur le même nœud ou non, le message est encodé, transmis, décodé de façon transparente. La transparence de localisation fonctionne mieux ici que dans RPC, parce que le modèle suppose déjà que des messages peuvent se perdre. Mais pour un déploiement progressif, il faut encore se soucier de compatibilité : Akka utilise par défaut la sérialisation Java (sans compatibilité), remplaçable par Protocol Buffers ; Orleans impose par défaut de basculer vers un nouveau cluster ; et changer un schéma d'enregistrement reste étonnamment ardu sous Erlang OTP.
À retenir
- Tout système évolutif fait coexister ancien et nouveau code, anciens et nouveaux formats : il faut maintenir la compatibilité ascendante (le code récent lit l'ancien) et descendante (le code ancien lit le récent), cette dernière étant la plus délicate.
- Les formats spécifiques au langage (Java Serializable, pickle…) couplent au langage, ouvrent des failles de sécurité et négligent le versionnage : à réserver à l'éphémère.
- JSON, XML, CSV restent imbattables comme formats d'échange entre organisations, malgré l'ambiguïté des nombres, l'absence de binaire et des schémas optionnels.
- Thrift et Protocol Buffers encodent par numéros de tag : on ajoute des champs
optionalà tags neufs, on ne change ni ne réutilise jamais un tag. - Avro sépare schéma de l'écrivain et schéma du lecteur, résolus par appariement des noms (sans tags) ; on n'ajoute ou ne retire qu'un champ doté d'une valeur par défaut. Idéal pour les fichiers Hadoop et les schémas générés dynamiquement.
- Trois modes de flux mobilisent la compatibilité : les bases (où les données survivent au code), les services (REST/RPC, où serveurs avant clients suffit), et le passage de messages asynchrone (courtiers, acteurs distribués), où tout encodage compatible permet de déployer les composants dans n'importe quel ordre.