Add initial support for RSockets (#2902)
* Add initial support for RSockets * Add `spring-integration-rsocket` module and respective dependencies * Implement `RSocketOutboundGateway` based on the Spring Messaging `RSocketRequester`. This component supports dynamic RSocket properties via expressions against request message. to handle `Publisher` for requests, it must be present in the request message `payload` instead of `FluxMessageChannel` upstream, since the last one just flattens events to be handled in the `MessageHandler` one by one. The result `Mono` is subscribed downstream in the `FluxMessageChannel` or directly by the `AbstractReplyProducingMessageHandler`. If result is a `Flux` it is just wrapped into the `Mono` to be processed downstream by end-user code. The point is that these request/replies are volatile and live in the particular context meanwhile a `FluxMessageChannel` is long living publisher in the application context boundaries. * The `RSocketOutboundGatewayIntegrationTests` is an adapted copy of `RSocketClientToServerIntegrationTests` from Spring Messaging * Add `doOnError()` into the `Flux` created in the `AbstractMessageProducingHandler` for `Publisher` replies * * Use singular for the `RSocket` term * Use no-op `Consumer` for the `strategiesConfigurer` and `factoryConfigurer` in the `RSocketOutboundGateway` and also `Assert.notNull()` in the appropriate setters to avoid null check during `RSocketRequester.builder()` initialization * Use `TcpServer.create().port(0)` in the `RSocketOutboundGatewayIntegrationTests` to allow to select free OS port and bind into it. The selected port is used later for client configuration in the `RSocketOutboundGateway` bean definition * * Change `RSocketOutboundGatewayIntegrationTests.PORT` to lower case
This commit is contained in:
committed by
Gary Russell
parent
f8f69c9129
commit
89e11f2c46
20
build.gradle
20
build.gradle
@@ -39,6 +39,7 @@ allprojects {
|
||||
if (version.endsWith('BUILD-SNAPSHOT')) {
|
||||
maven { url 'https://repo.spring.io/libs-snapshot' }
|
||||
}
|
||||
maven { url "https://oss.jfrog.org/artifactory/libs-snapshot" } // RSocket
|
||||
// maven { url 'https://repo.spring.io/libs-staging-local' }
|
||||
}
|
||||
|
||||
@@ -128,10 +129,11 @@ subprojects { subproject ->
|
||||
mysqlVersion = '8.0.15'
|
||||
pahoMqttClientVersion = '1.2.0'
|
||||
postgresVersion = '42.2.5'
|
||||
reactorNettyVersion = '0.8.6.RELEASE'
|
||||
reactorVersion = '3.2.8.RELEASE'
|
||||
reactorNettyVersion = '0.9.0.BUILD-SNAPSHOT'
|
||||
reactorVersion = '3.3.0.BUILD-SNAPSHOT'
|
||||
resilience4jVersion = '0.14.1'
|
||||
romeToolsVersion = '1.12.0'
|
||||
rsocketVersion = '0.12.2-RC3-SNAPSHOT'
|
||||
servletApiVersion = '4.0.1'
|
||||
smackVersion = '4.3.3'
|
||||
springAmqpVersion = project.hasProperty('springAmqpVersion') ? project.springAmqpVersion : '2.2.0.M1'
|
||||
@@ -141,7 +143,7 @@ subprojects { subproject ->
|
||||
springGemfireVersion = '2.2.0.M3'
|
||||
springSecurityVersion = '5.2.0.M2'
|
||||
springRetryVersion = '1.2.4.RELEASE'
|
||||
springVersion = project.hasProperty('springVersion') ? project.springVersion : '5.2.0.M1'
|
||||
springVersion = project.hasProperty('springVersion') ? project.springVersion : '5.2.0.BUILD-SNAPSHOT'
|
||||
springWsVersion = '3.0.7.RELEASE'
|
||||
tomcatVersion = "9.0.17"
|
||||
xstreamVersion = '1.4.11.1'
|
||||
@@ -596,6 +598,18 @@ project('spring-integration-rmi') {
|
||||
}
|
||||
}
|
||||
|
||||
project('spring-integration-rsocket') {
|
||||
description = 'Spring Integration RSocket Support'
|
||||
dependencies {
|
||||
compile project(":spring-integration-core")
|
||||
compile("io.projectreactor.netty:reactor-netty:$reactorNettyVersion")
|
||||
compile("io.rsocket:rsocket-core:$rsocketVersion")
|
||||
compile("io.rsocket:rsocket-transport-netty:$rsocketVersion")
|
||||
|
||||
testCompile "io.projectreactor:reactor-test:$reactorVersion"
|
||||
}
|
||||
}
|
||||
|
||||
project('spring-integration-scripting') {
|
||||
description = 'Spring Integration Scripting Support'
|
||||
dependencies {
|
||||
|
||||
@@ -82,8 +82,8 @@ public class FluxMessageChannel extends AbstractMessageChannel
|
||||
ConnectableFlux<?> connectableFlux =
|
||||
Flux.from(publisher)
|
||||
.handle((message, sink) -> sink.next(send(message)))
|
||||
.onErrorContinue((throwable, o) -> logger.warn("Error during processing event: " + o, throwable)
|
||||
)
|
||||
.onErrorContinue((throwable, event) ->
|
||||
logger.warn("Error during processing event: " + event, throwable))
|
||||
.doOnComplete(() -> this.publishers.remove(publisher))
|
||||
.publish();
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.reactivestreams.Publisher;
|
||||
|
||||
import org.springframework.integration.IntegrationMessageHeaderAccessor;
|
||||
import org.springframework.integration.channel.ReactiveStreamsSubscribableChannel;
|
||||
import org.springframework.integration.context.IntegrationContextUtils;
|
||||
import org.springframework.integration.core.MessageProducer;
|
||||
import org.springframework.integration.core.MessagingTemplate;
|
||||
import org.springframework.integration.routingslip.RoutingSlipRouteStrategy;
|
||||
@@ -283,6 +284,7 @@ public abstract class AbstractMessageProducingHandler extends AbstractMessageHan
|
||||
((ReactiveStreamsSubscribableChannel) messageChannel)
|
||||
.subscribeTo(
|
||||
Flux.from((Publisher<?>) reply)
|
||||
.doOnError((ex) -> sendErrorMessage(requestMessage, ex))
|
||||
.map(result -> createOutputMessage(result, requestHeaders)));
|
||||
}
|
||||
}
|
||||
@@ -311,25 +313,22 @@ public abstract class AbstractMessageProducingHandler extends AbstractMessageHan
|
||||
}
|
||||
|
||||
private void asyncNonReactiveReply(Message<?> requestMessage, Object reply, Object replyChannel) {
|
||||
|
||||
ListenableFuture<?> future;
|
||||
if (reply instanceof ListenableFuture<?>) {
|
||||
future = (ListenableFuture<?>) reply;
|
||||
}
|
||||
else {
|
||||
SettableListenableFuture<Object> settableListenableFuture = new SettableListenableFuture<>();
|
||||
|
||||
Mono.from((Publisher<?>) reply)
|
||||
.subscribe(settableListenableFuture::set, settableListenableFuture::setException);
|
||||
|
||||
future = settableListenableFuture;
|
||||
}
|
||||
|
||||
future.addCallback(new ReplyFutureCallback(requestMessage, replyChannel));
|
||||
}
|
||||
|
||||
private Object getOutputChannelFromRoutingSlip(Object reply, Message<?> requestMessage, List<?> routingSlip,
|
||||
AtomicInteger routingSlipIndex) {
|
||||
|
||||
if (routingSlipIndex.get() >= routingSlip.size()) {
|
||||
return null;
|
||||
}
|
||||
@@ -365,7 +364,7 @@ public abstract class AbstractMessageProducingHandler extends AbstractMessageHan
|
||||
}
|
||||
|
||||
protected Message<?> createOutputMessage(Object output, MessageHeaders requestHeaders) {
|
||||
AbstractIntegrationMessageBuilder<?> builder = null;
|
||||
AbstractIntegrationMessageBuilder<?> builder;
|
||||
if (output instanceof Message<?>) {
|
||||
if (this.noHeadersPropagation || !shouldCopyRequestHeaders()) {
|
||||
return (Message<?>) output;
|
||||
@@ -449,7 +448,7 @@ public abstract class AbstractMessageProducingHandler extends AbstractMessageHan
|
||||
}
|
||||
catch (Exception e) {
|
||||
Exception exceptionToLog =
|
||||
IntegrationUtils.wrapInHandlingExceptionIfNecessary(requestMessage, () -> null, e);
|
||||
IntegrationUtils.wrapInHandlingExceptionIfNecessary(requestMessage, () -> null, e);
|
||||
logger.error("Failed to send async reply", exceptionToLog);
|
||||
}
|
||||
}
|
||||
@@ -459,7 +458,7 @@ public abstract class AbstractMessageProducingHandler extends AbstractMessageHan
|
||||
Object errorChannel = requestHeaders.getErrorChannel();
|
||||
if (errorChannel == null) {
|
||||
try {
|
||||
errorChannel = getChannelResolver().resolveDestination("errorChannel");
|
||||
errorChannel = getChannelResolver().resolveDestination(IntegrationContextUtils.ERROR_CHANNEL_BEAN_NAME);
|
||||
}
|
||||
catch (DestinationResolutionException e) {
|
||||
// ignore
|
||||
|
||||
@@ -87,6 +87,9 @@ public abstract class AbstractReplyProducingMessageHandler extends AbstractMessa
|
||||
this.beanClassLoader = beanClassLoader;
|
||||
}
|
||||
|
||||
protected ClassLoader getBeanClassLoader() {
|
||||
return this.beanClassLoader;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected final void onInit() {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2019 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.rsocket.config;
|
||||
|
||||
import org.springframework.integration.config.xml.AbstractIntegrationNamespaceHandler;
|
||||
|
||||
/**
|
||||
* Namespace handler for Spring Integration's <em>RSocket</em> namespace.
|
||||
*
|
||||
* @author Artem Bilan
|
||||
*/
|
||||
public class RSocketNamespaceHandler extends AbstractIntegrationNamespaceHandler {
|
||||
|
||||
public void init() {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* Copyright 2019 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.rsocket.outbound;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.reactivestreams.Publisher;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.expression.EvaluationContext;
|
||||
import org.springframework.expression.Expression;
|
||||
import org.springframework.integration.expression.ExpressionUtils;
|
||||
import org.springframework.integration.expression.ValueExpression;
|
||||
import org.springframework.integration.handler.AbstractReplyProducingMessageHandler;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.rsocket.RSocketRequester;
|
||||
import org.springframework.messaging.rsocket.RSocketStrategies;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.util.MimeType;
|
||||
import org.springframework.util.MimeTypeUtils;
|
||||
|
||||
import io.rsocket.RSocket;
|
||||
import io.rsocket.RSocketFactory;
|
||||
import io.rsocket.transport.ClientTransport;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* An Outbound Messaging Gateway for RSocket client requests.
|
||||
*
|
||||
* @author Artem Bilan
|
||||
*
|
||||
* @since 5.2
|
||||
*
|
||||
* @see RSocketRequester
|
||||
*/
|
||||
public class RSocketOutboundGateway extends AbstractReplyProducingMessageHandler {
|
||||
|
||||
private final ClientTransport clientTransport;
|
||||
|
||||
private final Expression routeExpression;
|
||||
|
||||
private MimeType dataMimeType = MimeTypeUtils.TEXT_PLAIN;
|
||||
|
||||
private Consumer<RSocketFactory.ClientRSocketFactory> factoryConfigurer = (clientRSocketFactory) -> { };
|
||||
|
||||
private Consumer<RSocketStrategies.Builder> strategiesConfigurer = (builder) -> { };
|
||||
|
||||
private Expression commandExpression = new ValueExpression<>(Command.requestResponse);
|
||||
|
||||
private Expression publisherElementTypeExpression = new ValueExpression<>(String.class);
|
||||
|
||||
private Expression expectedResponseTypeExpression = new ValueExpression<>(String.class);
|
||||
|
||||
private Mono<RSocketRequester> rSocketRequesterMono;
|
||||
|
||||
private EvaluationContext evaluationContext;
|
||||
|
||||
public RSocketOutboundGateway(ClientTransport clientTransport, String route) {
|
||||
this(clientTransport, new ValueExpression<>(route));
|
||||
}
|
||||
|
||||
public RSocketOutboundGateway(ClientTransport clientTransport, Expression routeExpression) {
|
||||
Assert.notNull(clientTransport, "'clientTransport' must not be null");
|
||||
Assert.notNull(routeExpression, "'routeExpression' must not be null");
|
||||
this.clientTransport = clientTransport;
|
||||
this.routeExpression = routeExpression;
|
||||
setAsync(true);
|
||||
setPrimaryExpression(this.routeExpression);
|
||||
}
|
||||
|
||||
public void setDataMimeType(MimeType dataMimeType) {
|
||||
Assert.notNull(dataMimeType, "'dataMimeType' must not be null");
|
||||
this.dataMimeType = dataMimeType;
|
||||
}
|
||||
|
||||
public void setFactoryConfigurer(Consumer<RSocketFactory.ClientRSocketFactory> factoryConfigurer) {
|
||||
Assert.notNull(factoryConfigurer, "'factoryConfigurer' must not be null");
|
||||
this.factoryConfigurer = factoryConfigurer;
|
||||
}
|
||||
|
||||
public void setStrategiesConfigurer(Consumer<RSocketStrategies.Builder> strategiesConfigurer) {
|
||||
Assert.notNull(strategiesConfigurer, "'strategiesConfigurer' must not be null");
|
||||
this.strategiesConfigurer = strategiesConfigurer;
|
||||
}
|
||||
|
||||
public void setCommand(Command command) {
|
||||
setCommandExpression(new ValueExpression<>(command));
|
||||
}
|
||||
|
||||
public void setCommandExpression(Expression commandExpression) {
|
||||
Assert.notNull(commandExpression, "'commandExpression' must not be null");
|
||||
this.commandExpression = commandExpression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a type for a request {@link Publisher} elements.
|
||||
* @param publisherElementType the type of the request {@link Publisher} elements.
|
||||
* @see RSocketRequester.RequestSpec#data(Publisher, Class)
|
||||
*/
|
||||
public void setPublisherElementType(Class<?> publisherElementType) {
|
||||
Assert.notNull(publisherElementType, "'publisherElementType' must not be null");
|
||||
setPublisherElementTypeExpression(new ValueExpression<>(publisherElementType));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a SpEL expression to evaluate a request {@link Publisher} elements type at runtime against
|
||||
* a request message.
|
||||
* @param publisherElementTypeExpression the expression to evaluate a type for the request
|
||||
* {@link Publisher} elements.
|
||||
* @see RSocketRequester.RequestSpec#data
|
||||
*/
|
||||
public void setPublisherElementTypeExpression(Expression publisherElementTypeExpression) {
|
||||
this.publisherElementTypeExpression = publisherElementTypeExpression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the expected response type for the RSocket response.
|
||||
* @param expectedResponseType The expected type.
|
||||
* @see #setExpectedResponseTypeExpression(Expression)
|
||||
* @see RSocketRequester.ResponseSpec#retrieveMono
|
||||
* @see RSocketRequester.ResponseSpec#retrieveFlux
|
||||
*/
|
||||
public void setExpectedResponseType(Class<?> expectedResponseType) {
|
||||
Assert.notNull(expectedResponseType, "'expectedResponseType' must not be null");
|
||||
setExpectedResponseTypeExpression(new ValueExpression<>(expectedResponseType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the {@link Expression} to determine the type for the RSocket response.
|
||||
* @param expectedResponseTypeExpression The expected response type expression.
|
||||
* @see RSocketRequester.ResponseSpec#retrieveMono
|
||||
* @see RSocketRequester.ResponseSpec#retrieveFlux
|
||||
*/
|
||||
public void setExpectedResponseTypeExpression(Expression expectedResponseTypeExpression) {
|
||||
this.expectedResponseTypeExpression = expectedResponseTypeExpression;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void doInit() {
|
||||
super.doInit();
|
||||
this.rSocketRequesterMono =
|
||||
RSocketRequester.builder()
|
||||
.rsocketFactory(this.factoryConfigurer)
|
||||
.rsocketStrategies(this.strategiesConfigurer)
|
||||
.connect(this.clientTransport, this.dataMimeType);
|
||||
|
||||
this.evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
super.destroy();
|
||||
this.rSocketRequesterMono.block().rsocket().dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object handleRequestMessage(Message<?> requestMessage) {
|
||||
return this.rSocketRequesterMono.cache()
|
||||
.map((rSocketRequester) -> createRequestSpec(rSocketRequester, requestMessage))
|
||||
.map((requestSpec) -> createResponseSpec(requestSpec, requestMessage))
|
||||
.flatMap((responseSpec) -> performRequest(responseSpec, requestMessage));
|
||||
}
|
||||
|
||||
private RSocketRequester.RequestSpec createRequestSpec(RSocketRequester rSocketRequester,
|
||||
Message<?> requestMessage) {
|
||||
|
||||
String route = this.routeExpression.getValue(this.evaluationContext, requestMessage, String.class);
|
||||
Assert.notNull(route, () -> "The 'routeExpression' [" + this.routeExpression + "] must not evaluate to null");
|
||||
|
||||
return rSocketRequester.route(route);
|
||||
}
|
||||
|
||||
private RSocketRequester.ResponseSpec createResponseSpec(RSocketRequester.RequestSpec requestSpec,
|
||||
Message<?> requestMessage) {
|
||||
|
||||
Object payload = requestMessage.getPayload();
|
||||
|
||||
if (payload instanceof Publisher<?>) {
|
||||
Object publisherElementType = evaluateExpressionForType(requestMessage, this.publisherElementTypeExpression,
|
||||
"publisherElementTypeExpression");
|
||||
return responseSpecForPublisher(requestSpec, (Publisher<?>) payload, publisherElementType);
|
||||
}
|
||||
else {
|
||||
return requestSpec.data(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||
private RSocketRequester.ResponseSpec responseSpecForPublisher(RSocketRequester.RequestSpec requestSpec,
|
||||
Publisher<?> payload, Object publisherElementType) {
|
||||
|
||||
if (publisherElementType instanceof Class<?>) {
|
||||
return requestSpec.data(payload, (Class) publisherElementType);
|
||||
}
|
||||
else {
|
||||
return requestSpec.data(payload, (ParameterizedTypeReference) publisherElementType);
|
||||
}
|
||||
}
|
||||
|
||||
private Mono<?> performRequest(RSocketRequester.ResponseSpec responseSpec, Message<?> requestMessage) {
|
||||
Command command = this.commandExpression.getValue(this.evaluationContext, requestMessage, Command.class);
|
||||
Assert.notNull(command, () -> "The 'commandExpression' [" + this.commandExpression +
|
||||
"] must not evaluate to null");
|
||||
|
||||
Object expectedResponseType = null;
|
||||
if (!Command.fireAndForget.equals(command)) {
|
||||
expectedResponseType = evaluateExpressionForType(requestMessage, this.expectedResponseTypeExpression,
|
||||
"expectedResponseTypeExpression");
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case fireAndForget:
|
||||
return responseSpec.send();
|
||||
case requestResponse:
|
||||
if (expectedResponseType instanceof Class<?>) {
|
||||
return responseSpec.retrieveMono((Class<?>) expectedResponseType);
|
||||
}
|
||||
else {
|
||||
return responseSpec.retrieveMono((ParameterizedTypeReference<?>) expectedResponseType);
|
||||
}
|
||||
case requestStreamOrChannel:
|
||||
if (expectedResponseType instanceof Class<?>) {
|
||||
return Mono.just(responseSpec.retrieveFlux((Class<?>) expectedResponseType));
|
||||
}
|
||||
else {
|
||||
return Mono.just(responseSpec.retrieveFlux((ParameterizedTypeReference<?>) expectedResponseType));
|
||||
}
|
||||
default:
|
||||
throw new UnsupportedOperationException("Unsupported command: " + command);
|
||||
}
|
||||
}
|
||||
|
||||
private Object evaluateExpressionForType(Message<?> requestMessage, Expression expression, String propertyName) {
|
||||
Object type = expression.getValue(this.evaluationContext, requestMessage);
|
||||
Assert.state(type instanceof Class<?>
|
||||
|| type instanceof String
|
||||
|| type instanceof ParameterizedTypeReference<?>,
|
||||
() -> "The '" + propertyName + "' [" + expression +
|
||||
"] must evaluate to 'String' (class FQN), 'Class<?>' " +
|
||||
"or 'ParameterizedTypeReference<?>', not to: " + type);
|
||||
|
||||
if (type instanceof String) {
|
||||
try {
|
||||
return ClassUtils.forName((String) type, getBeanClassLoader());
|
||||
}
|
||||
catch (ClassNotFoundException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
else {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumeration of commands supported by the gateways.
|
||||
*/
|
||||
public enum Command {
|
||||
|
||||
/**
|
||||
* Perform {@link RSocket#fireAndForget fireAndForget}.
|
||||
* @see RSocketRequester.ResponseSpec#send()
|
||||
*/
|
||||
fireAndForget,
|
||||
|
||||
/**
|
||||
* Perform {@link RSocket#requestResponse requestResponse}.
|
||||
* @see RSocketRequester.ResponseSpec#retrieveMono
|
||||
*/
|
||||
requestResponse,
|
||||
|
||||
/**
|
||||
* Perform {@link RSocket#requestStream requestStream} or
|
||||
* {@link RSocket#requestChannel requestChannel} depending on whether
|
||||
* the request input consists of a single or multiple payloads.
|
||||
* @see RSocketRequester.ResponseSpec#retrieveFlux
|
||||
*/
|
||||
requestStreamOrChannel
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Provides classes representing outbound RSocket components.
|
||||
*/
|
||||
package org.springframework.integration.rsocket.outbound;
|
||||
@@ -0,0 +1 @@
|
||||
http\://www.springframework.org/schema/integration/rsocket=org.springframework.integration.rsocket.config.RSocketNamespaceHandler
|
||||
@@ -0,0 +1,2 @@
|
||||
http\://www.springframework.org/schema/integration/rsocket/spring-integration-rsocket-5.2.xsd=org/springframework/integration/rsocket/config/spring-integration-rsocket-5.2.xsd
|
||||
http\://www.springframework.org/schema/integration/rsocket/spring-integration-rsocket.xsd=org/springframework/integration/rsocket/config/spring-integration-rsocket-5.2.xsd
|
||||
@@ -0,0 +1,4 @@
|
||||
# Tooling related information for the integration rsocket namespace
|
||||
http\://www.springframework.org/schema/integration/rsocket@name=integration rsocket Namespace
|
||||
http\://www.springframework.org/schema/integration/rsocket@prefix=int-rsocket
|
||||
http\://www.springframework.org/schema/integration/rsocket@icon=org/springframework/integration/rsocket/config/spring-integration-rsocket.gif
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsd:schema xmlns="http://www.springframework.org/schema/integration/rsocket"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:tool="http://www.springframework.org/schema/tool"
|
||||
xmlns:integration="http://www.springframework.org/schema/integration"
|
||||
targetNamespace="http://www.springframework.org/schema/integration/rsocket"
|
||||
elementFormDefault="qualified"
|
||||
attributeFormDefault="unqualified">
|
||||
|
||||
<xsd:import namespace="http://www.springframework.org/schema/beans"/>
|
||||
<xsd:import namespace="http://www.springframework.org/schema/tool"/>
|
||||
<xsd:import namespace="http://www.springframework.org/schema/integration"
|
||||
schemaLocation="https://www.springframework.org/schema/integration/spring-integration-5.2.xsd"/>
|
||||
|
||||
<xsd:annotation>
|
||||
<xsd:documentation><![CDATA[
|
||||
Defines the configuration elements for Spring Integration's RSocket adapters.
|
||||
]]></xsd:documentation>
|
||||
</xsd:annotation>
|
||||
|
||||
</xsd:schema>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 578 B |
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
* Copyright 2019 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.rsocket.outbound;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.codec.CharSequenceEncoder;
|
||||
import org.springframework.core.codec.StringDecoder;
|
||||
import org.springframework.core.io.buffer.NettyDataBufferFactory;
|
||||
import org.springframework.integration.channel.FluxMessageChannel;
|
||||
import org.springframework.integration.channel.QueueChannel;
|
||||
import org.springframework.integration.config.EnableIntegration;
|
||||
import org.springframework.integration.dsl.IntegrationFlow;
|
||||
import org.springframework.integration.dsl.IntegrationFlows;
|
||||
import org.springframework.integration.dsl.MessageChannels;
|
||||
import org.springframework.integration.expression.FunctionExpression;
|
||||
import org.springframework.integration.support.MessageBuilder;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.MessageHandler;
|
||||
import org.springframework.messaging.MessageHandlingException;
|
||||
import org.springframework.messaging.PollableChannel;
|
||||
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||
import org.springframework.messaging.rsocket.MessageHandlerAcceptor;
|
||||
import org.springframework.messaging.rsocket.RSocketStrategies;
|
||||
import org.springframework.messaging.support.ErrorMessage;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import io.netty.buffer.PooledByteBufAllocator;
|
||||
import io.rsocket.RSocketFactory;
|
||||
import io.rsocket.frame.decoder.PayloadDecoder;
|
||||
import io.rsocket.transport.netty.client.TcpClientTransport;
|
||||
import io.rsocket.transport.netty.server.CloseableChannel;
|
||||
import io.rsocket.transport.netty.server.TcpServerTransport;
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.ReplayProcessor;
|
||||
import reactor.netty.tcp.TcpServer;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
/**
|
||||
* @author Artem Bilan
|
||||
*
|
||||
* @since 5.2
|
||||
*/
|
||||
@SpringJUnitConfig
|
||||
@DirtiesContext
|
||||
public class RSocketOutboundGatewayIntegrationTests {
|
||||
|
||||
private static final String ROUTE_HEADER = "rsocket_route";
|
||||
|
||||
private static final String COMMAND_HEADER = "rsocket_command";
|
||||
|
||||
private static AnnotationConfigApplicationContext context;
|
||||
|
||||
private static int port;
|
||||
|
||||
private static CloseableChannel server;
|
||||
|
||||
@Autowired
|
||||
private FluxMessageChannel inputChannel;
|
||||
|
||||
@Autowired
|
||||
private FluxMessageChannel resultChannel;
|
||||
|
||||
@Autowired
|
||||
private PollableChannel errorChannel;
|
||||
|
||||
@BeforeAll
|
||||
static void setup() {
|
||||
context = new AnnotationConfigApplicationContext(ServerConfig.class);
|
||||
TcpServer tcpServer =
|
||||
TcpServer.create().port(0)
|
||||
.doOnBound(server -> port = server.port());
|
||||
server = RSocketFactory.receive()
|
||||
.frameDecoder(PayloadDecoder.ZERO_COPY)
|
||||
.acceptor(context.getBean(MessageHandlerAcceptor.class))
|
||||
.transport(TcpServerTransport.create(tcpServer))
|
||||
.start()
|
||||
.block();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
context.close();
|
||||
server.dispose();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fireAndForget() {
|
||||
Disposable disposable = Flux.from(this.resultChannel).subscribe();
|
||||
this.inputChannel.send(
|
||||
MessageBuilder.withPayload("Hello")
|
||||
.setHeader(ROUTE_HEADER, "receive")
|
||||
.setHeader(COMMAND_HEADER, RSocketOutboundGateway.Command.fireAndForget)
|
||||
.build());
|
||||
|
||||
StepVerifier.create(context.getBean(ServerController.class).fireForgetPayloads)
|
||||
.expectNext("Hello")
|
||||
.thenCancel()
|
||||
.verify();
|
||||
|
||||
disposable.dispose();
|
||||
}
|
||||
|
||||
@Test
|
||||
void echo() {
|
||||
this.inputChannel.send(
|
||||
MessageBuilder.withPayload("Hello")
|
||||
.setHeader(ROUTE_HEADER, "echo")
|
||||
.setHeader(COMMAND_HEADER, RSocketOutboundGateway.Command.requestResponse)
|
||||
.build());
|
||||
|
||||
StepVerifier.create(
|
||||
Flux.from(this.resultChannel)
|
||||
.map(Message::getPayload)
|
||||
.cast(String.class))
|
||||
.expectNext("Hello")
|
||||
.thenCancel()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void echoAsync() {
|
||||
this.inputChannel.send(
|
||||
MessageBuilder.withPayload("Hello")
|
||||
.setHeader(ROUTE_HEADER, "echo-async")
|
||||
.setHeader(COMMAND_HEADER, RSocketOutboundGateway.Command.requestResponse)
|
||||
.build());
|
||||
|
||||
StepVerifier.create(
|
||||
Flux.from(this.resultChannel)
|
||||
.map(Message::getPayload)
|
||||
.cast(String.class))
|
||||
.expectNext("Hello async")
|
||||
.thenCancel()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void echoStream() {
|
||||
this.inputChannel.send(
|
||||
MessageBuilder.withPayload("Hello")
|
||||
.setHeader(ROUTE_HEADER, "echo-stream")
|
||||
.setHeader(COMMAND_HEADER, RSocketOutboundGateway.Command.requestStreamOrChannel)
|
||||
.build());
|
||||
|
||||
Message<?> resultMessage =
|
||||
Flux.from(this.resultChannel)
|
||||
.blockFirst();
|
||||
|
||||
assertThat(resultMessage)
|
||||
.isNotNull()
|
||||
.extracting(Message::getPayload)
|
||||
.isInstanceOf(Flux.class);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Flux<String> resultStream = (Flux<String>) resultMessage.getPayload();
|
||||
StepVerifier.create(resultStream)
|
||||
.expectNext("Hello 0").expectNextCount(6).expectNext("Hello 7")
|
||||
.thenCancel()
|
||||
.verify();
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void echoChannel() {
|
||||
this.inputChannel.send(
|
||||
MessageBuilder.withPayload(Flux.range(1, 10).map(i -> "Hello " + i))
|
||||
.setHeader(ROUTE_HEADER, "echo-channel")
|
||||
.setHeader(COMMAND_HEADER, RSocketOutboundGateway.Command.requestStreamOrChannel)
|
||||
.build());
|
||||
|
||||
Message<?> resultMessage =
|
||||
Flux.from(this.resultChannel)
|
||||
.blockFirst();
|
||||
|
||||
assertThat(resultMessage)
|
||||
.isNotNull()
|
||||
.extracting(Message::getPayload)
|
||||
.isInstanceOf(Flux.class);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Flux<String> resultStream = (Flux<String>) resultMessage.getPayload();
|
||||
StepVerifier.create(resultStream)
|
||||
.expectNext("Hello 1 async").expectNextCount(8).expectNext("Hello 10 async")
|
||||
.thenCancel()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidReturnValue() {
|
||||
this.inputChannel.send(
|
||||
MessageBuilder.withPayload("Hello")
|
||||
.setHeader(ROUTE_HEADER, "void-return-value")
|
||||
.setHeader(COMMAND_HEADER, RSocketOutboundGateway.Command.requestStreamOrChannel)
|
||||
.build());
|
||||
|
||||
Message<?> resultMessage =
|
||||
Flux.from(this.resultChannel)
|
||||
.blockFirst();
|
||||
|
||||
assertThat(resultMessage)
|
||||
.isNotNull()
|
||||
.extracting(Message::getPayload)
|
||||
.isInstanceOf(Flux.class);
|
||||
|
||||
Flux<?> resultStream = (Flux<?>) resultMessage.getPayload();
|
||||
StepVerifier.create(resultStream)
|
||||
.expectComplete()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidReturnValueFromExceptionHandler() {
|
||||
this.inputChannel.send(
|
||||
MessageBuilder.withPayload("bad")
|
||||
.setHeader(ROUTE_HEADER, "void-return-value")
|
||||
.setHeader(COMMAND_HEADER, RSocketOutboundGateway.Command.requestStreamOrChannel)
|
||||
.build());
|
||||
|
||||
Message<?> resultMessage =
|
||||
Flux.from(this.resultChannel)
|
||||
.blockFirst();
|
||||
|
||||
assertThat(resultMessage)
|
||||
.isNotNull()
|
||||
.extracting(Message::getPayload)
|
||||
.isInstanceOf(Flux.class);
|
||||
|
||||
Flux<?> resultStream = (Flux<?>) resultMessage.getPayload();
|
||||
StepVerifier.create(resultStream)
|
||||
.expectComplete()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleWithThrownException() {
|
||||
this.inputChannel.send(
|
||||
MessageBuilder.withPayload("a")
|
||||
.setHeader(ROUTE_HEADER, "thrown-exception")
|
||||
.setHeader(COMMAND_HEADER, RSocketOutboundGateway.Command.requestResponse)
|
||||
.build());
|
||||
|
||||
StepVerifier.create(
|
||||
Flux.from(this.resultChannel)
|
||||
.map(Message::getPayload)
|
||||
.cast(String.class))
|
||||
.expectNext("Invalid input error handled")
|
||||
.thenCancel()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleWithErrorSignal() {
|
||||
this.inputChannel.send(
|
||||
MessageBuilder.withPayload("a")
|
||||
.setHeader(ROUTE_HEADER, "error-signal")
|
||||
.setHeader(COMMAND_HEADER, RSocketOutboundGateway.Command.requestResponse)
|
||||
.build());
|
||||
|
||||
StepVerifier.create(
|
||||
Flux.from(this.resultChannel)
|
||||
.map(Message::getPayload)
|
||||
.cast(String.class))
|
||||
.expectNext("Invalid input error handled")
|
||||
.thenCancel()
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noMatchingRoute() {
|
||||
Disposable disposable = Flux.from(this.resultChannel).subscribe();
|
||||
this.inputChannel.send(
|
||||
MessageBuilder.withPayload("anything")
|
||||
.setHeader(ROUTE_HEADER, "invalid")
|
||||
.setHeader(COMMAND_HEADER, RSocketOutboundGateway.Command.requestResponse)
|
||||
.build());
|
||||
|
||||
Message<?> errorMessage = errorChannel.receive(10_000);
|
||||
|
||||
assertThat(errorMessage).isNotNull()
|
||||
.isInstanceOf(ErrorMessage.class)
|
||||
.extracting(Message::getPayload)
|
||||
.isInstanceOf(MessageHandlingException.class)
|
||||
.satisfies((ex) -> assertThat((Exception) ex)
|
||||
.hasMessageContaining("io.rsocket.exceptions.ApplicationErrorException: " +
|
||||
"No handler for destination 'invalid'"));
|
||||
|
||||
disposable.dispose();
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableIntegration
|
||||
public static class ClientConfig {
|
||||
|
||||
@Bean
|
||||
public MessageHandler rsocketOutboundGateway() {
|
||||
RSocketOutboundGateway rsocketOutboundGateway =
|
||||
new RSocketOutboundGateway(TcpClientTransport.create(port),
|
||||
new FunctionExpression<Message<?>>((m) -> m.getHeaders().get(ROUTE_HEADER)));
|
||||
rsocketOutboundGateway.setCommandExpression(
|
||||
new FunctionExpression<Message<?>>((m) -> m.getHeaders().get(COMMAND_HEADER)));
|
||||
rsocketOutboundGateway.setFactoryConfigurer((factory) -> factory.frameDecoder(PayloadDecoder.ZERO_COPY));
|
||||
rsocketOutboundGateway.setStrategiesConfigurer((strategies) ->
|
||||
strategies.decoder(StringDecoder.allMimeTypes())
|
||||
.encoder(CharSequenceEncoder.allMimeTypes())
|
||||
.dataBufferFactory(new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT)));
|
||||
return rsocketOutboundGateway;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IntegrationFlow rsocketOutboundFlow() {
|
||||
return IntegrationFlows.from(MessageChannels.flux("inputChannel"))
|
||||
.handle(rsocketOutboundGateway())
|
||||
.channel(c -> c.flux("resultChannel"))
|
||||
.get();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PollableChannel errorChannel() {
|
||||
return new QueueChannel();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class ServerConfig {
|
||||
|
||||
@Bean
|
||||
public ServerController controller() {
|
||||
return new ServerController();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MessageHandlerAcceptor messageHandlerAcceptor() {
|
||||
MessageHandlerAcceptor acceptor = new MessageHandlerAcceptor();
|
||||
acceptor.setRSocketStrategies(rsocketStrategies());
|
||||
return acceptor;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RSocketStrategies rsocketStrategies() {
|
||||
return RSocketStrategies.builder()
|
||||
.decoder(StringDecoder.allMimeTypes())
|
||||
.encoder(CharSequenceEncoder.allMimeTypes())
|
||||
.dataBufferFactory(new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT))
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Controller
|
||||
static class ServerController {
|
||||
|
||||
final ReplayProcessor<String> fireForgetPayloads = ReplayProcessor.create();
|
||||
|
||||
@MessageMapping("receive")
|
||||
void receive(String payload) {
|
||||
this.fireForgetPayloads.onNext(payload);
|
||||
}
|
||||
|
||||
@MessageMapping("echo")
|
||||
String echo(String payload) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
@MessageMapping("echo-async")
|
||||
Mono<String> echoAsync(String payload) {
|
||||
return Mono.delay(Duration.ofMillis(10)).map(aLong -> payload + " async");
|
||||
}
|
||||
|
||||
@MessageMapping("echo-stream")
|
||||
Flux<String> echoStream(String payload) {
|
||||
return Flux.interval(Duration.ofMillis(10)).map(aLong -> payload + " " + aLong);
|
||||
}
|
||||
|
||||
@MessageMapping("echo-channel")
|
||||
Flux<String> echoChannel(Flux<String> payloads) {
|
||||
return payloads.delayElements(Duration.ofMillis(10)).map(payload -> payload + " async");
|
||||
}
|
||||
|
||||
@MessageMapping("thrown-exception")
|
||||
Mono<String> handleAndThrow(String payload) {
|
||||
throw new IllegalArgumentException("Invalid input error");
|
||||
}
|
||||
|
||||
@MessageMapping("error-signal")
|
||||
Mono<String> handleAndReturnError(String payload) {
|
||||
return Mono.error(new IllegalArgumentException("Invalid input error"));
|
||||
}
|
||||
|
||||
@MessageMapping("void-return-value")
|
||||
Mono<Void> voidReturnValue(String payload) {
|
||||
return !payload.equals("bad") ?
|
||||
Mono.delay(Duration.ofMillis(10)).then(Mono.empty()) :
|
||||
Mono.error(new IllegalStateException("bad"));
|
||||
}
|
||||
|
||||
@MessageExceptionHandler
|
||||
Mono<String> handleException(IllegalArgumentException ex) {
|
||||
return Mono.delay(Duration.ofMillis(10)).map(aLong -> ex.getMessage() + " handled");
|
||||
}
|
||||
|
||||
@MessageExceptionHandler
|
||||
Mono<Void> handleExceptionWithVoidReturnValue(IllegalStateException ex) {
|
||||
return Mono.delay(Duration.ofMillis(10)).then(Mono.empty());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Configuration status="WARN">
|
||||
<Appenders>
|
||||
<Console name="STDOUT" target="SYSTEM_OUT">
|
||||
<PatternLayout pattern="%d %p [%t] [%c] - %m%n" />
|
||||
</Console>
|
||||
</Appenders>
|
||||
<Loggers>
|
||||
<Logger name="org.springframework" level="warn"/>
|
||||
<Logger name="org.springframework.integration" level="warn"/>
|
||||
<Logger name="org.springframework.integration.rsocket" level="warn"/>
|
||||
<Root level="warn">
|
||||
<AppenderRef ref="STDOUT" />
|
||||
</Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
Reference in New Issue
Block a user