Protobuf FieldMask pour l'écriture : un seul endpoint d'update, zéro ambiguïté
7 juillet 2026 · 6 min read · Read in English
Sommaire
Protobuf FieldMask pour l'écriture : un seul endpoint d'update, zéro ambiguïté
Dans la première partie, FieldMask permettait au client de dire « ne me renvoie que ces champs » pour que le serveur saute le travail coûteux en lecture. Le même petit type rend un service tout aussi utile côté écriture et là, l'enjeu est plus élevé, car une erreur ne gaspille pas du CPU, elle corrompt des données.
Cette partie parle de mutations : mettre à jour un produit sans un endpoint par champ, et sans effacer par accident les champs qu'on ne voulait pas toucher. Même exemple de boutique en ligne qu'avant, même outil, sens inverse.
Les deux mauvaises options
Vous voulez laisser les clients modifier un produit. Prenez ce message :
message Product {
string id = 1;
string title = 2;
Money price = 3;
string description = 4;
Promotion promotion = 5; // imbriqué : { discount_pct, ends_at }
}
Sans FieldMask, vous êtes coincé entre deux designs désagréables.
Option 1 : un RPC par champ. UpdateTitle, UpdatePrice, UpdateDescription, UpdatePromotionEndDate… Chaque nouveau champ, c'est une nouvelle méthode, un nouveau handler, une nouvelle ligne côté client. L'API grossit sans limite et chaque consommateur doit suivre.
Option 2 : un Update qui prend tout l'objet (PUT). Surface plus simple, mais le client doit désormais envoyer le produit complet à chaque fois, même pour changer un seul champ. Pire, c'est ambigu : si la description reçue est vide, le client voulait-il « effacer la description » ou « je ne l'ai simplement pas renseignée » ? Avec un PUT en remplacement total, « pas renseigné » et « mis à vide » sont identiques, si bien qu'un client qui lit, change le prix et renvoie l'objet peut effacer en silence tout champ qu'il n'a pas re-rempli.
| Un RPC par champ | PUT objet complet | |
|---|---|---|
| Surface d'API | explose avec le schéma | une méthode |
| Taille du payload | minuscule | tout l'objet à chaque fois |
| « Effacer » vs « laisser » | s/o | ambigu : risque de perte de données |
Le masque en fait un PATCH propre
Ajoutez un update_mask et l'ambiguïté disparaît :
service ProductService {
rpc UpdateProduct(UpdateProductRequest) returns (Product);
}
message UpdateProductRequest {
Product product = 1;
google.protobuf.FieldMask update_mask = 2;
}
La règle tient en une phrase : le serveur ne modifie que les champs listés dans le masque, et ignore tout le reste du payload. Pour changer le prix et rien d'autre :
product { price { amount: 1999 currency: "EUR" } }
update_mask { paths: "price" }
title, description, promotion sont absents du masque, donc le serveur les laisse exactement tels qu'ils sont en base, même s'ils sont vides dans le payload. Le client n'envoie que ce qu'il touche. C'est PATCH, exprimé en un champ standard.
Modifier et effacer avec le même mécanisme
Voici la partie élégante. Le masque dit quels champs changent ; le payload dit par quoi. Combinez les deux et vous obtenez « modifier » et « effacer » d'un seul design :
Champ dans update_mask ? | Valeur dans product ? | Résultat |
|---|---|---|
| oui | présente | modifié vers la nouvelle valeur |
| oui | vide/absente | effacé : remis à la valeur par défaut/nulle |
| non | peu importe | laissé intact |
Ainsi, pour augmenter le prix et retirer la date de fin de promotion en un seul appel :
product { price { amount: 1999 currency: "EUR" } }
update_mask { paths: "price" paths: "promotion.ends_at" }
price est dans le masque avec une valeur → mis à jour. promotion.ends_at est dans le masque mais sans valeur dans le payload → effacé. Tout le reste → intact. Une requête, une modification et un effacement, aucune ambiguïté.
Côté serveur, vous parcourez le masque et appliquez chaque chemin :
func (s *server) UpdateProduct(ctx context.Context, req *pb.UpdateProductRequest) (*pb.Product, error) {
mask := req.GetUpdateMask()
if mask == nil || len(mask.GetPaths()) == 0 {
return nil, status.Error(codes.InvalidArgument, "update_mask is required")
}
current, err := s.load(ctx, req.GetProduct().GetId())
if err != nil {
return nil, err
}
for _, path := range mask.GetPaths() {
switch path {
case "id", "created_at":
return nil, status.Errorf(codes.InvalidArgument, "field %q is immutable", path)
case "title":
current.Title = req.GetProduct().GetTitle()
case "price":
current.Price = req.GetProduct().GetPrice()
case "description":
current.Description = req.GetProduct().GetDescription()
case "promotion.ends_at":
current.Promotion.EndsAt = req.GetProduct().GetPromotion().GetEndsAt() // nil → effacé
default:
return nil, status.Errorf(codes.InvalidArgument, "unknown field %q", path)
}
}
return s.save(ctx, current)
}
Le runtime protobuf fournit aussi un utilitaire de merge (FieldMaskUtil.merge() en Java, des équivalents ailleurs) qui recopie pour vous les chemins masqués d'un message vers un autre, pratique, mais écrire le switch à la main rend explicite la logique modifier/effacer/rejeter, ce qui, en écriture, vaut bien quelques lignes de plus.
Les champs imbriqués, et le mur des champs répétés
Les champs imbriqués passent par la notation pointée : promotion.ends_at vise une feuille dans le sous-message sans remplacer tout le promotion. Vous descendez aussi profond que votre schéma. C'est précisément pour ça que le masque bat la soupe de flags : l'imbrication est gratuite.
Les champs répétés sont le mur. Un chemin peut nommer un champ répété, mais il ne peut pas aller à l'intérieur. Mettez recommendations dans le masque et vous remplacez toute la liste, car il n'existe pas de recommendations[2].title pour patcher un seul élément. Si vous avez besoin d'opérations au niveau des éléments (ajouter, retirer, réordonner), FieldMask ne vous les donnera pas ; modélisez-les comme des méthodes dédiées (AddRecommendation, RemoveRecommendation). Sachez-le avant de concevoir une « liste éditable » autour d'un masque.
Le piège du masque vide : l'inverse de la lecture
C'est celui qui mord, et c'est l'image miroir du comportement en lecture. En lecture, un masque vide veut dire par convention « donne-moi tout », un défaut sûr et sympathique. En écriture, cette même convention est dangereuse : un masque vide veut dire « chaque champ du payload est une modification voulue », autrement dit remplacement total. Tout champ que le client n'a pas rempli est effacé.
Ajoutez maintenant l'évolution du schéma. Vous livrez un nouveau champ warranty. Un vieux client, écrit avant l'existence de warranty, lit un produit, change le prix, et le renvoie sans masque. Son payload n'a pas de warranty. La sémantique de remplacement total efface consciencieusement la garantie de chaque produit que ce vieux client met à jour. Perte de données silencieuse et massive, sans que personne n'ait écrit de bug.
Deux défenses, utilisez les deux :
- Rendez le masque obligatoire en écriture. Rejetez une mise à jour dont l'
update_maskest vide (comme le fait le handler ci-dessus). Un client doit déclarer son intention explicitement ; « tout changer » ne devrait jamais être le défaut accidentel. - Rejetez les chemins inconnus et immuables. Échouez sur un chemin non reconnu, et sur les champs qui ne doivent jamais changer par cette méthode (
id,created_at, champs calculés côté serveur / en lecture seule). Mieux vaut unInvalidArgumentbien visible qu'un écrasement discret.
Récapitulatif
| Préoccupation | Comment l'update_mask la traite |
|---|---|
| Un endpoint par champ | Disparu : un seul UpdateProduct, le masque choisit les champs |
| Mise à jour partielle (PATCH) | Seuls les chemins masqués changent ; le reste est intact |
| Effacer un champ | Le lister dans le masque, le laisser vide dans le payload |
| Champs imbriqués | Notation pointée : promotion.ends_at |
| Édition d'éléments de liste | Non supporté : utilisez des méthodes add/remove dédiées |
| Masque vide | Rendez-le obligatoire : vide = remplacement total, donc perte de données |
| Chemins immuables / inconnus | Rejeter avec InvalidArgument |
FieldMask est le même petit type dans les deux sens. En lecture il sélectionne ce qui revient et vous laisse sauter le travail coûteux ; en écriture il sélectionne ce qui change et vous offre une sémantique PATCH propre, gratuitement. La différence d'enjeu est toute la leçon : une garde oubliée en lecture gaspille un peu de calcul, le même oubli en écriture peut effacer des données. Rendez le masque obligatoire, rejetez ce qui ne doit pas être touché, et l'update à endpoint unique devient à la fois plus simple et plus sûr que les alternatives.
Retour à la partie 1 : Protobuf FieldMask pour la lecture →.
S'abonner aux prochains articles
Recevez les nouveaux articles par e-mail. Pas de spam, désinscription à tout moment.
Related posts
Protobuf FieldMask : laissez vos clients d'API ne demander que ce dont ils ont besoin
Un seul endpoint, plein d'appelants, et des champs parfois très coûteux à produire. Protobuf FieldMask permet à chaque client de déclarer exactement les champs qu'il veut, pour que le serveur saute les requêtes SQL lourdes, les appels distants et le payload inutiles. L'idée, un exemple classique, et les pièges.
July 3, 2026
Déployer une API Express.js avec Docker
Comment déployer une API REST Express.js avec Docker, avec un focus sur les performances MongoDB, les builds Docker multi-stage, Jest pour les tests, et Nginx comme reverse proxy.
April 11, 2024
Maîtriser NestJS : libérer la puissance des relations avec TypeORM et les bases SQL
Libère la puissance des relations de données avec NestJS, TypeORM et les bases SQL. Maîtrise l'art de construire des structures de données complexes et des interactions fluides. Idéal pour les développeurs NestJS expérimentés comme pour les débutants qui veulent créer des applications à la pointe.
October 18, 2023