Skip to content

Latest commit

 

History

History
644 lines (478 loc) · 21.2 KB

File metadata and controls

644 lines (478 loc) · 21.2 KB

🔝 Retour au Sommaire

25.1.1 — Définition de messages .proto

Sous-section de 25.1 — Protocol Buffers (Protobuf) : Sérialisation Google


Vue d'ensemble

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.


Structure d'un fichier .proto

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.


Types scalaires

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

Choisir le bon type entier

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.


Définition de messages

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;
}

Numéros de champ

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;
}

Valeurs par défaut en proto3

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.

Le mot-clé optional

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.


Champs repeated (listes)

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.


Messages imbriqués

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).


Enums

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;
}

Conventions de nommage des enums

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.

allow_alias

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 (union)

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.


Maps

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.


Imports et packages

Packages

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;

Imports

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";  

Types Well-Known de Google

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;
}

Réservation de champs

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.


Conventions et style

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é.


Exemple complet

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.

⏭️ Génération de code C++