GH-881 - Support for SpEL expression in routing targets.

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().
This commit is contained in:
Oliver Drotbohm
2024-10-18 21:01:28 +02:00
parent 8d067e8107
commit d5477d1423
12 changed files with 118 additions and 23 deletions

View File

@@ -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);
});

View File

@@ -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) {

View File

@@ -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");
}
}

View File

@@ -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);
});

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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!");

View File

@@ -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();

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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())

View File

@@ -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]