Aller au contenu

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 champPUT objet complet
Surface d'APIexplose avec le schémaune méthode
Taille du payloadminusculetout l'objet à chaque fois
« Effacer » vs « laisser »s/oambigu : 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
ouiprésentemodifié vers la nouvelle valeur
ouivide/absenteeffacé : remis à la valeur par défaut/nulle
nonpeu importelaissé 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_mask est 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 un InvalidArgument bien visible qu'un écrasement discret.

Récapitulatif

PréoccupationComment l'update_mask la traite
Un endpoint par champDisparu : un seul UpdateProduct, le masque choisit les champs
Mise à jour partielle (PATCH)Seuls les chemins masqués changent ; le reste est intact
Effacer un champLe lister dans le masque, le laisser vide dans le payload
Champs imbriquésNotation pointée : promotion.ends_at
Édition d'éléments de listeNon supporté : utilisez des méthodes add/remove dédiées
Masque videRendez-le obligatoire : vide = remplacement total, donc perte de données
Chemins immuables / inconnusRejeter 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.

Propulsé par Buttondown.

Related posts

© 2026 < Denis AKPAGNONITE /> | N1BBzerLZXT