From d5477d1423faf8b358e61cf09cbb03f4d4016577 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Fri, 18 Oct 2024 21:01:28 +0200 Subject: [PATCH] GH-881 - Support for SpEL expression in routing targets. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now support SpEL expressions in routing targets for events to be externalized. Introduced a BrokerRouting.getTarget(Object) overload to allow access to the event object in the SpEL expression. To support those, event externalizers will have to call that method where they previously called ….getTarget(). --- .../RabbitEventExternalizerConfiguration.java | 2 +- .../modulith/events/RoutingTarget.java | 23 +++++-- .../events/RoutingTargetUnitTests.java | 18 ++++++ .../SnsEventExternalizerConfiguration.java | 2 +- .../SqsEventExternalizerConfiguration.java | 2 +- .../events/support/BrokerRouting.java | 63 ++++++++++++++++--- .../support/EventExternalizationSupport.java | 3 +- .../support/BrokerRoutingUnitTests.java | 9 +++ .../JmsEventExternalizerConfiguration.java | 12 +++- .../KafkaEventExternalizerConfiguration.java | 2 +- ...ssagingEventExternalizerConfiguration.java | 3 +- .../antora/modules/ROOT/pages/events.adoc | 2 +- 12 files changed, 118 insertions(+), 23 deletions(-) diff --git a/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java index 3307422d..11feaec6 100644 --- a/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java @@ -64,7 +64,7 @@ class RabbitEventExternalizerConfiguration { var routing = BrokerRouting.of(target, context); var headers = configuration.getHeadersFor(payload); - operations.convertAndSend(routing.getTarget(), routing.getKey(payload), payload, headers); + operations.convertAndSend(routing.getTarget(payload), routing.getKey(payload), payload, headers); return CompletableFuture.completedFuture(null); }); diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/RoutingTarget.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/RoutingTarget.java index d4eaa7cf..6f901538 100644 --- a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/RoutingTarget.java +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/RoutingTarget.java @@ -30,6 +30,7 @@ import org.springframework.util.StringUtils; */ public class RoutingTarget { + private static final String EXPRESSION_PREFIX = "#{"; private final String target; private final @Nullable String key; @@ -43,8 +44,8 @@ public class RoutingTarget { Assert.hasText(target, "Target must not be null or empty!"); - this.target = target; - this.key = key; + this.target = target.trim(); + this.key = key == null ? null : key.trim(); } /** @@ -58,8 +59,8 @@ public class RoutingTarget { Assert.notNull(source, "Routing target source must not be null!"); var parts = source.split("::", 2); - var target = parts[0].isBlank() ? null : parts[0]; - var key = parts.length == 2 ? parts[1] : null; + var target = parts[0].isBlank() ? null : parts[0].trim(); + var key = parts.length == 2 ? parts[1].trim() : null; return new ParsedRoutingTarget(target, key); } @@ -93,7 +94,7 @@ public class RoutingTarget { Assert.hasText(target, "Target must not be null or empty!"); - this.target = target; + this.target = target.trim(); } /** @@ -141,7 +142,17 @@ public class RoutingTarget { * @return whether the routing key is a SpEL expression. */ public boolean hasKeyExpression() { - return key != null && key.startsWith("#{"); + return key != null && key.startsWith(EXPRESSION_PREFIX); + } + + /** + * Returns whether either the target or key is using a SpEL expression. + * + * @return whether the routing key is a SpEL expression. + * @since 1.3 + */ + public boolean hasExpression() { + return hasKeyExpression() || target.startsWith(EXPRESSION_PREFIX); } RoutingTarget withTarget(String target) { diff --git a/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/RoutingTargetUnitTests.java b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/RoutingTargetUnitTests.java index f61f5b1d..fd0417b0 100644 --- a/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/RoutingTargetUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-api/src/test/java/org/springframework/modulith/events/RoutingTargetUnitTests.java @@ -82,4 +82,22 @@ class RoutingTargetUnitTests { assertThat(first.hashCode()).isEqualTo(second.hashCode()); assertThat(first.hashCode()).isNotEqualTo(third); } + + @Test // GH-881 + void trimsTargetAndKeyOnParsing() { + + var target = RoutingTarget.parse(" target :: key "); + + assertThat(target.getTarget()).isEqualTo("target"); + assertThat(target.getKey()).isEqualTo("key"); + } + + @Test // GH-881 + void trimsTargetAndKeyOnBuilding() { + + var target = RoutingTarget.forTarget(" target ").andKey(" key "); + + assertThat(target.getTarget()).isEqualTo("target"); + assertThat(target.getKey()).isEqualTo("key"); + } } diff --git a/spring-modulith-events/spring-modulith-events-aws-sns/src/main/java/org/springframework/modulith/events/aws/sns/SnsEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-aws-sns/src/main/java/org/springframework/modulith/events/aws/sns/SnsEventExternalizerConfiguration.java index 6ef7c774..c024dc3b 100644 --- a/spring-modulith-events/spring-modulith-events-aws-sns/src/main/java/org/springframework/modulith/events/aws/sns/SnsEventExternalizerConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-aws-sns/src/main/java/org/springframework/modulith/events/aws/sns/SnsEventExternalizerConfiguration.java @@ -78,7 +78,7 @@ class SnsEventExternalizerConfiguration { builder.groupId(key); } - operations.sendNotification(routing.getTarget(), builder.build()); + operations.sendNotification(routing.getTarget(payload), builder.build()); return CompletableFuture.completedFuture(null); }); diff --git a/spring-modulith-events/spring-modulith-events-aws-sqs/src/main/java/org/springframework/modulith/events/aws/sqs/SqsEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-aws-sqs/src/main/java/org/springframework/modulith/events/aws/sqs/SqsEventExternalizerConfiguration.java index cf097918..6dae7c65 100644 --- a/spring-modulith-events/spring-modulith-events-aws-sqs/src/main/java/org/springframework/modulith/events/aws/sqs/SqsEventExternalizerConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-aws-sqs/src/main/java/org/springframework/modulith/events/aws/sqs/SqsEventExternalizerConfiguration.java @@ -72,7 +72,7 @@ class SqsEventExternalizerConfiguration { return CompletableFuture.completedFuture(operations.send(sqsSendOptions -> { - var options = sqsSendOptions.queue(routing.getTarget()).payload(payload); + var options = sqsSendOptions.queue(routing.getTarget(payload)).payload(payload); var key = routing.getKey(payload); if (key != null) { diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/BrokerRouting.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/BrokerRouting.java index 6da9544d..86118779 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/BrokerRouting.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/BrokerRouting.java @@ -25,14 +25,14 @@ import org.springframework.util.Assert; /** * A {@link BrokerRouting} supports {@link RoutingTarget} instances that contain values matching the format - * {@code $target::$key} for which the key can actually be a SpEL expression. + * {@code $target::$key} for which both the target and key can be a SpEL expression. * * @author Oliver Drotbohm * @since 1.1 */ public class BrokerRouting { - private final RoutingTarget target; + protected final RoutingTarget target; /** * Creates a new {@link BrokerRouting} for the given {@link RoutingTarget}. @@ -54,18 +54,34 @@ public class BrokerRouting { * @return will never be {@literal null}. */ public static BrokerRouting of(RoutingTarget target, EvaluationContext context) { - return target.hasKeyExpression() ? new SpelBrokerRouting(target, context) : new BrokerRouting(target); + + return target.hasExpression() + ? new SpelBrokerRouting(target, context) + : new BrokerRouting(target); } /** * Returns the actual routing target. * * @return will never be {@literal null}. + * @deprecated since 1.3, call {@link #getTarget(Object)} instead. */ + @Deprecated public String getTarget() { return target.getTarget(); } + /** + * Returns the actual routing target for the given event. + * + * @param event must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.3 + */ + public String getTarget(Object event) { + return getTarget(); + } + /** * Resolves the routing key against the given event. In case the original {@link RoutingTarget} contained an * expression, the event will be used as root object to evaluate that expression. @@ -89,7 +105,8 @@ public class BrokerRouting { private static final SpelExpressionParser PARSER = new SpelExpressionParser(); private static final TemplateParserContext CONTEXT = new TemplateParserContext(); - private final Expression expression; + private final Expression targetExpression; + private final @Nullable Expression keyExpression; private final EvaluationContext context; /** @@ -103,15 +120,39 @@ public class BrokerRouting { super(target); - var key = target.getKey(); - - Assert.notNull(target.getKey(), "Routing key must not be null!"); Assert.notNull(context, "EvaluationContext must not be null!"); - this.expression = PARSER.parseExpression(key, CONTEXT); + this.keyExpression = target.getKey() == null ? null : PARSER.parseExpression(target.getKey(), CONTEXT); + this.targetExpression = PARSER.parseExpression(target.getTarget(), CONTEXT); this.context = context; } + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.support.BrokerRouting#getTarget() + */ + @Override + public String getTarget() { + return getTarget(null); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.support.BrokerRouting#getTarget(java.lang.Object) + */ + @Override + public String getTarget(@Nullable Object event) { + + var result = targetExpression.getValue(context, event); + + if (result == null) { + throw new IllegalStateException( + "Evaluation of target expression %s must not result in null!".formatted(targetExpression)); + } + + return result.toString(); + } + /* * (non-Javadoc) * @see org.springframework.modulith.events.support.BrokerRouting#getKey(java.lang.Object) @@ -120,6 +161,12 @@ public class BrokerRouting { @Override public String getKey(Object event) { + var expression = keyExpression; + + if (expression == null) { + return target.getTarget(); + } + var result = expression.getValue(context, event); return result == null ? null : result.toString(); diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizationSupport.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizationSupport.java index 7000a87b..141410e6 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizationSupport.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizationSupport.java @@ -24,6 +24,7 @@ import org.springframework.modulith.events.EventExternalizationConfiguration; import org.springframework.modulith.events.EventExternalized; import org.springframework.modulith.events.RoutingTarget; import org.springframework.modulith.events.core.ConditionalEventListener; +import org.springframework.transaction.annotation.Propagation; import org.springframework.util.Assert; /** @@ -66,7 +67,7 @@ abstract class EventExternalizationSupport implements ConditionalEventListener { * @param event must not be {@literal null}. * @return the externalization result, will never be {@literal null}. */ - @ApplicationModuleListener + @ApplicationModuleListener(propagation = Propagation.SUPPORTS) public CompletableFuture externalize(Object event) { Assert.notNull(event, "Object must not be null!"); diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/BrokerRoutingUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/BrokerRoutingUnitTests.java index 4bb22888..93ae1659 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/BrokerRoutingUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/BrokerRoutingUnitTests.java @@ -55,6 +55,15 @@ class BrokerRoutingUnitTests { verifyNoInteractions(context); } + @Test // GH-881 + void evaluatesSpelExpressionForTarget() { + + var target = RoutingTarget.forTarget("#{@bean.getKey(#this)}").withoutKey(); + var routing = BrokerRouting.of(target, getEvaluationContext()); + + assertThat(routing.getTarget(new TestEvent())).isEqualTo("foo"); + } + private static EvaluationContext getEvaluationContext() { var evaluationContext = new StandardEvaluationContext(); diff --git a/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java index 7b313bb8..18dcf4b3 100644 --- a/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java @@ -19,15 +19,19 @@ import java.util.concurrent.CompletableFuture; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.BeanFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.jms.core.JmsOperations; import org.springframework.modulith.events.EventExternalizationConfiguration; import org.springframework.modulith.events.config.EventExternalizationAutoConfiguration; import org.springframework.modulith.events.core.EventSerializer; +import org.springframework.modulith.events.support.BrokerRouting; import org.springframework.modulith.events.support.DelegatingEventExternalizer; /** @@ -48,15 +52,19 @@ class JmsEventExternalizerConfiguration { @Bean DelegatingEventExternalizer jmsEventExternalizer(EventExternalizationConfiguration configuration, - JmsOperations operations, EventSerializer serializer) { + JmsOperations operations, EventSerializer serializer, BeanFactory factory) { logger.debug("Registering domain event externalization to JMS…"); + var context = new StandardEvaluationContext(); + context.setBeanResolver(new BeanFactoryResolver(factory)); + return new DelegatingEventExternalizer(configuration, (target, payload) -> { var serialized = serializer.serialize(payload); + var routing = BrokerRouting.of(target, context); - operations.send(target.getTarget(), session -> session.createTextMessage(serialized.toString())); + operations.send(routing.getTarget(payload), session -> session.createTextMessage(serialized.toString())); return CompletableFuture.completedFuture(null); }); diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java index 655c9fab..940b2140 100644 --- a/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java @@ -71,7 +71,7 @@ class KafkaEventExternalizerConfiguration { var message = builder .setHeaderIfAbsent(KafkaHeaders.KEY, routing.getKey(payload)) - .setHeaderIfAbsent(KafkaHeaders.TOPIC, routing.getTarget()) + .setHeaderIfAbsent(KafkaHeaders.TOPIC, routing.getTarget(payload)) .build(); return operations.send(message); diff --git a/spring-modulith-events/spring-modulith-events-messaging/src/main/java/org/springframework/modulith/events/messaging/SpringMessagingEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-messaging/src/main/java/org/springframework/modulith/events/messaging/SpringMessagingEventExternalizerConfiguration.java index 91599fa5..83722bdd 100644 --- a/spring-modulith-events/spring-modulith-events-messaging/src/main/java/org/springframework/modulith/events/messaging/SpringMessagingEventExternalizerConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-messaging/src/main/java/org/springframework/modulith/events/messaging/SpringMessagingEventExternalizerConfiguration.java @@ -31,6 +31,7 @@ import org.springframework.messaging.MessageChannel; import org.springframework.messaging.support.MessageBuilder; import org.springframework.modulith.events.EventExternalizationConfiguration; import org.springframework.modulith.events.config.EventExternalizationAutoConfiguration; +import org.springframework.modulith.events.support.BrokerRouting; import org.springframework.modulith.events.support.DelegatingEventExternalizer; /** @@ -62,7 +63,7 @@ class SpringMessagingEventExternalizerConfiguration { return new DelegatingEventExternalizer(configuration, (target, payload) -> { - var targetChannel = target.getTarget(); + var targetChannel = BrokerRouting.of(target, context).getTarget(payload); var message = MessageBuilder .withPayload(payload) .setHeader(MODULITH_ROUTING_HEADER, target.toString()) diff --git a/src/docs/antora/modules/ROOT/pages/events.adoc b/src/docs/antora/modules/ROOT/pages/events.adoc index 53d910d6..fc051c44 100644 --- a/src/docs/antora/modules/ROOT/pages/events.adoc +++ b/src/docs/antora/modules/ROOT/pages/events.adoc @@ -404,7 +404,7 @@ By default, no routing key is used. === Annotation-based Event Externalization Configuration To define a custom routing key via the `@Externalized` annotations, a pattern of `$target::$key` can be used for the target/value attribute available in each of the particular annotations. -The key can be a SpEL expression which will get the event instance configured as root object. +Both the target and key can be a SpEL expression which will get the event instance configured as root object. .Defining a dynamic routing key via SpEL expression [tabs]