Add the rabbitmq module
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package com.ankurm.rabbit;
|
||||
|
||||
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
|
||||
import org.springframework.amqp.support.converter.MessageConverter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Spring Boot does <b>not</b> auto-configure a JSON message converter for RabbitMQ. The default
|
||||
* is {@code SimpleMessageConverter}, which handles {@code String}, {@code byte[]} and
|
||||
* {@code Serializable} and nothing else:
|
||||
*
|
||||
* <pre>
|
||||
* IllegalArgumentException: SimpleMessageConverter only supports String, byte[] and
|
||||
* Serializable payloads, received: com.ankurm.rabbit.OrderMessage
|
||||
* </pre>
|
||||
*
|
||||
* <p>Declaring one {@code MessageConverter} bean fixes both directions — the auto-configured
|
||||
* {@code RabbitTemplate} and the listener container factory both pick it up.
|
||||
*
|
||||
* <p>Note the class name. Spring AMQP 4.1 ships {@code Jackson2JsonMessageConverter} (Jackson 2)
|
||||
* and {@code JacksonJsonMessageConverter} (Jackson 3) side by side, exactly as Spring Kafka
|
||||
* ships {@code JsonSerializer} and {@code JacksonJsonSerializer}. Boot 4 is a Jackson 3
|
||||
* application; pick the one without the 2.
|
||||
*
|
||||
* @see <a href="../../../../../docs/01-the-on-ramp.md">docs/01-the-on-ramp.md</a>
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class ConverterConfiguration {
|
||||
|
||||
@Bean
|
||||
MessageConverter messageConverter() {
|
||||
return new JacksonJsonMessageConverter();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ankurm.rabbit;
|
||||
|
||||
/**
|
||||
* The payload. Kept to a String field so the tests are about routing and acknowledgement rather
|
||||
* than about converters.
|
||||
*/
|
||||
public record OrderMessage(String orderId, String detail) {
|
||||
|
||||
public static OrderMessage of(String orderId) {
|
||||
return new OrderMessage(orderId, "detail for " + orderId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.rabbit;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* All four AMQP 0-9-1 exchange types, a working dead-letter path, and the traps that make
|
||||
* RabbitMQ feel unpredictable until you know them.
|
||||
*
|
||||
* <p>Everything here runs against a real broker. See the module README for how the scripts start
|
||||
* one without Docker.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class RabbitDemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(RabbitDemoApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.ankurm.rabbit;
|
||||
|
||||
import org.springframework.amqp.core.Binding;
|
||||
import org.springframework.amqp.core.BindingBuilder;
|
||||
import org.springframework.amqp.core.DirectExchange;
|
||||
import org.springframework.amqp.core.FanoutExchange;
|
||||
import org.springframework.amqp.core.HeadersExchange;
|
||||
import org.springframework.amqp.core.Queue;
|
||||
import org.springframework.amqp.core.QueueBuilder;
|
||||
import org.springframework.amqp.core.TopicExchange;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The whole topology as beans. {@code RabbitAdmin} declares every {@code Exchange},
|
||||
* {@code Queue} and {@code Binding} bean when the connection is first opened.
|
||||
*
|
||||
* <p>Two things about that are worth knowing before you rely on it:
|
||||
* <ul>
|
||||
* <li>Declaration is <b>idempotent only if the arguments match</b>. Redeclaring an existing
|
||||
* queue with different arguments is a channel-level {@code PRECONDITION_FAILED} — see
|
||||
* docs/06-changing-your-mind.md.</li>
|
||||
* <li>Declarations happen on connection, not on context refresh, so a topology error surfaces
|
||||
* at the first publish rather than at startup.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see <a href="../../../../../docs/02-exchanges.md">docs/02-exchanges.md</a>
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class Topology {
|
||||
|
||||
public static final String DIRECT = "orders.direct";
|
||||
|
||||
public static final String FANOUT = "orders.fanout";
|
||||
|
||||
public static final String TOPIC = "orders.topic";
|
||||
|
||||
public static final String HEADERS = "orders.headers";
|
||||
|
||||
public static final String DLX = "orders.dlx";
|
||||
|
||||
public static final String Q_NEW = "orders.new";
|
||||
|
||||
public static final String Q_CANCEL = "orders.cancel";
|
||||
|
||||
public static final String Q_AUDIT = "audit.all";
|
||||
|
||||
public static final String Q_ANALYTICS = "analytics.all";
|
||||
|
||||
public static final String Q_EU = "orders.eu";
|
||||
|
||||
public static final String Q_HIGH = "orders.high";
|
||||
|
||||
public static final String Q_PRIORITY = "orders.priority";
|
||||
|
||||
public static final String Q_ANY = "orders.any";
|
||||
|
||||
public static final String Q_WORK = "orders.work";
|
||||
|
||||
public static final String Q_TTL = "orders.ttl";
|
||||
|
||||
public static final String Q_DLQ = "orders.dlq";
|
||||
|
||||
// --- direct: routing key must match the binding key exactly -----------------------------
|
||||
|
||||
@Bean
|
||||
DirectExchange directExchange() {
|
||||
return new DirectExchange(DIRECT);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue newQueue() {
|
||||
return QueueBuilder.durable(Q_NEW).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue cancelQueue() {
|
||||
return QueueBuilder.durable(Q_CANCEL).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Binding bindNew(DirectExchange directExchange, Queue newQueue) {
|
||||
return BindingBuilder.bind(newQueue).to(directExchange).with("new");
|
||||
}
|
||||
|
||||
@Bean
|
||||
Binding bindCancel(DirectExchange directExchange, Queue cancelQueue) {
|
||||
return BindingBuilder.bind(cancelQueue).to(directExchange).with("cancel");
|
||||
}
|
||||
|
||||
// --- fanout: routing key ignored entirely -----------------------------------------------
|
||||
|
||||
@Bean
|
||||
FanoutExchange fanoutExchange() {
|
||||
return new FanoutExchange(FANOUT);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue auditQueue() {
|
||||
return QueueBuilder.durable(Q_AUDIT).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue analyticsQueue() {
|
||||
return QueueBuilder.durable(Q_ANALYTICS).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Binding bindAudit(FanoutExchange fanoutExchange, Queue auditQueue) {
|
||||
return BindingBuilder.bind(auditQueue).to(fanoutExchange);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Binding bindAnalytics(FanoutExchange fanoutExchange, Queue analyticsQueue) {
|
||||
return BindingBuilder.bind(analyticsQueue).to(fanoutExchange);
|
||||
}
|
||||
|
||||
// --- topic: '*' is exactly one word, '#' is zero or more -------------------------------
|
||||
|
||||
@Bean
|
||||
TopicExchange topicExchange() {
|
||||
return new TopicExchange(TOPIC);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue euQueue() {
|
||||
return QueueBuilder.durable(Q_EU).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue highQueue() {
|
||||
return QueueBuilder.durable(Q_HIGH).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Binding bindEu(TopicExchange topicExchange, Queue euQueue) {
|
||||
return BindingBuilder.bind(euQueue).to(topicExchange).with("order.eu.*");
|
||||
}
|
||||
|
||||
@Bean
|
||||
Binding bindHigh(TopicExchange topicExchange, Queue highQueue) {
|
||||
return BindingBuilder.bind(highQueue).to(topicExchange).with("order.#.high");
|
||||
}
|
||||
|
||||
// --- headers: routing key ignored, header map matched ------------------------------------
|
||||
|
||||
@Bean
|
||||
HeadersExchange headersExchange() {
|
||||
return new HeadersExchange(HEADERS);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue priorityQueue() {
|
||||
return QueueBuilder.durable(Q_PRIORITY).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue anyQueue() {
|
||||
return QueueBuilder.durable(Q_ANY).build();
|
||||
}
|
||||
|
||||
/** x-match=all: every header must match. */
|
||||
@Bean
|
||||
Binding bindPriority(HeadersExchange headersExchange, Queue priorityQueue) {
|
||||
return BindingBuilder.bind(priorityQueue).to(headersExchange)
|
||||
.whereAll(Map.of("priority", "high", "region", "eu")).match();
|
||||
}
|
||||
|
||||
/** x-match=any: one is enough. */
|
||||
@Bean
|
||||
Binding bindAny(HeadersExchange headersExchange, Queue anyQueue) {
|
||||
return BindingBuilder.bind(anyQueue).to(headersExchange)
|
||||
.whereAny(Map.of("priority", "high", "region", "eu")).match();
|
||||
}
|
||||
|
||||
// --- the dead-letter path ----------------------------------------------------------------
|
||||
|
||||
@Bean
|
||||
DirectExchange deadLetterExchange() {
|
||||
return new DirectExchange(DLX);
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue deadLetterQueue() {
|
||||
// Deliberately NO dead-letter-exchange on the DLQ itself. Pointing a DLQ's DLX back at
|
||||
// the exchange that feeds it is the classic infinite loop.
|
||||
return QueueBuilder.durable(Q_DLQ).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Binding bindDlq(DirectExchange deadLetterExchange, Queue deadLetterQueue) {
|
||||
// The routing key used when dead-lettering is the message's ORIGINAL routing key unless
|
||||
// deadLetterRoutingKey() overrides it. Both work queues below set it explicitly to
|
||||
// "failed", so this one binding catches everything.
|
||||
return BindingBuilder.bind(deadLetterQueue).to(deadLetterExchange).with("failed");
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue workQueue() {
|
||||
return QueueBuilder.durable(Q_WORK)
|
||||
.deadLetterExchange(DLX)
|
||||
.deadLetterRoutingKey("failed")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Queue ttlQueue() {
|
||||
return QueueBuilder.durable(Q_TTL)
|
||||
.ttl(1500)
|
||||
.deadLetterExchange(DLX)
|
||||
.deadLetterRoutingKey("failed")
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
spring:
|
||||
application:
|
||||
name: rabbitmq-exchanges-and-dlq
|
||||
main:
|
||||
banner-mode: off
|
||||
rabbitmq:
|
||||
host: localhost
|
||||
port: 5672
|
||||
username: guest
|
||||
password: guest
|
||||
# BOTH of these are required to find out about an unroutable message, and they are set in
|
||||
# two different places. publisher-returns puts the connection factory into a mode where it
|
||||
# listens for returns at all; mandatory asks the broker to return this particular publish.
|
||||
# Setting only mandatory - which is what most examples show - changes nothing at all.
|
||||
# See docs/03-the-silent-drop.md.
|
||||
publisher-returns: true
|
||||
template:
|
||||
mandatory: true
|
||||
listener:
|
||||
simple:
|
||||
# MANUAL means nothing is removed from the queue until your code says so - and that
|
||||
# basicNack(requeue=false) is what feeds the dead-letter exchange.
|
||||
acknowledge-mode: manual
|
||||
prefetch: 1
|
||||
# Listener containers are not started automatically here; each test starts the one it
|
||||
# needs, so that a redelivery loop in one test cannot consume another test's messages.
|
||||
auto-startup: false
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.ankurm: INFO
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.ankurm.rabbit;
|
||||
|
||||
import com.rabbitmq.client.GetResponse;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.amqp.rabbit.core.RabbitAdmin;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The dead-letter path, driven with the AMQP primitives directly rather than through a listener
|
||||
* container. {@code basicGet} + {@code basicNack} is what a container does underneath, and doing
|
||||
* it by hand removes all the timing from the test.
|
||||
*
|
||||
* @see <a href="../../../../../docs/04-dead-lettering.md">docs/04-dead-lettering.md</a>
|
||||
*/
|
||||
@SpringBootTest
|
||||
class DeadLetterTest {
|
||||
|
||||
@Autowired
|
||||
RabbitTemplate template;
|
||||
|
||||
@Autowired
|
||||
RabbitAdmin admin;
|
||||
|
||||
@BeforeEach
|
||||
void drain() {
|
||||
List.of(Topology.Q_WORK, Topology.Q_TTL, Topology.Q_DLQ)
|
||||
.forEach((q) -> this.admin.purgeQueue(q, false));
|
||||
}
|
||||
|
||||
private int depth(String queue) {
|
||||
Properties properties = this.admin.getQueueProperties(queue);
|
||||
return (properties == null) ? -1
|
||||
: ((Number) properties.get(RabbitAdmin.QUEUE_MESSAGE_COUNT)).intValue();
|
||||
}
|
||||
|
||||
private int settled(String queue, int expected) throws InterruptedException {
|
||||
for (int i = 0; i < 150; i++) {
|
||||
if (depth(queue) == expected) {
|
||||
return expected;
|
||||
}
|
||||
Thread.sleep(20);
|
||||
}
|
||||
return depth(queue);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nackWithoutRequeueDeadLettersAndStampsTheReason() throws Exception {
|
||||
this.template.convertAndSend(Topology.DIRECT, "ignored", OrderMessage.of("o-1"),
|
||||
(m) -> m, null);
|
||||
// Publish straight to the work queue via the default exchange: the empty exchange name
|
||||
// routes by queue name, which is the one piece of AMQP that behaves like a magic
|
||||
// constant and is worth knowing.
|
||||
this.template.convertAndSend("", Topology.Q_WORK, OrderMessage.of("o-1"));
|
||||
assertThat(settled(Topology.Q_WORK, 1)).isEqualTo(1);
|
||||
|
||||
this.template.execute((channel) -> {
|
||||
GetResponse response = channel.basicGet(Topology.Q_WORK, false);
|
||||
assertThat(response).isNotNull();
|
||||
// requeue=false is the entire dead-letter trigger. There is no separate "send to
|
||||
// DLQ" call in AMQP.
|
||||
channel.basicNack(response.getEnvelope().getDeliveryTag(), false, false);
|
||||
return null;
|
||||
});
|
||||
|
||||
assertThat(settled(Topology.Q_DLQ, 1)).isEqualTo(1);
|
||||
assertThat(depth(Topology.Q_WORK)).isZero();
|
||||
|
||||
Map<String, Object> death = firstDeath(Topology.Q_DLQ);
|
||||
System.out.println("=== x-death after basicNack(requeue=false) ===");
|
||||
death.forEach((k, v) -> System.out.printf(" %-16s %s%n", k, v));
|
||||
assertThat(death).containsEntry("reason", "rejected");
|
||||
assertThat(death).containsEntry("queue", Topology.Q_WORK);
|
||||
assertThat(((Number) death.get("count")).intValue()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void messageTtlExpiryAlsoDeadLettersButWithADifferentReason() throws Exception {
|
||||
this.template.convertAndSend("", Topology.Q_TTL, OrderMessage.of("o-ttl"));
|
||||
// orders.ttl carries x-message-ttl=1500. Nobody consumes it; the broker expires it.
|
||||
assertThat(settled(Topology.Q_DLQ, 1)).isEqualTo(1);
|
||||
|
||||
Map<String, Object> death = firstDeath(Topology.Q_DLQ);
|
||||
System.out.println("=== x-death after x-message-ttl expiry ===");
|
||||
death.forEach((k, v) -> System.out.printf(" %-16s %s%n", k, v));
|
||||
// Same destination, different reason. This is the field that tells an operator whether
|
||||
// the consumer rejected the work or never got to it.
|
||||
assertThat(death).containsEntry("reason", "expired");
|
||||
assertThat(death).containsEntry("queue", Topology.Q_TTL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nackWithRequeueIsAnInfiniteLoopAndNeverDeadLetters() throws Exception {
|
||||
this.template.convertAndSend("", Topology.Q_WORK, OrderMessage.of("o-loop"));
|
||||
assertThat(settled(Topology.Q_WORK, 1)).isEqualTo(1);
|
||||
|
||||
int redeliveries = this.template.execute((channel) -> {
|
||||
int count = 0;
|
||||
for (int i = 0; i < 200; i++) {
|
||||
GetResponse response = channel.basicGet(Topology.Q_WORK, false);
|
||||
if (response == null) {
|
||||
break;
|
||||
}
|
||||
if (response.getEnvelope().isRedeliver()) {
|
||||
count++;
|
||||
}
|
||||
// requeue=true puts it back. The dead-letter exchange is never consulted,
|
||||
// x-death is never written, and the loop has no counter to exhaust.
|
||||
channel.basicNack(response.getEnvelope().getDeliveryTag(), false, true);
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
System.out.println("=== basicNack(requeue=true), 200 attempts ===");
|
||||
System.out.println(" redelivered " + redeliveries + " times");
|
||||
System.out.println(" dead-lettered " + depth(Topology.Q_DLQ));
|
||||
System.out.println(" still on queue " + depth(Topology.Q_WORK));
|
||||
|
||||
assertThat(redeliveries).isGreaterThan(150);
|
||||
// The message is exactly where it started, having been processed 200 times.
|
||||
assertThat(depth(Topology.Q_DLQ)).isZero();
|
||||
assertThat(settled(Topology.Q_WORK, 1)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> firstDeath(String queue) {
|
||||
var message = this.template.receive(queue, 5000);
|
||||
assertThat(message).isNotNull();
|
||||
List<Map<String, Object>> deaths =
|
||||
(List<Map<String, Object>>) message.getMessageProperties().getHeader("x-death");
|
||||
assertThat(deaths).isNotNull().isNotEmpty();
|
||||
return deaths.get(0);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ankurm.rabbit;
|
||||
|
||||
import com.rabbitmq.client.GetResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.amqp.core.Binding;
|
||||
import org.springframework.amqp.core.BindingBuilder;
|
||||
import org.springframework.amqp.core.DirectExchange;
|
||||
import org.springframework.amqp.core.Queue;
|
||||
import org.springframework.amqp.core.QueueBuilder;
|
||||
import org.springframework.amqp.rabbit.core.RabbitAdmin;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The third dead-letter trigger: a queue that is full.
|
||||
*
|
||||
* <p>Included because the article states all three triggers in one table, and this was the only
|
||||
* row not produced by a run. It is now.
|
||||
*
|
||||
* @see <a href="../../../../../docs/04-dead-lettering.md">docs/04-dead-lettering.md</a>
|
||||
*/
|
||||
@SpringBootTest
|
||||
class MaxLengthTest {
|
||||
|
||||
static final String Q_BOUNDED = "orders.bounded";
|
||||
|
||||
@Autowired
|
||||
RabbitTemplate template;
|
||||
|
||||
@Autowired
|
||||
RabbitAdmin admin;
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void exceedingMaxLengthDeadLettersTheOldestWithReasonMaxlen() throws Exception {
|
||||
this.admin.deleteQueue(Q_BOUNDED);
|
||||
Queue bounded = QueueBuilder.durable(Q_BOUNDED)
|
||||
.maxLength(2)
|
||||
.deadLetterExchange(Topology.DLX)
|
||||
.deadLetterRoutingKey("failed")
|
||||
.build();
|
||||
this.admin.declareQueue(bounded);
|
||||
Binding binding = BindingBuilder.bind(bounded)
|
||||
.to(new DirectExchange(Topology.DIRECT)).with("bounded");
|
||||
this.admin.declareBinding(binding);
|
||||
this.admin.purgeQueue(Topology.Q_DLQ, false);
|
||||
|
||||
// Three messages into a queue that holds two. RabbitMQ drops from the HEAD, so the
|
||||
// FIRST message is the one dead-lettered - the oldest, not the newest.
|
||||
for (String id : List.of("m-1", "m-2", "m-3")) {
|
||||
this.template.convertAndSend(Topology.DIRECT, "bounded", OrderMessage.of(id));
|
||||
}
|
||||
|
||||
var message = this.template.receive(Topology.Q_DLQ, 10_000);
|
||||
assertThat(message).isNotNull();
|
||||
List<Map<String, Object>> deaths =
|
||||
(List<Map<String, Object>>) message.getMessageProperties().getHeader("x-death");
|
||||
System.out.println("=== x-death after x-max-length overflow ===");
|
||||
deaths.get(0).forEach((k, v) -> System.out.printf(" %-16s %s%n", k, v));
|
||||
System.out.println(" body " + new String(message.getBody()));
|
||||
|
||||
assertThat(deaths.get(0)).containsEntry("reason", "maxlen");
|
||||
assertThat(deaths.get(0)).containsEntry("queue", Q_BOUNDED);
|
||||
assertThat(new String(message.getBody())).contains("m-1");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.ankurm.rabbit;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.amqp.core.MessageProperties;
|
||||
import org.springframework.amqp.rabbit.core.RabbitAdmin;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* All four exchange types, driven with real publishes and counted by draining the queues.
|
||||
*
|
||||
* @see <a href="../../../../../docs/02-exchanges.md">docs/02-exchanges.md</a>
|
||||
*/
|
||||
@SpringBootTest
|
||||
class RoutingTest {
|
||||
|
||||
@Autowired
|
||||
RabbitTemplate template;
|
||||
|
||||
@Autowired
|
||||
RabbitAdmin admin;
|
||||
|
||||
private static final List<String> QUEUES = List.of(Topology.Q_NEW, Topology.Q_CANCEL,
|
||||
Topology.Q_AUDIT, Topology.Q_ANALYTICS, Topology.Q_EU, Topology.Q_HIGH,
|
||||
Topology.Q_PRIORITY, Topology.Q_ANY);
|
||||
|
||||
@BeforeEach
|
||||
void drain() {
|
||||
QUEUES.forEach((q) -> this.admin.purgeQueue(q, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready message count, straight from the broker.
|
||||
*
|
||||
* <p>Note the {@code Number}: {@code RabbitAdmin.QUEUE_MESSAGE_COUNT} holds a {@code Long}
|
||||
* in Spring AMQP 4.1. Casting it to {@code Integer}, as every older example does, is a
|
||||
* {@code ClassCastException} at runtime.
|
||||
*/
|
||||
private int depth(String queue) {
|
||||
Properties properties = this.admin.getQueueProperties(queue);
|
||||
return (properties == null) ? -1
|
||||
: ((Number) properties.get(RabbitAdmin.QUEUE_MESSAGE_COUNT)).intValue();
|
||||
}
|
||||
|
||||
/** Publishing is asynchronous; give the broker a moment to route before counting. */
|
||||
private int settledDepth(String queue) {
|
||||
int last = -1;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
last = depth(queue);
|
||||
if (last > 0) {
|
||||
return last;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(20);
|
||||
}
|
||||
catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
@Test
|
||||
void directExchangeMatchesTheRoutingKeyExactly() {
|
||||
this.template.convertAndSend(Topology.DIRECT, "new", OrderMessage.of("o-1"));
|
||||
this.template.convertAndSend(Topology.DIRECT, "cancel", OrderMessage.of("o-2"));
|
||||
// No binding for "amend". The broker discards it - see UnroutableTest.
|
||||
this.template.convertAndSend(Topology.DIRECT, "amend", OrderMessage.of("o-3"));
|
||||
|
||||
assertThat(settledDepth(Topology.Q_NEW)).isEqualTo(1);
|
||||
assertThat(settledDepth(Topology.Q_CANCEL)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fanoutIgnoresTheRoutingKeyCompletely() {
|
||||
this.template.convertAndSend(Topology.FANOUT, "this-is-ignored", OrderMessage.of("o-1"));
|
||||
|
||||
assertThat(settledDepth(Topology.Q_AUDIT)).isEqualTo(1);
|
||||
assertThat(settledDepth(Topology.Q_ANALYTICS)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void topicWildcardsAreWordsNotCharacters() {
|
||||
// binding "order.eu.*" - exactly one word after order.eu
|
||||
// binding "order.#.high" - zero or more words between order. and .high
|
||||
Map<String, String> routingKeys = new LinkedHashMap<>();
|
||||
routingKeys.put("order.eu.high", "matches both");
|
||||
routingKeys.put("order.eu.low", "matches order.eu.* only");
|
||||
routingKeys.put("order.us.high", "matches order.#.high only");
|
||||
routingKeys.put("order.eu.west.high", "matches order.#.high only - * is one word");
|
||||
routingKeys.put("order.high", "matches order.#.high - # can be zero words");
|
||||
|
||||
System.out.println("=== topic exchange ===");
|
||||
System.out.printf("%-24s %-12s %-12s %s%n", "routing key", "orders.eu", "orders.high", "note");
|
||||
for (Map.Entry<String, String> entry : routingKeys.entrySet()) {
|
||||
this.admin.purgeQueue(Topology.Q_EU, false);
|
||||
this.admin.purgeQueue(Topology.Q_HIGH, false);
|
||||
this.template.convertAndSend(Topology.TOPIC, entry.getKey(), OrderMessage.of("o"));
|
||||
System.out.printf("%-24s %-12s %-12s %s%n", entry.getKey(), settledDepth(Topology.Q_EU) == 1,
|
||||
settledDepth(Topology.Q_HIGH) == 1, entry.getValue());
|
||||
}
|
||||
|
||||
this.admin.purgeQueue(Topology.Q_EU, false);
|
||||
this.admin.purgeQueue(Topology.Q_HIGH, false);
|
||||
// The one everybody gets wrong: '*' is one WORD, so it does not match two.
|
||||
this.template.convertAndSend(Topology.TOPIC, "order.eu.west.high", OrderMessage.of("o"));
|
||||
assertThat(depth(Topology.Q_EU)).isEqualTo(0);
|
||||
assertThat(settledDepth(Topology.Q_HIGH)).isEqualTo(1);
|
||||
|
||||
this.admin.purgeQueue(Topology.Q_HIGH, false);
|
||||
// And '#' really does match zero words.
|
||||
this.template.convertAndSend(Topology.TOPIC, "order.high", OrderMessage.of("o"));
|
||||
assertThat(settledDepth(Topology.Q_HIGH)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void headersExchangeMatchesTheHeaderMapNotTheRoutingKey() {
|
||||
send(Map.of("priority", "high", "region", "eu"));
|
||||
assertThat(settledDepth(Topology.Q_PRIORITY)).isEqualTo(1); // x-match=all: both present
|
||||
assertThat(settledDepth(Topology.Q_ANY)).isEqualTo(1); // x-match=any: either is enough
|
||||
|
||||
drain();
|
||||
send(Map.of("priority", "high"));
|
||||
assertThat(depth(Topology.Q_PRIORITY)).isEqualTo(0); // all: region missing
|
||||
assertThat(settledDepth(Topology.Q_ANY)).isEqualTo(1);
|
||||
|
||||
drain();
|
||||
send(Map.of("priority", "high", "region", "us"));
|
||||
// x-match=all needs every header to match by VALUE, not merely to be present.
|
||||
assertThat(depth(Topology.Q_PRIORITY)).isEqualTo(0);
|
||||
assertThat(settledDepth(Topology.Q_ANY)).isEqualTo(1);
|
||||
}
|
||||
|
||||
private void send(Map<String, Object> headers) {
|
||||
this.template.convertAndSend(Topology.HEADERS, "routing-key-is-ignored",
|
||||
OrderMessage.of("o-1"), (message) -> {
|
||||
MessageProperties properties = message.getMessageProperties();
|
||||
headers.forEach(properties::setHeader);
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ankurm.rabbit;
|
||||
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.testcontainers.rabbitmq.RabbitMQContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
/**
|
||||
* The Testcontainers route, for machines that have a Docker daemon.
|
||||
*
|
||||
* <p>{@code @ServiceConnection} supplies host, port, username and password to the
|
||||
* auto-configuration, so no {@code spring.rabbitmq.*} property and no
|
||||
* {@code @DynamicPropertySource} block is needed.
|
||||
*
|
||||
* <p>The Maven coordinate is <b>{@code org.testcontainers:testcontainers-rabbitmq}</b>.
|
||||
* Testcontainers 2.x prefixed every module artifact; the old {@code org.testcontainers:rabbitmq}
|
||||
* stopped at 1.21.4 and is not managed by the Boot 4.1 BOM.
|
||||
*
|
||||
* <p>The committed transcripts under {@code docs/output/} came from a broker started by
|
||||
* {@code scripts/broker.sh} instead, on a machine with no Docker — see the module README
|
||||
* for why that matters and what version it was.
|
||||
*/
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
public class TestcontainersConfiguration {
|
||||
|
||||
@Bean
|
||||
@ServiceConnection
|
||||
RabbitMQContainer rabbitContainer() {
|
||||
return new RabbitMQContainer(DockerImageName.parse("rabbitmq:4.1-management"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.ankurm.rabbit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.amqp.AmqpIOException;
|
||||
import org.springframework.amqp.core.Queue;
|
||||
import org.springframework.amqp.core.QueueBuilder;
|
||||
import org.springframework.amqp.core.ReturnedMessage;
|
||||
import org.springframework.amqp.rabbit.core.RabbitAdmin;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
/**
|
||||
* Two ways a RabbitMQ topology loses your work quietly.
|
||||
*
|
||||
* @see <a href="../../../../../docs/03-the-silent-drop.md">docs/03-the-silent-drop.md</a>
|
||||
* @see <a href="../../../../../docs/06-changing-your-mind.md">docs/06-changing-your-mind.md</a>
|
||||
*/
|
||||
@SpringBootTest
|
||||
class TopologyTrapsTest {
|
||||
|
||||
@Autowired
|
||||
RabbitTemplate template;
|
||||
|
||||
@Autowired
|
||||
RabbitAdmin admin;
|
||||
|
||||
@Test
|
||||
void anUnroutableMessageIsDiscardedUnlessYouAskForItBack() throws Exception {
|
||||
List<ReturnedMessage> returned = new CopyOnWriteArrayList<>();
|
||||
this.template.setReturnsCallback(returned::add);
|
||||
|
||||
// "amend" matches no binding on orders.direct. The broker has nowhere to put it.
|
||||
this.template.convertAndSend(Topology.DIRECT, "amend", OrderMessage.of("o-1"));
|
||||
for (int i = 0; i < 100 && returned.isEmpty(); i++) {
|
||||
Thread.sleep(20);
|
||||
}
|
||||
|
||||
// spring.rabbitmq.template.mandatory=true is what turns a silent discard into a return.
|
||||
assertThat(this.template.isMandatoryFor(null)).isTrue();
|
||||
assertThat(returned).hasSize(1);
|
||||
ReturnedMessage message = returned.get(0);
|
||||
System.out.println("=== returned message ===");
|
||||
System.out.println(" replyCode " + message.getReplyCode());
|
||||
System.out.println(" replyText " + message.getReplyText());
|
||||
System.out.println(" exchange " + message.getExchange());
|
||||
System.out.println(" routingKey " + message.getRoutingKey());
|
||||
|
||||
assertThat(message.getReplyCode()).isEqualTo(312);
|
||||
assertThat(message.getReplyText()).isEqualTo("NO_ROUTE");
|
||||
assertThat(message.getRoutingKey()).isEqualTo("amend");
|
||||
// Note what did NOT happen: convertAndSend returned normally. Publishing is fire and
|
||||
// forget at the protocol level, so even with mandatory=true the failure arrives
|
||||
// asynchronously on another thread. Nothing throws.
|
||||
}
|
||||
|
||||
@Test
|
||||
void redeclaringAQueueWithDifferentArgumentsIsAPreconditionFailure() {
|
||||
// orders.ttl already exists with x-message-ttl=1500. Same name, different argument.
|
||||
Queue conflicting = QueueBuilder.durable(Topology.Q_TTL)
|
||||
.ttl(9999)
|
||||
.deadLetterExchange(Topology.DLX)
|
||||
.deadLetterRoutingKey("failed")
|
||||
.build();
|
||||
|
||||
assertThatExceptionOfType(AmqpIOException.class)
|
||||
.isThrownBy(() -> this.admin.declareQueue(conflicting))
|
||||
.satisfies((ex) -> {
|
||||
String detail = rootMessage(ex);
|
||||
System.out.println("=== redeclaring orders.ttl with x-message-ttl=9999 ===");
|
||||
System.out.println(" " + detail);
|
||||
assertThat(detail).contains("PRECONDITION_FAILED")
|
||||
.contains("inequivalent arg 'x-message-ttl'");
|
||||
});
|
||||
|
||||
// Queue arguments are immutable. There is no ALTER QUEUE. Changing a TTL, a max-length
|
||||
// or a dead-letter exchange on an existing queue means: declare a new queue, move the
|
||||
// consumers, drain the old one, delete it. Plan the rename into the change.
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable throwable) {
|
||||
// The useful text is on the cause. AmqpIOException's own message is just
|
||||
// "java.io.IOException", which is why this failure is so often reported as "IOException"
|
||||
// with no further detail.
|
||||
Throwable current = throwable;
|
||||
StringBuilder all = new StringBuilder();
|
||||
while (current != null) {
|
||||
all.append(current.getMessage()).append(" | ");
|
||||
current = current.getCause();
|
||||
}
|
||||
return all.toString();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user