GH-3957: Add JmsInboundGateway.replyToExpression (#8560)

* GH-3957: Add JmsInboundGateway.replyToExpression

Fixes https://github.com/spring-projects/spring-integration/issues/3957

Sometimes we cannot use a standard `JmsReplyTo` property for sending replies from the server.
A `DestinationResolver` API does not have access to the request message.

* Introduce a `ChannelPublishingJmsMessageListener.replyToExpression` property to evaluate
a reply destination against request JMS `Message`
* Use this expression only of no `JmsReplyTo` property
* Expose this property on Java DSL level
* To simplify end-user experience with lambda configuration for this property, introduce a `CheckedFunction`
which essentially re-throws exception "sneaky" way

* Fix Javadoc for `CheckedFunction`

* * Fix language in docs
* Fix Javadocs lines length
* Regular `catch` and re-throw in the `CheckedFunction`
This commit is contained in:
Artem Bilan
2023-02-22 15:23:13 -05:00
committed by GitHub
parent 3f99424d93
commit acd8a03d4d
7 changed files with 198 additions and 15 deletions

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.integration.util;
import java.util.function.Function;
/**
* A Function-like interface which allows throwing Error.
*
* @param <T> the input type.
* @param <R> the output type.
*
* @author Artem Bilan
*
* @since 6.1
*/
@FunctionalInterface
public interface CheckedFunction<T, R> {
R apply(T t) throws Throwable; // NOSONAR
default Function<T, R> unchecked() {
return t1 -> {
try {
return apply(t1);
}
catch (Throwable t) { // NOSONAR
if (t instanceof RuntimeException runtimeException) {
throw runtimeException;
}
else if (t instanceof Error error) {
throw error;
}
else {
throw new IllegalStateException(t);
}
}
};
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -30,7 +30,10 @@ import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.log.LogAccessor;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.integration.core.MessagingTemplate;
import org.springframework.integration.expression.ExpressionUtils;
import org.springframework.integration.gateway.MessagingGatewaySupport;
import org.springframework.integration.support.DefaultMessageBuilderFactory;
import org.springframework.integration.support.MessageBuilderFactory;
@@ -42,6 +45,7 @@ import org.springframework.jms.support.converter.MessageConverter;
import org.springframework.jms.support.converter.SimpleMessageConverter;
import org.springframework.jms.support.destination.DestinationResolver;
import org.springframework.jms.support.destination.DynamicDestinationResolver;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessagingException;
@@ -90,12 +94,16 @@ public class ChannelPublishingJmsMessageListener
private DestinationResolver destinationResolver = new DynamicDestinationResolver();
private Expression replyToExpression;
private JmsHeaderMapper headerMapper = new DefaultJmsHeaderMapper();
private BeanFactory beanFactory;
private MessageBuilderFactory messageBuilderFactory = new DefaultMessageBuilderFactory();
private StandardEvaluationContext evaluationContext;
/**
* Specify whether a JMS reply Message is expected.
* @param expectReply true if a reply is expected.
@@ -261,6 +269,17 @@ public class ChannelPublishingJmsMessageListener
this.destinationResolver = destinationResolver;
}
/**
* Set a SpEL expression to resolve a 'replyTo' destination from a request
* {@link jakarta.jms.Message} as a root evaluation object
* if {@link jakarta.jms.Message#getJMSReplyTo()} is null.
* @param replyToExpression the SpEL expression for 'replyTo' destination.
* @since 6.1
*/
public void setReplyToExpression(Expression replyToExpression) {
this.replyToExpression = replyToExpression;
}
/**
* Provide a {@link MessageConverter} implementation to use when
* converting between JMS Messages and Spring Integration Messages.
@@ -382,6 +401,7 @@ public class ChannelPublishingJmsMessageListener
}
this.gatewayDelegate.afterPropertiesSet();
this.messageBuilderFactory = IntegrationUtils.getMessageBuilderFactory(this.beanFactory);
this.evaluationContext = ExpressionUtils.createStandardEvaluationContext(this.beanFactory);
}
protected void start() {
@@ -417,21 +437,22 @@ public class ChannelPublishingJmsMessageListener
/**
* Determine a reply destination for the given message.
* <p> This implementation first checks the boolean 'error' flag which signifies that the reply is an error message.
* If reply is not an error it will first check the JMS Reply-To {@link Destination} of the supplied request message;
* if that is not <code>null</code> it is returned; if it is <code>null</code>, then the configured
* {@link #resolveDefaultReplyDestination default reply destination} is returned; if this too is <code>null</code>,
* It will first check the JMS Reply-To {@link Destination}
* of the supplied request message;
* if that is null, then the configured {@link #replyToExpression} is evaluated
* (if any), then a{@link #resolveDefaultReplyDestination default reply destination}
* is returned; if this too is null,
* then an {@link InvalidDestinationException} is thrown.
* @param request the original incoming JMS message
* @param session the JMS Session to operate on
* @return the reply destination (never <code>null</code>)
* @return the reply destination (never null)
* @throws JMSException if thrown by JMS API methods
* @throws InvalidDestinationException if no {@link Destination} can be determined
* @see #setDefaultReplyDestination
* @see jakarta.jms.Message#getJMSReplyTo()
*/
private Destination getReplyDestination(jakarta.jms.Message request, Session session) throws JMSException {
Destination replyTo = request.getJMSReplyTo();
Destination replyTo = resolveReplyTo(request, session);
if (replyTo == null) {
replyTo = resolveDefaultReplyDestination(session);
if (replyTo == null) {
@@ -440,6 +461,24 @@ public class ChannelPublishingJmsMessageListener
}
}
return replyTo;
}
@Nullable
private Destination resolveReplyTo(jakarta.jms.Message request, Session session) throws JMSException {
Destination replyTo = request.getJMSReplyTo();
if (replyTo == null) {
if (this.replyToExpression != null) {
Object replyToValue = this.replyToExpression.getValue(this.evaluationContext, request);
if (replyToValue instanceof Destination destination) {
return destination;
}
else if (replyToValue instanceof String destinationName) {
return this.destinationResolver.resolveDestinationName(session, destinationName, false);
}
}
}
return replyTo;
}
/**

View File

@@ -103,9 +103,9 @@ public class JmsInboundGateway extends MessagingGatewaySupport implements Orderl
}
/**
* Set to false to prevent listener container shutdown when the endpoint is stopped.
* Set to {@code false} to prevent listener container shutdown when the endpoint is stopped.
* Then, if so configured, any cached consumer(s) in the container will remain.
* Otherwise the shared connection and will be closed and the listener invokers shut
* Otherwise, the shared connection and will be closed and the listener invokers shut
* down; this behavior is new starting with version 5.1. Default: true.
* @param shutdownContainerOnStop false to not shutdown.
* @since 5.1

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2016-2022 the original author or authors.
* Copyright 2016-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,11 +19,15 @@ package org.springframework.integration.jms.dsl;
import java.util.function.Consumer;
import jakarta.jms.Destination;
import jakarta.jms.Message;
import org.springframework.expression.Expression;
import org.springframework.integration.dsl.MessagingGatewaySpec;
import org.springframework.integration.expression.FunctionExpression;
import org.springframework.integration.jms.ChannelPublishingJmsMessageListener;
import org.springframework.integration.jms.JmsHeaderMapper;
import org.springframework.integration.jms.JmsInboundGateway;
import org.springframework.integration.util.CheckedFunction;
import org.springframework.jms.listener.AbstractMessageListenerContainer;
import org.springframework.jms.support.converter.MessageConverter;
import org.springframework.jms.support.destination.DestinationResolver;
@@ -137,6 +141,44 @@ public class JmsInboundGatewaySpec<S extends JmsInboundGatewaySpec<S>>
return _this();
}
/**
* Set a SpEL expression to resolve a 'replyTo' destination from a request {@link jakarta.jms.Message}
* as a root evaluation object if {@link jakarta.jms.Message#getJMSReplyTo()} is null.
* @param replyToExpression the SpEL expression for 'replyTo' destination.
* @return the spec.
* @since 6.1
* @see ChannelPublishingJmsMessageListener#setReplyToExpression(Expression)
*/
public S replyToExpression(String replyToExpression) {
return replyToExpression(PARSER.parseExpression(replyToExpression));
}
/**
* Set a function to resolve a 'replyTo' destination from a request {@link jakarta.jms.Message}
* as a root evaluation object if {@link jakarta.jms.Message#getJMSReplyTo()} is null.
* @param replyToFunction the function for 'replyTo' destination.
* @return the spec.
* @since 6.1
* @see ChannelPublishingJmsMessageListener#setReplyToExpression(Expression)
*/
public S replyToFunction(CheckedFunction<Message, ?> replyToFunction) {
return replyToExpression(new FunctionExpression<>(replyToFunction.unchecked()));
}
/**
* Set a SpEL expression to resolve a 'replyTo' destination from a request {@link jakarta.jms.Message}
* as a root evaluation object if {@link jakarta.jms.Message#getJMSReplyTo()} is null.
* @param replyToExpression the SpEL expression for 'replyTo' destination.
* @return the spec.
* @since 6.1
* @see ChannelPublishingJmsMessageListener#setReplyToExpression(Expression)
*/
public S replyToExpression(Expression replyToExpression) {
this.target.getListener().setReplyToExpression(replyToExpression);
return _this();
}
/**
* @param messageConverter the messageConverter.
* @return the spec.

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2016-2022 the original author or authors.
* Copyright 2016-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -22,6 +22,8 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.jms.JMSException;
import jakarta.jms.TextMessage;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ListableBeanFactory;
@@ -137,6 +139,9 @@ public class JmsTests extends ActiveMQMultiContextTests {
@Autowired
private CountDownLatch redeliveryLatch;
@Autowired
JmsTemplate jmsTemplate;
@Test
public void testPollingFlow() {
this.controlBus.send("@'integerMessageSource.inboundChannelAdapter'.start()");
@@ -264,6 +269,19 @@ public class JmsTests extends ActiveMQMultiContextTests {
this.jmsMessageDrivenRedeliveryFlowContainer.stop();
}
@Test
public void customReplyToHeader() throws JMSException {
this.jmsTemplate.send("jmsPipelineTest", session -> {
TextMessage message = session.createTextMessage("test data");
message.setStringProperty("myReplyTo", "replyToQueue");
return message;
});
jakarta.jms.Message replyMessage = this.jmsTemplate.receive("replyToQueue");
assertThat(replyMessage).isNotNull();
assertThat(replyMessage.getBody(String.class)).isEqualTo("TEST DATA");
}
@MessagingGateway(defaultRequestChannel = "controlBus.input")
private interface ControlBusGateway {
@@ -278,7 +296,9 @@ public class JmsTests extends ActiveMQMultiContextTests {
@Bean
public JmsTemplate jmsTemplate() {
return new JmsTemplate(connectionFactory);
JmsTemplate jmsTemplate = new JmsTemplate(connectionFactory);
jmsTemplate.setReceiveTimeout(10_000);
return jmsTemplate;
}
@Bean(name = PollerMetadata.DEFAULT_POLLER)
@@ -422,6 +442,7 @@ public class JmsTests extends ActiveMQMultiContextTests {
}))
.requestDestination("jmsPipelineTest")
.replyToFunction(message -> message.getStringProperty("myReplyTo"))
.configureListenerContainer(c ->
c.transactionManager(mock(PlatformTransactionManager.class))))
.filter(payload -> !"junk".equals(payload))

View File

@@ -411,11 +411,32 @@ Starting with version 5.1, when the endpoint is stopped while the application re
Previously, the connection and consumers remained open.
To revert to the previous behavior, set the `shutdownContainerOnStop` on the `JmsInboundGateway` to `false`.
By default, the `JmsInboundGateway` looks for a `jakarta.jms.Message.getJMSReplyTo()` property in the received message to determine where to send a reply.
Otherwise, it can be configured with a static `defaultReplyDestination`, or `defaultReplyQueueName` or `defaultReplyTopicName`.
In addition, starting with version 6.1, a `replyToExpression` can be configured on a provided `ChannelPublishingJmsMessageListener` to determine the reply destination dynamically, if the standard `JMSReplyTo` property is `null` on the request.
The received `jakarta.jms.Message` is used the root evaluation context object.
The following example demonstrates how to use Java DSL API to configure an inbound JMS gateway with a custom reply destination resolved from the request message:
====
[source,java]
----
@Bean
public IntegrationFlow jmsInboundGatewayFlow(ConnectionFactory connectionFactory) {
return IntegrationFlow.from(
Jms.inboundGateway(connectionFactory)
.requestDestination("requestDestination")
.replyToFunction(message -> message.getStringProperty("myReplyTo")))
.<String, String>transform(String::toUpperCase)
.get();
}
----
====
[[jms-outbound-gateway]]
=== Outbound Gateway
The outbound gateway creates JMS messages from Spring Integration messages and sends them to a 'request-destination'.
It then handles the JMS reply message either by using a selector to receive from the 'reply-destination' that you configure or, if no 'reply-destination' is provided, by creating JMS `TemporaryQueue` (or `TemporaryTopic` if `replyPubSubDomain= true`) instances.
The outbound gateway creates JMS messages from Spring Integration messages and sends them to a `request-destination`.
It then handles the JMS reply message either by using a selector to receive from the `reply-destination` that you configure or, if no `reply-destination` is provided, by creating JMS `TemporaryQueue` (or `TemporaryTopic` if `replyPubSubDomain= true`) instances.
[[jms-outbound-gateway-memory-caution]]
[CAUTION]

View File

@@ -27,9 +27,14 @@ See <<./zip.adoc#zip,Zip Support>> for more information.
[[x6.1-general]]
=== General Changes
[[x6.1-web-sockets]]
=== Web Sockets Changes
A `ClientWebSocketContainer` can now be configured with a predefined `URI` instead of a combination of `uriTemplate` and `uriVariables`.
See <<./web-sockets.adoc#web-socket-overview, WebSocket Overview>> for more information.
[[x6.1-jms]]
=== JMS Changes
The `JmsInboundGateway`, via its `ChannelPublishingJmsMessageListener`, can now be configured with a `replyToExpression` to resolve a reply destination against the request message at runtime.
See <<./jms.adoc#jms-inbound-gateway, JMS Inbound Gateway>> for more information.