🔝 Retour au Sommaire
Le fichier .proto est le cœur du système Protobuf. C'est un fichier texte qui décrit la structure des messages échangés dans un langage de description d'interface (IDL) neutre, indépendant de tout langage de programmation. Il sert à la fois de spécification (documentation formelle du format des données), de source de vérité (le code C++ est généré automatiquement depuis ce fichier), et de contrat (partagé entre tous les services qui échangent ces messages).
Bien écrire ses fichiers .proto est aussi important que bien écrire ses headers C++. Un schéma mal conçu peut causer des problèmes de rétrocompatibilité, des ambiguïtés de typage, ou des inefficacités d'encodage qui seront difficiles à corriger une fois le format déployé en production.
Un fichier .proto se compose de plusieurs éléments déclaratifs, organisés dans un ordre conventionnel :
// 1. Déclaration de syntaxe (obligatoire, première ligne non-commentaire)
syntax = "proto3";
// 2. Package (namespace logique)
package myapp.config;
// 3. Imports d'autres fichiers .proto
import "google/protobuf/timestamp.proto";
import "common/types.proto";
// 4. Options (configuration de la génération de code)
option cc_enable_arenas = true;
// 5. Définitions de messages, enums, services
message ServerConfig {
// ...
}
enum LogLevel {
// ...
}Chaque élément est détaillé dans les sections qui suivent.
Protobuf définit un ensemble de types scalaires qui correspondent aux types primitifs des langages cibles. Voici les correspondances avec C++ :
| Type Protobuf | Type C++ | Encodage | Usage typique |
|---|---|---|---|
double |
double |
64 bits fixe | Flottants double précision |
float |
float |
32 bits fixe | Flottants simple précision |
int32 |
int32_t |
Varint | Entiers signés (inefficace pour les négatifs) |
int64 |
int64_t |
Varint | Entiers signés 64 bits |
uint32 |
uint32_t |
Varint | Entiers non signés |
uint64 |
uint64_t |
Varint | Entiers non signés 64 bits |
sint32 |
int32_t |
Varint ZigZag | Entiers signés (efficace pour les négatifs) |
sint64 |
int64_t |
Varint ZigZag | Entiers signés 64 bits (négatifs fréquents) |
fixed32 |
uint32_t |
32 bits fixe | Entiers > 2²⁸ fréquents |
fixed64 |
uint64_t |
64 bits fixe | Entiers > 2⁵⁶ fréquents |
sfixed32 |
int32_t |
32 bits fixe | Entiers signés à distribution large |
sfixed64 |
int64_t |
64 bits fixe | Entiers signés 64 bits à distribution large |
bool |
bool |
Varint | Booléens |
string |
std::string |
Longueur-préfixée | Texte UTF-8 |
bytes |
std::string |
Longueur-préfixée | Données binaires arbitraires |
Le choix entre les variantes d'entiers impacte la compacité de l'encodage :
int32 / int64 — encodage varint standard. Efficace pour les valeurs positives petites (identifiants, compteurs). Très inefficace pour les valeurs négatives, qui sont encodées sur 10 octets quel que soit leur module.
sint32 / sint64 — encodage varint avec ZigZag. Transforme les entiers signés en valeurs positives avant l'encodage varint (0 → 0, -1 → 1, 1 → 2, -2 → 3, ...). À utiliser quand les valeurs négatives sont fréquentes (différences, offsets, coordonnées relatives).
fixed32 / fixed64 — encodage sur exactement 4 ou 8 octets. Plus efficace que varint quand les valeurs sont systématiquement grandes (hachages, timestamps epoch en nanosecondes).
uint32 / uint64 — varint standard pour les valeurs non signées. Le choix naturel pour les tailles, compteurs et identifiants qui ne sont jamais négatifs.
Règle pratique : utiliser int32/int64 par défaut, passer à sint32/sint64 si les valeurs négatives sont courantes, et à fixed32/fixed64 si les valeurs sont systématiquement proches de la valeur maximale du type.
Un message est l'unité fondamentale de Protobuf. Il regroupe un ensemble de champs typés, chacun identifié par un numéro de champ unique :
message User {
string name = 1;
string email = 2;
int32 age = 3;
bool active = 4;
}Les numéros de champ sont essentiels au fonctionnement de Protobuf. Dans le format binaire, c'est le numéro (pas le nom) qui identifie chaque champ. Cela a plusieurs implications :
Les numéros doivent être uniques au sein d'un message. Deux champs ne peuvent pas porter le même numéro.
Les numéros ne doivent jamais être réutilisés. Si un champ est supprimé, son numéro doit être réservé pour éviter qu'un futur champ ne le reprenne, ce qui causerait des erreurs de désérialisation silencieuses avec les anciens messages.
Les numéros 1-15 sont encodés sur 1 octet (numéro + wire type). Les numéros 16-2047 nécessitent 2 octets. Pour optimiser la compacité, les champs les plus fréquemment renseignés devraient recevoir les numéros 1 à 15.
La plage valide est 1 à 536 870 911 (2²⁹ - 1), à l'exception de la plage 19000-19999 réservée à l'implémentation de Protobuf.
message Metric {
// Champs fréquents → numéros 1-15 (1 octet)
string name = 1;
double value = 2;
int64 timestamp = 3;
// Champs moins fréquents → numéros 16+ (2 octets)
map<string, string> labels = 16;
string description = 17;
string unit = 18;
}En proto3, tous les champs scalaires ont une valeur par défaut implicite qui est la « valeur zéro » du type :
| Type | Valeur par défaut |
|---|---|
Numérique (int32, double, etc.) |
0 |
bool |
false |
string |
"" (chaîne vide) |
bytes |
b"" (bytes vide) |
enum |
Première valeur (numéro 0) |
| Message | Non défini (has_*() retourne false) |
Un champ dont la valeur est égale à sa valeur par défaut n'est pas encodé dans le flux binaire. C'est une source de compacité, mais aussi un piège : il est impossible de distinguer « le champ vaut 0 » de « le champ n'a pas été renseigné ». Le mot-clé optional résout ce problème quand la distinction est nécessaire.
Réintroduit en proto3 (protobuf 3.15+), optional génère une méthode has_*() qui permet de distinguer l'absence d'une valeur explicite :
message ServerConfig {
string host = 1; // pas de has_host() en proto3
int32 port = 2; // pas de has_port()
optional int32 workers = 3; // has_workers() disponible
optional bool debug = 4; // has_debug() disponible
}myapp::ServerConfig config;
config.set_port(0);
// Sans optional : impossible de savoir si port a été défini à 0
// ou s'il n'a jamais été renseigné
bool port_set = (config.port() != 0); // ❌ faux négatif si port == 0
// Avec optional : distinction explicite
config.set_workers(0);
bool workers_set = config.has_workers(); // ✅ true — la valeur a été définie
config.clear_workers();
bool workers_cleared = config.has_workers(); // false — le champ est absent Utiliser optional pour tout champ où la distinction « absent vs valeur zéro » a une signification métier : un timeout de 0 signifie-t-il « pas de timeout » ou « timeout immédiat » ? Si la réponse n'est pas évidente, optional clarifie l'intention.
Le mot-clé repeated déclare un champ qui peut contenir zéro ou plusieurs valeurs, équivalent à un std::vector en C++ :
message Cluster {
string name = 1;
repeated string node_addresses = 2;
repeated int32 ports = 3;
}En C++, les champs repeated exposent une interface similaire à RepeatedField (types scalaires) ou RepeatedPtrField (messages et chaînes) :
myapp::Cluster cluster;
cluster.set_name("prod-eu");
// Ajout d'éléments
cluster.add_node_addresses("10.0.1.1");
cluster.add_node_addresses("10.0.1.2");
cluster.add_node_addresses("10.0.1.3");
cluster.add_ports(8080);
cluster.add_ports(8443);
// Accès
int count = cluster.node_addresses_size(); // 3
const std::string& first = cluster.node_addresses(0); // "10.0.1.1"
// Itération (compatible range-based for)
for (const auto& addr : cluster.node_addresses()) {
std::print("Node : {}\n", addr);
}Les champs repeated de types scalaires (int32, double, etc.) sont encodés en mode packed par défaut en proto3, ce qui les rend significativement plus compacts qu'un encodage individuel de chaque élément.
Les messages peuvent être imbriqués pour former des structures hiérarchiques. Un message peut être défini à l'intérieur d'un autre (scope local) ou au niveau du fichier (scope global) :
message DeploymentSpec {
string image = 1;
int32 replicas = 2;
// Message imbriqué (scope local à DeploymentSpec)
message ResourceLimits {
int32 cpu_millicores = 1;
int32 memory_mb = 2;
}
ResourceLimits limits = 3;
ResourceLimits requests = 4;
// Référence à un message externe
HealthCheck health_check = 5;
}
// Message au niveau du fichier (réutilisable par d'autres messages)
message HealthCheck {
string path = 1;
int32 interval_seconds = 2;
int32 timeout_seconds = 3;
}En C++, un message imbriqué est généré comme une classe imbriquée :
myapp::DeploymentSpec spec;
spec.set_image("nginx:1.27");
spec.set_replicas(3);
// Accès au sous-message via mutable_*()
auto* limits = spec.mutable_limits();
limits->set_cpu_millicores(500);
limits->set_memory_mb(256);
auto* hc = spec.mutable_health_check();
hc->set_path("/health");
hc->set_interval_seconds(10);
// Lecture du sous-message (const reference)
const auto& req = spec.requests();
int cpu = req.cpu_millicores(); // 0 (valeur par défaut, non renseigné) La règle pour les sous-messages : mutable_*() retourne un pointeur modifiable (et crée le sous-message s'il n'existe pas), tandis que l'accesseur sans préfixe retourne une référence constante. has_*() est toujours disponible pour les champs de type message (même sans optional).
Les enums Protobuf sont des types nommés à valeurs entières. En proto3, la première valeur doit avoir le numéro 0 et sert de valeur par défaut :
enum LogLevel {
LOG_LEVEL_UNSPECIFIED = 0; // valeur par défaut obligatoire
LOG_LEVEL_TRACE = 1;
LOG_LEVEL_DEBUG = 2;
LOG_LEVEL_INFO = 3;
LOG_LEVEL_WARN = 4;
LOG_LEVEL_ERROR = 5;
LOG_LEVEL_FATAL = 6;
}
message LogConfig {
LogLevel level = 1;
string output = 2;
}La convention recommandée par le style guide Protobuf est de préfixer chaque valeur par le nom de l'enum en SCREAMING_SNAKE_CASE. La raison est que les valeurs d'enum en proto3 partagent le scope de leur message parent (pas de scoping comme les enum class C++), ce qui peut causer des collisions :
// ❌ Collision possible si deux enums ont "UNKNOWN" dans le même scope
enum Status { UNKNOWN = 0; ACTIVE = 1; }
enum Priority { UNKNOWN = 0; LOW = 1; } // erreur : UNKNOWN déjà défini
// ✅ Préfixage pour éviter les collisions
enum Status { STATUS_UNSPECIFIED = 0; STATUS_ACTIVE = 1; }
enum Priority { PRIORITY_UNSPECIFIED = 0; PRIORITY_LOW = 1; } La valeur *_UNSPECIFIED = 0 est une convention forte : elle signale explicitement que le champ n'a pas été renseigné, plutôt que de donner une signification métier à la valeur par défaut.
Par défaut, deux valeurs d'enum ne peuvent pas partager le même numéro. L'option allow_alias lève cette restriction pour les cas de synonymes :
enum HttpMethod {
option allow_alias = true;
HTTP_METHOD_UNSPECIFIED = 0;
HTTP_METHOD_GET = 1;
HTTP_METHOD_POST = 2;
HTTP_METHOD_PUT = 3;
HTTP_METHOD_PATCH = 3; // alias de PUT dans certains contextes legacy
}oneof déclare un ensemble de champs mutuellement exclusifs — un seul peut être renseigné à la fois. C'est l'équivalent Protobuf d'un std::variant en C++ :
message NotificationTarget {
string notification_id = 1;
string message = 2;
oneof target {
string email = 10;
string slack_channel = 11;
string webhook_url = 12;
}
}En C++, oneof génère un _case() pour identifier le champ actif et des accesseurs pour chaque alternative :
myapp::NotificationTarget notif;
notif.set_notification_id("n-001");
notif.set_message("Déploiement réussi");
notif.set_slack_channel("#deployments");
// Identifier le champ actif
switch (notif.target_case()) {
case myapp::NotificationTarget::kEmail:
std::print("Email : {}\n", notif.email());
break;
case myapp::NotificationTarget::kSlackChannel:
std::print("Slack : {}\n", notif.slack_channel());
break;
case myapp::NotificationTarget::kWebhookUrl:
std::print("Webhook : {}\n", notif.webhook_url());
break;
case myapp::NotificationTarget::TARGET_NOT_SET:
std::print("Aucune cible\n");
break;
}Définir un champ d'un oneof efface automatiquement le champ précédemment actif. Les champs oneof ne peuvent pas être repeated ni map.
proto3 supporte nativement les maps, équivalentes à std::map en C++ :
message ServiceRegistry {
string cluster_name = 1;
map<string, ServiceEndpoint> services = 2;
map<string, string> metadata = 3;
}
message ServiceEndpoint {
string host = 1;
int32 port = 2;
bool healthy = 3;
}myapp::ServiceRegistry registry;
registry.set_cluster_name("prod");
// Insertion
auto& services = *registry.mutable_services();
services["api"].set_host("10.0.1.1");
services["api"].set_port(8080);
services["api"].set_healthy(true);
services["worker"].set_host("10.0.2.1");
services["worker"].set_port(9090);
// Métadonnées simples
(*registry.mutable_metadata())["region"] = "eu-west";
(*registry.mutable_metadata())["version"] = "3.2.1";
// Lecture
for (const auto& [name, endpoint] : registry.services()) {
std::print("{} → {}:{}\n", name, endpoint.host(), endpoint.port());
}Les clés de map doivent être de type scalaire (entiers ou chaînes). Les valeurs peuvent être n'importe quel type, y compris des messages. Les maps ne garantissent pas d'ordre d'itération.
Le package définit un namespace logique qui évite les collisions de noms entre fichiers .proto de différentes équipes ou projets. Il se traduit en namespaces C++ :
package mycompany.platform.config;
message ServerConfig { /* ... */ }En C++ généré :
mycompany::platform::config::ServerConfig config;Un fichier .proto peut importer les définitions d'un autre fichier :
// common/types.proto
syntax = "proto3";
package common;
message Timestamp {
int64 seconds = 1;
int32 nanos = 2;
}// services/config.proto
syntax = "proto3";
package services;
import "common/types.proto";
message Event {
string name = 1;
common.Timestamp created_at = 2; // type importé, qualifié par son package
}L'option import public rend les types importés transitifs — les fichiers qui importent le fichier courant voient aussi les types publiquement importés :
// all_types.proto — agrège et réexporte les types communs
import public "common/types.proto";
import public "common/enums.proto"; Protobuf fournit un ensemble de types standard dans le package google.protobuf, utilisables via un simple import :
import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/any.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/struct.proto";
import "google/protobuf/empty.proto"; | Type | Usage |
|---|---|
Timestamp |
Point dans le temps (secondes + nanos depuis epoch) |
Duration |
Durée (secondes + nanos) |
Any |
Message de type arbitraire (emballage polymorphique) |
StringValue, Int32Value, etc. |
Wrappers pour distinguer « absent » de « valeur zéro » |
Struct, Value, ListValue |
JSON dynamique en Protobuf |
Empty |
Message vide (retour de RPC sans données) |
FieldMask |
Sous-ensemble de champs à lire/modifier |
Timestamp et Duration sont les plus couramment utilisés. Ils évitent de réinventer le format de représentation temporelle dans chaque projet :
import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
message Deployment {
string name = 1;
google.protobuf.Timestamp created_at = 2;
google.protobuf.Duration timeout = 3;
}Quand un champ est supprimé d'un message, son numéro et son nom doivent être réservés pour empêcher leur réutilisation accidentelle. Sans cette précaution, un nouveau champ portant l'ancien numéro serait interprété comme l'ancien champ par les consommateurs qui n'ont pas encore mis à jour leur schéma :
message User {
string name = 1;
// Le champ 'email' (numéro 2) a été supprimé en v3
// Le champ 'phone' (numéro 5) a été supprimé en v4
reserved 2, 5;
reserved "email", "phone";
string username = 3;
int32 age = 4;
string contact = 6; // remplace email et phone
}La clause reserved provoque une erreur de compilation protoc si quelqu'un tente de réutiliser un numéro ou un nom réservé. C'est une protection essentielle pour la rétrocompatibilité dans les systèmes à longue durée de vie.
Le style guide officiel de Protobuf recommande les conventions suivantes :
| Élément | Convention | Exemple |
|---|---|---|
| Noms de fichiers | snake_case.proto | server_config.proto |
| Noms de messages | PascalCase | ServerConfig |
| Noms de champs | snake_case | cert_path |
| Noms d'enums | PascalCase | LogLevel |
| Valeurs d'enums | SCREAMING_SNAKE_CASE | LOG_LEVEL_INFO |
| Noms de packages | lowercase.dotted | mycompany.config |
| Noms de services (gRPC) | PascalCase | ConfigService |
Ces conventions sont vérifiables automatiquement par l'outil Buf (buf lint), qui détecte les violations de style et les problèmes de rétrocompatibilité.
Voici un fichier .proto réaliste qui illustre l'ensemble des concepts couverts dans cette section :
syntax = "proto3";
package myapp.config.v1;
import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
// Configuration complète du service
message ServiceConfig {
string service_name = 1;
string version = 2;
ServerConfig server = 3;
DatabaseConfig database = 4;
LogConfig logging = 5;
google.protobuf.Timestamp last_updated = 15;
}
message ServerConfig {
string host = 1;
int32 port = 2;
optional int32 workers = 3;
optional TlsConfig tls = 4;
repeated string allowed_origins = 5;
message TlsConfig {
string cert_path = 1;
string key_path = 2;
bool verify_client = 3;
}
}
message DatabaseConfig {
string host = 1;
int32 port = 2;
string name = 3;
optional int32 connection_pool_size = 4;
optional google.protobuf.Duration query_timeout = 5;
oneof auth {
PasswordAuth password_auth = 10;
CertAuth cert_auth = 11;
}
message PasswordAuth {
string username = 1;
string password = 2;
}
message CertAuth {
string client_cert = 1;
string client_key = 2;
}
}
enum LogLevel {
LOG_LEVEL_UNSPECIFIED = 0;
LOG_LEVEL_TRACE = 1;
LOG_LEVEL_DEBUG = 2;
LOG_LEVEL_INFO = 3;
LOG_LEVEL_WARN = 4;
LOG_LEVEL_ERROR = 5;
LOG_LEVEL_FATAL = 6;
}
message LogConfig {
LogLevel level = 1;
repeated LogOutput outputs = 2;
map<string, string> extra_fields = 3;
}
message LogOutput {
oneof destination {
StdoutOutput stdout = 1;
FileOutput file = 2;
}
message StdoutOutput {
bool colorize = 1;
}
message FileOutput {
string path = 1;
int64 max_size_bytes = 2;
int32 max_backups = 3;
}
}Ce fichier utilise les packages versionnés (v1), les messages imbriqués, oneof pour les alternatives mutuellement exclusives, optional pour les champs à présence explicite, repeated pour les listes, map pour les paires clé-valeur, les types Well-Known de Google, et les conventions de nommage recommandées.
La section 25.1.2 montrera comment ce fichier .proto est transformé en code C++ par protoc et comment le code généré s'utilise dans une application.