GH-2453: RMQ: Full Support for Alternate Exchange

Resolves https://github.com/spring-cloud/spring-cloud-stream/issues/2453

Previously, to configure the use of an alternative exchange (used to route
messages when no queue is bound), the user had to manually declare the
exchange and any bindings, and modify the destination exchange using a
`DeclarablesCustomizer` bean.

Add first class support to configure the destination exchange and, optionally,
provision the alternate exchange as well as optionally binding a specific
queue to it.

Resolves #2502
This commit is contained in:
Gary Russell
2022-08-31 13:17:29 -04:00
committed by Oleg Zhurakousky
parent 9a18600413
commit 4fda96507c
7 changed files with 310 additions and 15 deletions

View File

@@ -53,7 +53,7 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>true</skipTests>
<skipTests>false</skipTests>
</configuration>
</plugin>
<plugin>

View File

@@ -20,9 +20,11 @@ import java.util.Optional;
import jakarta.validation.constraints.Min;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.core.MessageDeliveryMode;
import org.springframework.expression.Expression;
import org.springframework.expression.common.LiteralExpression;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -149,6 +151,12 @@ public class RabbitProducerProperties extends RabbitCommonProperties {
*/
private String streamMessageConverterBeanName;
/**
* Configure an alternate exchange for when no queues are bound.
* @since 4.0
*/
private AlternateExchange alternateExchange;
/**
* @param requestHeaderPatterns the patterns.
* @deprecated - use {@link #setHeaderPatterns(String[])}.
@@ -302,4 +310,100 @@ public class RabbitProducerProperties extends RabbitCommonProperties {
this.streamMessageConverterBeanName = streamMessageConverterBeanName;
}
@Nullable
public AlternateExchange getAlternateExchange() {
return this.alternateExchange;
}
public void setAlternateExchange(AlternateExchange alternate) {
this.alternateExchange = alternate;
}
public static class AlternateExchange {
/**
* The alternate exchange name.
*/
private String name;
/**
* Whether the exchange exists or should be provisioned.
*/
private boolean exists = false;
/**
* The alternate exchange type.
*/
private String type = ExchangeTypes.TOPIC;
/**
* Bind a durable queue to the alternate exchange.
*/
private Binding binding;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public boolean isExists() {
return this.exists;
}
public void setExists(boolean exists) {
this.exists = exists;
}
public String getType() {
return this.type;
}
public void setType(String type) {
this.type = type;
}
public Binding getBinding() {
return this.binding;
}
public void setBinding(Binding binding) {
this.binding = binding;
}
public static class Binding {
/**
* The routing key.
*/
private String routingKey = "#";
/**
* The queue name.
*/
private String queue;
public String getRoutingKey() {
return this.routingKey;
}
public void setRoutingKey(String routingKey) {
this.routingKey = routingKey;
}
public String getQueue() {
return this.queue;
}
public void setQueue(String queue) {
this.queue = queue;
}
}
}
}

View File

