Quando um serviço precisa de uma resposta imediata de outro, a fila não serve e o JSON sobre HTTP/1.1 desperdiça bytes e tempo. Neste artigo a Livraria ganha um serviço de frete e conversa com ele por gRPC, com contrato Protobuf, HTTP/2, streaming e prazos que atravessam serviços.
Mensagens e chamadas
O artigo anterior tratou de comunicação assíncrona: a API publica um fato e não espera ninguém. Há situações em que isso é o modelo errado. Ao fechar o carrinho, o cliente precisa ver o valor do frete agora, e esse valor vem de outro serviço. É uma chamada síncrona, uma pergunta que exige resposta.
Poderíamos fazer mais uma API REST com JSON. Para comunicação entre serviços, o gRPC tem vantagens:
- Contrato primeiro. A interface é escrita num arquivo
.proto, e o código de cliente e servidor é gerado a partir dele, em qualquer linguagem suportada: C#, Go, Java, Python, Rust, Node e outras. Não há como o cliente e o servidor discordarem sobre o nome de um campo. - Protobuf, um formato binário compacto e rápido de serializar, costuma ocupar bem menos bytes que o JSON equivalente.
- HTTP/2: várias chamadas simultâneas numa única conexão TCP, cabeçalhos comprimidos e fluxos independentes.
- Streaming nativo nos dois sentidos.
- Prazos (deadlines) e cancelamento fazem parte do protocolo e se propagam entre serviços.
O preço é que não se testa com o navegador nem com curl puro, as mensagens não são legíveis a olho, e navegadores não falam gRPC diretamente (existe o gRPC-Web, com limitações). Por isso a regra usual é: REST/JSON na fronteira com o mundo, gRPC entre os serviços de dentro. O gRPC nasceu no Google, que o abriu em 2015 a partir do sistema interno Stubby, e hoje é um projeto da Cloud Native Computing Foundation. A implementação .NET, grpc-dotnet, é mantida em conjunto pela Microsoft e roda sobre o Kestrel.
O contrato
O contrato fica numa pasta protos/ na raiz da solução, compartilhada por quem serve e por quem consome:
// protos/frete.proto
syntax = "proto3";
option csharp_namespace = "Livraria.Frete.Contratos";
package livraria.frete.v1;
service Frete {
// Chamada unária: uma pergunta, uma resposta
rpc Calcular (PedidoDeFrete) returns (CotacaoDeFrete);
// Streaming do servidor: uma pergunta, várias respostas conforme chegam
rpc CotarTransportadoras (PedidoDeFrete) returns (stream CotacaoDeFrete);
}
message PedidoDeFrete {
string cep_destino = 1;
repeated Volume volumes = 2;
}
message Volume {
int32 peso_gramas = 1;
int32 quantidade = 2;
}
message CotacaoDeFrete {
string transportadora = 1;
int64 valor_centavos = 2;
int32 prazo_dias_uteis = 3;
}
Alguns pontos da linguagem Protobuf merecem atenção.
Os números são o contrato, não os nomes. No fio, valor_centavos = 2 viaja como "campo 2", e o nome nem é transmitido. Renomear um campo é seguro. Mudar seu número ou seu tipo quebra todos os clientes. Um campo removido deve ter o número marcado como reserved 2;, para que ninguém o reutilize no futuro com outro significado e mensagens antigas não sejam lidas como novas.
Não existe decimal. Protobuf tem inteiros e ponto flutuante binário, e dinheiro em double é o erro que evitamos desde "Cada Valor Tem um Tipo". É o mesmo problema que encontramos no MongoDB antes do Decimal128. As saídas comuns são representar em centavos num int64, como fizemos, ou uma mensagem com parte inteira e parte nanométrica (o padrão google.type.Money / DecimalValue). Centavos são mais simples e exatos para reais.
Em proto3, campos escalares não têm "ausente". Um int32 não enviado é lido como 0, e uma string ausente como "". Se prazo_dias_uteis = 0 puder significar "entrega no mesmo dia" e "não informado", declare o campo como optional int32, que gera no C# uma propriedade HasPrazoDiasUteis.
O pacote tem versão (v1). Uma mudança incompatível vira livraria.frete.v2, servido ao lado do v1 até os clientes migrarem, a mesma estratégia de evolução dos eventos do artigo anterior.
O servidor de frete
dotnet new grpc -n Livraria.Frete -o src/Livraria.Frete
O template já traz o pacote Grpc.AspNetCore. No .csproj, apontamos para o contrato compartilhado:
<ItemGroup>
<Protobuf Include="..\..\protos\frete.proto" GrpcServices="Server" Link="Protos\frete.proto" />
</ItemGroup>
A cada build, o pacote Grpc.Tools roda o compilador protoc e gera as classes das mensagens (com PedidoDeFrete.CepDestino, em PascalCase) e uma classe base abstrata Frete.FreteBase. Implementar o serviço é sobrescrever os métodos dela:
using Grpc.Core;
using Livraria.Frete.Contratos;
public sealed class ServicoDeFrete(TabelaDeTransportadoras tabela) : Frete.FreteBase
{
public override Task<CotacaoDeFrete> Calcular(PedidoDeFrete pedido, ServerCallContext contexto)
{
Validar(pedido);
var melhor = tabela.Todas()
.Select(t => t.Cotar(pedido))
.OrderBy(c => c.ValorCentavos)
.ThenBy(c => c.PrazoDiasUteis)
.First();
return Task.FromResult(melhor);
}
public override async Task CotarTransportadoras(
PedidoDeFrete pedido,
IServerStreamWriter<CotacaoDeFrete> saida,
ServerCallContext contexto)
{
Validar(pedido);
foreach (var transportadora in tabela.Todas())
{
contexto.CancellationToken.ThrowIfCancellationRequested();
var cotacao = await transportadora.CotarRemotamenteAsync(
pedido, contexto.CancellationToken); // cada uma leva um tempo diferente
await saida.WriteAsync(cotacao);
}
}
private static void Validar(PedidoDeFrete pedido)
{
var digitos = pedido.CepDestino.Where(char.IsAsciiDigit).Count();
if (digitos != 8)
throw new RpcException(new Status(StatusCode.InvalidArgument,
"O CEP de destino deve ter 8 dígitos."));
if (pedido.Volumes.Count == 0)
throw new RpcException(new Status(StatusCode.InvalidArgument,
"Informe ao menos um volume."));
}
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
builder.Services.AddSingleton<TabelaDeTransportadoras>();
var app = builder.Build();
app.MapGrpcService<ServicoDeFrete>();
app.Run();
O resto do ASP.NET Core continua disponível: DI, configuração, logging, autenticação JWT e políticas. Serviços gRPC são endpoints como os outros, e app.MapGrpcService<T>().RequireAuthorization(...) funciona da mesma forma.
O TabelaDeTransportadoras é uma simulação: cada transportadora calcula um valor a partir da região (o primeiro dígito do CEP indica a região postal) e do peso total, e CotarRemotamenteAsync espera um tempo aleatório para imitar a consulta a sistemas externos. É esse atraso desigual que torna o streaming interessante: o cliente mostra a primeira cotação em 200 ms em vez de esperar a mais lenta, que chega em 3 segundos.
HTTP/2 sem TLS
O gRPC exige HTTP/2. Com TLS, o cliente e o servidor negociam a versão durante o handshake (ALPN). Em desenvolvimento, ou numa rede interna em que o TLS termina num proxy, usa-se HTTP/2 em texto claro (h2c). Como não há negociação, o endpoint precisa ser configurado para aceitar só HTTP/2:
"Kestrel": {
"EndpointDefaults": { "Protocols": "Http2" }
}
Os erros do gRPC
O gRPC tem seus próprios códigos de status, independentes dos do HTTP. Os mais usados:
| Código | Quando usar | Equivalente HTTP aproximado |
|---|---|---|
InvalidArgument |
entrada inválida | 400 |
NotFound |
recurso inexistente | 404 |
PermissionDenied / Unauthenticated |
autorização / autenticação | 403 / 401 |
FailedPrecondition |
estado não permite a operação | 409 / 412 |
DeadlineExceeded |
o prazo do cliente acabou | 504 |
Unavailable |
serviço fora do ar ou sobrecarregado; pode tentar de novo | 503 |
Internal |
bug | 500 |
O servidor sinaliza erros lançando RpcException, e o cliente os recebe como RpcException com o mesmo StatusCode. Exceções não tratadas viram Unknown sem detalhes, pelo mesmo motivo que um 500 não traz o stack trace. A opção EnableDetailedErrors inclui a mensagem da exceção na resposta e deve ficar restrita ao ambiente de desenvolvimento.
A distinção entre Unavailable e Internal importa para o cliente: o primeiro diz "tente de novo daqui a pouco" e o segundo diz "repetir não vai ajudar". É o mesmo raciocínio 4xx/5xx de "Validação, ProblemDetails e OpenAPI", com um grau a mais de precisão.
O cliente na API
dotnet add src/Livraria.Api package Grpc.Net.ClientFactory
dotnet add src/Livraria.Api package Google.Protobuf
dotnet add src/Livraria.Api package Grpc.Tools
<ItemGroup>
<Protobuf Include="..\..\protos\frete.proto" GrpcServices="Client" Link="Protos\frete.proto" />
</ItemGroup>
Agora o gerador produz Frete.FreteClient. Ele é registrado pela client factory do gRPC, que se apoia na IHttpClientFactory e gerencia o ciclo de vida das conexões HTTP/2:
builder.Services
.AddGrpcClient<Frete.FreteClient>(o =>
o.Address = new Uri(builder.Configuration["Servicos:Frete"]!))
.AddStandardResilienceHandler();
AddStandardResilienceHandler, do pacote Microsoft.Extensions.Http.Resilience (construído sobre o Polly), acrescenta retentativas com espera crescente, circuit breaker e timeout por tentativa. Como o builder do cliente gRPC é um IHttpClientBuilder, a mesma resiliência dos clientes HTTP vale aqui. Retentar só é seguro porque Calcular é uma consulta sem efeitos colaterais. Uma chamada gRPC que registra algo precisaria de idempotência, como o POST /pedidos.
O endpoint da API que o site vai usar no carrinho:
public sealed record ItemDoCarrinho(int ProdutoId, int Quantidade);
public sealed record PedidoDeCotacao(string Cep, List<ItemDoCarrinho> Itens);
public sealed record CotacaoResposta(string Transportadora, decimal Valor, int PrazoDiasUteis);
static async Task<Results<Ok<CotacaoResposta>, ProblemHttpResult>> CotarFrete(
PedidoDeCotacao dados,
Frete.FreteClient frete,
IOptions<OpcoesDeFrete> opcoes,
CancellationToken ct)
{
var pedido = new PedidoDeFrete { CepDestino = dados.Cep };
pedido.Volumes.Add(new Volume
{
PesoGramas = opcoes.Value.PesoPadraoPorLivroGramas,
Quantidade = dados.Itens.Sum(i => i.Quantidade)
});
try
{
var cotacao = await frete.CalcularAsync(
pedido,
deadline: DateTime.UtcNow.AddSeconds(2),
cancellationToken: ct);
return TypedResults.Ok(new CotacaoResposta(
cotacao.Transportadora,
cotacao.ValorCentavos / 100m,
cotacao.PrazoDiasUteis));
}
catch (RpcException e) when (e.StatusCode == StatusCode.InvalidArgument)
{
return TypedResults.Problem(statusCode: 400, title: "Cotação inválida", detail: e.Status.Detail);
}
catch (RpcException e) when (e.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded)
{
return TypedResults.Problem(statusCode: 503, title: "Serviço de frete indisponível",
detail: "Tente novamente em instantes.");
}
}
Volumes é um campo repeated, e o código gerado o expõe como uma RepeatedField<T> somente leitura como propriedade: não se atribui uma lista nova, acrescenta-se com Add. O mesmo vale para map. Um peso padrão por livro é uma simplificação consciente. Um sistema real guardaria peso e dimensões de cada produto.
Os erros do serviço interno são traduzidos para o contrato público. O cliente da API recebe ProblemDetails, nunca uma RpcException, e nem sabe que o gRPC existe. Isso é mais uma aplicação de fronteiras que protegem contratos.
Prazos e cancelamento
O deadline de 2 segundos é diferente de um timeout local. Ele viaja com a chamada, no cabeçalho grpc-timeout, e o servidor sabe quanto tempo o cliente ainda está disposto a esperar. Quando o prazo estoura, o cliente recebe DeadlineExceeded, e o ServerCallContext.CancellationToken do servidor é cancelado, abortando o trabalho que ninguém mais vai ler.
Em cadeias de serviços isso vale ainda mais. Se o serviço de frete consultasse um serviço de endereços por gRPC, ele deveria repassar o que resta do prazo, e não iniciar um prazo novo de 2 segundos. A client factory faz isso sozinha:
// no Livraria.Frete, ao chamar outro serviço gRPC durante uma chamada recebida
builder.Services
.AddGrpcClient<Enderecos.EnderecosClient>(o => o.Address = new Uri("..."))
.EnableCallContextPropagation();
Com a propagação, prazo e cancelamento da chamada de entrada passam automaticamente às chamadas de saída. Se o usuário fechar o carrinho, o cancelamento desce pela API, pelo frete e pelos endereços, e todos param. É o CancellationToken de "Assincronia Avançada" atravessando a rede.
Consumindo o streaming
static async IAsyncEnumerable<CotacaoResposta> CotarTodas(
PedidoDeFrete pedido,
Frete.FreteClient frete,
[EnumeratorCancellation] CancellationToken ct)
{
using var chamada = frete.CotarTransportadoras(
pedido, deadline: DateTime.UtcNow.AddSeconds(5), cancellationToken: ct);
await foreach (var c in chamada.ResponseStream.ReadAllAsync(ct))
yield return new CotacaoResposta(c.Transportadora, c.ValorCentavos / 100m, c.PrazoDiasUteis);
}
O fluxo de respostas é um IAsyncEnumerable<T>, o mesmo tipo de "Assincronia Avançada". Ele se encaixa direto no SSE nativo do .NET 10 mencionado em "Tempo real com SignalR":
app.MapPost("/fretes/cotacoes/stream", (PedidoDeCotacao dados, Frete.FreteClient frete,
IOptions<OpcoesDeFrete> opcoes, CancellationToken ct) =>
TypedResults.ServerSentEvents(CotarTodas(MontarPedido(dados, opcoes.Value), frete, ct)))
.AllowAnonymous();
O navegador recebe cada cotação assim que a transportadora responde, e a mais barata aparece primeiro mesmo que a mais cara demore. O using na chamada é importante: descartar a chamada antes do fim cancela o stream no servidor e libera a conexão HTTP/2.
Há mais dois tipos de streaming no gRPC. No streaming do cliente, o cliente envia várias mensagens e recebe uma resposta, por exemplo o upload de um catálogo em lotes. No bidirecional, os dois lados enviam quando quiserem, como um canal de comandos de um armazém robotizado. A mecânica é a mesma, com IClientStreamWriter<T> e IAsyncStreamReader<T>.
Testando e explorando
grpcurl e reflexão. Como as mensagens são binárias, ferramentas genéricas precisam conhecer o contrato. Com o pacote Grpc.AspNetCore.Server.Reflection, o serviço descreve a si mesmo:
builder.Services.AddGrpcReflection();
if (app.Environment.IsDevelopment())
app.MapGrpcReflectionService();
grpcurl -plaintext localhost:5090 list
grpcurl -plaintext -d '{"cep_destino":"01310100","volumes":[{"peso_gramas":400,"quantidade":2}]}' \
localhost:5090 livraria.frete.v1.Frete/Calcular
Postman e as ferramentas de IDE também chamam serviços gRPC usando a reflexão ou o .proto. A exposição da reflexão segue a mesma regra do documento OpenAPI: é um mapa detalhado do serviço, útil em desenvolvimento e redes internas, e desnecessário na internet.
Testes de integração. A WebApplicationFactory funciona igual: GrpcChannel.ForAddress(fabrica.Server.BaseAddress, new GrpcChannelOptions { HttpHandler = fabrica.Server.CreateHandler() }) cria um canal em memória, e new Frete.FreteClient(canal) o cliente. Na API, os testes substituem o Frete.FreteClient por uma implementação falsa. As classes geradas têm métodos virtuais e um construtor protegido justamente para permitir isso, ou os testes apontam o endereço para uma instância do serviço de frete em memória.
Dois cuidados de infraestrutura
Balanceamento de carga. O HTTP/2 mantém uma conexão longa pela qual passam todas as chamadas. Um balanceador de camada 4 (TCP) distribui conexões, não chamadas: a API abre uma conexão para a réplica 1 do frete, e as réplicas 2 e 3 ficam ociosas para sempre. As soluções são um balanceador de camada 7 que entende HTTP/2 (Envoy, YARP, nginx configurado para gRPC, os balanceadores de aplicação das nuvens) ou balanceamento no cliente: o GrpcChannel do .NET resolve os endereços das réplicas via DNS e distribui as chamadas entre elas (LoadBalancingConfigs com RoundRobinConfig).
Navegadores e REST. Se um dia o frete precisar ser chamado direto do navegador, há duas saídas. O gRPC-Web (app.UseGrpcWeb()) é um protocolo adaptado que os navegadores suportam, sem streaming do cliente. A transcodificação JSON (Microsoft.AspNetCore.Grpc.JsonTranscoding) expõe o mesmo serviço como REST/JSON a partir de anotações google.api.http no .proto, gerando inclusive o OpenAPI. Um mesmo serviço atende os dois mundos.
A Livraria agora tem as três formas de comunicação que um sistema distribuído usa: HTTP com JSON para o mundo externo, mensagens assíncronas para fatos e reações, e gRPC para perguntas entre serviços que precisam de resposta rápida. Os serviços são pequenos e independentes, e isso torna urgentes as perguntas que a Fase 6 ainda não respondeu: quem de fora pode chamar a API a partir do navegador, como impedir que um cliente abusivo derrube tudo, como mudar o contrato sem quebrar quem já o usa, e, quando uma cotação de frete demora 4 segundos, em qual dos cinco processos o tempo foi gasto.
Fontes e leituras recomendadas
- gRPC — Introduction to gRPC e Core concepts: https://grpc.io/docs/what-is-grpc/
- Protocol Buffers — Language Guide (proto3): https://protobuf.dev/programming-guides/proto3/
- Microsoft Learn — Overview for gRPC on .NET: https://learn.microsoft.com/aspnet/core/grpc/
- Microsoft Learn — gRPC client factory integration in .NET (propagação de prazo e cancelamento): https://learn.microsoft.com/aspnet/core/grpc/clientfactory
- Microsoft Learn — Reliable gRPC services with deadlines and cancellation: https://learn.microsoft.com/aspnet/core/grpc/deadlines-cancellation
- Microsoft Learn — gRPC JSON transcoding in ASP.NET Core: https://learn.microsoft.com/aspnet/core/grpc/json-transcoding
Exercícios
Exercício 1
A equipe quer renomear valor_centavos para valor_em_centavos, trocar o tipo de prazo_dias_uteis de int32 para string ("3 a 5 dias") e remover transportadora. Classifique cada mudança como compatível ou incompatível no fio e proponha o caminho seguro para as incompatíveis.
Ver resposta
✓ Resposta: Renomear mantendo o número 2 é compatível no fio, porque o nome não trafega. Quebra apenas o código-fonte de quem usa a propriedade gerada, que dá erro de compilação e é corrigido com facilidade. A transcodificação JSON, se usada, muda o nome no JSON. Trocar o tipo do campo 3 é incompatível: um cliente antigo leria bytes de string como inteiro, ou vice-versa. O caminho seguro é criar string prazo_descricao = 4;, manter o campo 3 preenchido durante a transição e, depois que todos os clientes migrarem, removê-lo com reserved 3;. Remover transportadora também é incompatível semanticamente: clientes antigos passam a ler string vazia, sem erro, o que é pior que uma falha. Deve-se primeiro garantir que nenhum cliente depende do campo, depois removê-lo com reserved 1; reserved "transportadora";. Mudanças maiores justificam um pacote livraria.frete.v2.
Exercício 2
A API chama o frete com prazo de 2 s. O frete chama o serviço de endereços sem EnableCallContextPropagation e com timeout próprio de 5 s. Descreva o que acontece quando o serviço de endereços está lento (4 s).
Ver resposta
✓ Resposta: Aos 2 s, o prazo da API estoura: a API recebe DeadlineExceeded e responde 503 ao cliente, e o CancellationToken da chamada no serviço de frete é cancelado. Sem propagação, porém, a chamada do frete para endereços usa um token e um prazo independentes, e continua esperando até completar aos 4 s. O serviço de endereços trabalha 4 s e o frete processa o resultado para ninguém. Sob carga, isso multiplica o desperdício: cada requisição abandonada continua consumindo recursos em todas as camadas abaixo, justamente quando o sistema já está lento, e o efeito piora a lentidão. Com propagação, o prazo restante (menos de 2 s) e o cancelamento descem, e a chamada aos endereços é abortada junto.
Exercício 3
Explique por que três réplicas do serviço de frete atrás de um balanceador TCP resultam em uma réplica sobrecarregada e duas ociosas, e mostre como configurar o balanceamento no cliente .NET.
Ver resposta
✓ Resposta: O cliente gRPC abre uma conexão HTTP/2 e multiplexa todas as chamadas por ela, por muito tempo. O balanceador TCP decide o destino só na abertura da conexão: escolhe a réplica 1 e nunca mais é consultado. Todas as chamadas daquela API vão para a réplica 1. Com várias instâncias da API, a distribuição fica aleatória e desigual. No cliente: builder.Services.AddGrpcClient<Frete.FreteClient>(o => o.Address = new Uri("dns:///frete.interno:5090")).ConfigureChannel(c => { c.Credentials = ChannelCredentials.Insecure; c.ServiceConfig = new ServiceConfig { LoadBalancingConfigs = { new RoundRobinConfig() } }; });. O esquema dns:/// faz o canal resolver todos os IPs do nome, que num Kubernetes vêm de um headless service, e abrir uma subconexão por réplica, distribuindo as chamadas em rodízio. O canal volta a resolver o DNS periodicamente e quando uma conexão cai, e assim percebe réplicas novas.
Exercício 4
Por que o código de frete usa int64 valor_centavos e não double valor? Mostre um valor de frete que seria exibido errado com double depois de somado a um subtotal em decimal.
Ver resposta
✓ Resposta: double representa números em base 2, e a maioria das frações decimais não tem representação exata. O frete R$ 0,10 vira aproximadamente 0,1000000000000000055511151231257827. O traiçoeiro é que a conversão para decimal esconde o problema: ela arredonda para 15 dígitos significativos, e (decimal)(0.1 + 0.2) dá exatamente 0,3, assim como (decimal)(19.99 * 3) dá 59,97 (medido no .NET 10). O erro aparece nas contas e decisões feitas ainda em double: 0.1 + 0.2 == 0.3 é false; dez fretes de 0,10 somados em double dão 0,9999999999999999, diferente de 1; e Math.Round(1.005, 2, MidpointRounding.AwayFromZero) dá 1, enquanto o mesmo arredondamento em decimal dá 1,01, o centavo que some do imposto. Com int64 em centavos, a aritmética no fio é exata (1999 × 3 = 5997), e a conversão / 100m para decimal na fronteira também é exata. É a mesma lição de "Cada Valor Tem um Tipo" e do Decimal128 no Mongo: dinheiro nunca passa por ponto flutuante binário.
Exercício 5
Escreva o teste de unidade do endpoint CotarFrete que verifica a tradução de Unavailable em 503, sem subir nenhum servidor gRPC.
Ver resposta
✓ Resposta: As classes cliente geradas têm métodos virtuais e um construtor protegido sem argumentos, próprio para testes. Crie sealed class FreteIndisponivel : Frete.FreteClient { public override AsyncUnaryCall<CotacaoDeFrete> CalcularAsync(PedidoDeFrete request, Metadata? headers = null, DateTime? deadline = null, CancellationToken cancellationToken = default) => throw new RpcException(new Status(StatusCode.Unavailable, "fora do ar")); }. Chame o método estático do endpoint diretamente, var resultado = await EndpointsDeFrete.CotarFrete(new PedidoDeCotacao("01310100", [new(1, 2)]), new FreteIndisponivel(), Options.Create(new OpcoesDeFrete()), CancellationToken.None);, e verifique var problema = Assert.IsType<ProblemHttpResult>(resultado.Result); Assert.Equal(503, problema.StatusCode);. O teste roda em microssegundos e documenta o contrato de degradação. Por ter escrito o endpoint como método nomeado com TypedResults, como em "A API da Livraria", ele pode ser testado como uma função comum.