gRPC - 01 - Protocol Buffers : définir ton contrat d'API

Écrire des fichiers .proto pour définir tes services gRPC. Messages, types, enums et bonnes pratiques de schema.

01 - Protocol Buffers : définir ton contrat d'API

Ce que tu vas apprendre

  • La syntaxe des fichiers .proto
  • Les types de messages, champs, enums et services
  • Les regles de compatibilité pour faire évoluer ton schema
  • La génération de code TypeScript

Prerequisites

00 - Pourquoi gRPC existe


Le fichier .proto : ton contrat

En REST, le contrat d'API est souvent implicite. Tu esperes que le serveur retourne les bons champs dans le bon format. OpenAPI/Swagger ajoute de la documentation, mais c'est optionnel et souvent en retard sur le code.

En gRPC, le contrat est le point de depart. Tu ecris un fichier .proto qui decrit exactement les messages et les services. A partir de ce fichier, tu générés le code client et serveur. Si le client attend un champ name de type string et que le serveur envoie un int32, ca ne compile pas. Point.

Syntaxe de base

protobuf// order.proto
syntax = "proto3";

package ecommerce.orders;

// Un service definit les operations disponibles
service OrderService {
  rpc CreateOrder (CreateOrderRequest) returns (Order);
  rpc GetOrder (GetOrderRequest) returns (Order);
  rpc ListOrders (ListOrdersRequest) returns (ListOrdersResponse);
  rpc CancelOrder (CancelOrderRequest) returns (Order);
}

// Les messages definissent la structure des donnees
message CreateOrderRequest {
  string user_id = 1;
  repeated OrderItem items = 2;
  Address shipping_address = 3;
}

message Order {
  string id = 1;
  string user_id = 2;
  repeated OrderItem items = 3;
  OrderStatus status = 4;
  int64 total_cents = 5;
  int64 created_at = 6;
}

message OrderItem {
  string product_id = 1;
  int32 quantity = 2;
  int64 unit_price_cents = 3;
}

message GetOrderRequest {
  string order_id = 1;
}

message ListOrdersRequest {
  string user_id = 1;
  int32 page_size = 2;
  string page_token = 3;
}

message ListOrdersResponse {
  repeated Order orders = 1;
  string next_page_token = 2;
}

message CancelOrderRequest {
  string order_id = 1;
  string reason = 2;
}

Quelques points importants :

  • syntax = "proto3" : utilise toujours proto3. proto2 est l'ancienne version, plus complexe et moins bien supportee.
  • package : un namespace pour éviter les conflits de noms entre fichiers .proto.
  • Les numeros de champs (1, 2, 3...) sont essentiels. C'est le binaire qui utilise ces numeros, pas les noms. Ne les change jamais une fois en production.
  • repeated = un tableau. repeated OrderItem items = 2 signifie "zero ou plusieurs items".

Les types scalaires

Protobuf a ses propres types. Voici les plus courants :

Proto type TypeScript équivalent Usage
string string Texte, UUIDs
int32 number Entiers (max ~2 milliards)
int64 string ou Long Timestamps, IDs numériques
float / double number Decimaux
bool boolean Booleens
bytes Buffer Donnees binaires

Attention : int64 en Protobuf ne mappe pas directement a un number en JavaScript (precision limitee a 2^53). Selon le generateur de code, ca deviendra un string ou un type Long. Pour les timestamps, int64 en millisecondes marche bien.

Les enums

protobufenum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;
  ORDER_STATUS_PENDING = 1;
  ORDER_STATUS_CONFIRMED = 2;
  ORDER_STATUS_SHIPPED = 3;
  ORDER_STATUS_DELIVERED = 4;
  ORDER_STATUS_CANCELLED = 5;
}

Regles importantes :

  • La valeur 0 est la valeur par défaut. Nomme-la _UNSPECIFIED pour rendre ca explicite.
  • Prefixe les valeurs avec le nom de l'enum (ORDER_STATUS_) pour éviter les conflits globaux.

Si tu viens de la serie sur les domaines et cycles de vie, ces états te parlent. Les enums Protobuf sont un bon moyen de formaliser les états d'un cycle de vie dans un contrat d'API.