@@ -56,6 +56,7 @@ import org.springframework.cloud.stream.binder.rabbit.properties.RabbitCommonPro
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitConsumerProperties;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitConsumerProperties.ContainerType;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitProducerProperties;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitProducerProperties.AlternateExchange;
import org.springframework.cloud.stream.provisioning.ConsumerDestination;
import org.springframework.cloud.stream.provisioning.ProducerDestination;
import org.springframework.cloud.stream.provisioning.ProvisioningException;
@@ -119,9 +120,9 @@ public class RabbitExchangeQueueProvisioner
ExtendedProducerProperties<RabbitProducerProperties> producerProperties) {
final String exchangeName = applyPrefix(
producerProperties.getExtension().getPrefix(), name);
Exchange exchange = buildExchange(producerProperties.getExtension(),
exchangeName);
String beanNameQualifier = "prod" + this.producerExchangeBeanNameQualifier.incrementAndGet();
Exchange exchange = buildExchange(producerProperties.getExtension(),
exchangeName, producerProperties.getExtension().getAlternateExchange(), beanNameQualifier);
if (producerProperties.getExtension().isDeclareExchange()) {
declareExchange(exchangeName, beanNameQualifier, exchange);
}
@@ -245,7 +246,7 @@ public class RabbitExchangeQueueProvisioner
}
String prefix = properties.getExtension().getPrefix();
final String exchangeName = applyPrefix(prefix, name);
Exchange exchange = buildExchange(properties.getExtension(), exchangeName);
Exchange exchange = buildExchange(properties.getExtension(), exchangeName, null, null);
if (properties.getExtension().isDeclareExchange()) {
declareExchange(exchangeName, anonymous ? anonymousGroup : group, exchange);
}
@@ -364,25 +365,31 @@ public class RabbitExchangeQueueProvisioner
routingKey = "#";
}
Map<String, Object> arguments = new HashMap<>(extendedProperties.getQueueBindingArguments());
return createBinding(exchange, queue, routingKey, arguments, queue.getName());
}
private Binding createBinding(Exchange exchange, Queue queue, String routingKey,
@Nullable Map<String, Object> arguments, String beanName) {
if (exchange instanceof TopicExchange) {
Binding binding = BindingBuilder.bind(queue).to((TopicExchange) exchange)
.with(routingKey);
declareBinding(queue.getName(), binding);
declareBinding(beanName, binding);
return binding;
}
else if (exchange instanceof DirectExchange) {
Binding binding = BindingBuilder.bind(queue).to((DirectExchange) exchange)
.with(routingKey);
declareBinding(queue.getName(), binding);
declareBinding(beanName, binding);
return binding;
}
else if (exchange instanceof FanoutExchange) {
Binding binding = BindingBuilder.bind(queue).to((FanoutExchange) exchange);
declareBinding(queue.getName(), binding);
declareBinding(beanName, binding);
return binding;
}
else if (exchange instanceof HeadersExchange) {
Binding binding = new Binding(queue.getName(), DestinationType.QUEUE, exchange.getName(), "", arguments);
Binding binding = new Binding(beanName, DestinationType.QUEUE, exchange.getName(), "", arguments);
declareBinding(queue.getName(), binding);
return binding;
}
@@ -587,8 +594,9 @@ public class RabbitExchangeQueueProvisioner
return prefix + name;
}
private Exchange buildExchange(RabbitCommonProperties properties,
String exchangeName) {
private Exchange buildExchange(RabbitCommonProperties properties, String exchangeName,
@Nullable AlternateExchange alternate, @Nullable String beanNameQualifier) {
try {
ExchangeBuilder builder = new ExchangeBuilder(exchangeName,
properties.getExchangeType());
@@ -599,6 +607,10 @@ public class RabbitExchangeQueueProvisioner
if (properties.isDelayedExchange()) {
builder.delayed();
}
if (alternate != null && !alternate.isExists()) {
builder.alternate(alternate.getName());
configureAlternate(alternate, beanNameQualifier);
}
return builder.build();
}
catch (Exception e) {
@@ -606,8 +618,26 @@ public class RabbitExchangeQueueProvisioner
}
}
private void configureAlternate(AlternateExchange alternate, String beanNameQualifier) {
Exchange exchange = customizeAndDeclare(new ExchangeBuilder(alternate.getName(), alternate.getType())
.durable(true)
.build());
addToAutoDeclareContext(alternate.getName() + "." + beanNameQualifier + ".exchange", exchange);
AlternateExchange.Binding binding = alternate.getBinding();
if (binding != null) {
Queue queue = new Queue(binding.getQueue());
String beanName = alternate.getName() + "." + binding.getQueue() + "." + beanNameQualifier;
declareQueue(beanName, queue);
Binding toBind = createBinding(exchange, queue, binding.getRoutingKey(), null, beanName);
}
}
private void declareExchange(final String rootName, String group, final Exchange exchangeArg) {
Exchange exchange = exchangeArg;
Exchange exchange = customizeAndDeclare(exchangeArg);
addToAutoDeclareContext(rootName + "." + group + ".exchange", exchange);
}
private Exchange customizeAndDeclare(Exchange exchange) {
for (DeclarableCustomizer customizer : this.customizers) {
exchange = (Exchange) customizer.apply(exchange);
}
@@ -631,7 +661,7 @@ public class RabbitExchangeQueueProvisioner
e);
}
}
addToAutoDeclareContext(rootName + "." + group + ".exchange", exchange);
return exchange;
}
private void addToAutoDeclareContext(String name, Declarable bean) {
@@ -719,6 +749,15 @@ public class RabbitExchangeQueueProvisioner
}
}
}
AlternateExchange alternate = properties.getExtension().getAlternateExchange();
if (alternate != null) {
removeSingleton(alternate.getName() + "." + qual + ".exchange");
RabbitProducerProperties.AlternateExchange.Binding binding = alternate.getBinding();
if (binding != null) {
removeSingleton(alternate.getName() + "." + binding.getQueue() + "." + qual);
removeSingleton(alternate.getName() + "." + binding.getQueue() + "." + qual + ".binding");
}
}
}
}
}

