Pular para o conteúdo principal

victorstein.dev

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. O v1 no nome é uma convenção para versionar a API: uma mudança incompatível vira um pedidos.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 um STATUS_DESCONHECIDO = 0: um campo que ninguém preencheu fica “desconhecido” em vez de, por acidente, “pendente”;
  • service: as funções disponíveis. O stream no retorno do AcompanharPedido quer 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:

  1. As mensagens ficam pequenas (veremos daqui a pouco quanto);
  2. 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). O go vet avisa 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.NewClient cria a conexão. Assim como o sql.Open do 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 com credentials.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 DeadlineExceeded e o ctx do servidor é cancelado também;
  • status.FromError recupera o código e a mensagem do erro que o servidor enviou. Os atalhos status.Code(err) e status.Convert(err) fazem o mesmo de forma mais curta;
  • No stream, chamamos Recv() em loop até receber io.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 o encoding/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 protoc com uma configuração mais simples, além de um linter para os arquivos .proto e uma checagem que avisa quando uma alteração quebra a compatibilidade com os clientes.

# Referências

  1. gRPC - Quick start em Go
  2. gRPC - Basics tutorial em Go
  3. Protocol Buffers - Language Guide (proto3)
  4. gRPC - Status codes
  5. Protocol Buffers - Encoding