Messages imbriques et Address

protobufmessage Address {
  string street = 1;
  string city = 2;
  string zip_code = 3;
  string country = 4;
}

message CreateOrderRequest {
  string user_id = 1;
  repeated OrderItem items = 2;
  Address shipping_address = 3;  // message imbrique
}

Les messages peuvent contenir d'autres messages. C'est comme des objets TypeScript imbriques. Un champ de type message est optionnel par défaut en proto3 (il peut etre null).

Les regles d'évolution du schema

C'est la partie la plus importante et la moins comprise. Quand ton service est en production et que des clients l'utilisent, tu ne peux pas changer le schema n'importe comment.

Ce que tu peux faire :

  • Ajouter de nouveaux champs (avec de nouveaux numeros)
  • Ajouter de nouvelles valeurs d'enum
  • Deprecier des champs (mais les garder dans le schema)

Ce que tu ne dois jamais faire :

  • Changer le numero d'un champ existant
  • Changer le type d'un champ existant
  • Réutiliser le numero d'un champ supprime
protobuf// Version 1
message Order {
  string id = 1;
  string user_id = 2;
  int64 total_cents = 3;
}

// Version 2 -- OK : on ajoute un champ
message Order {
  string id = 1;
  string user_id = 2;
  int64 total_cents = 3;
  string currency = 4;  // Nouveau champ, nouveau numero
}

// Version 2 -- INTERDIT : on change le type du champ 3
message Order {
  string id = 1;
  string user_id = 2;
  string total = 3;  // Etait int64, maintenant string = CASSE
}

Un ancien client qui ne connaît pas le champ currency (numero 4) l'ignorera. Un nouveau client qui l'attend recevra la valeur par défaut ("" pour un string) si le serveur est en ancienne version. C'est de la backward et forward compatibility gratuite.

C'est un avantage majeur sur REST/JSON, ou le versioning d'API est un problème recurrent. Protobuf rend les changements cassants visibles a la compilation.

Génération de code

Tu as ton fichier .proto. Il faut maintenant générer le code TypeScript.

Option 1 : @grpc/proto-loader (chargement dynamique)

typescriptimport * as protoLoader from "@grpc/proto-loader";
import * as grpc from "@grpc/grpc-js";

const packageDef = protoLoader.loadSync("order.proto", {
  keepCase: true,
  longs: String,
  defaults: true
});
const proto = grpc.loadPackageDefinition(packageDef);

Simple mais pas de types TypeScript. Tu travailles avec any.

Option 2 : ts-proto (génération statique) -- recommande

bashnpx protoc --ts_proto_out=./src/generated \
  --ts_proto_opt=outputServices=grpc-js \
  --ts_proto_opt=esModuleInterop=true \
  ./proto/order.proto

Ca généré des interfaces TypeScript completes. Tu as l'autocompletion, le type checking, et les erreurs a la compilation. C'est l'approche que je recommande et qu'on utilisera dans les articles suivants.

Option 3 : buf (le build system Protobuf moderne)

yaml# buf.gen.yaml
version: v1
plugins:
  - plugin: ts
    out: src/generated
    opt: outputServices=grpc-js

Buf simplifie la gestion des fichiers .proto : linting, breaking change détection, génération de code. Si tu as beaucoup de fichiers .proto, Buf vaut le detour.

Sur paltemps.fr, l'API est en REST avec Elysia, donc pas de Protobuf. Mais si on devait extraire un service interne, le fichier .proto deviendrait le contrat entre les deux. C'est plus fiable qu'une documentation OpenAPI qu'on oublie de mettre à jour.


Résumé

  • Le fichier .proto est le contrat d'API : messages, services, types
  • Les numeros de champs sont sacres -- ne les change jamais, ne les réutilisé jamais
  • proto3 est le standard actuel, utilise-le par défaut
  • La backward compatibility est gratuite si tu suis les regles
  • ts-proto ou buf pour la génération de code TypeScript type

Article précédent : 00 - Pourquoi gRPC existe Article suivant : 02 - Creer un serveur gRPC en TypeScript

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.