🔝 Retour au Sommaire
Les sections 25.1.1 et 25.1.2 ont couvert la définition des messages .proto et la structure du code C++ généré. Cette section ferme la boucle en détaillant les mécanismes de sérialisation (objet C++ → flux binaire) et de désérialisation (flux binaire → objet C++), la gestion des erreurs, le streaming de messages multiples, et l'un des aspects les plus critiques de Protobuf dans un système en production : l'évolution de schéma et la rétrocompatibilité.
La méthode la plus courante. SerializeToString encode le message dans une std::string utilisée comme buffer binaire :
#include "config.pb.h"
#include <print>
myapp::ServerConfig config;
config.set_host("0.0.0.0");
config.set_port(8080);
config.set_workers(4);
config.add_allowed_origins("https://app.example.com");
std::string binary;
if (!config.SerializeToString(&binary)) {
std::print(stderr, "Échec de la sérialisation\n");
// En proto3, cela n'arrive quasiment jamais pour un message valide
}
std::print("Sérialisé : {} octets\n", binary.size());SerializeToString retourne false uniquement si le message n'est pas correctement initialisé (champs required manquants en proto2). En proto3, où tous les champs sont optionnels par défaut, l'échec de sérialisation est extrêmement rare. Néanmoins, le test du retour reste une bonne pratique défensive.
⚠️ Lastd::stringproduite contient des données binaires arbitraires, y compris des octets nuls. Elle ne doit pas être traitée comme une chaîne de texte (pas dec_str(), pas d'affichage direct). C'est un conteneur d'octets, pas une chaîne humainement lisible.
#include <fstream>
// Écriture binaire dans un fichier
std::ofstream file("config.pb", std::ios::binary);
if (!file.is_open()) {
std::print(stderr, "Impossible de créer le fichier\n");
return;
}
if (!config.SerializeToOstream(&file)) {
std::print(stderr, "Échec de l'écriture\n");
}Le flag std::ios::binary est important sur Windows, où le mode texte transformerait certains octets (conversion \n ↔ \r\n). Sur Linux, la différence est invisible, mais l'inclure systématiquement est une bonne habitude pour la portabilité.
Pour les contextes où l'allocation doit être contrôlée (buffers réseau, mémoire partagée, arenas), SerializeToArray écrit dans un buffer fourni par l'appelant :
// Calculer la taille nécessaire
size_t size = config.ByteSizeLong();
// Allouer le buffer
std::vector<uint8_t> buffer(size);
// Sérialiser dans le buffer
if (!config.SerializeToArray(buffer.data(), static_cast<int>(buffer.size()))) {
std::print(stderr, "Échec de la sérialisation dans le buffer\n");
}
// buffer contient maintenant les données binaires
// Prêt pour send(), write(), memcpy(), etc.ByteSizeLong() retourne la taille exacte du message sérialisé. C'est une opération relativement légère — elle parcourt les champs pour calculer la taille sans effectuer l'encodage. Appeler ByteSizeLong() avant SerializeToArray garantit que le buffer est correctement dimensionné.
SerializePartialToString (et ses variantes Partial) sérialise le message même s'il n'est pas complètement initialisé (champs requis manquants en proto2). En proto3, Serialize* et SerializePartial* sont fonctionnellement identiques, mais la version Partial est légèrement plus rapide car elle ne vérifie pas IsInitialized() :
std::string data;
config.SerializePartialToString(&data); // pas de vérification d'initialisation Pour les boucles à très haut débit où chaque microseconde compte et où la validité du message est garantie par construction, cette optimisation peut être pertinente.
myapp::ServerConfig restored;
if (!restored.ParseFromString(binary)) {
std::print(stderr, "Données binaires invalides\n");
return;
}
std::print("Host : {}\n", restored.host());
std::print("Port : {}\n", restored.port()); ParseFromString retourne false si les données binaires sont malformées ou tronquées. C'est la première ligne de défense contre les données corrompues. Un retour false signifie que le message est dans un état indéterminé et ne doit pas être utilisé.
myapp::ServerConfig config;
std::ifstream file("config.pb", std::ios::binary);
if (!file.is_open()) {
std::print(stderr, "Fichier introuvable\n");
return;
}
if (!config.ParseFromIstream(&file)) {
std::print(stderr, "Fichier Protobuf invalide ou corrompu\n");
return;
}// buffer et size proviennent par exemple de recv() ou mmap()
myapp::ServerConfig config;
if (!config.ParseFromArray(buffer.data(), buffer.size())) {
std::print(stderr, "Buffer Protobuf invalide\n");
return;
}Plusieurs aspects du comportement méritent d'être compris :
Les champs absents prennent leur valeur par défaut. Si le binaire a été produit par une version du schéma qui ne contenait pas le champ workers, alors restored.workers() retournera 0 (valeur par défaut de int32). C'est le mécanisme de rétrocompatibilité de Protobuf.
Les champs inconnus sont préservés. Si le binaire contient des champs dont les numéros ne correspondent à aucun champ du schéma actuel, ces champs sont stockés dans une zone opaque du message. Si le message est resérialisé, ces champs inconnus sont réémis dans le flux binaire. Ce comportement garantit le round-trip même quand le consommateur a un schéma plus ancien que le producteur.
Le parsing est tolérant par design. Protobuf est conçu pour fonctionner dans des systèmes distribués où les composants évoluent à des rythmes différents. Un message peut contenir plus ou moins de champs que ce que le code local attend, et la désérialisation fonctionne sans erreur dans les deux cas.
ParseFromString efface le message avant de parser. Tout appel à ParseFromString commence par un Clear() implicite. Si vous souhaitez fusionner les données binaires avec un message existant (comme un overlay), utilisez MergeFromString à la place.
En proto3, la sérialisation échoue uniquement dans des cas extrêmes : mémoire insuffisante pour allouer la chaîne de sortie, ou corruption interne du message. En pratique, un if (!msg.SerializeToString(&data)) suffit comme garde, sans nécessiter de diagnostic détaillé.
La désérialisation est plus susceptible d'échouer, car les données entrantes ne sont pas sous le contrôle du programme. Les causes de ParseFromString retournant false incluent :
- Données tronquées — le buffer a été coupé avant la fin du message.
- Wire type incohérent — un champ est encodé avec un wire type incompatible avec sa définition (ex: un varint là où un length-delimited est attendu).
- Profondeur de récursion excessive — les sous-messages imbriqués dépassent la limite de profondeur (100 par défaut), protection contre les données malveillantes.
- Taille excessive — le message dépasse la limite de taille par défaut (64 Mo). Cette limite est configurable via
CodedInputStream::SetTotalBytesLimit().
Quand les données proviennent d'un réseau non sécurisé ou d'un stockage potentiellement corrompu, un traitement plus défensif est approprié :
#include <google/protobuf/io/coded_stream.h>
#include <google/protobuf/io/zero_copy_stream_impl_lite.h>
bool parse_untrusted(const std::string& data, myapp::ServerConfig& config,
int max_size_bytes = 1024 * 1024, // 1 Mo max
int max_recursion_depth = 32) {
google::protobuf::io::ArrayInputStream raw_input(
data.data(), static_cast<int>(data.size()));
google::protobuf::io::CodedInputStream input(&raw_input);
// Limiter la taille pour éviter les DoS
input.SetTotalBytesLimit(max_size_bytes);
// Limiter la profondeur de récursion
input.SetRecursionLimit(max_recursion_depth);
if (!config.ParseFromCodedStream(&input)) {
return false;
}
// Vérifier que tout le flux a été consommé
if (!input.ConsumedEntireMessage()) {
config.Clear();
return false;
}
return true;
}La vérification ConsumedEntireMessage() détecte les cas où des données supplémentaires suivent le message — ce qui peut indiquer une confusion de framing (voir la section streaming ci-dessous) ou une tentative d'injection.
Le format binaire Protobuf n'est pas auto-délimitant : rien dans le flux ne signale où un message se termine. Si deux messages sont concaténés dans un buffer, le parser ne peut pas distinguer la frontière entre eux. C'est un piège classique.
// ❌ INCORRECT — les deux messages fusionnent dans le flux
std::string stream;
msg1.SerializeToString(&stream);
std::string temp;
msg2.SerializeToString(&temp);
stream += temp; // concaténation naïve
// Le parse lira msg1 ET les octets de msg2 comme un seul message
myapp::ServerConfig restored;
restored.ParseFromString(stream); // résultat imprévisible La solution standard est de préfixer chaque message par sa taille. Protobuf fournit des utilitaires pour ce pattern :
#include <google/protobuf/util/delimited_message_util.h>
// Écriture de messages multiples
void write_messages(const std::string& path,
const std::vector<myapp::User>& users) {
std::ofstream file(path, std::ios::binary);
google::protobuf::io::OstreamOutputStream raw_output(&file);
for (const auto& user : users) {
if (!google::protobuf::util::SerializeDelimitedToZeroCopyStream(
user, &raw_output)) {
std::print(stderr, "Échec écriture message\n");
return;
}
}
}
// Lecture de messages multiples
std::vector<myapp::User> read_messages(const std::string& path) {
std::vector<myapp::User> users;
std::ifstream file(path, std::ios::binary);
google::protobuf::io::IstreamInputStream raw_input(&file);
bool clean_eof = false;
while (true) {
myapp::User user;
if (!google::protobuf::util::ParseDelimitedFromZeroCopyStream(
&user, &raw_input, &clean_eof)) {
break;
}
users.push_back(std::move(user));
}
if (!clean_eof) {
std::print(stderr, "Attention : flux tronqué ou corrompu\n");
}
return users;
}Le format length-delimited préfixe chaque message par un varint indiquant sa taille en octets. C'est le même format que gRPC utilise pour encadrer les messages dans un flux HTTP/2.
Si les utilitaires delimited_message_util ne sont pas disponibles (runtime lite, par exemple), le framing se fait manuellement :
// Écriture : taille (4 octets big-endian) + données
bool write_framed(std::ostream& out, const google::protobuf::MessageLite& msg) {
std::string data;
if (!msg.SerializeToString(&data)) return false;
uint32_t size = static_cast<uint32_t>(data.size());
// Écriture big-endian de la taille
uint8_t header[4] = {
static_cast<uint8_t>((size >> 24) & 0xFF),
static_cast<uint8_t>((size >> 16) & 0xFF),
static_cast<uint8_t>((size >> 8) & 0xFF),
static_cast<uint8_t>(size & 0xFF)
};
out.write(reinterpret_cast<char*>(header), 4);
out.write(data.data(), static_cast<std::streamsize>(data.size()));
return out.good();
}
// Lecture : taille (4 octets) puis données
bool read_framed(std::istream& in, google::protobuf::MessageLite& msg) {
uint8_t header[4];
in.read(reinterpret_cast<char*>(header), 4);
if (in.gcount() != 4) return false;
uint32_t size = (static_cast<uint32_t>(header[0]) << 24)
| (static_cast<uint32_t>(header[1]) << 16)
| (static_cast<uint32_t>(header[2]) << 8)
| static_cast<uint32_t>(header[3]);
// Protection contre les tailles aberrantes
if (size > 64 * 1024 * 1024) return false; // 64 Mo max
std::string data(size, '\0');
in.read(data.data(), size);
if (static_cast<uint32_t>(in.gcount()) != size) return false;
return msg.ParseFromString(data);
}Le choix de l'encodage de la taille (varint Protobuf, 4 octets big-endian, 4 octets little-endian) dépend du protocole. L'essentiel est que le producteur et le consommateur s'accordent sur le format de framing.
C'est l'un des aspects les plus importants de Protobuf dans un système en production. Les services sont déployés indépendamment, les clients mobiles ne se mettent pas à jour simultanément, et les données stockées sur disque survivent aux changements de code. Le format Protobuf a été conçu dès l'origine pour gérer ces situations.
Ajouter un nouveau champ. C'est l'opération la plus courante. Les anciens consommateurs ignorent le champ (il est stocké comme champ inconnu). Les anciens producteurs n'envoient pas le champ, et les nouveaux consommateurs voient la valeur par défaut.
// v1
message User {
string name = 1;
string email = 2;
}
// v2 — ajout d'un champ
message User {
string name = 1;
string email = 2;
int32 age = 3; // nouveau — les anciens clients ne l'envoient pas
string phone = 4; // nouveau — les anciens clients ne l'envoient pas
}Supprimer un champ (en réservant son numéro). Les anciens producteurs continuent d'envoyer le champ, mais les nouveaux consommateurs l'ignorent. Le numéro doit être réservé pour éviter sa réutilisation.
// v3 — suppression de 'phone'
message User {
string name = 1;
string email = 2;
int32 age = 3;
reserved 4;
reserved "phone";
}Renommer un champ. Les noms ne sont pas encodés dans le format binaire — seuls les numéros comptent. Renommer un champ est transparent pour la compatibilité binaire (mais casse la compatibilité JSON, où les noms de champs sont utilisés).
Ajouter une valeur à un enum. Les anciens consommateurs stockent la valeur inconnue et la préservent en cas de resérialisation.
Changer le type d'un champ. Remplacer un int32 par un string pour le même numéro de champ provoquera des erreurs de désérialisation silencieuses ou bruyantes selon les types impliqués.
Réutiliser un numéro de champ supprimé. Le nouveau champ sera interprété comme l'ancien par les consommateurs qui n'ont pas mis à jour leur schéma, avec des données incohérentes.
Changer un champ entre optional et repeated. Le wire type change, causant des erreurs de parsing.
Supprimer une valeur d'enum. Les anciens producteurs continuent d'envoyer cette valeur, qui sera traitée comme inconnue par les nouveaux consommateurs.
Pour les évolutions majeures qui ne peuvent pas respecter les règles de compatibilité, la convention est de versionner le package :
// Version 1
package myapp.api.v1;
message User { /* ... */ }
// Version 2 (incompatible)
package myapp.api.v2;
message User { /* structure complètement différente */ } Les deux versions coexistent dans le code. Une couche d'adaptation convertit entre les versions :
myapp::api::v2::User convert(const myapp::api::v1::User& v1) {
myapp::api::v2::User v2;
v2.set_display_name(v1.name()); // champ renommé
v2.set_primary_email(v1.email());
// ...
return v2;
}Ce pattern est utilisé massivement par les API Google Cloud, qui maintiennent souvent plusieurs versions en parallèle.
// Scénario : le producteur utilise v2 du schéma,
// le consommateur utilise encore v1
// --- Côté producteur (v2) ---
myapp::v2::User user_v2;
user_v2.set_name("Alice");
user_v2.set_email("alice@example.com");
user_v2.set_age(30); // champ ajouté en v2
user_v2.set_phone("+33..."); // champ ajouté en v2
std::string wire_data;
user_v2.SerializeToString(&wire_data);
// --- Côté consommateur (v1 — ne connaît pas 'age' ni 'phone') ---
myapp::v1::User user_v1;
user_v1.ParseFromString(wire_data); // ✅ succès
std::print("Name : {}\n", user_v1.name()); // "Alice"
std::print("Email : {}\n", user_v1.email()); // "alice@example.com"
// 'age' et 'phone' sont stockés comme champs inconnus
// Si le consommateur v1 resérialise le message...
std::string round_tripped;
user_v1.SerializeToString(&round_tripped);
// ...les champs inconnus sont préservés !
myapp::v2::User restored_v2;
restored_v2.ParseFromString(round_tripped);
std::print("Age : {}\n", restored_v2.age()); // 30 — préservé !
std::print("Phone : {}\n", restored_v2.phone()); // "+33..." — préservé ! Ce comportement de round-trip des champs inconnus est une propriété essentielle pour les systèmes de type message broker (Kafka, Pub/Sub) où un intermédiaire peut ne pas connaître la dernière version du schéma.
Les performances exactes dépendent du hardware, du compilateur et de la structure des messages. À titre indicatif, sur un processeur x86-64 moderne avec GCC -O2 :
| Opération | Débit typique | Comparaison JSON (nlohmann) |
|---|---|---|
| Sérialisation (petit message, ~100 octets) | 5-10 M msg/s | 5-15x plus rapide |
| Désérialisation (petit message) | 3-8 M msg/s | 5-20x plus rapide |
| Sérialisation (gros message, ~10 Ko) | 200-500 K msg/s | 3-10x plus rapide |
| Désérialisation (gros message) | 150-400 K msg/s | 5-15x plus rapide |
Les gains sont plus marqués pour la désérialisation que pour la sérialisation, car le format binaire élimine le parsing textuel (tokenisation, conversion de chaînes en nombres, gestion de l'échappement).
Réutiliser les objets message. Plutôt que de créer un nouveau message à chaque opération, appeler Clear() et réutiliser l'objet existant. Cela évite les allocations mémoire répétées pour les champs string et repeated :
myapp::User user; // alloué une seule fois
for (const auto& record : records) {
user.Clear(); // reset sans désallocation
user.set_name(record.name);
user.set_email(record.email);
user.SerializeToString(&output_buffer);
send(output_buffer);
}Utiliser les arenas (cf. section 25.1.2) pour les workloads à haut débit où de nombreux messages sont créés et détruits rapidement.
Pré-calculer la taille. Si la même structure est sérialisée répétitivement, ByteSizeLong() peut être appelé une fois pour dimensionner le buffer, puis SerializeToArray utilisé pour écrire directement sans allocation intermédiaire.
Éviter les copies inutiles. Utiliser set_name(std::move(str)) au lieu de set_name(str) quand la chaîne source n'est plus nécessaire. De même, mutable_name()->assign(...) évite une copie quand la valeur est construite programmatiquement.
La conversion JSON couverte en section 25.1.2 (MessageToJsonString/JsonStringToMessage) est le pont le plus courant entre Protobuf et le monde textuel. Un pattern fréquent dans les services web :
// API endpoint : accepte JSON en entrée, utilise Protobuf en interne
std::string handle_request(const std::string& json_body) {
// JSON → Protobuf
myapp::CreateUserRequest request;
auto status = google::protobuf::util::JsonStringToMessage(
json_body, &request);
if (!status.ok()) {
return R"({"error": "Invalid request format"})";
}
// Traitement interne en Protobuf
myapp::CreateUserResponse response = process(request);
// Protobuf → JSON pour la réponse
std::string json_response;
google::protobuf::util::MessageToJsonString(response, &json_response);
return json_response;
}Protobuf dispose d'un format texte natif (celui produit par DebugString()), parsable via google::protobuf::TextFormat :
#include <google/protobuf/text_format.h>
// Message → texte
std::string text;
google::protobuf::TextFormat::PrintToString(config, &text);
// Produit :
// host: "0.0.0.0"
// port: 8080
// workers: 4
// Texte → message
myapp::ServerConfig parsed;
if (!google::protobuf::TextFormat::ParseFromString(text, &parsed)) {
std::print(stderr, "Text format invalide\n");
}Le Text Format est utile pour les fichiers de configuration en phase de développement, pour les fixtures de tests unitaires, et pour le débogage. Il n'est pas recommandé en production (pas de rétrocompatibilité formelle, parsing plus lent que le binaire).
| Mode | Méthode | Sortie | Usage |
|---|---|---|---|
| Binaire (standard) | SerializeToString |
std::string (octets) |
Communication inter-services, stockage |
| Binaire (fichier) | SerializeToOstream |
Fichier binaire | Persistance sur disque |
| Binaire (buffer) | SerializeToArray |
Buffer pré-alloué | Réseau bas niveau, mémoire partagée |
| Binaire (délimité) | SerializeDelimitedTo* |
Flux de messages | Streaming, logs séquentiels |
| JSON | MessageToJsonString |
std::string (texte) |
API REST, interopérabilité |
| Text Format | TextFormat::PrintToString |
std::string (texte) |
Débogage, tests, fixtures |
| Mode | Méthode de parsing | Source |
|---|---|---|
| Binaire (standard) | ParseFromString |
std::string |
| Binaire (fichier) | ParseFromIstream |
std::ifstream |
| Binaire (buffer) | ParseFromArray |
void* + taille |
| Binaire (délimité) | ParseDelimitedFrom* |
Flux de messages |
| JSON | JsonStringToMessage |
std::string (texte) |
| Text Format | TextFormat::ParseFromString |
std::string (texte) |
Cette section clôt le sous-chapitre 25.1 sur Protocol Buffers. La section 25.2 aborde FlatBuffers, qui adopte une approche radicalement différente avec sa philosophie zero-copy.