98/100 Dias de Golang - gRPC e Protocol Buffers
Table of Contents
#
gRPC e Protocol Buffers
Nesta série já fizemos aplicações se comunicarem de várias formas: TCP puro (dia 51), UDP (dia 54), WebSockets (dia 53) e, claro, APIs REST com JSON. Hoje vamos ver a forma mais comum de serviços conversarem entre si em arquiteturas de microsserviços: o gRPC.
O gRPC é um framework de RPC (Remote Procedure Call) criado pelo Google. A ideia do RPC é que chamar uma função em outro servidor pareça chamar uma função local:
pedido, err := client.CriarPedido(ctx, req)
Por baixo dos panos isso vira uma requisição pela rede, mas para quem escreve o código é só uma chamada de função, com tipos definidos e checados pelo compilador.
O gRPC é formado por duas peças:
- Protocol Buffers (protobuf): a linguagem para definir as mensagens e os serviços, e o formato binário em que os dados trafegam;
- HTTP/2 como transporte, o que permite várias chamadas simultâneas numa mesma conexão e streaming nos dois sentidos.
#
REST com JSON ou gRPC?
Com REST, o contrato da API costuma ficar numa documentação (quando existe), e cada cliente escreve à mão as structs, as URLs e o tratamento de erros. Se o servidor renomeia um campo, o cliente só descobre quando quebrar.
No gRPC, o contrato é um arquivo .proto. A partir dele, uma ferramenta gera o código do servidor e do cliente, em Go e em mais de dez outras linguagens. Se o contrato mudar de um jeito incompatível, o código para de compilar.
Em resumo:
- gRPC: comunicação entre serviços internos, quando performance importa, quando há streaming, quando os serviços são escritos em linguagens diferentes;
- REST/JSON: APIs públicas e para o navegador. O navegador não fala gRPC diretamente, então para o front-end o REST continua sendo o caminho mais simples.
#
Instalando as ferramentas
Precisamos de três coisas: o compilador protoc e os dois plugins que geram código Go:
# protoc: baixe o zip da sua plataforma em
# https://github.com/protocolbuffers/protobuf/releases (usei a v33.2)
# e coloque o binário bin/protoc no PATH
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.0
O protoc-gen-go gera as structs das mensagens e o protoc-gen-go-grpc gera o código do servidor e do cliente. Os dois vão para o $GOPATH/bin (normalmente ~/go/bin), que precisa estar no PATH.
E as bibliotecas do projeto:
go mod init loja-grpc
go get google.golang.org/grpc@v1.77.0 google.golang.org/protobuf@v1.36.11
#
O contrato: o arquivo .proto
Vamos criar um serviço de pedidos com três operações: criar um pedido, buscar um pedido e acompanhar um pedido, recebendo as mudanças de status em tempo real.
proto/pedidos/pedidos.proto:
syntax = "proto3";
package pedidos.v1;
option go_package = "loja-grpc/proto/pedidos";
service PedidoService {
rpc CriarPedido(CriarPedidoRequest) returns (Pedido);
rpc BuscarPedido(BuscarPedidoRequest) returns (Pedido);
rpc AcompanharPedido(BuscarPedidoRequest) returns (stream AtualizacaoStatus);
}
enum Status {
STATUS_DESCONHECIDO = 0;
STATUS_PENDENTE = 1;
STATUS_PAGO = 2;
STATUS_ENVIADO = 3;
STATUS_ENTREGUE = 4;
}
message Item {
string produto = 1;
int32 quantidade = 2;
int64 preco_centavos = 3;
}
message Pedido {
int64 id = 1;
string cliente = 2;
repeated Item itens = 3;
int64 total_centavos = 4;
Status status = 5;
}
message CriarPedidoRequest {
string cliente = 1;
repeated Item itens = 2;
}
message BuscarPedidoRequest {
int64 id = 1;
}
message AtualizacaoStatus {
int64 pedido_id = 1;
Status status = 2;
}
Vamos por partes:
package pedidos.v1: o nome do pacote no mundo protobuf. Ov1no nome é uma convenção para versionar a API: uma mudança incompatível vira umpedidos.v2, e as duas versões podem conviver;go_package: o caminho do pacote Go que vai ser gerado;message: equivale a uma struct.repeatedé uma lista (vira um slice);enum: em proto3 o primeiro valor precisa ser 0, e ele é o valor padrão. Por isso usamos umSTATUS_DESCONHECIDO = 0: um campo que ninguém preencheu fica “desconhecido” em vez de, por acidente, “pendente”;service: as funções disponíveis. Ostreamno retorno doAcompanharPedidoquer dizer que o servidor vai enviar várias mensagens em vez de uma.
##
Os números dos campos
O detalhe mais importante do protobuf são os números depois de cada campo (= 1, = 2…). Na mensagem binária não vai o nome do campo, vai o número. Isso tem duas consequências:
- As mensagens ficam pequenas (veremos daqui a pouco quanto);
- Esses números nunca podem mudar. Dá para renomear um campo à vontade, porque o nome nem trafega. Mas se trocar o número, ou reaproveitar o número de um campo removido para outro campo, clientes antigos vão ler o dado errado. Para remover um campo com segurança, use
reserved 3;para que ninguém reutilize o número.
Adicionar campos novos é seguro: quem não conhece o campo simplesmente o ignora. É isso que permite atualizar servidor e clientes em momentos diferentes.
##
Gerando o código
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/pedidos/pedidos.proto
O comando cria dois arquivos ao lado do .proto:
pedidos.pb.go: as structs das mensagens;pedidos_grpc.pb.go: as interfaces e o código do servidor e do cliente.
A struct Pedido gerada:
type Pedido struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Cliente string `protobuf:"bytes,2,opt,name=cliente,proto3" json:"cliente,omitempty"`
Itens []*Item `protobuf:"bytes,3,rep,name=itens,proto3" json:"itens,omitempty"`
TotalCentavos int64 `protobuf:"varint,4,opt,name=total_centavos,json=totalCentavos,proto3" json:"total_centavos,omitempty"`
Status Status `protobuf:"varint,5,opt,name=status,proto3,enum=pedidos.v1.Status" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
Ela também ganha getters (GetId(), GetCliente()…) que funcionam mesmo se o ponteiro for nil, retornando o valor padrão. Por isso o código com protobuf costuma usar req.GetCliente() em vez de req.Cliente.
As mensagens geradas têm campos internos (
state,sizeCache) e não devem ser copiadas por valor: use sempre ponteiros (*pb.Pedido). Ogo vetavisa se você copiar.
E a interface que o nosso servidor precisa implementar:
type PedidoServiceServer interface {
CriarPedido(context.Context, *CriarPedidoRequest) (*Pedido, error)
BuscarPedido(context.Context, *BuscarPedidoRequest) (*Pedido, error)
AcompanharPedido(*BuscarPedidoRequest, grpc.ServerStreamingServer[AtualizacaoStatus]) error
mustEmbedUnimplementedPedidoServiceServer()
}
Cada rpc do .proto virou um método. Repare que os métodos comuns recebem um context.Context (o dia 89 de novo!), e o de streaming recebe um objeto stream para enviar as mensagens. O grpc.ServerStreamingServer[T] usa generics (dia 13).
#
O servidor
server/main.go:
package main
import (
"context"
"log"
"net"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/reflection"
"google.golang.org/grpc/status"
pb "loja-grpc/proto/pedidos"
)
type servidor struct {
pb.UnimplementedPedidoServiceServer
mu sync.Mutex
pedidos map[int64]*pb.Pedido
proximo int64
}
func (s *servidor) CriarPedido(ctx context.Context, req *pb.CriarPedidoRequest) (*pb.Pedido, error) {
if req.GetCliente() == "" {
return nil, status.Error(codes.InvalidArgument, "cliente é obrigatório")
}
if len(req.GetItens()) == 0 {
return nil, status.Error(codes.InvalidArgument, "o pedido precisa ter pelo menos um item")
}
var total int64
for _, item := range req.GetItens() {
total += int64(item.GetQuantidade()) * item.GetPrecoCentavos()
}
s.mu.Lock()
defer s.mu.Unlock()
s.proximo++
pedido := &pb.Pedido{
Id: s.proximo,
Cliente: req.GetCliente(),
Itens: req.GetItens(),
TotalCentavos: total,
Status: pb.Status_STATUS_PENDENTE,
}
s.pedidos[pedido.Id] = pedido
return pedido, nil
}
func (s *servidor) BuscarPedido(ctx context.Context, req *pb.BuscarPedidoRequest) (*pb.Pedido, error) {
s.mu.Lock()
defer s.mu.Unlock()
pedido, ok := s.pedidos[req.GetId()]
if !ok {
return nil, status.Errorf(codes.NotFound, "pedido %d não encontrado", req.GetId())
}
return pedido, nil
}
func (s *servidor) AcompanharPedido(req *pb.BuscarPedidoRequest, stream grpc.ServerStreamingServer[pb.AtualizacaoStatus]) error {
if _, err := s.BuscarPedido(stream.Context(), req); err != nil {
return err
}
etapas := []pb.Status{pb.Status_STATUS_PAGO, pb.Status_STATUS_ENVIADO, pb.Status_STATUS_ENTREGUE}
for _, etapa := range etapas {
select {
case <-time.After(500 * time.Millisecond): // simulando o pedido avançando
case <-stream.Context().Done():
return stream.Context().Err()
}
s.mu.Lock()
s.pedidos[req.GetId()].Status = etapa
s.mu.Unlock()
if err := stream.Send(&pb.AtualizacaoStatus{PedidoId: req.GetId(), Status: etapa}); err != nil {
return err
}
}
return nil
}
func logInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
inicio := time.Now()
resp, err := handler(ctx, req)
log.Printf("%s código=%s duração=%v", info.FullMethod, status.Code(err), time.Since(inicio).Round(time.Microsecond))
return resp, err
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal(err)
}
srv := grpc.NewServer(grpc.UnaryInterceptor(logInterceptor))
pb.RegisterPedidoServiceServer(srv, &servidor{pedidos: make(map[int64]*pb.Pedido)})
reflection.Register(srv)
log.Println("servidor gRPC rodando em :50051")
log.Fatal(srv.Serve(lis))
}
O UnimplementedPedidoServiceServer embutido. Ele implementa todos os métodos retornando o erro “não implementado”. Se no futuro alguém adicionar um rpc CancelarPedido no .proto e regerar o código, o nosso servidor continua compilando, e quem chamar o método novo recebe um erro claro em vez de o servidor quebrar. O gerador obriga esse embed através do método mustEmbed....
Erros com status codes. O gRPC não usa os status do HTTP (404, 400…), ele tem os seus próprios códigos: NotFound, InvalidArgument, AlreadyExists, PermissionDenied, Unauthenticated, DeadlineExceeded, Internal… Retornamos com status.Error(codes.X, "mensagem"). Um erro comum, como errors.New, chega no cliente como Unknown.
Streaming. No AcompanharPedido o servidor chama stream.Send várias vezes, uma para cada mudança de status, e a chamada termina quando a função retorna. O stream.Context() é cancelado se o cliente desistir ou o prazo dele acabar, e aí paramos de trabalhar, igual ao servidor HTTP do post de context.
Interceptors são os middlewares do gRPC, como o AuthMiddleware do post anterior. O logInterceptor envolve cada chamada e registra o método, o código de status e a duração. Para autenticação, ele leria o token dos metadata da chamada (o equivalente aos headers HTTP). O UnaryInterceptor só pega as chamadas comuns: para os streams existe o StreamInterceptor.
Reflection. O reflection.Register permite que ferramentas perguntem ao servidor quais serviços e métodos ele tem. Vamos usar isso para testar com o grpcurl.
#
O cliente
client/main.go:
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
pb "loja-grpc/proto/pedidos"
)
func main() {
conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
client := pb.NewPedidoServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pedido, err := client.CriarPedido(ctx, &pb.CriarPedidoRequest{
Cliente: "Victor",
Itens: []*pb.Item{
{Produto: "Teclado", Quantidade: 1, PrecoCentavos: 25000},
{Produto: "Mouse", Quantidade: 2, PrecoCentavos: 8990},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("pedido criado: id=%d total=R$ %.2f status=%s\n",
pedido.GetId(), float64(pedido.GetTotalCentavos())/100, pedido.GetStatus())
_, err = client.BuscarPedido(ctx, &pb.BuscarPedidoRequest{Id: 999})
if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound {
fmt.Println("erro esperado:", st.Code(), "-", st.Message())
}
_, err = client.CriarPedido(ctx, &pb.CriarPedidoRequest{Cliente: "Ana"})
fmt.Println("erro de validação:", status.Code(err), "-", status.Convert(err).Message())
stream, err := client.AcompanharPedido(ctx, &pb.BuscarPedidoRequest{Id: pedido.GetId()})
if err != nil {
log.Fatal(err)
}
for {
atualizacao, err := stream.Recv()
if errors.Is(err, io.EOF) {
fmt.Println("acompanhamento finalizado")
break
}
if err != nil {
log.Fatal(err)
}
fmt.Printf("pedido %d: %s\n", atualizacao.GetPedidoId(), atualizacao.GetStatus())
}
curto, cancelCurto := context.WithTimeout(context.Background(), 700*time.Millisecond)
defer cancelCurto()
stream, err = client.AcompanharPedido(curto, &pb.BuscarPedidoRequest{Id: pedido.GetId()})
if err != nil {
log.Fatal(err)
}
for {
atualizacao, err := stream.Recv()
if err != nil {
fmt.Println("stream interrompido:", status.Code(err))
break
}
fmt.Printf("pedido %d: %s\n", atualizacao.GetPedidoId(), atualizacao.GetStatus())
}
}
grpc.NewClientcria a conexão. Assim como osql.Opendo dia 96, ele não conecta na hora: a conexão é aberta na primeira chamada. A conexão deve ser criada uma vez e reaproveitada, ela suporta várias chamadas simultâneas;insecure.NewCredentials()desativa o TLS. Serve para testar localmente; em produção, a comunicação deve ser criptografada comcredentials.NewTLS;- O context com timeout é o deadline da chamada. Ele viaja junto com a requisição para o servidor: se o prazo acabar, o cliente recebe
DeadlineExceedede octxdo servidor é cancelado também; status.FromErrorrecupera o código e a mensagem do erro que o servidor enviou. Os atalhosstatus.Code(err)estatus.Convert(err)fazem o mesmo de forma mais curta;- No stream, chamamos
Recv()em loop até receberio.EOF, que significa que o servidor terminou de enviar.
Rodando o servidor num terminal e o cliente em outro:
go run ./server
go run ./client
Saída do cliente:
pedido criado: id=1 total=R$ 429.80 status=STATUS_PENDENTE
erro esperado: NotFound - pedido 999 não encontrado
erro de validação: InvalidArgument - o pedido precisa ter pelo menos um item
pedido 1: STATUS_PAGO
pedido 1: STATUS_ENVIADO
pedido 1: STATUS_ENTREGUE
acompanhamento finalizado
pedido 1: STATUS_PAGO
stream interrompido: DeadlineExceeded
As atualizações do stream chegaram uma a uma, a cada 500ms. No segundo acompanhamento, o prazo de 700ms deu tempo de receber só a primeira atualização antes do DeadlineExceeded.
E o log do servidor, gerado pelo interceptor:
2025/12/15 10:00:00 servidor gRPC rodando em :50051
2025/12/15 10:00:01 /pedidos.v1.PedidoService/CriarPedido código=OK duração=4µs
2025/12/15 10:00:01 /pedidos.v1.PedidoService/BuscarPedido código=NotFound duração=9µs
2025/12/15 10:00:01 /pedidos.v1.PedidoService/CriarPedido código=InvalidArgument duração=5µs
Os dois streams não aparecem no log porque, como comentado, o UnaryInterceptor não pega chamadas de streaming.
#
Testando com grpcurl
Para APIs REST usamos o curl. Para gRPC existe o grpcurl:
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@v1.9.3
Graças à reflection, ele descobre sozinho o que o servidor oferece:
$ grpcurl -plaintext localhost:50051 list
grpc.reflection.v1.ServerReflection
grpc.reflection.v1alpha.ServerReflection
pedidos.v1.PedidoService
$ grpcurl -plaintext localhost:50051 describe pedidos.v1.PedidoService
pedidos.v1.PedidoService is a service:
service PedidoService {
rpc AcompanharPedido ( .pedidos.v1.BuscarPedidoRequest ) returns ( stream .pedidos.v1.AtualizacaoStatus );
rpc BuscarPedido ( .pedidos.v1.BuscarPedidoRequest ) returns ( .pedidos.v1.Pedido );
rpc CriarPedido ( .pedidos.v1.CriarPedidoRequest ) returns ( .pedidos.v1.Pedido );
}
E chamamos os métodos passando a requisição em JSON (o -plaintext é o equivalente ao insecure do cliente):
$ grpcurl -plaintext -d '{"cliente": "Ana", "itens": [{"produto": "Monitor", "quantidade": 1, "preco_centavos": 120000}]}' \
localhost:50051 pedidos.v1.PedidoService/CriarPedido
{
"id": "2",
"cliente": "Ana",
"itens": [
{
"produto": "Monitor",
"quantidade": 1,
"precoCentavos": "120000"
}
],
"totalCentavos": "120000",
"status": "STATUS_PENDENTE"
}
$ grpcurl -plaintext -d '{"id": 999}' localhost:50051 pedidos.v1.PedidoService/BuscarPedido
ERROR:
Code: NotFound
Message: pedido 999 não encontrado
Repare que, no JSON, os campos int64 (id, totalCentavos) aparecem como string, e o quantidade, que é int32, como número. Isso faz parte da especificação de conversão entre protobuf e JSON: o JavaScript não consegue representar com precisão inteiros maiores que 2⁵³, então os int64 viram string para não perder dígitos. Os nomes também foram convertidos para camelCase.
Se você precisar converter mensagens protobuf para JSON no seu código, use o pacote
google.golang.org/protobuf/encoding/protojson, que segue essas regras, e não oencoding/json.
#
Quanto menor fica a mensagem?
Comparando o mesmo pedido serializado em protobuf e em JSON:
package main
import (
"encoding/json"
"fmt"
"google.golang.org/protobuf/proto"
pb "loja-grpc/proto/pedidos"
)
func main() {
pedido := &pb.Pedido{
Id: 1,
Cliente: "Victor",
Itens: []*pb.Item{
{Produto: "Teclado", Quantidade: 1, PrecoCentavos: 25000},
{Produto: "Mouse", Quantidade: 2, PrecoCentavos: 8990},
},
TotalCentavos: 42980,
Status: pb.Status_STATUS_PENDENTE,
}
binario, _ := proto.Marshal(pedido)
texto, _ := json.Marshal(pedido)
fmt.Println("protobuf:", len(binario), "bytes")
fmt.Println("json: ", len(texto), "bytes")
fmt.Println(string(texto))
}
protobuf: 47 bytes
json: 188 bytes
{"id":1,"cliente":"Victor","itens":[{"produto":"Teclado","quantidade":1,"preco_centavos":25000},{"produto":"Mouse","quantidade":2,"preco_centavos":8990}],"total_centavos":42980,"status":1}
A mensagem em protobuf ficou 4 vezes menor. Olhando o JSON dá para ver de onde vem a diferença: os nomes "produto", "quantidade" e "preco_centavos" se repetem para cada item, enquanto no protobuf cada campo é identificado pelo seu número, usando 1 byte. Os números também são guardados num formato de tamanho variável (varint): o 1 ocupa 1 byte, e não os 8 de um int64. Além de menor, o binário também é mais rápido de serializar e desserializar.
#
Os quatro tipos de chamada
Usamos dois dos quatro tipos de chamada do gRPC:
- Unary (
CriarPedido,BuscarPedido): uma requisição, uma resposta. O mais comum; - Server streaming (
AcompanharPedido): uma requisição, várias respostas. Bom para notificações e para enviar resultados grandes aos poucos; - Client streaming: várias requisições, uma resposta. Por exemplo, enviar um arquivo em pedaços ou uma sequência de leituras de sensores. No
.proto:rpc Enviar(stream Leitura) returns (Resumo); - Bidirectional streaming: os dois lados enviam mensagens de forma independente, pela mesma conexão. É um chat, como o do dia 52, mas com tipos definidos. No
.proto:rpc Conversar(stream Mensagem) returns (stream Mensagem);
Para projetos maiores, vale conhecer o buf, uma ferramenta que substitui o
protoccom uma configuração mais simples, além de um linter para os arquivos.protoe uma checagem que avisa quando uma alteração quebra a compatibilidade com os clientes.