DevGuideTK
← Trilha Backend

REST, GraphQL e gRPC

Três estilos de API, o problema que cada um resolve e como escolher entre eles.

O endpoint REST deste mesmo pedido está implementado, nas cinco linguagens, no tópico HTTP e HTTPS. Aqui o foco é o que REST não tem: o contrato que gera código, no gRPC, e os resolvedores, no GraphQL.

gRPC: um contrato, cinco linguagens

Tudo parte de um arquivo .proto. Ele é a fonte da verdade, e o código das duas pontas é gerado a partir dele.

syntax = "proto3";

package loja.v1;

option go_package = "exemplo.com/loja/v1;lojav1";

service Pedidos {
  rpc BuscarPedido (BuscarPedidoRequest) returns (Pedido);
}

message BuscarPedidoRequest {
  string id = 1;
}

message Pedido {
  string id = 1;
  string status = 2;
  repeated Item itens = 3;
}

message Item {
  string produto = 1;
  int32 quantidade = 2;
}

O número depois de cada campo (= 1, = 2) é o identificador dele no formato binário. O nome do campo nem trafega na rede.

A implementação do servidor fica assim em cada linguagem:

import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";

const definicao = protoLoader.loadSync("pedidos.proto");
const pacote = grpc.loadPackageDefinition(definicao) as any;

const pedidos = new Map([
  [
    "981",
    {
      id: "981",
      status: "enviado",
      itens: [{ produto: "teclado", quantidade: 2 }],
    },
  ],
]);

const servidor = new grpc.Server();

servidor.addService(pacote.loja.v1.Pedidos.service, {
  BuscarPedido(chamada: any, responder: any) {
    const pedido = pedidos.get(chamada.request.id);
    if (!pedido) {
      return responder({
        code: grpc.status.NOT_FOUND,
        message: "pedido não encontrado",
      });
    }
    responder(null, pedido);
  },
});

servidor.bindAsync(
  "0.0.0.0:50051",
  grpc.ServerCredentials.createInsecure(),
  () => console.log("gRPC na porta 50051"),
);

O que muda de uma linguagem para outra

O contrato é idêntico. O que varia é quando o código é gerado e como cada linguagem sinaliza um erro.

Geração do códigoTipos verificadosComo devolver "não encontrado"
NodeNo exemplo, em tempo de execução, lendo o .protoNão, sem um gerador de tipos à parteObjeto de erro com code na função de resposta
PythonAntes, com grpc_tools.protoc, gerando *_pb2.pyNão, sem arquivos de tipos adicionaiscontext.abort(...)
GoAntes, com protoc e os plugins de GoSim, na compilaçãoRetorno de status.Error(...)
JavaNo build, pelo plugin de Maven ou GradleSim, na compilaçãoresposta.onError(...)
C#No build, pelo pacote Grpc.ToolsSim, na compilaçãoExceção RpcException

Dois detalhes passam despercebidos em código gerado por IA:

  • Em Go, a estrutura embute UnimplementedPedidosServer. Quando o contrato ganha uma função nova, o servidor antigo continua compilando e responde "não implementado" para ela, em vez de quebrar o build.
  • Em Node com carregamento dinâmico, um erro de digitação no nome de um campo não gera erro nenhum. O campo é ignorado e o cliente recebe o valor padrão.

O gRPC tem códigos de status próprios, diferentes dos códigos do HTTP: NOT_FOUND, INVALID_ARGUMENT, UNAUTHENTICATED, PERMISSION_DENIED, UNAVAILABLE, DEADLINE_EXCEEDED. A lógica da escolha é a mesma do HTTP: o código diz a quem chamou se vale a pena tentar de novo.

Testar pela linha de comando

O grpcurl faz pelo gRPC o que o curl faz pelo HTTP:

grpcurl -plaintext -proto pedidos.proto \
  -d '{"id": "981"}' \
  localhost:50051 loja.v1.Pedidos/BuscarPedido

Evoluir o contrato sem quebrar clientes

MudançaÉ segura?
Adicionar um campo com número novoSim. Clientes antigos ignoram o campo
Remover um campo e reservar o número com reservedSim
Renomear um campo, mantendo o númeroSim no formato binário; quebra quem usa a tradução para JSON
Trocar o número de um campoNão. Clientes antigos leem o dado no lugar errado
Reusar o número de um campo removidoNão. Dados antigos são interpretados com o tipo novo
Trocar o tipo de um campoNão, salvo poucos pares compatíveis

GraphQL: esquema e resolvedores

O esquema declara os tipos, e cada campo é atendido por uma função chamada resolvedor.

type Query {
  pedidos: [Pedido!]!
}

type Pedido {
  id: ID!
  status: String!
  cliente: Cliente!
}

type Cliente {
  id: ID!
  nome: String!
}

A implementação mais direta dos resolvedores é esta:

const resolvers = {
  Query: {
    pedidos: () => db.pedidos.listar(),
  },
  Pedido: {
    // executado uma vez para CADA pedido da lista
    cliente: (pedido) => db.clientes.buscarPorId(pedido.clienteId),
  },
};

Para uma lista de 50 pedidos, esse código faz 51 consultas ao banco: uma para os pedidos e uma para o cliente de cada pedido. É o problema N+1, e ele não aparece em teste com três registros.

