Publicação e assinatura
Um evento, vários consumidores independentes, e o desacoplamento que isso traz.
Os exemplos usam o RabbitMQ com uma troca do tipo topic chamada pedidos. O serviço de pedidos publica os eventos pedido.criado, pedido.pago e pedido.cancelado; o serviço de estoque assina dois deles, com uma fila durável que leva o nome do serviço.
Criar as assinaturas na infraestrutura
As assinaturas são criadas antes de o publicador começar, para que nenhum evento seja publicado numa troca sem destino. Com o rabbitmqadmin 2, que acompanha o RabbitMQ 4:
rabbitmqadmin declare exchange --name pedidos --type topic
# A assinatura do serviço de estoque: uma fila e duas ligações
rabbitmqadmin declare queue --name estoque.pedidos
rabbitmqadmin declare binding --source pedidos --destination-type queue \
--destination estoque.pedidos --routing-key pedido.criado
rabbitmqadmin declare binding --source pedidos --destination-type queue \
--destination estoque.pedidos --routing-key pedido.cancelado
# A assinatura do serviço de análise: todos os eventos de pedido
rabbitmqadmin declare queue --name analise.pedidos
rabbitmqadmin declare binding --source pedidos --destination-type queue \
--destination analise.pedidos --routing-key "pedido.#"O mesmo pode ficar num arquivo de definições do RabbitMQ, ou na ferramenta de infraestrutura como código da equipe. Os assinantes também declaram a troca, a fila e as ligações ao iniciar, como garantia; declarar algo que já existe com os mesmos argumentos não tem efeito.
O assinante
Cada instância do serviço de estoque consome a mesma fila estoque.pedidos. O RabbitMQ divide as mensagens entre elas, e cada evento é processado uma vez pelo serviço.
import amqp from "amqplib";
const TROCA = "pedidos";
const FILA = "estoque.pedidos"; // nome do serviço, não da instância
const conexao = await amqp.connect(process.env.RABBITMQ_URL!);
const canal = await conexao.createChannel();
await canal.assertExchange(TROCA, "topic", { durable: true });
await canal.assertQueue(FILA, { durable: true });
await canal.bindQueue(FILA, TROCA, "pedido.criado");
await canal.bindQueue(FILA, TROCA, "pedido.cancelado");
await canal.prefetch(10);
await canal.consume(FILA, async (mensagem) => {
if (!mensagem) return;
try {
const evento = JSON.parse(mensagem.content.toString());
switch (evento.tipo) {
case "pedido.criado":
await reservarItens(evento.id, evento.dados);
break;
case "pedido.cancelado":
await liberarItens(evento.id, evento.dados);
break;
// outros tipos são ignorados
}
canal.ack(mensagem);
} catch (erro) {
console.error("falha ao processar", erro);
canal.nack(mensagem, false, false); // vai para a fila de mensagens mortas
}
}, { noAck: false });import json
import os
import pika
TROCA = "pedidos"
FILA = "estoque.pedidos" # nome do serviço, não da instância
conexao = pika.BlockingConnection(pika.URLParameters(os.environ["RABBITMQ_URL"]))
canal = conexao.channel()
canal.exchange_declare(exchange=TROCA, exchange_type="topic", durable=True)
canal.queue_declare(queue=FILA, durable=True)
for chave in ("pedido.criado", "pedido.cancelado"):
canal.queue_bind(queue=FILA, exchange=TROCA, routing_key=chave)
canal.basic_qos(prefetch_count=10)
def ao_receber(canal, metodo, propriedades, corpo):
try:
evento = json.loads(corpo)
if evento["tipo"] == "pedido.criado":
reservar_itens(evento["id"], evento["dados"])
elif evento["tipo"] == "pedido.cancelado":
liberar_itens(evento["id"], evento["dados"])
# outros tipos são ignorados
canal.basic_ack(delivery_tag=metodo.delivery_tag)
except Exception:
# vai para a fila de mensagens mortas
canal.basic_nack(delivery_tag=metodo.delivery_tag, requeue=False)
canal.basic_consume(queue=FILA, on_message_callback=ao_receber, auto_ack=False)
canal.start_consuming()package main
import (
"context"
"encoding/json"
"log"
"os"
amqp "github.com/rabbitmq/amqp091-go"
)
const (
troca = "pedidos"
fila = "estoque.pedidos" // nome do serviço, não da instância
)
type Evento struct {
ID string `json:"id"`
Tipo string `json:"tipo"`
Versao int `json:"versao"`
Dados json.RawMessage `json:"dados"`
}
func main() {
conexao, err := amqp.Dial(os.Getenv("RABBITMQ_URL"))
if err != nil {
log.Fatal(err)
}
defer conexao.Close()
canal, err := conexao.Channel()
if err != nil {
log.Fatal(err)
}
must(canal.ExchangeDeclare(troca, "topic", true, false, false, false, nil))
_, err = canal.QueueDeclare(fila, true, false, false, false, nil)
must(err)
for _, chave := range []string{"pedido.criado", "pedido.cancelado"} {
must(canal.QueueBind(fila, chave, troca, false, nil))
}
must(canal.Qos(10, 0, false))
mensagens, err := canal.Consume(fila, "", false, false, false, false, nil)
must(err)
ctx := context.Background()
for mensagem := range mensagens {
var evento Evento
if err := json.Unmarshal(mensagem.Body, &evento); err != nil {
mensagem.Nack(false, false)
continue
}
if err := tratar(ctx, evento); err != nil {
log.Println("falha ao processar:", err)
mensagem.Nack(false, false)
continue
}
mensagem.Ack(false)
}
}
func tratar(ctx context.Context, evento Evento) error {
switch evento.Tipo {
case "pedido.criado":
return reservarItens(ctx, evento.ID, evento.Dados)
case "pedido.cancelado":
return liberarItens(ctx, evento.ID, evento.Dados)
}
return nil // outros tipos são ignorados
}
func must(err error) {
if err != nil {
log.Fatal(err)
}
}import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.DeliverCallback;
public class AssinanteDeEstoque {
private static final String TROCA = "pedidos";
private static final String FILA = "estoque.pedidos";
// Sem esta linha, um campo novo no evento quebra o assinante
private static final ObjectMapper JSON = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
record Evento(String id, String tipo, int versao, JsonNode dados) {}
public static void main(String[] args) throws Exception {
var fabrica = new ConnectionFactory();
fabrica.setUri(System.getenv("RABBITMQ_URL"));
var canal = fabrica.newConnection().createChannel();
canal.exchangeDeclare(TROCA, BuiltinExchangeType.TOPIC, true);
canal.queueDeclare(FILA, true, false, false, null);
canal.queueBind(FILA, TROCA, "pedido.criado");
canal.queueBind(FILA, TROCA, "pedido.cancelado");
canal.basicQos(10);
DeliverCallback aoReceber = (etiqueta, entrega) -> {
long id = entrega.getEnvelope().getDeliveryTag();
try {
var evento = JSON.readValue(entrega.getBody(), Evento.class);
switch (evento.tipo()) {
case "pedido.criado" -> reservarItens(evento.id(), evento.dados());
case "pedido.cancelado" -> liberarItens(evento.id(), evento.dados());
default -> { } // outros tipos são ignorados
}
canal.basicAck(id, false);
} catch (Exception erro) {
canal.basicNack(id, false, false);
}
};
canal.basicConsume(FILA, false, aoReceber, etiqueta -> {});
}
}using System.Text.Json;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
const string Troca = "pedidos";
const string Fila = "estoque.pedidos"; // nome do serviço, não da instância
// Web: nomes em camelCase; campos desconhecidos já são ignorados
var opcoesJson = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var fabrica = new ConnectionFactory
{
Uri = new Uri(Environment.GetEnvironmentVariable("RABBITMQ_URL")!),
};
await using var conexao = await fabrica.CreateConnectionAsync();
await using var canal = await conexao.CreateChannelAsync();
await canal.ExchangeDeclareAsync(Troca, ExchangeType.Topic, durable: true);
await canal.QueueDeclareAsync(Fila, durable: true, exclusive: false, autoDelete: false);
foreach (var chave in new[] { "pedido.criado", "pedido.cancelado" })
{
await canal.QueueBindAsync(Fila, Troca, chave);
}
await canal.BasicQosAsync(prefetchSize: 0, prefetchCount: 10, global: false);
var consumidor = new AsyncEventingBasicConsumer(canal);
consumidor.ReceivedAsync += async (_, entrega) =>
{
try
{
var evento = JsonSerializer.Deserialize<Evento>(entrega.Body.Span, opcoesJson)!;
switch (evento.Tipo)
{
case "pedido.criado":
await ReservarItens(evento.Id, evento.Dados);
break;
case "pedido.cancelado":
await LiberarItens(evento.Id, evento.Dados);
break;
// outros tipos são ignorados
}
await canal.BasicAckAsync(entrega.DeliveryTag, multiple: false);
}
catch (Exception)
{
await canal.BasicNackAsync(entrega.DeliveryTag, multiple: false, requeue: false);
}
};
await canal.BasicConsumeAsync(Fila, autoAck: false, consumer: consumidor);
await Task.Delay(Timeout.Infinite);
record Evento(string Id, string Tipo, int Versao, JsonElement Dados);A rejeição sem devolver à fila (requeue: false) só leva a mensagem para a fila de mensagens mortas se houver uma política configurada, como no tópico de filas. Sem ela, a mensagem é descartada.
O que muda de uma linguagem para outra
| Biblioteca | Campo desconhecido no JSON | |
|---|---|---|
| Node | amqplib | JSON.parse aceita tudo; com zod, z.object descarta os campos a mais por padrão |
| Python | pika | json.loads devolve um dicionário; no pydantic, o padrão é ignorar campos a mais |
| Go | amqp091-go | Ignorado por padrão; DisallowUnknownFields torna erro |
| Java | amqp-client | O ObjectMapper do Jackson 2 falha por padrão; o Spring Boot já desliga essa opção |
| C# | RabbitMQ.Client 7 | Ignorado por padrão no System.Text.Json |
A coluna de campos desconhecidos é a que importa para a evolução dos eventos. Um assinante Java com o ObjectMapper padrão é derrubado pela primeira mudança compatível do publicador, porque toda mensagem com o campo novo falha e vai para as mensagens mortas.
O erro do tutorial: uma fila por instância
O tutorial oficial do RabbitMQ sobre publicação e assinatura usa uma fila sem nome, exclusiva da conexão:
# Certo para o tutorial, errado para eventos de negócio
resultado = canal.queue_declare(queue="", exclusive=True)
fila = resultado.method.queue # nome gerado: amq.gen-...
canal.queue_bind(exchange="pedidos", queue=fila, routing_key="pedido.criado")Esse desenho serve para o que o tutorial mostra, a distribuição de logs para quem estiver olhando. Num serviço de negócio, ele tem dois defeitos:
| Defeito | Consequência |
|---|---|
| Cada instância cria a sua fila | Com três réplicas, cada evento é processado três vezes |
| A fila exclusiva some quando a conexão cai | Os eventos publicados durante uma reinicialização se perdem |
A correção é a do exemplo acima: um nome fixo, igual em todas as instâncias, com durable: true e sem exclusive.
O publicador
O publicador monta o envelope, publica na troca com o tipo do evento como chave de roteamento e espera a confirmação do intermediário.
import amqp from "amqplib";
import { randomUUID } from "node:crypto";
const conexao = await amqp.connect(process.env.RABBITMQ_URL!);
const canal = await conexao.createConfirmChannel();
await canal.assertExchange("pedidos", "topic", { durable: true });
// "mandatory" devolve a mensagem quando nenhuma fila a recebe
canal.on("return", (mensagem) => {
console.error("evento sem assinante:", mensagem.fields.routingKey);
});
export async function publicar(tipo: string, dados: unknown) {
const evento = {
id: randomUUID(),
tipo,
versao: 1,
ocorridoEm: new Date().toISOString(),
origem: "pedidos",
dados,
};
canal.publish("pedidos", tipo, Buffer.from(JSON.stringify(evento)), {
messageId: evento.id,
type: tipo,
contentType: "application/json",
persistent: true,
mandatory: true,
});
await canal.waitForConfirms();
}
await publicar("pedido.criado", { pedidoId: 981, itens: [{ produtoId: 7, quantidade: 2 }] });| Detalhe | Por quê |
|---|---|
| A chave de roteamento é o tipo do evento | Os assinantes filtram por ela (pedido.criado, pedido.#) |
id no envelope e em messageId | Cada assinante descarta reentregas pelo mesmo identificador |
persistent: true | A mensagem sobrevive a uma reinicialização do intermediário, desde que a fila seja durável |
mandatory: true | Uma mensagem que não chega a nenhuma fila volta ao publicador, em vez de sumir sem aviso |
| Canal com confirmação | waitForConfirms só termina quando o intermediário aceitou a mensagem |
O return não é um erro de negócio: um evento pode não ter assinantes. Mas, se um tipo que deveria ter assinantes começa a voltar, uma ligação foi removida ou nunca foi criada, e o aviso aparece no log em vez de virar dados perdidos.
Na aplicação real, publicar não é chamada logo depois de gravar o pedido, e sim pelo processo que lê a tabela de saída do padrão outbox.
Idempotência por assinante
Cada assinante registra os eventos que já processou, como no tópico de filas. Se vários serviços compartilham o mesmo banco, a chave inclui o nome do assinante, porque o mesmo evento precisa ser processado uma vez por cada um:
CREATE TABLE eventos_processados (
assinante TEXT NOT NULL,
evento_id TEXT NOT NULL,
processado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (assinante, evento_id)
);-- Dentro da mesma transação do efeito
INSERT INTO eventos_processados (assinante, evento_id)
VALUES ('estoque', $1)
ON CONFLICT DO NOTHING;
-- 0 linhas inseridas: o evento já foi processado, não faz nadaO mesmo desenho no Kafka
No Kafka, o papel da fila nomeada é do group.id. A configuração do assinante de estoque, com os nomes de propriedade aceitos tanto pelo cliente Java quanto pelos clientes baseados no librdkafka (Python, Go, C# e Node):
# O nome do serviço, igual em todas as instâncias
group.id=estoque
# Um grupo novo começa do início do tópico, e não só pelos eventos novos
auto.offset.reset=earliest
# A posição é confirmada pelo código, depois de processar
enable.auto.commit=falseAs instâncias do grupo dividem as partições do tópico. Use o id do pedido como chave da mensagem, para que os eventos de um mesmo pedido caiam na mesma partição e cheguem em ordem.
Lista de revisão
Assinatura
- Cada serviço tem uma assinatura com nome fixo, igual em todas as instâncias?
- A assinatura é durável, e não exclusiva ou temporária?
- As assinaturas existem antes de o publicador começar a publicar?
- Dois serviços diferentes nunca compartilham a mesma assinatura (fila ou
group.id)?
Evento
- O nome está no passado e descreve um fato, não um comando?
- O envelope tem
id,tipo,versaoeocorridoEm? - O conteúdo traz o que a maioria dos assinantes precisa, sem despejar a tabela inteira?
- Mudanças no formato só acrescentam campos, ou publicam uma versão nova ao lado da antiga?
Assinante
- Campos e tipos de evento desconhecidos são ignorados, e não tratados como erro?
- O processamento é idempotente, com o registro na mesma transação do efeito?
- A confirmação acontece só depois de processar, com limite de mensagens sem confirmação?
- Há fila de mensagens mortas e alguém é avisado quando ela recebe algo?
Publicador
- Os eventos saem pelo padrão outbox, e não logo depois da gravação?
- A publicação é confirmada pelo intermediário?
- Um evento que não chega a nenhuma assinatura gera um aviso?
- Existe um catálogo de quem assina cada tipo de evento?
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.
Construir publicador e assinantes
Quero construir um publicador e dois assinantes de eventos, testando cada comportamento. Pergunte qual linguagem e qual intermediário eu uso. Vamos em etapas. Em cada uma, deixe que eu tente e depois mostre a sua versão: 1. Subir o intermediário com Docker e criar o tópico e as duas assinaturas pela linha de comando, antes de qualquer código. 2. Um publicador que monta o envelope do evento e espera a confirmação do intermediário. 3. Um assinante com confirmação manual, que trata dois tipos de evento e ignora os demais. 4. Subir três instâncias do mesmo assinante e confirmar que cada evento é processado uma vez. 5. Repetir com uma fila exclusiva por instância e observar o processamento triplicado. 6. Derrubar um assinante, publicar eventos e confirmar que eles esperam por ele. 7. Acrescentar um campo novo no evento e confirmar que os assinantes continuam funcionando. Em cada etapa, diga o que observar no painel ou nos logs.
Migrar um evento para uma versão nova
Preciso fazer uma mudança incompatível num evento que vários serviços assinam, como trocar o campo total, em centavos, por um objeto com valor e moeda. Pergunte qual linguagem e qual intermediário eu uso, e quantos serviços assinam o evento. Conduza o plano por etapas, com código: 1. Como publicar a versão nova ao lado da antiga, pelo tipo do evento ou por um campo de versão. 2. Como cada assinante passa a ler a versão nova, um de cada vez. 3. Como descobrir quais assinantes ainda dependem da versão antiga. 4. Quando e como parar de publicar a versão antiga. 5. Como um registro de esquemas teria impedido a mudança incompatível de ser publicada por engano. Em cada etapa, pergunte o que pode dar errado antes de seguir.
Revisar código de publicação e assinatura
Vou colar abaixo código que publica ou assina eventos. Revise com foco em entrega, duplicidade e evolução. Verifique, nesta ordem, e para cada item diga se está correto, incorreto ou ausente, citando a linha: 1. A assinatura tem nome fixo, igual em todas as instâncias, e é durável? Há filas exclusivas, nomes gerados ou group.id com valor aleatório ou nome da máquina? 2. Dois serviços diferentes podem estar usando a mesma assinatura? 3. A assinatura existe antes de o publicador começar, ou só é criada quando o assinante inicia? 4. Há uso de um mecanismo efêmero, como o Redis Pub/Sub, para eventos que não podem se perder? 5. O assinante ignora campos e tipos de evento desconhecidos? 6. O assinante é idempotente e confirma só depois de processar? 7. O evento tem id, tipo, versão e data do fato? O nome descreve um fato ou um comando? 8. A publicação é confirmada, e há risco de gravar no banco e não publicar? Para cada problema, descreva o cenário concreto em que ele perde, duplica ou quebra o processamento de eventos, e mostre a correção. Não reescreva o que já está correto. Código: [cole aqui]