gRPC e Protocol Buffers: comunicação eficiente entre serviços

gRPC e Protocol Buffers: comunicação eficiente entre serviços

gRPC em Go com Protocol Buffers: contrato e código gerado, interceptors com autenticação também nos streams, usuário tirado do token e não da mensagem, difusão de eventos com um canal por assinante, health check padrão do gRPC e testes com o servidor em memória.
Golang

• • 21 min de leitura

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

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.

Comentários

Mais em Golang

Estudando e se aprofundando em Go
Estudando e se aprofundando em Go

Por que aprender Go hoje: a linguagem de Docker e Kubernetes, o que ela…

Profiling e otimização de performance em Go
Profiling e otimização de performance em Go

Profiling em Go medido: benchmarks com -benchmem, pprof por HTTP e em testes…

Maps: criação, iteração e boas práticas
Maps: criação, iteração e boas práticas

Maps em Go: make e literais, o map nil que aceita leitura e não aceita…