A correção agrupa as buscas feitas durante a mesma requisição:

import DataLoader from "dataloader";

// Recebe todos os ids pedidos no mesmo ciclo e faz UMA consulta
const criarLoaderDeClientes = () =>
  new DataLoader(async (ids: readonly string[]) => {
    const clientes = await db.clientes.buscarPorIds(ids);
    const porId = new Map(clientes.map((c) => [c.id, c]));
    return ids.map((id) => porId.get(id) ?? null);
  });

const resolvers = {
  Pedido: {
    cliente: (pedido, _args, contexto) =>
      contexto.loaders.clientes.load(pedido.clienteId),
  },
};

O loader precisa ser criado a cada requisição. Um loader compartilhado entre requisições guarda dados de um usuário e os entrega a outro.

Lista de revisão

REST

  • Os endereços nomeiam recursos, e não telas ou ações?
  • Existe um formato único de erro e de paginação em toda a API?
  • Há um arquivo OpenAPI que acompanha o código?

GraphQL

  • Os resolvedores de campos relacionados usam um loader criado por requisição?
  • Existe limite de profundidade e de complexidade das consultas?
  • A autorização é conferida em cada campo sensível?
  • Os erros chegam ao monitoramento, mesmo com status 200?

gRPC

  • Algum número de campo foi alterado ou reutilizado no .proto?
  • Os campos removidos estão marcados como reserved?
  • As chamadas definem prazo (deadline)? Sem ele, uma chamada pode esperar para sempre.
  • Os códigos de status distinguem erro de quem chamou de falha do servidor?
  • O balanceador distribui pedidos, e não só conexões?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Implementar o mesmo serviço nos três estilos

    Quero implementar o mesmo serviço em REST, GraphQL e gRPC para sentir as diferenças na prática.
    
    Pergunte em qual linguagem eu quero trabalhar e quais bibliotecas ou frameworks eu já uso.
    
    O serviço é simples: listar pedidos, buscar um pedido por id com seus itens e criar um pedido. Os dados ficam em memória.
    
    Vamos em etapas. Em cada uma, explique o que será feito, deixe que eu tente escrever, e depois mostre a sua versão comentando as diferenças:
    1. A versão REST, com códigos de status corretos e um formato único de erro.
    2. A versão GraphQL: esquema, resolvedores e a mutação de criação.
    3. A versão gRPC: o arquivo .proto, a geração do código e a implementação do servidor.
    4. Um cliente de linha de comando para cada versão (curl, uma consulta GraphQL e grpcurl).
    
    Depois de pronto, monte comigo uma tabela comparando as três versões: linhas de código, onde os erros de contrato aparecem, como cada uma sinaliza "não encontrado" e "dados inválidos", e o que foi mais trabalhoso em cada estilo.
  • Resolver o N+1 no GraphQL

    Quero aprender a identificar e corrigir consultas N+1 em um servidor GraphQL.
    
    Pergunte qual linguagem e qual biblioteca de GraphQL eu uso.
    
    Depois:
    1. Monte um esquema pequeno com pedidos, clientes e itens, e os resolvedores escritos do jeito mais direto.
    2. Mostre uma consulta que lista pedidos com o nome do cliente e peça que eu conte quantas consultas ao banco ela dispara para 50 pedidos. Corrija a minha conta.
    3. Explique como funciona o agrupamento de buscas com DataLoader (ou o equivalente na minha linguagem): o que é agrupado, em que momento a busca é disparada e por que a ordem do resultado importa.
    4. Reescreva os resolvedores com o loader e mostre a nova contagem de consultas.
    5. Explique por que o loader deve ser criado a cada requisição e o que acontece, em termos de segurança e de dados desatualizados, se ele for compartilhado.
    6. Mostre como registrar em log a quantidade de consultas por requisição, para detectar o problema em produção.
    
    Ao final, descreva dois casos em que o loader não resolve e a saída é outra (por exemplo, uma consulta com junção feita no resolvedor pai).
  • Revisar um contrato .proto ou um esquema GraphQL

    Vou colar abaixo um arquivo .proto ou um esquema GraphQL, possivelmente com a versão anterior para comparação. Revise com foco em compatibilidade e em riscos de produção.
    
    Se for um arquivo .proto, verifique:
    1. Números de campo alterados ou reutilizados em relação à versão anterior.
    2. Campos removidos sem a marcação reserved.
    3. Mudanças de tipo incompatíveis.
    4. Nomes de pacote, de serviço e de mensagem que permitam versionamento futuro.
    5. Valores de enumeração sem um valor zero que signifique "não especificado".
    
    Se for um esquema GraphQL, verifique:
    1. Campos removidos ou com tipo alterado sem um período marcados como obsoletos.
    2. Listas sem paginação.
    3. Campos que provavelmente causam consultas N+1.
    4. Campos sensíveis alcançáveis por mais de um caminho, que exigem autorização por campo.
    5. Mudanças de obrigatoriedade que quebram clientes existentes.
    
    Para cada problema, explique o que acontece com um cliente antigo em produção e mostre a correção. Não comente o que já está correto.
    
    Conteúdo:
    [cole aqui]