REST com JSON funciona muito bem para a maioria das APIs. Mas em arquiteturas de microsserviços onde centenas de serviços se comunicam entre si com milhares de requisições por segundo, o custo de serializar e deserializar JSON, negociar headers HTTP/1.1 e reabrir conexões a cada requisição se torna mensurável.
gRPC resolve esses problemas com três escolhas fundamentais: Protocol Buffers como formato de serialização binária (compacto e rápido), HTTP/2 como transporte (multiplexação, compressão de headers, streaming bidirecional) e geração automática de código cliente e servidor a partir de definições de interface. O resultado é comunicação tipada, eficiente e com contrato formal.
Instalação das ferramentas
# Compilador de Protocol Buffers
# Linux
apt install protobuf-compiler
# macOS
brew install protobuf
# Plugins Go para protoc
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# Adicionar ao PATH
export PATH="$PATH:$(go env GOPATH)/bin"
# Dependências Go
go get google.golang.org/grpc
go get google.golang.org/protobuf
Protocol Buffers: definindo contratos
O arquivo .proto é o coração do gRPC — define mensagens, serviços e métodos de forma agnóstica à linguagem:
// proto/tarefa/v1/tarefa.proto
syntax = "proto3";
package tarefa.v1;
option go_package = "github.com/usuario/taskapi/gen/tarefa/v1;tarefav1";
import "google/protobuf/timestamp.proto";
// Enumeração de prioridade
enum Prioridade {
PRIORIDADE_DESCONHECIDA = 0; // valor zero obrigatório
PRIORIDADE_BAIXA = 1;
PRIORIDADE_MEDIA = 2;
PRIORIDADE_ALTA = 3;
PRIORIDADE_URGENTE = 4;
}
// Mensagem de tarefa
message Tarefa {
int64 id = 1;
int64 usuario_id = 2;
string titulo = 3;
string descricao = 4;
bool concluida = 5;
Prioridade prioridade = 6;
google.protobuf.Timestamp prazo = 7;
google.protobuf.Timestamp criado_em = 8;
google.protobuf.Timestamp atualizado_em = 9;
}
// Requisições e respostas
message CriarTarefaRequest {
int64 usuario_id = 1;
string titulo = 2;
string descricao = 3;
Prioridade prioridade = 4;
google.protobuf.Timestamp prazo = 5;
}
message CriarTarefaResponse {
Tarefa tarefa = 1;
}
message BuscarTarefaRequest {
int64 id = 1;
int64 usuario_id = 2;
}
message BuscarTarefaResponse {
Tarefa tarefa = 1;
}
message ListarTarefasRequest {
int64 usuario_id = 1;
int32 pagina = 2;
int32 limite = 3;
optional bool concluida = 4;
optional Prioridade prioridade = 5;
}
message ListarTarefasResponse {
repeated Tarefa tarefas = 1;
int64 total = 2;
}
message AtualizarTarefaRequest {
int64 id = 1;
int64 usuario_id = 2;
string titulo = 3;
string descricao = 4;
bool concluida = 5;
Prioridade prioridade = 6;
google.protobuf.Timestamp prazo = 7;
}
message AtualizarTarefaResponse {
Tarefa tarefa = 1;
}
message DeletarTarefaRequest {
int64 id = 1;
int64 usuario_id = 2;
}
message DeletarTarefaResponse {}
// Definição do serviço
service TarefaService {
rpc CriarTarefa (CriarTarefaRequest) returns (CriarTarefaResponse);
rpc BuscarTarefa (BuscarTarefaRequest) returns (BuscarTarefaResponse);
rpc ListarTarefas (ListarTarefasRequest) returns (ListarTarefasResponse);
rpc AtualizarTarefa(AtualizarTarefaRequest) returns (AtualizarTarefaResponse);
rpc DeletarTarefa (DeletarTarefaRequest) returns (DeletarTarefaResponse);
// Streaming server-side: recebe eventos em tempo real
rpc AssistirTarefas(AssistirTarefasRequest) returns (stream TarefaEvento);
}
message AssistirTarefasRequest {
int64 usuario_id = 1;
}
message TarefaEvento {
string tipo = 1; // "criada", "atualizada", "deletada"
Tarefa tarefa = 2;
google.protobuf.Timestamp ocorrido_em = 3;
}
Gerando código Go
# Estrutura do projeto
mkdir -p proto/tarefa/v1 gen/tarefa/v1
# Gerar código Go
protoc \
--proto_path=proto \
--go_out=gen \
--go_opt=paths=source_relative \
--go-grpc_out=gen \
--go-grpc_opt=paths=source_relative \
proto/tarefa/v1/tarefa.proto
O protoc gera dois arquivos: - gen/tarefa/v1/tarefa.pb.go — structs e serialização - gen/tarefa/v1/tarefa_grpc.pb.go — interfaces de servidor e cliente
# Makefile — as linhas de comando começam com TAB, e não com espaços
.PHONY: proto
proto:
find proto -name "*.proto" | xargs -I{} protoc \
--proto_path=proto \
--go_out=gen \
--go_opt=paths=source_relative \
--go-grpc_out=gen \
--go-grpc_opt=paths=source_relative \
{}
Implementando o servidor gRPC
// internal/grpc/tarefa_server.go
package grpcserver
import (
"context"
"errors"
"sync"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
tarefav1 "github.com/usuario/taskapi/gen/tarefa/v1"
"github.com/usuario/taskapi/internal/domain"
"github.com/usuario/taskapi/internal/repository"
"github.com/usuario/taskapi/internal/usecase/tarefa"
)
type TarefaServer struct {
tarefav1.UnimplementedTarefaServiceServer // embedding obrigatório para compatibilidade futura
criar *tarefa.CriarTarefa
buscar *tarefa.BuscarTarefa
listar *tarefa.ListarTarefas
atualizar *tarefa.AtualizarTarefa
deletar *tarefa.DeletarTarefa
// Cada assinante de AssistirTarefas tem o seu canal. Um canal único,
// lido por todos, entregaria cada evento a um assinante só
mu sync.Mutex
assinantes map[chan TarefaEventoInterno]int64 // canal → usuário
}
type TarefaEventoInterno struct {
Tipo string
Tarefa *domain.Tarefa
}
func NovoTarefaServer(
criar *tarefa.CriarTarefa,
buscar *tarefa.BuscarTarefa,
listar *tarefa.ListarTarefas,
atualizar *tarefa.AtualizarTarefa,
deletar *tarefa.DeletarTarefa,
) *TarefaServer {
return &TarefaServer{
criar: criar,
buscar: buscar,
listar: listar,
atualizar: atualizar,
deletar: deletar,
assinantes: make(map[chan TarefaEventoInterno]int64),
}
}
// publicar entrega o evento a cada assinante do dono da tarefa, sem
// bloquear: um assinante lento, com o canal cheio, perde o evento
func (s *TarefaServer) publicar(e TarefaEventoInterno) {
s.mu.Lock()
defer s.mu.Unlock()
for ch, usuarioID := range s.assinantes {
if usuarioID != e.Tarefa.UsuarioID {
continue
}
select {
case ch <- e:
default:
}
}
}
func (s *TarefaServer) assinar(usuarioID int64) (chan TarefaEventoInterno, func()) {
ch := make(chan TarefaEventoInterno, 16)
s.mu.Lock()
s.assinantes[ch] = usuarioID
s.mu.Unlock()
return ch, func() {
s.mu.Lock()
delete(s.assinantes, ch)
s.mu.Unlock()
}
}
// usuarioAutenticado lê o usuário que o interceptor validou. O usuario_id
// das mensagens é ignorado: confiar nele deixaria qualquer cliente
// autenticado agir em nome de outro usuário
func usuarioAutenticado(ctx context.Context) (int64, error) {
id, ok := UsuarioIDDoContexto(ctx)
if !ok {
return 0, status.Error(codes.Unauthenticated, "usuário não autenticado")
}
return id, nil
}
func (s *TarefaServer) CriarTarefa(
ctx context.Context,
req *tarefav1.CriarTarefaRequest,
) (*tarefav1.CriarTarefaResponse, error) {
usuarioID, err := usuarioAutenticado(ctx)
if err != nil {
return nil, err
}
var prazo *time.Time
if req.Prazo != nil {
t := req.Prazo.AsTime()
prazo = &t
}
t, err := s.criar.Executar(ctx, tarefa.CriarInput{
UsuarioID: usuarioID,
Titulo: req.Titulo,
Descricao: req.Descricao,
Prioridade: domain.PrioridadeTarefa(req.Prioridade),
Prazo: prazo,
})
if err != nil {
return nil, traduzirErroGRPC(err)
}
// Publica evento para streaming
s.publicar(TarefaEventoInterno{Tipo: "criada", Tarefa: t})
return &tarefav1.CriarTarefaResponse{
Tarefa: domainParaProto(t),
}, nil
}
func (s *TarefaServer) BuscarTarefa(
ctx context.Context,
req *tarefav1.BuscarTarefaRequest,
) (*tarefav1.BuscarTarefaResponse, error) {
usuarioID, err := usuarioAutenticado(ctx)
if err != nil {
return nil, err
}
t, err := s.buscar.Executar(ctx, req.Id, usuarioID)
if err != nil {
return nil, traduzirErroGRPC(err)
}
return &tarefav1.BuscarTarefaResponse{
Tarefa: domainParaProto(t),
}, nil
}
func (s *TarefaServer) ListarTarefas(
ctx context.Context,
req *tarefav1.ListarTarefasRequest,
) (*tarefav1.ListarTarefasResponse, error) {
usuarioID, err := usuarioAutenticado(ctx)
if err != nil {
return nil, err
}
filtro := repository.FiltroTarefa{
Pagina: int(req.Pagina),
Limite: int(req.Limite),
}
if req.Concluida != nil {
filtro.Concluida = req.Concluida
}
resultado, err := s.listar.Executar(ctx, tarefa.ListarInput{
UsuarioID: usuarioID,
Filtro: filtro,
})
if err != nil {
return nil, traduzirErroGRPC(err)
}
protoTarefas := make([]*tarefav1.Tarefa, len(resultado.Tarefas))
for i, t := range resultado.Tarefas {
protoTarefas[i] = domainParaProto(t)
}
return &tarefav1.ListarTarefasResponse{
Tarefas: protoTarefas,
Total: resultado.Total,
}, nil
}
// AssistirTarefas implementa streaming server-side
func (s *TarefaServer) AssistirTarefas(
req *tarefav1.AssistirTarefasRequest,
stream tarefav1.TarefaService_AssistirTarefasServer,
) error {
ctx := stream.Context()
usuarioID, err := usuarioAutenticado(ctx)
if err != nil {
return err
}
eventos, cancelar := s.assinar(usuarioID)
defer cancelar()
for {
select {
case <-ctx.Done():
return ctx.Err()
case evento := <-eventos:
if err := stream.Send(&tarefav1.TarefaEvento{
Tipo: evento.Tipo,
Tarefa: domainParaProto(evento.Tarefa),
OcorridoEm: timestamppb.Now(),
}); err != nil {
return err
}
}
}
}
// Converte domínio para proto
func domainParaProto(t *domain.Tarefa) *tarefav1.Tarefa {
proto := &tarefav1.Tarefa{
Id: t.ID,
UsuarioId: t.UsuarioID,
Titulo: t.Titulo,
Descricao: t.Descricao,
Concluida: t.Concluida,
Prioridade: tarefav1.Prioridade(t.Prioridade),
CriadoEm: timestamppb.New(t.CriadoEm),
AtualizadoEm: timestamppb.New(t.AtualizadoEm),
}
if t.Prazo != nil {
proto.Prazo = timestamppb.New(*t.Prazo)
}
return proto
}
// Traduz erros de domínio para códigos gRPC
func traduzirErroGRPC(err error) error {
switch {
case errors.Is(err, domain.ErrTarefaNaoEncontrada):
return status.Error(codes.NotFound, err.Error())
case errors.Is(err, domain.ErrTituloDeTarefaVazio):
return status.Error(codes.InvalidArgument, err.Error())
case errors.Is(err, domain.ErrTarefaSemPermissao):
return status.Error(codes.PermissionDenied, err.Error())
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, err.Error())
case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, err.Error())
default:
return status.Error(codes.Internal, "erro interno")
}
}
Dois cuidados deste servidor merecem destaque. O primeiro é a identidade: os métodos usam o usuário que o interceptor extraiu do token, e não o usuario_id das mensagens. Numa versão anterior, que confiava no campo, um cliente pedia as tarefas de outro usuário apenas trocando o número. O segundo é a difusão dos eventos: cada chamada a AssistirTarefas recebe o seu canal. A versão anterior tinha um canal único, lido por todos os assinantes, e cada evento chegava a um assinante só; num teste com dois usuários assistindo e dez tarefas criadas por cada um, cada um recebeu cinco. AtualizarTarefa e DeletarTarefa seguem o mesmo formato e não foram repetidos; sem implementação, o UnimplementedTarefaServiceServer embutido responde codes.Unimplemented.
Interceptors: middlewares do gRPC
// internal/grpc/interceptors.go
package grpcserver
import (
"context"
"log/slog"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// Interceptor de logging unário
func InterceptorLogging(logger *slog.Logger) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
inicio := time.Now()
resp, err := handler(ctx, req)
code := codes.OK
if err != nil {
code = status.Code(err)
}
logger.Info("gRPC unário",
"método", info.FullMethod,
"código", code,
"duração_ms", time.Since(inicio).Milliseconds(),
"erro", err,
)
return resp, err
}
}
// Interceptor de recuperação de pânico
func InterceptorRecuperacao() grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (resp interface{}, err error) {
defer func() {
if r := recover(); r != nil {
slog.Error("pânico no gRPC",
"método", info.FullMethod,
"panic", r,
)
err = status.Errorf(codes.Internal, "erro interno")
}
}()
return handler(ctx, req)
}
}
// Interceptor de logging para streams
func InterceptorStreamLogging(logger *slog.Logger) grpc.StreamServerInterceptor {
return func(
srv interface{},
ss grpc.ServerStream,
info *grpc.StreamServerInfo,
handler grpc.StreamHandler,
) error {
inicio := time.Now()
err := handler(srv, ss)
logger.Info("gRPC stream",
"método", info.FullMethod,
"código", status.Code(err),
"duração_ms", time.Since(inicio).Milliseconds(),
)
return err
}
}
type chaveUsuarioID struct{}
// UsuarioIDDoContexto devolve o usuário autenticado pelo interceptor.
// Os métodos do serviço usam este valor, e nunca um ID vindo da requisição.
func UsuarioIDDoContexto(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(chaveUsuarioID{}).(int64)
return id, ok
}
// Métodos públicos: só o health check padrão do gRPC
var metodosSemAuth = map[string]bool{
"/grpc.health.v1.Health/Check": true,
"/grpc.health.v1.Health/Watch": true,
}
// autenticar é comum aos interceptors unário e de stream: valida o
// token e devolve um contexto com o ID do usuário
func autenticar(ctx context.Context, metodo, segredo string) (context.Context, error) {
if metodosSemAuth[metodo] {
return ctx, nil
}
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "metadata ausente")
}
tokens := md.Get("authorization")
if len(tokens) == 0 {
return nil, status.Error(codes.Unauthenticated, "token ausente")
}
usuarioID, err := validarJWT(tokens[0], segredo)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "token inválido")
}
return context.WithValue(ctx, chaveUsuarioID{}, usuarioID), nil
}
// validarJWT confere a assinatura HS256 e a expiração, e lê o ID do usuário
// do campo sub. WithValidMethods recusa tokens com outro algoritmo, inclusive "none"
func validarJWT(cabecalho, segredo string) (int64, error) {
token := strings.TrimPrefix(cabecalho, "Bearer ")
claims := &jwt.RegisteredClaims{}
_, err := jwt.ParseWithClaims(token, claims,
func(*jwt.Token) (any, error) { return []byte(segredo), nil },
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithExpirationRequired(),
)
if err != nil {
return 0, err
}
return strconv.ParseInt(claims.Subject, 10, 64)
}
// Interceptor de autenticação para chamadas unárias
func InterceptorAuth(segredo string) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
ctx, err := autenticar(ctx, info.FullMethod, segredo)
if err != nil {
return nil, err
}
return handler(ctx, req)
}
}
// streamAutenticado troca o contexto do stream pelo que tem o usuário
type streamAutenticado struct {
grpc.ServerStream
ctx context.Context
}
func (s *streamAutenticado) Context() context.Context { return s.ctx }
// Interceptor de autenticação para streams. Sem ele, AssistirTarefas
// ficaria aberto a qualquer cliente, com ou sem token
func InterceptorStreamAuth(segredo string) grpc.StreamServerInterceptor {
return func(
srv interface{},
ss grpc.ServerStream,
info *grpc.StreamServerInfo,
handler grpc.StreamHandler,
) error {
ctx, err := autenticar(ss.Context(), info.FullMethod, segredo)
if err != nil {
return err
}
return handler(srv, &streamAutenticado{ServerStream: ss, ctx: ctx})
}
}
No gRPC, chamadas unárias e streams passam por cadeias de interceptors separadas. Um interceptor de autenticação registrado só com ChainUnaryInterceptor deixa os streams abertos: na primeira versão deste artigo, AssistirTarefas aceitava clientes sem token. A função autenticar é compartilhada pelos dois interceptors, e o de stream troca o contexto do stream por um que carrega o usuário. A lista de métodos públicos contém só o health check padrão, grpc.health.v1.Health, registrado no main; a versão anterior liberava um HealthCheck que o serviço nem definia.
Servidor principal
// cmd/grpc/main.go
package main
import (
"fmt"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
tarefav1 "github.com/usuario/taskapi/gen/tarefa/v1"
grpcserver "github.com/usuario/taskapi/internal/grpc"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// ... inicialização de banco, repositórios e casos de uso
// Servidor gRPC com interceptors encadeados
segredo := os.Getenv("JWT_SECRET")
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
grpcserver.InterceptorRecuperacao(),
grpcserver.InterceptorLogging(logger),
grpcserver.InterceptorAuth(segredo),
),
// Streams têm a sua própria cadeia: a autenticação precisa estar aqui também
grpc.ChainStreamInterceptor(
grpcserver.InterceptorStreamLogging(logger),
grpcserver.InterceptorStreamAuth(segredo),
),
grpc.MaxRecvMsgSize(4*1024*1024), // 4 MB
grpc.MaxSendMsgSize(4*1024*1024),
)
// Registra o serviço
tarefaServer := grpcserver.NovoTarefaServer(
criarTarefa, buscarTarefa, listarTarefas, atualizarTarefa, deletarTarefa,
)
tarefav1.RegisterTarefaServiceServer(srv, tarefaServer)
// Health check padrão do gRPC, usado por balanceadores e orquestradores
saude := health.NewServer()
healthpb.RegisterHealthServer(srv, saude)
// Reflection permite ferramentas como grpcurl descobrir serviços
reflection.Register(srv)
// Inicia o listener
porta := os.Getenv("GRPC_PORT")
if porta == "" {
porta = "50051"
}
lis, err := net.Listen("tcp", ":"+porta)
if err != nil {
logger.Error("falha ao escutar", "err", err)
os.Exit(1)
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
logger.Info("servidor gRPC iniciado", "porta", porta)
if err := srv.Serve(lis); err != nil {
logger.Error("servidor encerrado", "err", err)
}
}()
<-quit
logger.Info("encerrando servidor gRPC...")
srv.GracefulStop()
logger.Info("servidor encerrado")
}
Cliente gRPC
// internal/grpc/cliente/tarefa_cliente.go
package cliente
import (
"context"
"fmt"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
tarefav1 "github.com/usuario/taskapi/gen/tarefa/v1"
)
type ClienteTarefa struct {
conn *grpc.ClientConn
client tarefav1.TarefaServiceClient
}
func NovoClienteTarefa(endereco string) (*ClienteTarefa, error) {
conn, err := grpc.NewClient(
endereco,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(4*1024*1024),
),
)
if err != nil {
return nil, fmt.Errorf("conectar ao servidor gRPC: %w", err)
}
return &ClienteTarefa{
conn: conn,
client: tarefav1.NewTarefaServiceClient(conn),
}, nil
}
func (c *ClienteTarefa) Fechar() error {
return c.conn.Close()
}
func (c *ClienteTarefa) comToken(ctx context.Context, token string) context.Context {
md := metadata.New(map[string]string{
"authorization": "Bearer " + token,
})
return metadata.NewOutgoingContext(ctx, md)
}
func (c *ClienteTarefa) CriarTarefa(
ctx context.Context,
token string,
usuarioID int64,
titulo, descricao string,
prioridade tarefav1.Prioridade,
) (*tarefav1.Tarefa, error) {
ctx = c.comToken(ctx, token)
ctx, cancelar := context.WithTimeout(ctx, 5*time.Second)
defer cancelar()
resp, err := c.client.CriarTarefa(ctx, &tarefav1.CriarTarefaRequest{
UsuarioId: usuarioID,
Titulo: titulo,
Descricao: descricao,
Prioridade: prioridade,
})
if err != nil {
return nil, fmt.Errorf("criar tarefa: %w", err)
}
return resp.Tarefa, nil
}
// AssistirTarefas recebe eventos em tempo real via streaming
func (c *ClienteTarefa) AssistirTarefas(
ctx context.Context,
token string,
usuarioID int64,
onEvento func(*tarefav1.TarefaEvento),
) error {
ctx = c.comToken(ctx, token)
stream, err := c.client.AssistirTarefas(ctx, &tarefav1.AssistirTarefasRequest{
UsuarioId: usuarioID,
})
if err != nil {
return fmt.Errorf("iniciar streaming: %w", err)
}
for {
evento, err := stream.Recv()
if err != nil {
return fmt.Errorf("receber evento: %w", err)
}
onEvento(evento)
}
}
Testando com grpcurl
# Instalar grpcurl
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
# Health check: público, não precisa de token
grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check
# Listar serviços (requer reflection, que é um stream e também passa
# pela autenticação)
grpcurl -plaintext -H "authorization: Bearer SEU_TOKEN" localhost:50051 list
# Descrever serviço
grpcurl -plaintext -H "authorization: Bearer SEU_TOKEN" \
localhost:50051 describe tarefa.v1.TarefaService
# O usuário vem do token: o campo usuario_id das mensagens é ignorado
# Criar tarefa
grpcurl -plaintext \
-H "authorization: Bearer SEU_TOKEN" \
-d '{"titulo": "Estudar gRPC", "prioridade": "PRIORIDADE_ALTA"}' \
localhost:50051 \
tarefa.v1.TarefaService/CriarTarefa
# Listar tarefas
grpcurl -plaintext \
-H "authorization: Bearer SEU_TOKEN" \
-d '{"pagina": 1, "limite": 10}' \
localhost:50051 \
tarefa.v1.TarefaService/ListarTarefas
# Streaming
grpcurl -plaintext \
-H "authorization: Bearer SEU_TOKEN" \
-d '{}' \
localhost:50051 \
tarefa.v1.TarefaService/AssistirTarefas
gRPC-Gateway: REST e gRPC da mesma definição
O grpc-gateway gera automaticamente um proxy HTTP/REST a partir das definições proto:
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
// Adicione ao proto com opções HTTP
import "google/api/annotations.proto";
service TarefaService {
rpc CriarTarefa (CriarTarefaRequest) returns (CriarTarefaResponse) {
option (google.api.http) = {
post: "/api/v1/tarefas"
body: "*"
};
}
rpc ListarTarefas (ListarTarefasRequest) returns (ListarTarefasResponse) {
option (google.api.http) = {
get: "/api/v1/tarefas"
};
}
rpc BuscarTarefa (BuscarTarefaRequest) returns (BuscarTarefaResponse) {
option (google.api.http) = {
get: "/api/v1/tarefas/{id}"
};
}
}
Com isso, a mesma implementação serve tanto clientes gRPC quanto clientes REST — sem duplicar código.
gRPC troca o JSON por um contrato em Protocol Buffers, gera o código dos dois lados e acrescenta streaming e interceptors. O contrato e o código gerado tiram do caminho uma classe inteira de erros de integração; os que sobram estão no que o código escreve à mão. A autenticação precisa estar nas duas cadeias de interceptors, a unária e a de streams. A identidade vem do token, e não de um campo da mensagem. E um canal compartilhado não faz difusão: cada evento chega a um assinante só. São defeitos que compilam, passam no teste feliz e só aparecem com dois clientes ao mesmo tempo ou com um cliente mal-intencionado. Testes com bufconn, que sobem o servidor real em memória, são a forma mais barata de exercitá-los.
Fontes e leituras recomendadas
-
Documentação oficial gRPC Go — Guia completo com exemplos. https://grpc.io/docs/languages/go/
-
Protocol Buffers — Go — Referência da linguagem proto3. https://protobuf.dev/getting-started/gotutorial/
-
grpc-gateway — Proxy REST automático a partir de definições proto. https://github.com/grpc-ecosystem/grpc-gateway
-
grpcurl — Cliente gRPC de linha de comando. https://github.com/fullstorydev/grpcurl
-
buf — Ferramenta moderna para gerenciar arquivos proto. https://buf.build
-
Evans — Cliente gRPC interativo para desenvolvimento. https://github.com/ktr0731/evans
Exercícios
Exercício 1
Na primeira versão deste artigo, InterceptorAuth estava em ChainUnaryInterceptor, e a cadeia de streams tinha só logging. As chamadas unárias exigiam token. O que acontecia com AssistirTarefas?
Ver resposta
✓ Resposta: No gRPC, um interceptor unário só é chamado para métodos unários; métodos com stream passam pela cadeia de StreamServerInterceptor, que é configurada à parte. Como a autenticação não estava nessa cadeia, qualquer cliente, mesmo sem token, abria AssistirTarefas; somado ao uso do usuario_id da mensagem, podia assistir às tarefas de qualquer usuário. A versão do artigo extrai a verificação para uma função, autenticar, e a usa nos dois interceptors. O de stream embrulha o grpc.ServerStream para devolver um contexto com o usuário, porque o stream não aceita trocar o contexto de outra forma. Num teste com o servidor real em memória, o stream aberto sem token passou a falhar com Unauthenticated desc = token ausente. A reflection, usada pelo grpcurl, também é um stream, e por isso o grpcurl list do artigo passou a enviar o token.
Exercício 2
O servidor original lia req.UsuarioId em ListarTarefas. O interceptor validava o token e guardava o ID do usuário no contexto. Que ataque essa combinação permitia?
Ver resposta
✓ Resposta: Qualquer usuário com um token válido podia ler, e nos outros métodos alterar, as tarefas de outro usuário, só trocando o número no campo usuario_id. A autenticação confirmava quem fazia a chamada, mas o servidor decidia em nome de quem agir a partir de um dado controlado pelo cliente: é a falha conhecida como IDOR. Num teste com o servidor original, uma chamada com usuario_id = 2 no corpo devolveu as 10 tarefas do usuário 2. A versão do artigo ignora o campo e usa sempre o usuário do contexto, preenchido pelo interceptor a partir do token; no mesmo teste, o usuário 1, pedindo usuario_id = 2, recebeu só as próprias tarefas. O ideal é também retirar o campo do contrato, para que ninguém volte a confiar nele; em Protocol Buffers, um campo removido deve ter o número marcado como reserved, para não ser reaproveitado.
Exercício 3
Dois usuários assistem às suas tarefas com AssistirTarefas, e cada um cria 10 tarefas. Com o servidor original, cada um recebeu só 5 eventos. Num teste anterior, que criava as tarefas alternando os usuários, os dois receberam 10. Explique os dois resultados.
Ver resposta
✓ Resposta: O servidor tinha um canal só, s.eventos, e cada chamada a AssistirTarefas lia dele. Um valor enviado num canal é recebido por um único leitor, e não por todos: quando o evento de uma tarefa do usuário 1 chegava ao laço do usuário 2, era descartado pelo filtro, e o usuário 1 nunca o via. O Go entrega aos leitores que estão esperando, em ordem de chegada, e por isso, com as tarefas criadas alternando 1, 2, 1, 2, cada evento caiu por coincidência no leitor certo, e o teste passou. Com 10 tarefas seguidas de cada usuário, a alternância dos leitores não batia mais, e metade dos eventos se perdeu. A versão do artigo dá a cada assinante o seu canal e publica em todos os do dono da tarefa; no mesmo teste, os dois receberam 10. O teste que passava é um bom alerta: um teste de concorrência que depende da ordem dos dados pode esconder exatamente o defeito que deveria pegar.
Exercício 4
O servidor original publicava cada evento com go func() { s.eventos <- evento }(), num canal com buffer de 100. Num serviço em que ninguém está assistindo, o que acontece depois de muitas tarefas criadas?
Ver resposta
✓ Resposta: As 100 primeiras publicações ocupam o buffer, e a partir daí cada nova goroutine fica bloqueada no envio, para sempre, porque não há leitor. Cada uma mantém viva a tarefa que carrega. Num teste sem assinantes, 300 tarefas criadas levaram o processo de 11 para 211 goroutines: as 200 que não couberam no buffer. Em produção, isso é um vazamento lento de memória que só aparece depois de dias. A versão do artigo publica de forma síncrona e sem bloquear: percorre os assinantes do usuário e usa select com default, descartando o evento quando o canal de um assinante está cheio. No mesmo teste, as goroutines ficaram estáveis em 17 depois de 300 tarefas. Descartar é uma decisão consciente: para um aviso em tempo real, perder um evento de um cliente lento é aceitável; para algo que não pode se perder, o lugar é uma fila durável, e não um canal em memória.
Exercício 5
O interceptor original liberava sem token o método /tarefa.v1.TarefaService/HealthCheck, e o validarJWT chamado por ele não aparecia no artigo. O que estava errado em cada caso, e o que a versão atual garante?
Ver resposta
✓ Resposta: O serviço não definia nenhum método HealthCheck, e a liberação não servia para nada; pior, sugeria que o health check existia. O gRPC tem um serviço de saúde padrão, grpc.health.v1.Health, que balanceadores e orquestradores sabem consultar; a versão do artigo o registra no main e libera os seus métodos. Num teste, a consulta respondeu SERVING sem token. Quanto ao validarJWT, sem a implementação o código não compilava, e quem a escrevesse por conta própria poderia repetir um erro clássico: aceitar o algoritmo indicado no próprio token. A versão do artigo usa o golang-jwt/jwt/v5 com WithValidMethods([]string{"HS256"}) e WithExpirationRequired(); no mesmo teste, tokens com alg: none e tokens vencidos foram recusados com Unauthenticated.