View File

@@ -17,6 +17,7 @@
package org.springframework.cloud.stream.binder.rabbit.provisioning;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
import com.rabbitmq.client.Channel;
@@ -24,6 +25,10 @@ import com.rabbitmq.client.impl.AMQImpl.Queue.DeclareOk;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.core.Declarables;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Exchange;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.Connection;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.utils.test.TestUtils;
@@ -31,6 +36,8 @@ import org.springframework.cloud.stream.binder.ExtendedConsumerProperties;
import org.springframework.cloud.stream.binder.ExtendedProducerProperties;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitConsumerProperties;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitProducerProperties;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitProducerProperties.AlternateExchange;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitProducerProperties.AlternateExchange.Binding;
import org.springframework.cloud.stream.provisioning.ConsumerDestination;
import org.springframework.cloud.stream.provisioning.ProducerDestination;
import org.springframework.context.ApplicationContext;
@@ -126,6 +133,89 @@ public class RabbitExchangeQueueProvisionerTests {
assertThat(ctx.getBeansOfType(Declarables.class)).isEmpty();
}
@Test
void producerDeclarationsWithAlternateNoQueue() throws IOException {
ConnectionFactory cf = mock(ConnectionFactory.class);
Connection conn = mock(Connection.class);
given(cf.createConnection()).willReturn(conn);
Channel channel = mock(Channel.class);
given(conn.createChannel(anyBoolean())).willReturn(channel);
RabbitExchangeQueueProvisioner provisioner = new RabbitExchangeQueueProvisioner(cf);
RabbitProducerProperties props = new RabbitProducerProperties();
AlternateExchange alternate = new AlternateExchange();
alternate.setName("altEx");
props.setAlternateExchange(alternate);
ExtendedProducerProperties<RabbitProducerProperties> properties =
new ExtendedProducerProperties<RabbitProducerProperties>(props);
ProducerDestination dest = provisioner.provisionProducerDestination("withAlt", properties);
ApplicationContext ctx =
TestUtils.getPropertyValue(provisioner, "autoDeclareContext", ApplicationContext.class);
Map<String, Declarables> declarables = ctx.getBeansOfType(Declarables.class);
assertThat(declarables).hasSize(2);
String qual = TestUtils.getPropertyValue(dest, "beanNameQualifier", String.class);
Declarables mainEx = declarables.get("withAlt." + qual + ".exchange");
assertThat(mainEx).isNotNull();
Exchange exch = (Exchange) mainEx.getDeclarables().iterator().next();
assertThat(exch.getArguments().get("alternate-exchange")).isEqualTo("altEx");
Declarables altEx = declarables.get("altEx." + qual + ".exchange");
assertThat(altEx).isNotNull();
exch = (Exchange) altEx.getDeclarables().iterator().next();
assertThat(exch).isInstanceOf(TopicExchange.class);
provisioner.cleanAutoDeclareContext(dest, properties);
assertThat(ctx.getBeansOfType(Declarables.class)).isEmpty();
}
@Test
void producerDeclarationsWithAlternateWithQueue() throws IOException {
ConnectionFactory cf = mock(ConnectionFactory.class);
Connection conn = mock(Connection.class);
given(cf.createConnection()).willReturn(conn);
Channel channel = mock(Channel.class);
willReturn(new DeclareOk("x", 0, 0))
.given(channel).queueDeclare(any(), eq(Boolean.TRUE), eq(Boolean.FALSE), eq(Boolean.FALSE), any());
given(conn.createChannel(anyBoolean())).willReturn(channel);
RabbitExchangeQueueProvisioner provisioner = new RabbitExchangeQueueProvisioner(cf);
RabbitProducerProperties props = new RabbitProducerProperties();
AlternateExchange alternate = new AlternateExchange();
alternate.setName("altEx");
alternate.setType(ExchangeTypes.DIRECT);
Binding binding = new Binding();
binding.setQueue("altQ");
binding.setRoutingKey("altRK");
alternate.setBinding(binding);
props.setAlternateExchange(alternate);
ExtendedProducerProperties<RabbitProducerProperties> properties =
new ExtendedProducerProperties<RabbitProducerProperties>(props);
ProducerDestination dest = provisioner.provisionProducerDestination("withAlt", properties);
ApplicationContext ctx =
TestUtils.getPropertyValue(provisioner, "autoDeclareContext", ApplicationContext.class);
Map<String, Declarables> declarables = ctx.getBeansOfType(Declarables.class);
assertThat(declarables).hasSize(4);
String qual = TestUtils.getPropertyValue(dest, "beanNameQualifier", String.class);
Declarables mainEx = declarables.get("withAlt." + qual + ".exchange");
assertThat(mainEx).isNotNull();
Exchange exch = (Exchange) mainEx.getDeclarables().iterator().next();
assertThat(exch).isInstanceOf(TopicExchange.class);
assertThat(exch.getArguments().get("alternate-exchange")).isEqualTo("altEx");
Declarables altEx = declarables.get("altEx." + qual + ".exchange");
assertThat(altEx).isNotNull();
exch = (Exchange) altEx.getDeclarables().iterator().next();
assertThat(exch).isInstanceOf(DirectExchange.class);
Declarables queueDec = declarables.get("altEx.altQ." + qual);
assertThat(queueDec).isNotNull();
Declarables bindingDec = declarables.get("altEx.altQ." + qual + ".binding");
assertThat(bindingDec).isNotNull();
org.springframework.amqp.core.Binding bdg = (org.springframework.amqp.core.Binding) bindingDec.getDeclarables()
.iterator().next();
assertThat(bdg.getExchange()).isEqualTo("altEx");
assertThat(bdg.getDestination()).isEqualTo("altQ");
assertThat(bdg.getRoutingKey()).isEqualTo("altRK");
provisioner.cleanAutoDeclareContext(dest, properties);
assertThat(ctx.getBeansOfType(Declarables.class)).isEmpty();
}
@Test
void producerDeclarationsWithGroupsAndDlq() throws IOException {
ConnectionFactory cf = mock(ConnectionFactory.class);

View File

@@ -104,6 +104,7 @@ import org.springframework.cloud.stream.binder.rabbit.properties.RabbitCommonPro
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitConsumerProperties;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitConsumerProperties.ContainerType;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitProducerProperties;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitProducerProperties.AlternateExchange;
import org.springframework.cloud.stream.binder.rabbit.provisioning.RabbitExchangeQueueProvisioner;
import org.springframework.cloud.stream.binder.test.junit.rabbit.RabbitTestSupport;
import org.springframework.cloud.stream.config.BindingProperties;
@@ -388,6 +389,35 @@ public class RabbitBinderTests extends
verifyAutoDeclareContextClear(binder);
}
@Test
void producerWithAlternateExchange(TestInfo testInfo) throws Exception {
RabbitTestBinder binder = getBinder();
DirectChannel moduleOutputChannel = createBindableChannel("output",
new BindingProperties());
ExtendedProducerProperties<RabbitProducerProperties> producerProps = createProducerProperties(testInfo);
AlternateExchange alternate = new AlternateExchange();
alternate.setName("altEx");
RabbitProducerProperties.AlternateExchange.Binding binding =
new RabbitProducerProperties.AlternateExchange.Binding();
binding.setQueue("altQ");
alternate.setBinding(binding);
producerProps.getExtension().setAlternateExchange(alternate);
Binding<MessageChannel> producerBinding = binder.bindProducer("alt.0",
moduleOutputChannel, producerProps);
final Message<?> message = MessageBuilder.withPayload("altMessage".getBytes())
.build();
moduleOutputChannel.send(message);
producerBinding.unbind();
verifyAutoDeclareContextClear(binder);
RabbitTemplate template = new RabbitTemplate(this.rabbitTestSupport.getResource());
Object received = template.receiveAndConvert("altQ", 10_000);
assertThat(received).isEqualTo("altMessage".getBytes());
RabbitAdmin admin = new RabbitAdmin(template.getConnectionFactory());
admin.deleteQueue("altQ");
admin.deleteExchange("altEx");
}
@Test
public void testConsumerProperties() throws Exception {
RabbitTestBinder binder = getBinder();

View File

@@ -62,6 +62,7 @@ import org.springframework.cloud.stream.binder.rabbit.RabbitMessageChannelBinder
import org.springframework.cloud.stream.binder.rabbit.RabbitTestContainer;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitConsumerProperties;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitProducerProperties;
import org.springframework.cloud.stream.binder.rabbit.properties.RabbitProducerProperties.AlternateExchange;
import org.springframework.cloud.stream.binder.test.junit.rabbit.RabbitTestSupport;
import org.springframework.cloud.stream.binding.BindingService;
import org.springframework.cloud.stream.config.ConsumerEndpointCustomizer;
@@ -100,7 +101,7 @@ public class RabbitBinderModuleTests {
private static final ConnectionFactory MOCK_CONNECTION_FACTORY = mock(ConnectionFactory.class, Mockito.RETURNS_MOCKS);
@RegisterExtension
private RabbitTestSupport rabbitTestSupport = new RabbitTestSupport(true, RABBITMQ.getAmqpPort(), RABBITMQ.getHttpPort());
private final RabbitTestSupport rabbitTestSupport = new RabbitTestSupport(true, RABBITMQ.getAmqpPort(), RABBITMQ.getHttpPort());
private ConfigurableApplicationContext context;
@@ -365,7 +366,12 @@ public class RabbitBinderModuleTests {
"--spring.cloud.stream.rabbit.default.consumer.exchange-type=direct",
"--spring.cloud.stream.rabbit.bindings.process-out-0.producer.batch-size=512",
"--spring.cloud.stream.rabbit.default.consumer.max-concurrency=4",
"--spring.cloud.stream.rabbit.bindings.process-in-0.consumer.exchange-type=fanout");
"--spring.cloud.stream.rabbit.bindings.process-in-0.consumer.exchange-type=fanout",
"--spring.cloud.stream.rabbit.bindings.process-out-0.producer.alternateExchange.name=altEx",
"--spring.cloud.stream.rabbit.bindings.process-out-0.producer.alternate-exchange.exists=true",
"--spring.cloud.stream.rabbit.bindings.process-out-0.producer.alternate-exchange.type=direct",
"--spring.cloud.stream.rabbit.bindings.process-out-0.producer.alternate-exchange.binding.queue=altQ",
"--spring.cloud.stream.rabbit.bindings.process-out-0.producer.alternate-exchange.binding.routing-key=altRK");
BinderFactory binderFactory = context.getBean(BinderFactory.class);
Binder<?, ?, ?> rabbitBinder = binderFactory.getBinder(null,
MessageChannel.class);
@@ -377,6 +383,12 @@ public class RabbitBinderModuleTests {
rabbitProducerProperties.getRoutingKeyExpression().getExpressionString())
.isEqualTo("fooRoutingKey");
assertThat(rabbitProducerProperties.getBatchSize()).isEqualTo(512);
AlternateExchange alternate = rabbitProducerProperties.getAlternateExchange();
assertThat(alternate.getName()).isEqualTo("altEx");
assertThat(alternate.isExists()).isTrue();
assertThat(alternate.getType()).isEqualTo("direct");
assertThat(alternate.getBinding().getQueue()).isEqualTo("altQ");
assertThat(alternate.getBinding().getRoutingKey()).isEqualTo("altRK");
RabbitConsumerProperties rabbitConsumerProperties = (RabbitConsumerProperties) ((ExtendedPropertiesBinder) rabbitBinder)
.getExtendedConsumerProperties("process-in-0");

View File

@@ -682,7 +682,27 @@ in the format of `spring.cloud.stream.rabbit.default.<property>=<value>`.
Also, keep in mind that binding specific property will override its equivalent in the default.
altermateExchange.binding.queue::
If the exchange does not already exist, and a `name` is provided, bind this queue to the alternate exhange.
A simple durable queue with no arguments is provisioned; if more sophisticated configuration is required, you must configure and bind the queue yourself.
+
Default: `null`
alternateExchange.binding.routingKey
If the exchange does not already exist, and a `name` and `queue` is provided, bind the queue to the alternate exhange using this routing key.
+
Default: `#` (for the default `topic` alternate exchange)
alternateExchange.exists::
Whether the alternate exchange exists, or needs to be provisioned.
+
Default: `false`
alternateExchange.type::
If the alternate exchange does not already exist, the type of exchange to provision.
+
Default: `topic`
alternateExchange.name::
Configure an alternate exchange on the destination exchange.
+
Default: `null`
autoBindDlq::
Whether to automatically declare the DLQ and bind it to the binder DLX.
+