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"),
);from concurrent import futures
import grpc
import pedidos_pb2
import pedidos_pb2_grpc
PEDIDOS = {
"981": pedidos_pb2.Pedido(
id="981",
status="enviado",
itens=[pedidos_pb2.Item(produto="teclado", quantidade=2)],
)
}
class Pedidos(pedidos_pb2_grpc.PedidosServicer):
def BuscarPedido(self, request, context):
pedido = PEDIDOS.get(request.id)
if pedido is None:
context.abort(grpc.StatusCode.NOT_FOUND, "pedido não encontrado")
return pedido
servidor = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
pedidos_pb2_grpc.add_PedidosServicer_to_server(Pedidos(), servidor)
servidor.add_insecure_port("[::]:50051")
servidor.start()
servidor.wait_for_termination()package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
lojav1 "exemplo.com/loja/v1"
)
type servidor struct {
lojav1.UnimplementedPedidosServer
}
var pedidos = map[string]*lojav1.Pedido{
"981": {
Id: "981",
Status: "enviado",
Itens: []*lojav1.Item{{Produto: "teclado", Quantidade: 2}},
},
}
func (s *servidor) BuscarPedido(
ctx context.Context,
req *lojav1.BuscarPedidoRequest,
) (*lojav1.Pedido, error) {
pedido, existe := pedidos[req.GetId()]
if !existe {
return nil, status.Error(codes.NotFound, "pedido não encontrado")
}
return pedido, nil
}
func main() {
porta, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal(err)
}
s := grpc.NewServer()
lojav1.RegisterPedidosServer(s, &servidor{})
log.Fatal(s.Serve(porta))
}import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import java.util.Map;
public class PedidosService extends PedidosGrpc.PedidosImplBase {
private static final Map<String, Pedido> PEDIDOS = Map.of(
"981",
Pedido.newBuilder()
.setId("981")
.setStatus("enviado")
.addItens(Item.newBuilder().setProduto("teclado").setQuantidade(2))
.build());
@Override
public void buscarPedido(
BuscarPedidoRequest request,
StreamObserver<Pedido> resposta) {
Pedido pedido = PEDIDOS.get(request.getId());
if (pedido == null) {
resposta.onError(Status.NOT_FOUND
.withDescription("pedido não encontrado")
.asRuntimeException());
return;
}
resposta.onNext(pedido);
resposta.onCompleted();
}
public static void main(String[] args) throws Exception {
Server servidor = ServerBuilder.forPort(50051)
.addService(new PedidosService())
.build()
.start();
servidor.awaitTermination();
}
}using Grpc.Core;
using Loja.V1;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
var app = builder.Build();
app.MapGrpcService<PedidosService>();
app.Run();
public class PedidosService : Pedidos.PedidosBase
{
private static readonly Dictionary<string, Pedido> Dados = new()
{
["981"] = new Pedido
{
Id = "981",
Status = "enviado",
Itens = { new Item { Produto = "teclado", Quantidade = 2 } },
},
};
public override Task<Pedido> BuscarPedido(
BuscarPedidoRequest request,
ServerCallContext context)
{
if (!Dados.TryGetValue(request.Id, out var pedido))
{
throw new RpcException(
new Status(StatusCode.NotFound, "pedido não encontrado"));
}
return Task.FromResult(pedido);
}
}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ódigo | Tipos verificados | Como devolver "não encontrado" | |
|---|---|---|---|
| Node | No exemplo, em tempo de execução, lendo o .proto | Não, sem um gerador de tipos à parte | Objeto de erro com code na função de resposta |
| Python | Antes, com grpc_tools.protoc, gerando *_pb2.py | Não, sem arquivos de tipos adicionais | context.abort(...) |
| Go | Antes, com protoc e os plugins de Go | Sim, na compilação | Retorno de status.Error(...) |
| Java | No build, pelo plugin de Maven ou Gradle | Sim, na compilação | resposta.onError(...) |
| C# | No build, pelo pacote Grpc.Tools | Sim, na compilação | Exceçã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/BuscarPedidoEvoluir o contrato sem quebrar clientes
| Mudança | É segura? |
|---|---|
| Adicionar um campo com número novo | Sim. Clientes antigos ignoram o campo |
Remover um campo e reservar o número com reserved | Sim |
| Renomear um campo, mantendo o número | Sim no formato binário; quebra quem usa a tradução para JSON |
| Trocar o número de um campo | Não. Clientes antigos leem o dado no lugar errado |
| Reusar o número de um campo removido | Não. Dados antigos são interpretados com o tipo novo |
| Trocar o tipo de um campo | Nã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]