Trois services Go internes ont besoin des prix crypto en temps réel. La première implémentation : un polling REST sur un agrégateur maison, toutes les secondes. Résultat : 3 services × 60 req/min = 180 requêtes par minute pour des données qui changent toutes les 200 ms. Latence de 500 ms à 1 s selon le moment où la requête arrive dans le cycle. Et ça, c'est sans compter le coût de sérialisation JSON pour chaque round-trip.
SSE aurait pu fonctionner — j'en ai un article dédié. Mais SSE est conçu pour pousser des événements vers un navigateur. Service-to-service, le protocole HTTP/1.1 unidirectionnel apporte plus de contraintes que de bénéfices. WebSockets sont bidirectionnels, mais ils sont stateful, complexes à gérer à l'échelle, et il n'y a pas de contrat de données — n'importe qui envoie n'importe quoi.
gRPC server streaming règle exactement ce problème. Une connexion HTTP/2 persistante.
Le serveur pousse les mises à jour dès qu'elles arrivent. Le contrat est défini dans
un fichier .proto — versionné, typé, avec génération de code des deux côtés.
C'est le bon outil.
REST vs gRPC : le vrai comparatif
Avant de plonger dans le code, un comparatif honnête. Pas les bullet points marketing, mais les vraies questions que vous allez vous poser en choisissant l'un ou l'autre.
| REST / JSON | gRPC / Protobuf | |
|---|---|---|
| Format | JSON (texte) | Protobuf (binaire) |
| Contrat | OpenAPI (optionnel) | .proto (obligatoire) |
| Streaming | Non natif (SSE / WS en surcouche) | Natif (4 modes) |
| Taille payload | ~1x (référence) | ~3–10x plus petit |
| Browser | ✅ natif | ⚠️ gRPC-web seulement |
| Génération de code | Optionnel | Obligatoire |
| Débogage | curl, Postman | grpcurl, Evans |
Lecture honnête : REST reste la réponse évidente pour les API publiques et les clients navigateur. gRPC s'impose quand les deux extrémités sont des services Go internes, qu'il faut du streaming, et que vous voulez des contrats stricts entre équipes sans passer par une spécification OpenAPI maintenue à la main.
Définir le contrat en Protobuf
Tout commence par le fichier .proto. C'est la source de vérité partagée
entre le serveur et ses clients. On commence par définir les messages — l'équivalent
typé des corps JSON — puis le service et ses méthodes.
syntax = "proto3";
package pricefeed;
option go_package = "./pb";
message PriceUpdate {
string pair = 1; // "BTC/USDT"
string exchange = 2; // "binance"
double bid = 3;
double ask = 4;
int64 timestamp = 5; // unix millis
}
message SubscribeRequest {
repeated string pairs = 1; // ["BTC/USDT", "ETH/USDT"]
}
service PriceFeed {
// Server streaming : le client s'abonne, le serveur pousse les mises à jour
rpc Subscribe(SubscribeRequest) returns (stream PriceUpdate);
// Unary : dernier prix connu pour une paire
rpc GetLatest(SubscribeRequest) returns (PriceUpdate);
}
Le mot-clé stream devant le type de retour signale un server streaming RPC.
Le client envoie une seule requête (SubscribeRequest) et reçoit un flux
indéfini de PriceUpdate jusqu'à ce que la connexion soit fermée — par le
serveur, par le client, ou par un timeout de contexte.
Une fois le fichier écrit, protoc génère le code Go correspondant :
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
pricefeed.proto
Résultat : deux fichiers Go dans ./pb/ — les structs de messages et
les interfaces serveur/client. Ne pas modifier ces fichiers à la main : ils seront
écrasés à la prochaine génération.
Implémenter le serveur Go
Le serveur implémente l'interface générée par protoc. L'embedding de
pb.UnimplementedPriceFeedServer garantit la compatibilité forward : si
de nouvelles méthodes sont ajoutées au service sans que le serveur les implémente,
les appels retournent Unimplemented au lieu de compiler en erreur.
type PriceFeedServer struct {
pb.UnimplementedPriceFeedServer
updates chan *pb.PriceUpdate
}
func (s *PriceFeedServer) Subscribe(req *pb.SubscribeRequest, stream pb.PriceFeed_SubscribeServer) error {
pairs := make(map[string]bool)
for _, p := range req.Pairs {
pairs[p] = true
}
for {
select {
case update := <-s.updates:
if !pairs[update.Pair] {
continue
}
if err := stream.Send(update); err != nil {
// Client déconnecté — pas d'erreur à remonter
return nil
}
case <-stream.Context().Done():
return nil // Client a annulé la souscription
}
}
}
Quelques points importants dans cette implémentation :
- Le channel
updatesest alimenté par la goroutine qui collecte les prix des exchanges (Binance, Kraken, etc.). Le serveur gRPC ne fait que distribuer. stream.Send()retourne une erreur si le client est déconnecté. Retournernilici est intentionnel : ce n'est pas une erreur serveur, c'est une déconnexion normale.stream.Context().Done()catch les cancellations explicites du client et les timeouts de contexte. Sans ça, la goroutine continuerait à tourner après la déconnexion.
Pour démarrer le serveur :
func main() {
updates := make(chan *pb.PriceUpdate, 100)
srv := grpc.NewServer()
pb.RegisterPriceFeedServer(srv, &PriceFeedServer{updates: updates})
lis, err := net.Listen("tcp", ":50051")
if err != nil {
slog.Error("failed to listen", "error", err)
os.Exit(1)
}
slog.Info("gRPC server listening", "addr", ":50051")
if err := srv.Serve(lis); err != nil {
slog.Error("serve error", "error", err)
os.Exit(1)
}
}
Le client Go
Le client est tout aussi simple. La connexion gRPC est réutilisable — on la crée une fois et on l'injecte dans les services qui en ont besoin.
func connectPriceFeed(ctx context.Context, addr string) error {
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return fmt.Errorf("dial: %w", err)
}
defer conn.Close()
client := pb.NewPriceFeedClient(conn)
stream, err := client.Subscribe(ctx, &pb.SubscribeRequest{
Pairs: []string{"BTC/USDT", "ETH/USDT"},
})
if err != nil {
return fmt.Errorf("subscribe: %w", err)
}
for {
update, err := stream.Recv()
if err == io.EOF {
return nil // Serveur a fermé le stream proprement
}
if err != nil {
return fmt.Errorf("recv: %w", err)
}
slog.Info("price update",
"pair", update.Pair,
"bid", update.Bid,
"ask", update.Ask,
"exchange", update.Exchange,
)
}
}
Note sur insecure.NewCredentials() : c'est pour le développement local
et les communications intra-cluster (mTLS géré au niveau réseau par le service mesh).
En production sur réseau public, utiliser credentials.NewClientTLSFromFile()
ou credentials.NewTLS().
La boucle stream.Recv() est bloquante. Si le serveur ne pousse pas de
mise à jour, le client attend — sans consommer de CPU. C'est HTTP/2 qui gère ça,
pas de polling actif. Quand le contexte est annulé (shutdown du service client,
timeout, etc.), Recv() retourne une erreur et la boucle s'arrête proprement.
Les 4 modes de streaming gRPC
gRPC supporte quatre patterns de communication. On a utilisé le server streaming ci-dessus, mais il est utile de connaître les trois autres pour choisir le bon outil selon le cas.
Unary — requête / réponse
Le comportement REST classique. Un appel, une réponse.
Idéal pour les lectures ponctuelles (GetLatest dans notre service).
rpc GetLatest(SubscribeRequest) returns (PriceUpdate);
Server streaming — abonnement à un flux
Ce qu'on a implémenté. Le client envoie une requête, le serveur pousse autant de réponses qu'il veut. Parfait pour les feeds : prix, métriques, logs.
rpc Subscribe(SubscribeRequest) returns (stream PriceUpdate);
Client streaming — envoi de données en batch
Le client envoie un flux de requêtes, le serveur répond une seule fois à la fin. Cas d'usage : ingestion de données en bulk, envoi de fichier segmenté, flux d'ordres à agréger avant traitement.
rpc BatchOrders(stream Order) returns (BatchResult);
Bidirectionnel streaming — flux dans les deux sens
Client et serveur s'envoient des flux simultanément. Cas d'usage légitimes : chat, édition collaborative, protocoles de négociation. Mais attention — c'est le mode le plus complexe à implémenter et à déboguer. Pour la plupart des besoins de microservices, server streaming ou unary suffisent. Le bidirectionnel est souvent sur-ingénié.
rpc MarketDataFeed(stream MarketQuery) returns (stream MarketEvent);
Conclusion
gRPC n'est pas là pour remplacer REST. Ce sont deux outils avec des cas d'usage différents, et confondre les deux amène soit à de la complexité inutile (gRPCifier une API publique) soit à du polling bancal (REST-ifier un flux temps réel).
Le bon outil ici, c'est gRPC, quand les trois conditions sont réunies : services internes en Go, besoin de streaming de données, contrats stricts entre équipes. Sur ce cas concret, passer du polling REST au server streaming gRPC a réduit la latence de ~800 ms à <50 ms, supprimé 180 req/min de charge réseau inutile, et donné un contrat versionné entre le service agrégateur et ses consommateurs.
Pour les API exposées au navigateur ou à des clients tiers : REST reste la réponse. Pour du service-to-service avec des contraintes temps réel : c'est gRPC.