gRPC: contratos binários e streaming entre serviços

gRPC: contratos binários e streaming entre serviços

A Livraria ganha um serviço de frete e conversa com ele por gRPC: contrato em Protobuf, números de campo como contrato, dinheiro em centavos, códigos de status próprios, cliente com resiliência, prazos que se propagam entre serviços, streaming até o SSE e balanceamento.
Linguagem C#

• • 18 min de leitura

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

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.

Comentários

Mais em Linguagem C#

Validação, ProblemDetails e OpenAPI: uma API que explica a si mesma
Validação, ProblemDetails e OpenAPI: uma API que explica a si mesma

Uma API que diz ao cliente o que ele errou: validação nativa do .NET 10 com…

Produção: CORS, rate limiting, versionamento e OpenTelemetry
Produção: CORS, rate limiting, versionamento e OpenTelemetry

Antes de ir para o público, a API da Livraria aprende a se defender e a ser…

Source Generators: Código Que Escreve Código Durante a Compilação
Source Generators: Código Que Escreve Código Durante a Compilação

Source generators no C#: o compilador escrevendo código durante a compilação…