Olá, pessoal. Tudo bem?
Há algum tempo, desenvolvi um conteúdo sobre gRPC para criar um bootcamp e disseminar este conhecimento entre os times de software da Becomex. Embora o modelo de bootcamp não tenha sido implementado na prática, o material ficou excelente. Por isso, decidi transformar a parte teórica em um post e compartilhar esse conteúdo com a comunidade.
Para aprender qualquer coisa na vida, começar pelo básico é sempre uma ótima ideia. Antes de mergulharmos no gRPC, vamos entender o que é RPC e o caminho que nos trouxe até aqui. Neste artigo, você terá uma breve introdução aos conceitos de RPC, a diferença básica entre RPC e REST, e entenderá por que o gRPC é tão rápido e uma excelente alternativa para a transição de grandes volumes de dados. Além disso, vamos apresentar exemplos de implementação para facilitar o entendimento.
Então, ajeite-se na cadeira e prepare sua garrafa d'água, porque vem muita informação boa por aí!
A origem
O RPC ou Remote Procedure Call (chamada de processo remoto em português) é uma técnica que permite realizar a chamada de uma função de forma remota. Com o RPC, o recurso A solicita a execução de uma função ao recurso B, estes podem ser componentes distintos que compõem um grande circuito, ou computadores conectados em rede, mostrando que esse modelo de comunicação não está vinculado somente ao protocolo HTTP, mas também se aplicando facilmente a outros protocolos de comunicação.
As proposições teóricas de modelos de chamadas remotas em redes de computadores começaram a ser feitas na década de 70, tendo como marco importante a publicação da RFC 707 no final do ano de 1975 e no começo do ano de 1976 sob o título A High-Level Framework for Network-Based Resource Sharing. Esse documento, dentre outras coisas, enfatizou a necessidade de padronização dos protocolos de comunicação entre sistemas.
O primeiro uso comercial do modelo de comunicação RPC ocorreu em um projeto desenvolvido pela Xerox PARC em 1981. Foi durante esse período que o termo RPC teria sido cunhado por Bruce Jay Nelson, um dos cientistas da computação da empresa.
Anos mais tarde, o Google criou sua própria arquitetura RPC. Essa infraestrutura de propósito geral chamada Stubby foi utilizada por mais de uma década para conectar o vasto número de microsserviços operando dentro e entre seus data centers. Como afirmado pela própria Google, o Stubby possuía vários recursos excelentes, mas estava altamente acoplado à sua infraestrutura interna para ser considerado adequado para o lançamento ao público. Em 2015, a Google publica então o gRPC, um projeto de código aberto que nasce como a evolução do Stubby.
Ao contrário do que muitas pessoas podem pensar, o "g" em gRPC não significa Google. Na realidade, a cada nova versão lançada do gRPC, o "g" é ressignificado. Clique aqui e confira a lista de nomes atribuídos ao “g”. Para todos os efeitos, podemos entender o termo gRPC como high performance Remote Procedure Call ou chamada de processo remoto de alta performance no nosso bom português.
Diferença entre RPC e REST
O modelo REST Representational State Transfer é potencialmente o mais popular atualmente, e a maioria das APIs web com que trabalhamos são REST ou pseudo REST. Por isso, parece ser uma boa ideia explicar como o RPC funciona comparando-o a um modelo mais conhecido.
Fundamentalmente, o REST expõe os recursos ou entidades de sua aplicação e usa os métodos HTTP para fornecer significado semântico às ações realizadas. Já o RPC expõe as operações que a aplicação pode realizar e, em geral, usa apenas dois diferentes métodos HTTP (quando a comunicação se dá por esse protocolo).
Em uma API REST, se você desejar expor dados sobre usuários, você provavelmente teria o seguinte cenário:
GET api/v1/users
GET api/v1/users/:id
POST api/v1/users
PUT api/v1/users/:id
DELETE api/v1/users/:id
Em uma API RPC, ao invés de expor uma rota de usuários e usar os métodos HTTP para atribuir significado às operações, cada rota se parecerá como uma função específica. Veja o exemplo:
GET api/v1/getUserList
GET api/v1/getUserById
POST api/v1/createUser
POST api/v1/updateUser
POST api/v1/deleteUser
Cada modelo de comunicação oferece diferentes níveis de facilidade para representar as operações de uma aplicação. O REST, por exemplo, é ideal para operações de CRUD (criar, ler, atualizar, deletar), enquanto o RPC é mais eficiente para executar ações da sua aplicação, como enviar e-mails ou realizar cálculos. No entanto, nada impede que o RPC seja usado para operações de CRUD, assim como o REST pode ser aplicado para executar funções em uma API.
De onde vem o high performance do gRPC?
A essa altura, você já deve ter percebido que o gRPC nada mais é do que a implementação de um modelo de API RPC, construído de modo a fornecer um alto desempenho em suas execuções. Mas como esse alto desempenho é construído? A primeira coisa que precisamos entender é que o gRPC não é uma tecnologia em si, mas uma junção de várias técnicas e tecnologias. Bora explorar cada uma delas?
Execução sob o protocolo de comunicação HTTP/2
Implementar uma API gRPC significa utilizar o protocolo HTTP/2 para estabelecer a conexão e a troca de informação entre o cliente e o servidor. O HTTP/2 que também foi criado pelo Google oferece vários recursos de melhoria de desempenho em relação ao seu antecessor. Aqui, vamos explorar duas de suas principais características, mas você pode ter uma leitura completa consultando a RFC 7540.
Stream e Multiplexação
Uma das principais limitações do HTTP/1.1 é a sua restrição no envio e recebimento de dados. Nesta versão, não é possível enviar ou receber mais de uma mensagem por conexão, o que significa que, se você precisar enviar duas mensagens ao servidor, terá que abrir duas conexões separadas. Isso torna o processo mais lento, já que a cada requisição é necessário refazer o handshake com o servidor e reenviar eventuais informações repetitivas entre as requisições. Além disso, por questões de desempenho, o número de conexões simultâneas que um browser pode fazer com um servidor é limitado. Para entender melhor essa limitação, recomendo o artigo Why does your browser limit the number of concurrent network calls?.
O protocolo HTTP/2, por outro lado, permite algo chamado multiplexação, o que significa que é possível enviar mais de uma requisição ao servidor por meio de uma única conexão. Isso resolve as limitações de conexões simultâneas e os tempos de handshake que a versão anterior impunha. A imagem abaixo, retirada do livro HTTP/2 in Action, ilustra de forma simples esse funcionamento.
Compressão e redução dos headers
Outra característica interessante do HTTP/2 é a compressão dos headers das mensagens. Em alguns casos, os headers de uma requisição podem ser maiores que o próprio payload, e o HTTP/2 faz uma compressão eficiente dessas informações. Além disso, como essa versão mantém uma única conexão para várias solicitações, não é necessário reenviar todos os headers em cada requisição. O HTTP/2 apenas retransmite os headers que foram alterados e, para os que permanecem iguais, envia apenas um índice de referência.
Protocol Buffers
O Protocol Buffers (ou Protobuf, para os íntimos) é um método muito eficiente de serialização de dados. O Protobuf usa uma linguagem de definição de interface (IDL) para descrever arquivos agnósticos de plataformas e linguagens de programação. Esses arquivos contêm as especificações dos contratos e funções que o servidor pode fornecer e tanto o cliente quanto o servidor mantêm uma cópia desses arquivos, o que garante a coesão na troca de informações. Além disso, isso separa o contexto das mensagens trocadas na rede do seu conteúdo, diferentemente de contratos em JSON, onde contexto e conteúdo são enviados necessariamente juntos.
Veja o exemplo abaixo, que utiliza a IDL do Protobuf para descrever um contrato de um objeto que representa um produto, com as propriedades partnumber, que significam o código e a quantidade de um produto:
syntax = "proto3";
message Produto {
string partnumber = 1;
int32 quantidade = 2;
}
Na primeira linha, definimos a sintaxe "proto3". Na terceira, declaramos uma mensagem (ou objeto) chamada "Produto", que pode ser usada tanto como entrada quanto saída de uma função. As linhas quatro e cinco definem as propriedades desse objeto, indicando o tipo, nome e índice de cada uma. E aqui está o interessante: nós definimos índices que determinam a posição das propriedades, o que significa que a mensagem enviada via gRPC não precisa carregar o nome da propriedade, apenas seu índice.
Para ilustrar de forma mais clara, veja o exemplo em JSON que seria necessário para representar essa mensagem:
{
"partnumber": "Boinas",
"quantidade": 150
}
Cada caractere da mensagem acima precisa ser transmitido pela rede por meio físico do servidor até o cliente. Quanto maior a mensagem, mais tempo levará para ser transmitida. O Protobuf codifica essa mesma mensagem em um conjunto muito menor de dados, o que acelera a transmissão pela rede. A codificação dessa mensagem pelo Protobuf seria a seguinte: 0A06426F696E6173109601
. Não se preocupe, vou explicar como chegamos a esse valor em breve 😉. E ainda, graças ao HTTP/2, esses dados serão comprimidos antes de serem transmitidos.
O Protobuf, que também foi criado pelo Google, pode ser usado de forma independente, sem estar necessariamente no contexto do gRPC. Se salvarmos o exemplo anterior em um arquivo chamado Produto.proto
, podemos rodar o seguinte comando no terminal: echo 'partnumber: "boinas"; quantidade: 150' | protoc --encode=Produto produto.proto > produto.bin
(você precisará instalar o protoc para isso). O comando echo
indica que queremos imprimir um conteúdo, o texto entre aspas simples é o conteúdo que queremos imprimir, concatenamos essa impressão com a chamada do protoc
indicando que queremos fazer o encode do objeto "Produto" --encode=Produto
especificado dentro do arquivo produto.proto
, por fim definimos que o resultado deve ser salvo dentro do arquivo produto.bin
. Para visualizar arquivos com essa codificação, gosto de usar o VS Code com a extensão Hex Editor. É assim que você verá a sequência de caracteres 0A06426F696E6173109601
mencionada anteriormente.
O resultado gerado por essa operação é uma sequência hexadecimal, onde cada par de caracteres representa um conjunto de 8 bits. Vamos dar uma olhada mais detalhada nessa estrutura.
O primeiro bit de cada par é o most significant bit (MSB), ou continuation bit. Essa é uma estratégia inteligente de codificação para indicar se o próximo par faz parte da mesma representação de dado ou se é um dado separado. Quando o valor é 0, o par contém toda a informação. Se for 1, é necessário combinar os bits com o próximo par para interpretar o dado.
Os pares 0A e 10, destacados em vermelho na imagem, representam as definições das propriedades enviadas. Após o primeiro bit (MSB), os próximos 4 bits indicam o índice da propriedade (lembrando que no arquivo .proto definimos índices representados aqui), e os três bits seguintes destacados em negrito indicam o tipo da mensagem.
O par 06, destacado em azul, indica o comprimento da mensagem de texto (neste caso, 6 conjuntos), sempre que o tipo do dado for uma string, o dado será precedido por um ou mais conjuntos que definem o comprimento do texto transmitido na propriedade. Em seguida, destacado em cinza, temos a mensagem "Boinas", codificada.
Os últimos dois conjuntos, 96 e 01, representam o valor da propriedade "quantidade". Sabemos que devemos interpretar os dois pares juntos porque o primeiro par, 96, possui o valor 1 no MSB. Para interpretar um valor numérico serializado pelo Protobuf, removemos o MSB da sequência e aplicamos a técnica little-endian, ordenando os bits menos significativos à frente. Isso resulta na sequência 10010110, que representa o valor 150 que informamos em nossa mensagem de teste.
Se quiser entender mais sobre o processo de codificação do Protobuf, você pode consultar as especificações aqui.
Sintaxe e tipos padrões
Já vimos um pouco da sintaxe da IDL do Protobuf nos exemplos anteriores, mas vamos nos aprofundar em alguns conceitos básicos.
syntax = "proto3";
service DocumentoService {
rpc List(ListDocumentoRequest) returns (stream ListDocumentoResponse);
}
message ListDocumentoRequest {
reserved 3;
int64 analise_id = 1;
optional int32 tipo_documento = 2;
optional string cnpj_participante = 4
}
message ListDocumentoResponse {
Documento documento = 1;
}
message Documento {
int64 documentoId = 1;
string chave_eletronica = 2;
bool valido = 3;
string cnpj_emitente = 4;
repeated Produto = 5;
}
message Produto {
string partnumber = 1;
doubel valor = 2;
}
No exemplo acima, usamos o termo service para indicar que estamos descrevendo as funções de um serviço chamado DocumentoService
. Em seguida, usamos o termo rpc
para descrever as funções que esse serviço suporta. Definimos uma função chamada List
, que recebe um objeto ListDocumentoRequest
e retorna um stream de dados chamado ListDocumentoResponse
. O uso de stream indica que o volume de dados pode ser alto, permitindo que o cliente processe os dados de forma parcial conforme o servidor os envia.
Nosso exemplo continua com a definição da estrutura do objeto ListDocumentoRequest
. Nele, especificamos que a posição de índice 3 é reserved
, o que significa que nenhuma propriedade pode ocupar essa posição. A posição 3 não tem nada de especial, foi apenas uma escolha para demonstrar essa possibilidade. Também definimos uma propriedade chamada analise_id
como int64
, e as duas propriedades seguintes são marcadas com o termo optional
. Isso significa que, ao transmitir a mensagem, podemos omitir esses dois parâmetros.
Um ponto importante a se observar ao definir propriedades como optional
no Protobuf é que, ao fazer isso, habilitamos uma propriedade chamada Has{MinhaVariável}
no objeto convertido para a nossa linguagem de programação. Antes de acessar a propriedade optional
, é essencial verificar se essa propriedade está marcada como true
ou false
. Se tentarmos recuperar a propriedade diretamente, sem essa verificação, receberemos o valor padrão do tipo de dado em questão (0 para inteiros e vazio para strings por exemplo).
Veja um exemplo em C# de como recuperar esses dados:
private DocumentoFilter MapToFilter(ListDocumentoRequest request)
{
return new DocumentoFilter()
{
AnaliseId = request.Query.AnaliseId,
TipoDocumento = request.Query.HasTipoDocumento
? request.Query.TipoDocumento
: null,
CnpjParticipante = request.Query.CnpjParticipante
};
}
Antes de acessar o parâmetro TipoDocumento
, foi necessário verificar o estado da propriedade HasTipoDocumento
. Se tivéssemos acessado TipoDocumento
diretamente, o valor retornado seria zero. No caso de CnpjParticipante
, podemos recuperar o valor diretamente, mesmo que seja opcional. Fazer isso não causará erro, o retorno será apenas uma string vazia. Se você precisar diferenciar entre strings vazias e valores nulos (quando o usuário não fornecer esse dado), deverá usar a propriedade HasCnpjParticipante
, da mesma forma como fizemos com TipoDocumento
.
Seguindo com a análise da sintaxe do arquivo .proto, temos a definição do objeto ListDocumentoResponse
. Nesse objeto, há uma propriedade do tipo Documento
, que é um objeto personalizado. No Protobuf, podemos encadear objetos livremente, e Documento
possui uma propriedade do tipo Produto
como exemplo. O termo repeated
que precede o tipo Produto
indica que essa propriedade é, na verdade, uma lista de produtos. Você pode consultar os tipos padrões e termos reservados da IDL do Protobuf aqui.
Um ponto importante a se observar quando estiver criando seus objetos. Caso sua entidade possua uma propriedade repeat
que pode receber uma lista muito grande dados, convêm não inclui-la no retorno de sua entidade, ao invés disso, crie uma outra função que retorne essa lista e permita que seu cliente chame simultaneamente as rotas da entidade pai e de sua lista de propriedades, dessa forma o desempenho na operação dos serviços do gRPC será muito superior.
Também é possível criar enumeradores no Protobuf e passar parâmetros que aceitam valores nulos, o que elimina a necessidade do termo optional
. Se quiser saber mais sobre isso, você pode consultar a documentação. Um conselho: se usar propriedades complexas que podem transmitir valores nulos, não use o termo optional
, já que não faz sentido declarar optional
em propriedades com tipos nullables.
Criando serviços gRPC
Finalmente chegamos à parte mais esperada: colocar a mão na massa! Para iniciar um projeto de servidor gRPC em C#, você pode usar o seguinte comando: dotnet new grpc -o NomeDoProjeto
.
O resultado será um Program
semelhante ao seguinte:
using GrpcDocumento.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddGrpc();
var app = builder.Build();
// Configure the HTTP request pipeline.
app.MapGrpcService<GreeterService>();
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
app.Run();
A função MapGrpcService
inclui os arquivos de Services que você criar, fornecendo os endpoints definidos nos arquivos .proto. Para começar, basta criar seus próprios arquivos .proto e compilar o projeto. O .NET gerará as classes e tipos necessários para que seu serviço herde a classe base gerada pela compilação do arquivo .proto. Um exemplo disso pode ser visto no template, onde o serviço GreeterService
herda as implementações de Greeter.GreeterBase
e implementa os métodos definidos para o RPC.
Com o conteúdo que vimos até agora, você já deve ser capaz de criar APIs gRPC simples. Então, vou pular para algo mais avançado e divertido: a criação de métodos que recebem e enviam streams de dados. Se você precisar de um passo a passo mais detalhado para a criação de servidores gRPC, pode acessar o tutorial: Criar um cliente e um servidor gRPC no ASP.NET Core disponibilizado pela Microsoft.
Vamos supor que você precise criar uma função que receba e envie um stream de dados. Um exemplo de implementação seria o seguinte:
public async override Task List(
IAsyncStreamReader<ListDocumentoRequest> requestStream,
IServerStreamWriter<ListDocumentoResponse> responseStream,
ServerCallContext context)
{
while (await requestStream.MoveNext())
{
var filter = MapToFilter(requestStream.Current);
var documentoList = _documentoService.GetDocumentoList(filter);
await foreach (var documento in documentoList)
{
await responseStream.WriteAsync(documento);
}
}
}
Quando o arquivo .proto define a entrada de dados como stream, o tipo recebido na função será IAsyncStreamReader<T>
, onde T
é o tipo de dado de entrada. Se o retorno da função for definido como stream, o tipo retornado será IServerStreamWriter<T>
. No requestStream
, você pode usar a função MoveNext
para aguardar o próximo pacote de dados enviado pelo cliente ou processar o pacote já recebido. No responseStream
, você pode usar a função WriteAsync
para enviar pacotes de resposta ao cliente.
Se você está lidando com streams, provavelmente trabalhará com grandes volumes de dados. Nesse caso, é interessante realizar consultas paginadas no banco de dados, para otimizar a eficiência e reduzir o volume de dados em trânsito. Confira abaixo um exemplo de implementação para a função GetDocumentoList
usada no exemplo anterior:
public async IAsyncEnumerable<DocumentoResponse> GetDocumentoList(DocumentoFilter)
{
var documentoQuery = _repository.GetDocumentoQueryByFilter(filter).AsNoTracking();
var skip = 0;
var count = 0;
do
{
count = 0;
foreach (var documento in documentoQuery.Skip(skip).Take(1000))
{
count++;
yield return MapToResponse(documento);
}
skip += 1000
} while (count == 1000);
}
O código acima realiza consultas paginadas de 1000 em 1000 registros e processa os dados sob demanda, usando IAsyncEnumerable
e yield return
até que todos os dados compatíveis filtrados sejam retornados.
Um destaque importante ao criar servidores gRPC que retornam streams de dados é que os dados enviados para o client são armazenados em um buffer. Como esse buffer não é infinito, se você enviar dados mais rápido do que o client pode consumir, poderão ocorrer erros de estouro de buffer. Você pode ignorar esse problema e deixar que o client lide com o processamento, ou pode ser uma pessoa legal e implementar técnicas que evitem isso, como aguardar um sinal do client para enviar dados paginados sob demanda, de acordo com sua capacidade de processamento.
Consumindo uma API gRPC
Agora, suponha que você já tenha um projeto e deseja consumir uma API gRPC. O processo é bem simples. Primeiro, instale as seguintes bibliotecas:
- Grpc.Net.Client
- Google.Protobuf
- Grpc.Tools
Em seguida, adicione o código necessário no arquivo csproj
para mapear os arquivos .proto desejados:
<ItemGroup>
<Protobut Includ="Protos\documento.proto" GrpcServices="Client" />
</ItemGroup>
Para saber mais sobre as estruturas e relacionamentos entre os nugets do gRPC e sua arquitetura, consulte o blog gRPC.
Agora vamos implementar a função que cria a conexão com o servidor e chama o endpoint RPC
public async Task CallDocumentoServiceAsync(IEnumerable<string> analiseIdList)
{
using var channel = GrpcChannel.ForAddress("endereco_grpc");
var client = new DocumentoServiceClient(channel);
var duplexStream = client.List();
await SendRequestMessageAsync(analiseIdList, duplexStream);
await ReceiveResponseMessageAsync(duplexStream);
}
public async Task SendRequestMessage(
IEnumerable<string> analiseIdList,
AsyncDuplexStreamingCall<ListDocumentoRequest, ListDocumentoResponse> duplexStream)
{
foreach (var analiseId in analiseIdList)
{
var grpcRequest = new ListDocumentoRequest
{
AnaliseId = analiseId
};
await duplexStream.RequestStream.WriteAsync(grpcRequest);
}
await duplexStream.RequestStream.CompleteAsync();
}
public async ReceiveResponseMessageAsync(
AsyncDuplexStreamingCall<ListDocumentoRequest, ListDocumentoResponse> duplexStream)
{
while (await duplexStream.ResponseStream.Move())
{
// TODO: Sua implementação
}
}
A função acima cria um canal gRPC com o endereço do servidor especificado e usa o tipo DocumentoServiceClient
gerado a partir da compilação do arquivo .proto para definir o serviço que será chamado. Em seguida, chamamos a função RPC List
, que recebe e envia um stream de dados. O retorno da função é um duplexStream.
Em seguida, na função SendRequestMessage
, fazemos o stream das requisições, escrevendo cada solicitação com await duplexStream.RequestStream.WriteAsync(grpcRequest);
. Depois de enviar todos os pacotes, necessários, é importante informar ao servidor que finalizamos as requisições, chamando a função await duplexStream.RequestStream.CompleteAsync();
.
Por fim, a função ReceiveResponseMessageAsync
lida com o stream de retorno do servidor, percorrendo cada um dos retornos com duplexStream.ResponseStream.MoveNext()
para processar os objetos retornados.
No exemplo fornecido, estamos enviando todas as requisições e, em seguida, recebendo todos os retornos. No entanto, a conexão gRPC é bidirecional, permitindo enviar e receber dados simultaneamente. Isso significa que poderíamos colocar cada função em uma thread separada, otimizando ainda mais o desempenho do envio e recebimento de dados entre cliente e servidor.
Caso você queira construir um servidor gRPC mas não queira criar um cliente apenas para testes, é possível usar o postman para consumir APIs gRPC, facilitando o teste das suas funções.
Conclusão e referências adicionais
O gRPC é uma ferramenta incrível para trafegar grandes volumes de dados, e há muitos cenários onde ele pode ser aplicado. Tenho certeza de que você poderá tirar grande proveito desse conhecimento.
Abaixo estão alguns links adicionais que você pode gostar: