gRPC en Go : streaming temps réel pour microservices

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 updates est 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é. Retourner nil ici 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.

Commentaires (0)