Refatoração de um sistema acoplado para comunicação por eventos com RabbitMQ
quarta entrega do Projeto de Bloco: Engenharia de Softwares Escaláveis (DR5)
| 10 | 3 | 3 | 10 | 209 | 103 |
|---|---|---|---|---|---|
| padrões de mensagem | serviços de domínio, um banco cada | nós no cluster RabbitMQ | cenários de demonstração | testes automatizados | requisições na coleção HTTP |
O Infnet Hub é uma plataforma educacional com feed de publicações, curtidas, comentários, vagas de carreira e desempenho acadêmico por competência. Cada entrega deu um passo:
flowchart LR
TP1["TP1<br/>monólito em camadas"] --> TP2["TP2<br/>PostgreSQL"] --> TP3["TP3<br/>microsserviços<br/>Spring Cloud"] --> TP4["TP4<br/>eventos<br/>RabbitMQ"]
style TP4 fill:#1e3a8a,stroke:#3b8ef5,color:#ffffff
Esta entrega troca a integração síncrona por eventos. O sistema deixa de perguntar e passa a anunciar: o que era uma chamada HTTP a um serviço que podia estar fora do ar virou um evento gravado numa tabela, entregue pelo RabbitMQ e aplicado pelo consumidor no próprio banco dele.
Important
A mudança que importa não é a troca de protocolo: o tempo deixou de ser requisito da integração. O nome de um aluno já está no banco do boletim desde que ele foi cadastrado, e chegou lá mesmo que o boletim estivesse desligado naquele momento.
| Novidade | O que é |
|---|---|
📨 notificacao-service |
Serviço novo, que só consome: transforma eventos em notificações por pessoa e as entrega ao vivo por Server-Sent Events |
| 🧩 Dez padrões de mensagem | Cada um com caso de uso real, inclusive uma saga coreografada com compensação para remover uma pessoa dos três bancos (LGPD) |
| 🛡️ Garantias de entrega | Caixa de saída transacional, confirmação de publicação, consumidor idempotente e filas de mensagens mortas |
| 🔐 Autenticação de verdade | Keycloak com OIDC, PKCE e PAR, gateway no padrão Backend for Frontend; fim do cabeçalho X-Usuario-Id que qualquer um podia forjar |
| 🐇 Broker em cluster | Três nós do RabbitMQ com filas quorum replicadas: um nó cai e o sistema segue sem perder mensagem |
| 🔒 Tráfego interno cifrado | HTTPS, AMQPS e TLS no PostgreSQL e entre os nós do broker, com o certificado conferido em todos os trechos |
| 📊 Painel de operação | Prometheus e Grafana: filas, consumidores, caixa de saída, sagas e tempo de resposta |
| Problema no TP3 | Solução no TP4 |
|---|---|
boletim-service chamava infnethub-core por OpenFeign a cada consulta; com o core fora do ar, a tela mostrava "indisponível" |
Réplica local aluno_replica, alimentada por eventos. O Feign saiu do projeto |
As notificações do front-end eram falsas, uma lista fixa em inbox.ts |
notificacao-service, alimentado por eventos, com entrega ao vivo |
| Excluir um usuário não se propagava: matrículas ficavam órfãs no outro banco, e quem tinha posts nem podia ser excluído (409) | Saga de expurgo: o core bloqueia, o boletim anonimiza ou recusa, o core conclui ou desfaz o bloqueio e avisa quem pediu |
Com o core desligado, o boletim continua respondendo com o nome do aluno:
| Antes — TP3 | Depois — TP4 |
|---|---|
![]() |
![]() |
| Com o core fora do ar, a identificação do aluno ficava indisponível | Com o core fora do ar, o nome vem da réplica local, posta lá por eventos |
flowchart LR
FE[front-end<br/>Next.js] --> GW[api-gateway<br/>BFF + OAuth2]
KC[(Keycloak)] -.OIDC.-> GW
GW --> CORE[infnethub-core]
GW --> BOL[boletim-service]
GW --> NOT[notificacao-service]
CORE -->|publica| MQ{{RabbitMQ<br/>3 nós}}
MQ -->|"usuario.*"| BOL
MQ -->|"usuario.* post.* vaga.*"| NOT
MQ -->|comandos| NOT
CORE <-->|request/reply| MQ
MQ <-->|responde| BOL
CORE --- DB1[(postgres<br/>core + outbox)]
BOL --- DB2[(postgres<br/>boletim + réplica)]
NOT --- DB3[(postgres<br/>notificação)]
NOT -.SSE.-> FE
style MQ fill:#ff6600,stroke:#b34700,color:#ffffff
style NOT fill:#14532d,stroke:#22c55e,color:#ffffff
Note
Não há mais chamada direta entre serviços de negócio. A única ligação bidirecional é a consulta pontual que antecede uma exclusão, e ela também passa pelo broker.
Topologia no RabbitMQ
infnethub.eventos topic usuario.* post.* vaga.* → uma fila por consumidor
infnethub.comandos direct notificacao.enviar → fila de trabalho
notificacao.agendar → sala de espera (TTL → DLX)
infnethub.consultas direct boletim.situacao → fila com validade de 10 s
infnethub.dlx direct <fila>.dlq → mensagens que esgotaram tentativas
infnethub.notificacoes.aovivo fanout → uma fila exclusiva por instância
Filas nomeadas por consumidor, não por evento: boletim.usuarios e notificacao.eventos
recebem os mesmos eventos de usuário, cada uma no seu ritmo, com sua própria fila de mensagens
mortas.
Portas
Só o que um navegador abre é publicado, e só no 127.0.0.1 da máquina:
| Para | Host |
|---|---|
| Front-end | 20000 |
| API Gateway | 20080 |
| Keycloak (login) | 20180 |
| Grafana (painel de operação) | 20300 |
| RabbitMQ (painel de gestão) | 20673 |
| PostgreSQL / AMQP, para rodar um serviço pela IDE | 20432–20434 / 20672 |
Os serviços Java e o Eureka não têm porta no host: chega-se a eles pelo gateway, e só por ele.
| Padrão | Mecanismo | Caso de uso |
|---|---|---|
| Publish/Subscribe | exchange topic |
usuario.cadastrado alimenta a réplica do boletim e gera as boas-vindas |
| Event-Carried State Transfer | payload com o estado | UsuarioAtualizado leva nome, escola e papel: o boletim não pergunta de volta |
| Event Notification | payload com ids | PostCurtido leva o mínimo; quem quiser o post busca o post |
| Comando ponto a ponto | exchange direct |
EnviarAviso: a secretaria fala com a turma |
| Competing Consumers | fila compartilhada | --scale notificacao=3 → 9 consumidores na mesma fila |
| Fanout | exchange fanout |
a notificação chega à instância que tem a conexão SSE aberta |
| Retry + Dead Letter | x-delivery-limit + DLX |
mensagem venenosa sai de circulação e fica guardada |
| Mensagem com atraso | cascata de 22 níveis com TTL de 2ⁿ s | "avise quando faltarem 24 h para o prazo", sem bloqueio de cabeça de fila |
| Request/Reply | reply-to + correlation-id |
situação acadêmica antes de excluir um cadastro |
| Saga coreografada | eventos nos dois sentidos + estado no core | remoção (LGPD): bloqueia → anonimiza ou recusa → conclui ou compensa |
Evento, comando e consulta são três coisas diferentes, e o módulo contratos diz isso no
sistema de tipos:
| Evento | Comando | Consulta | |
|---|---|---|---|
| diz | aconteceu isto | faça isto | me diga isto |
| destinatários | nenhum, ou muitos | exatamente um | um, que responde |
| pode ser recusado | não: fato é fato | sim | sim |
| passa pela caixa de saída | sim | sim | não |
Tip
A última linha é a que mais importa: uma pergunta cuja resposta chegue tarde não vale nada, então não recebe as garantias caras que um evento recebe.
O que separa "usar RabbitMQ" de "usar RabbitMQ bem":
| Garantia | Como |
|---|---|
| 📥 Caixa de saída transacional | O evento é gravado na mesma transação do agregado; um relay entrega depois. Publicar direto teria uma falha em cada ordem |
✅ Confirmação de publicação com mandatory |
Sem fila ligada à chave, o broker devolve em vez de descartar, e o evento continua pendente |
| 🔁 Consumidor idempotente | mensagem_processada com o messageId como chave primária, gravada na mesma transação do efeito |
| 🧱 Filas quorum com limite de entregas | x-delivery-limit: 5 impede que uma mensagem defeituosa derrube o consumidor em laço |
| 🔢 Ordem por agregado, versão no payload | Um evento atrasado de um usuário é descartado pelo consumidor, sem reter os dos outros |
Com o broker inteiro fora do ar, o relay registrou as tentativas, não perdeu nada e publicou sozinho quando o broker voltou:
tentativas: 20
último erro: broker inacessível: java.net.UnknownHostException: rabbitmq
Até o TP3 a identidade era o cabeçalho X-Usuario-Id, aceito sem verificação. A correção é
Keycloak + BFF, e não um JWT entregue ao navegador, porque um token em localStorage é
legível por qualquer script da página.
sequenceDiagram
participant N as Navegador
participant G as api-gateway (BFF)
participant K as Keycloak
G->>K: pedido de login por trás (PAR + PKCE)
K-->>G: referência de uso único
G-->>N: redireciona só com client_id e a referência
N->>K: usuário e senha
K-->>G: código → tokens
G-->>N: cookie SESSION (HttpOnly)
N->>G: chamadas com o cookie
G->>G: TokenRelay: o token segue para os serviços
- o navegador recebe só um cookie
HttpOnly, que o JavaScript não lê; - cada serviço valida o token por conta própria: confiar em "veio do gateway" é confiar na rede;
- quem não é navegador (scripts, a coleção HTTP) usa
Authorization: Bearer.
Note
Contas de demonstração, todas com a senha infnet:
| Usuário | Papel | Pode |
|---|---|---|
lucas.mendonca |
ALUNO | ver o próprio boletim, publicar no feed |
carlos.oliveira |
PROFESSOR | ver o boletim de qualquer aluno, ler a auditoria, avisar a turma |
atendimento |
SECRETARIA | publicar vagas, administrar o cadastro, avisar a turma |
suporte.ti |
SUPORTE_TI | entrar no painel de operação (Grafana); nenhum outro papel entra |
docker compose up -dSobe dezesseis contêineres na ordem certa, com depends_on: service_healthy: o gerador de
certificados, Keycloak, três nós do RabbitMQ, três PostgreSQL, Eureka, os três serviços, gateway,
front-end, Prometheus e Grafana.
| Para ver | Onde |
|---|---|
| 🖥️ Aplicação | http://localhost:20000 |
| 📊 Painel de operação | http://localhost:20300 (Grafana, só suporte.ti) |
| 🐇 Painel do RabbitMQ | http://localhost:20673 (infnethub / infnethub) |
| 🔑 Keycloak | http://localhost:20180 |
Para derrubar tudo: docker compose down.
Warning
O ambiente do Compose é de desenvolvimento: senhas conhecidas, realm importado de arquivo e autoridade certificadora local. Uma implantação real troca esses três itens de configuração, sem mudar o código.
Rodando um serviço pela IDE
O broker só aceita AMQP por TLS. Copie a autoridade de desenvolvimento para fora do Docker:
./infra/certificados/exportar.shNa configuração de execução do serviço, acrescente a variável SPRING_RABBITMQ_SSL_ENABLED=true
e as opções de JVM
-Djavax.net.ssl.trustStore=target/certificados/confianca.p12 -Djavax.net.ssl.trustStorePassword=infnethub-confianca -Djavax.net.ssl.trustStoreType=PKCS12.
Os bancos e o AMQP do primeiro nó estão no 127.0.0.1 para isso.
Toda conexão entre contêineres é cifrada, e em todas o certificado é conferido:
| Trecho | Como |
|---|---|
| serviços → PostgreSQL | TLS com sslmode=verify-full; o banco recusa conexão sem TLS |
| serviços → RabbitMQ | AMQPS (TLS 1.3) na 5671; a porta sem cifragem é desligada |
| nó ↔ nó do RabbitMQ | distribuição Erlang por TLS, com verificação mútua |
| gateway → serviços; serviços → Eureka e Keycloak; front-end → gateway | HTTPS |
| Prometheus → alvos; Grafana → Prometheus | HTTPS |
A autoridade é de cada ambiente: gerada na primeira subida por infra/certificados/gerar.sh,
emite um único certificado e tem a chave apagada em seguida.
O Grafana pede login pelo Keycloak e só aceita o papel SUPORTE_TI. O painel Infnet Hub — operação mostra nós do broker, mensagens paradas e mortas, caixa de saída de cada serviço, sagas abertas, filas e consumidores, avisos na sala de espera, taxas de publicação e tempo de resposta.
Dez cenários reproduzíveis. Nenhum é simulação: os contêineres são derrubados de verdade, e o que o roteiro mostra é o que o RabbitMQ e os bancos respondem.
./demo/cenarios.sh # lista os cenários
./demo/cenarios.sh 2 # roda um
./demo/cenarios.sh todos # roda os dezTip
No Windows, rode pelo PowerShell chamando o Bash do Git pelo caminho completo (o bash do PATH
pode ser o do WSL, que não enxerga as ferramentas do Windows). Há também demo/cenarios.ps1.
& "$env:USERPROFILE\scoop\apps\git\current\bin\bash.exe" ./demo/cenarios.sh 1| # | Cenário | O que prova |
|---|---|---|
| 1 | O consumidor pode estar fora do ar | a fila acumula e é aplicada quando ele volta |
| 2 | O boletim funciona sem o core | o acoplamento do TP3, desfeito |
| 3 | O broker pode estar fora do ar | caixa de saída transacional |
| 4 | A mensagem que não dá para processar | recusa e fila de mensagens mortas |
| 5 | A mesma mensagem duas vezes | consumidor idempotente |
| 6 | Três instâncias dividindo trabalho | competing consumers e fanout |
| 7 | Pedido para agora e para depois | atraso em cascata: um aviso de 5 s chega em 5 s, mesmo atrás de um de 1 hora |
| 8 | A pergunta que espera resposta | request/reply, a exceção síncrona |
| 9 | Remover uma pessoa de três bancos | saga: conclusão, recusa com compensação e prazo vencido |
| 10 | Um nó do broker cai | cluster de três nós: novo líder na hora, eventos seguem |
A captura da execução de cada cenário está no relatório, na seção 24. Algumas telas:
| O sino alimentado por eventos | A saga de expurgo, nos três desfechos |
|---|---|
![]() |
![]() |
| Cluster de três nós | Um nó fora, o sistema segue |
|---|---|
![]() |
![]() |
209 testes, todos passando:
./mvnw test| Módulo | contratos |
infnethub-core |
boletim-service |
notificacao-service |
api-gateway |
eureka-server |
|---|---|---|---|---|---|---|
| Testes | 24 | 68 | 68 | 24 | 23 | 2 |
Os testes de mensageria usam Testcontainers com RabbitMQContainer e @ServiceConnection:
um broker de verdade, não um simulador. É o único jeito honesto de provar confirmação de
publicação, devolução por falta de fila, expiração com dead letter e limite de entregas.
103 requisições em treze arquivos, executadas pelo HTTP Client da JetBrains em contêiner, com as asserções de cada bloco rodando sem alteração:
./verificar.sh gateway98 requests completed, 0 have failed tests
Aguardando a saga de expurgo dar a volta…
9 requests completed, 0 have failed tests
Coleção HTTP: OK — relatório JUnit em target/http-reports
O que cada arquivo cobre
| Arquivo | Cobre |
|---|---|
00-autenticacao.http |
um token por papel, e a recusa de credencial inválida |
01–05 |
usuários, posts, comentários, vagas, histórico |
06-erros.http |
400, 404, 401 sem token, 401 com token inválido, 403 por papel |
07-boletim.http |
boletim, desempenho e a regra de acesso por pessoa |
08-notificacoes.http |
leitura, contagem, baixa e a data com fuso |
09-avisos.http |
o comando: 403 de aluno, 202, alcance e agendamento |
10-consulta-boletim.http |
o request/reply, e a entrega atrasada que não chegou |
11-expurgo.http |
a saga aberta: 202 em SOLICITADO, 409 no pedido repetido, o bloqueio em todas as leituras |
12-expurgo-desfecho.http |
o desfecho: REVERTIDO com motivo, a pessoa de volta e o aviso para quem pediu |
A saga leva perto de um segundo para dar a volta, e o cliente HTTP não sabe esperar: por isso são duas chamadas, com a pausa explícita no script.
Tudo atrás do gateway, em http://localhost:20080. Nenhuma rota responde sem token.
Rotas novas no TP4
| Método | Rota | Papel | Descrição |
|---|---|---|---|
GET |
/api/v1/notificacoes |
autenticado | as notificações de quem pede |
GET |
/api/v1/notificacoes/nao-lidas |
autenticado | o número do sino |
GET |
/api/v1/notificacoes/ao-vivo |
autenticado | fluxo SSE |
POST |
/api/v1/notificacoes/lidas |
autenticado | marca as escolhidas como lidas ({"ids":[…]}) |
POST |
/api/v1/notificacoes/nao-lidas |
autenticado | devolve o destaque às escolhidas |
POST |
/api/v1/notificacoes/lidas/todas |
autenticado | baixa geral |
POST |
/api/v1/notificacoes/excluir |
autenticado | exclusão em lote ({"ids":[…]}) |
POST |
/api/v1/avisos |
professor, secretaria | envia um aviso; aceita atrasoSegundos · responde 202 |
GET |
/api/v1/usuarios/{id}/situacao-academica |
professor, secretaria | pergunta ao boletim pelo broker |
POST |
/api/v1/usuarios/eventos/reenvio |
secretaria | republica o estado atual, para a carga de um consumidor novo |
DELETE |
/api/v1/usuarios/{id} |
secretaria, coordenação | mudou: abre a saga de expurgo · responde 202 |
GET |
/api/v1/usuarios/{id}/expurgo |
secretaria, coordenação | histórico dos processos de expurgo |
202, e não 201, no aviso: não nasce recurso com endereço próprio, nasce um pedido aceito. 202, e não 204, na remoção: o que acontece nos outros serviços ainda está em curso. Um segundo pedido com a saga em curso responde 409.
Contrato de erro
{
"timestamp": "2026-09-22T22:28:57.842",
"status": 400,
"error": "Corpo da requisição inválido",
"message": "Não foi possível interpretar o corpo enviado."
}Árvore de pastas
TP4-PB_eventos/
├── contratos/ os contratos das mensagens — a linguagem comum
│ └── …/contratos/
│ ├── MensagemDeIntegracao.java metadados comuns
│ ├── EventoDeIntegracao.java fato anunciado
│ ├── comando/ pedido endereçado
│ ├── consulta/ pergunta e resposta
│ ├── usuario/ feed/ vaga/ os contratos versionados
│ └── Canais.java exchanges, rotas e filas
├── infnethub-core/ identidade, feed, vagas · PRODUTOR
│ └── …/mensageria/
│ ├── CaixaDeSaida.java grava na transação do agregado
│ ├── RelayDoOutbox.java publica, espera o ack, marca
│ └── ConsultaAoBoletim.java request/reply, com prazo
│ └── …/expurgo/SagaDeExpurgo.java a saga: bloqueia, conclui ou compensa
├── boletim-service/ desempenho acadêmico · CONSUMIDOR
│ └── …/aluno/ réplica local + participação na saga
├── notificacao-service/ NOVO no TP4 · só consome
│ ├── …/mensageria/ eventos e comandos, em filas separadas
│ └── …/aovivo/ fanout entre instâncias + SSE
├── api-gateway/ roteamento + BFF (OIDC, PAR, sessão, TokenRelay)
├── eureka-server/ descoberta
├── frontend/ Next.js
├── infra/
│ ├── rabbitmq/ topologia, cluster de três nós e TLS
│ ├── keycloak/ realm e o tema da tela de login
│ ├── certificados/ autoridade de desenvolvimento (gerada, não versionada)
│ ├── postgres/pg_hba.conf conexão pela rede só com TLS
│ ├── prometheus/ o que coletar, e de onde
│ └── grafana/ painel de operação provisionado
├── demo/cenarios.sh · .ps1 dez cenários reproduzíveis
├── http/ coleção de 103 requisições
├── doc/
│ ├── RELATORIO_TP4-PB.md relatório técnico
│ ├── asyncapi.yaml especificação dos canais
│ └── screenshots/ capturas da demonstração
└── docker-compose.yml
| Camada | Tecnologias |
|---|---|
| Back-end | |
| Mensageria | |
| Dados | |
| Segurança | |
| Front-end | |
| Observabilidade | |
| Testes | |
| Infra |
A documentação completa está em doc/RELATORIO_TP4-PB.md, em seis
partes:
| Parte | Conteúdo |
|---|---|
| I | Contexto e avaliação: prós e contras medidos neste sistema, e onde eventos não valem |
| II | Padrões de mensagem: catálogo, topologia e a sequência de cada fluxo |
| III | Garantias de entrega: outbox, confirmações, idempotência, quorum e DLQ |
| IV | Abstrações do Spring Boot e as decisões de ferramenta |
| V | A refatoração: Feign → réplica, notificações reais, autenticação |
| VI | Qualidade e operação: testes, demonstração, armadilhas e cobertura das rúbricas |
A especificação dos canais está em doc/asyncapi.yaml (AsyncAPI 3.0).
Licença de Código Visível — Somente Leitura. Todos os direitos reservados. Este repositório é source-available, não código aberto: é público para que o trabalho possa ser lido, estudado e avaliado.
| Permissão | |
|---|---|
| ✅ Livre | Ler e analisar o código nesta página; citar trechos curtos com atribuição |
| 🎓 Avaliação acadêmica | O corpo docente e a coordenação da instituição podem obter e executar o projeto no necessário à correção |
| ⛔ Requer autorização escrita | Copiar, clonar ou executar · modificar · redistribuir ou hospedar · uso comercial · treinar modelos de IA · apresentar como trabalho acadêmico próprio |
Caution
Isso inclui clonar e rodar localmente. Um fork permitido pelos Termos do GitHub não amplia a concessão de leitura nem constitui autorização. Pedir autorização é gratuito e costuma ser respondido: abra uma issue no repositório. Ausência de resposta não equivale a autorização.
O texto integral, com as definições, a cláusula de integridade acadêmica e o aviso de proteção de dados (LGPD), está em LICENSE.
O repositório não contém dados pessoais reais: os registros semeados e os usuários do realm do Keycloak são fictícios. A identidade deixou de ser um cabeçalho forjável, o acesso ao boletim é verificado por pessoa e a trilha de auditoria registra a identidade do token.
O que a arquitetura de eventos acrescentou, e como cada ponto foi tratado
- Dado pessoal replicado. Nome, escola e papel do aluno existem em dois bancos, o do core e
a
aluno_replicado boletim. A eliminação alcança os dois pela saga de expurgo. - A eliminação é uma saga, e não um
DELETE. O core bloqueia o cadastro e anuncia o pedido; o boletim apaga a réplica (as matrículas ficam, sem identificação) ou recusa, se houver competência em avaliação; o core então anonimiza o cadastro (nome "Usuário removido", e-mailremovido+{id}@infnethub.invalid) ou desfaz o bloqueio e avisa quem pediu. Se o boletim não responder, o prazo desfaz o bloqueio; se responder depois, o core o faz restaurar o que apagou. O processo fica registrado emexpurgo_lgpd. - A trilha de auditoria também é anonimizada, na mesma transação que conclui a saga: continua mostrando o que aconteceu e em que ordem, sem identificar ninguém.
- Dado pessoal em filas.
UsuarioRemovidoV1e o pedido de expurgo levam só o id. As filas de mensagens mortas descartam o conteúdo em sete dias (política do broker). - Aviso de cookies. Consentimento por categoria (essenciais, preferências, estado das janelas), com o mesmo peso para aceitar e rejeitar, e a opção de apagar os dados locais.
- Trânsito cifrado. Toda conexão entre contêineres é TLS, com o certificado conferido.
| Asset | Autor | Licença |
|---|---|---|
| Ilustrações 3D da página 404 (antena, satélite e astronauta) | Vecteezy | Vecteezy License, atribuição exigida |
As ilustrações foram convertidas para WebP e ajustadas em cor e recorte. Os originais não são versionados: somam cerca de 59 MB e nenhum é carregado em execução.






