From bcf4f3911b6d2ee672611c53f5235510447daa0a Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Tue, 6 Nov 2018 14:14:48 -0500 Subject: [PATCH 01/17] Reactive InvocableHandlerMethod in spring-messaging See gh-21987 --- spring-messaging/spring-messaging.gradle | 1 + .../HandlerMethodArgumentResolver.java | 52 ++++ ...andlerMethodArgumentResolverComposite.java | 142 +++++++++++ .../HandlerMethodReturnValueHandler.java | 53 ++++ ...dlerMethodReturnValueHandlerComposite.java | 108 ++++++++ .../reactive/InvocableHandlerMethod.java | 213 ++++++++++++++++ .../invocation/reactive/package-info.java | 10 + .../reactive/InvocableHandlerMethodTests.java | 237 ++++++++++++++++++ .../reactive/StubArgumentResolver.java | 74 ++++++ 9 files changed, 890 insertions(+) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolver.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolverComposite.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandlerComposite.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethod.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/package-info.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/StubArgumentResolver.java diff --git a/spring-messaging/spring-messaging.gradle b/spring-messaging/spring-messaging.gradle index b8e29c0960..37e85a3d6e 100644 --- a/spring-messaging/spring-messaging.gradle +++ b/spring-messaging/spring-messaging.gradle @@ -24,6 +24,7 @@ dependencies { exclude group: "org.springframework", module: "spring-context" } testCompile("org.apache.activemq:activemq-stomp:5.8.0") + testCompile("io.projectreactor:reactor-test") testCompile("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}") testCompile("org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}") testCompile("org.xmlunit:xmlunit-matchers:2.6.2") diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolver.java new file mode 100644 index 0000000000..8edf9be8a9 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolver.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; + +/** + * Strategy interface for resolving method parameters into argument values + * in the context of a given {@link Message}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface HandlerMethodArgumentResolver { + + /** + * Whether the given {@linkplain MethodParameter method parameter} is + * supported by this resolver. + * @param parameter the method parameter to check + * @return {@code true} if this resolver supports the supplied parameter; + * {@code false} otherwise + */ + boolean supportsParameter(MethodParameter parameter); + + /** + * Resolves a method parameter into an argument value from a given message. + * @param parameter the method parameter to resolve. + * This parameter must have previously been passed to + * {@link #supportsParameter(org.springframework.core.MethodParameter)} + * which must have returned {@code true}. + * @param message the currently processed message + * @return {@code Mono} for the argument value, possibly empty + */ + Mono resolveArgument(MethodParameter parameter, Message message); + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolverComposite.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolverComposite.java new file mode 100644 index 0000000000..8263ceb67e --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolverComposite.java @@ -0,0 +1,142 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * Resolves method parameters by delegating to a list of registered + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + * Previously resolved method parameters are cached for faster lookups. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +class HandlerMethodArgumentResolverComposite implements HandlerMethodArgumentResolver { + + protected final Log logger = LogFactory.getLog(getClass()); + + private final List argumentResolvers = new LinkedList<>(); + + private final Map argumentResolverCache = + new ConcurrentHashMap<>(256); + + + /** + * Add the given {@link HandlerMethodArgumentResolver}. + */ + public HandlerMethodArgumentResolverComposite addResolver(HandlerMethodArgumentResolver resolver) { + this.argumentResolvers.add(resolver); + return this; + } + + /** + * Add the given {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + */ + public HandlerMethodArgumentResolverComposite addResolvers(@Nullable HandlerMethodArgumentResolver... resolvers) { + if (resolvers != null) { + Collections.addAll(this.argumentResolvers, resolvers); + } + return this; + } + + /** + * Add the given {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + */ + public HandlerMethodArgumentResolverComposite addResolvers( + @Nullable List resolvers) { + + if (resolvers != null) { + this.argumentResolvers.addAll(resolvers); + } + return this; + } + + /** + * Return a read-only list with the contained resolvers, or an empty list. + */ + public List getResolvers() { + return Collections.unmodifiableList(this.argumentResolvers); + } + + /** + * Clear the list of configured resolvers. + */ + public void clear() { + this.argumentResolvers.clear(); + } + + + /** + * Whether the given {@linkplain MethodParameter method parameter} is + * supported by any registered {@link HandlerMethodArgumentResolver}. + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return getArgumentResolver(parameter) != null; + } + + /** + * Iterate over registered + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers} and + * invoke the one that supports it. + * @throws IllegalStateException if no suitable + * {@link HandlerMethodArgumentResolver} is found. + */ + @Override + public Mono resolveArgument(MethodParameter parameter, Message message) { + HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter); + if (resolver == null) { + throw new IllegalArgumentException( + "Unsupported parameter type [" + parameter.getParameterType().getName() + "]." + + " supportsParameter should be called first."); + } + return resolver.resolveArgument(parameter, message); + } + + /** + * Find a registered {@link HandlerMethodArgumentResolver} that supports + * the given method parameter. + */ + @Nullable + private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) { + HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter); + if (result == null) { + for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) { + if (methodArgumentResolver.supportsParameter(parameter)) { + result = methodArgumentResolver; + this.argumentResolverCache.put(parameter, result); + break; + } + } + } + return result; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java new file mode 100644 index 0000000000..9cf6c894ce --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2017 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 + * + * http://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.messaging.handler.invocation.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * Handle the return value from the invocation of an annotated {@link Message} + * handling method. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface HandlerMethodReturnValueHandler { + + /** + * Whether the given {@linkplain MethodParameter method return type} is + * supported by this handler. + * @param returnType the method return type to check + * @return {@code true} if this handler supports the supplied return type; + * {@code false} otherwise + */ + boolean supportsReturnType(MethodParameter returnType); + + /** + * Handle the given return value. + * @param returnValue the value returned from the handler method + * @param returnType the type of the return value. This type must have previously + * been passed to {@link #supportsReturnType(MethodParameter)} + * and it must have returned {@code true}. + * @return {@code Mono} to indicate when handling is complete. + */ + Mono handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, Message message); + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandlerComposite.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandlerComposite.java new file mode 100644 index 0000000000..480484d456 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandlerComposite.java @@ -0,0 +1,108 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * A HandlerMethodReturnValueHandler that wraps and delegates to others. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class HandlerMethodReturnValueHandlerComposite implements HandlerMethodReturnValueHandler { + + protected final Log logger = LogFactory.getLog(getClass()); + + + private final List returnValueHandlers = new ArrayList<>(); + + + /** + * Return a read-only list with the configured handlers. + */ + public List getReturnValueHandlers() { + return Collections.unmodifiableList(this.returnValueHandlers); + } + + /** + * Clear the list of configured handlers. + */ + public void clear() { + this.returnValueHandlers.clear(); + } + + /** + * Add the given {@link HandlerMethodReturnValueHandler}. + */ + public HandlerMethodReturnValueHandlerComposite addHandler(HandlerMethodReturnValueHandler returnValueHandler) { + this.returnValueHandlers.add(returnValueHandler); + return this; + } + + /** + * Add the given {@link HandlerMethodReturnValueHandler HandlerMethodReturnValueHandlers}. + */ + public HandlerMethodReturnValueHandlerComposite addHandlers( + @Nullable List handlers) { + + if (handlers != null) { + this.returnValueHandlers.addAll(handlers); + } + return this; + } + + @Override + public boolean supportsReturnType(MethodParameter returnType) { + return getReturnValueHandler(returnType) != null; + } + + @Override + public Mono handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, Message message) { + HandlerMethodReturnValueHandler handler = getReturnValueHandler(returnType); + if (handler == null) { + throw new IllegalStateException("No handler for return value type: " + returnType.getParameterType()); + } + if (logger.isTraceEnabled()) { + logger.trace("Processing return value with " + handler); + } + return handler.handleReturnValue(returnValue, returnType, message); + } + + @SuppressWarnings("ForLoopReplaceableByForEach") + @Nullable + private HandlerMethodReturnValueHandler getReturnValueHandler(MethodParameter returnType) { + for (int i = 0; i < this.returnValueHandlers.size(); i++) { + HandlerMethodReturnValueHandler handler = this.returnValueHandlers.get(i); + if (handler.supportsReturnType(returnType)) { + return handler; + } + } + return null; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethod.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethod.java new file mode 100644 index 0000000000..0ab85b55fc --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethod.java @@ -0,0 +1,213 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import reactor.core.publisher.Mono; + +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; + +/** + * Extension of {@link HandlerMethod} that invokes the underlying method with + * argument values resolved from the current HTTP request through a list of + * {@link HandlerMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class InvocableHandlerMethod extends HandlerMethod { + + private static final Mono EMPTY_ARGS = Mono.just(new Object[0]); + + private static final Object NO_ARG_VALUE = new Object(); + + + private HandlerMethodArgumentResolverComposite resolvers = new HandlerMethodArgumentResolverComposite(); + + private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + private ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance(); + + + /** + * Create an instance from a {@code HandlerMethod}. + */ + public InvocableHandlerMethod(HandlerMethod handlerMethod) { + super(handlerMethod); + } + + /** + * Create an instance from a bean instance and a method. + */ + public InvocableHandlerMethod(Object bean, Method method) { + super(bean, method); + } + + + /** + * Configure the argument resolvers to use to use for resolving method + * argument values against a {@code ServerWebExchange}. + */ + public void setArgumentResolvers(List resolvers) { + this.resolvers.addResolvers(resolvers); + } + + /** + * Return the configured argument resolvers. + */ + public List getResolvers() { + return this.resolvers.getResolvers(); + } + + /** + * Set the ParameterNameDiscoverer for resolving parameter names when needed + * (e.g. default request attribute name). + *

Default is a {@link DefaultParameterNameDiscoverer}. + */ + public void setParameterNameDiscoverer(ParameterNameDiscoverer nameDiscoverer) { + this.parameterNameDiscoverer = nameDiscoverer; + } + + /** + * Return the configured parameter name discoverer. + */ + public ParameterNameDiscoverer getParameterNameDiscoverer() { + return this.parameterNameDiscoverer; + } + + /** + * Configure a reactive registry. This is needed for cases where the response + * is fully handled within the controller in combination with an async void + * return value. + *

By default this is an instance of {@link ReactiveAdapterRegistry} with + * default settings. + * @param registry the registry to use + */ + public void setReactiveAdapterRegistry(ReactiveAdapterRegistry registry) { + this.reactiveAdapterRegistry = registry; + } + + + /** + * Invoke the method for the given exchange. + * @param message the current message + * @param providedArgs optional list of argument values to match by type + * @return a Mono with the result from the invocation. + */ + public Mono invoke(Message message, Object... providedArgs) { + + return getMethodArgumentValues(message, providedArgs).flatMap(args -> { + Object value; + try { + ReflectionUtils.makeAccessible(getBridgedMethod()); + value = getBridgedMethod().invoke(getBean(), args); + } + catch (IllegalArgumentException ex) { + assertTargetBean(getBridgedMethod(), getBean(), args); + String text = (ex.getMessage() != null ? ex.getMessage() : "Illegal argument"); + return Mono.error(new IllegalStateException(formatInvokeError(text, args), ex)); + } + catch (InvocationTargetException ex) { + return Mono.error(ex.getTargetException()); + } + catch (Throwable ex) { + // Unlikely to ever get here, but it must be handled... + return Mono.error(new IllegalStateException(formatInvokeError("Invocation failure", args), ex)); + } + + MethodParameter returnType = getReturnType(); + ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(returnType.getParameterType()); + return isAsyncVoidReturnType(returnType, adapter) ? + Mono.from(adapter.toPublisher(value)) : Mono.justOrEmpty(value); + }); + } + + private Mono getMethodArgumentValues(Message message, Object... providedArgs) { + if (ObjectUtils.isEmpty(getMethodParameters())) { + return EMPTY_ARGS; + } + MethodParameter[] parameters = getMethodParameters(); + List> argMonos = new ArrayList<>(parameters.length); + for (MethodParameter parameter : parameters) { + parameter.initParameterNameDiscovery(this.parameterNameDiscoverer); + Object providedArg = findProvidedArgument(parameter, providedArgs); + if (providedArg != null) { + argMonos.add(Mono.just(providedArg)); + continue; + } + if (!this.resolvers.supportsParameter(parameter)) { + return Mono.error(new MethodArgumentResolutionException( + message, parameter, formatArgumentError(parameter, "No suitable resolver"))); + } + try { + argMonos.add(this.resolvers.resolveArgument(parameter, message) + .defaultIfEmpty(NO_ARG_VALUE) + .doOnError(cause -> logArgumentErrorIfNecessary(parameter, cause))); + } + catch (Exception ex) { + logArgumentErrorIfNecessary(parameter, ex); + argMonos.add(Mono.error(ex)); + } + } + return Mono.zip(argMonos, values -> + Stream.of(values).map(o -> o != NO_ARG_VALUE ? o : null).toArray()); + } + + private void logArgumentErrorIfNecessary(MethodParameter parameter, Throwable cause) { + // Leave stack trace for later, if error is not handled.. + String causeMessage = cause.getMessage(); + if (!causeMessage.contains(parameter.getExecutable().toGenericString())) { + if (logger.isDebugEnabled()) { + logger.debug(formatArgumentError(parameter, causeMessage)); + } + } + } + + private boolean isAsyncVoidReturnType(MethodParameter returnType, @Nullable ReactiveAdapter reactiveAdapter) { + if (reactiveAdapter != null && reactiveAdapter.supportsEmpty()) { + if (reactiveAdapter.isNoValue()) { + return true; + } + Type parameterType = returnType.getGenericParameterType(); + if (parameterType instanceof ParameterizedType) { + ParameterizedType type = (ParameterizedType) parameterType; + if (type.getActualTypeArguments().length == 1) { + return Void.class.equals(type.getActualTypeArguments()[0]); + } + } + } + return false; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/package-info.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/package-info.java new file mode 100644 index 0000000000..27a85c242d --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/package-info.java @@ -0,0 +1,10 @@ +/** + * Common infrastructure for invoking message handler methods with non-blocking, + * and reactive contracts. + */ +@NonNullApi +@NonNullFields +package org.springframework.messaging.handler.invocation.reactive; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java new file mode 100644 index 0000000000..c0e2d36f08 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java @@ -0,0 +1,237 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.ResolvableMethod; +import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link InvocableHandlerMethod}. + * + * @author Rossen Stoyanchev + * @author Juergen Hoeller + */ +public class InvocableHandlerMethodTests { + + private final Message message = mock(Message.class); + + private final List resolvers = new ArrayList<>(); + + + @Test + public void resolveArg() { + this.resolvers.add(new StubArgumentResolver(99)); + this.resolvers.add(new StubArgumentResolver("value")); + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + Object value = invokeAndBlock(new Handler(), method); + + assertEquals(1, getStubResolver(0).getResolvedParameters().size()); + assertEquals(1, getStubResolver(1).getResolvedParameters().size()); + assertEquals("99-value", value); + assertEquals("intArg", getStubResolver(0).getResolvedParameters().get(0).getParameterName()); + assertEquals("stringArg", getStubResolver(1).getResolvedParameters().get(0).getParameterName()); + } + + @Test + public void resolveNoArgValue() { + this.resolvers.add(new StubArgumentResolver(Integer.class)); + this.resolvers.add(new StubArgumentResolver(String.class)); + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + Object value = invokeAndBlock(new Handler(), method); + + assertEquals(1, getStubResolver(0).getResolvedParameters().size()); + assertEquals(1, getStubResolver(1).getResolvedParameters().size()); + assertEquals("null-null", value); + } + + @Test + public void cannotResolveArg() { + try { + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + invokeAndBlock(new Handler(), method); + fail("Expected exception"); + } + catch (MethodArgumentResolutionException ex) { + assertNotNull(ex.getMessage()); + assertTrue(ex.getMessage().contains("Could not resolve parameter [0]")); + } + } + + @Test + public void resolveProvidedArg() { + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + Object value = invokeAndBlock(new Handler(), method, 99, "value"); + + assertNotNull(value); + assertEquals(String.class, value.getClass()); + assertEquals("99-value", value); + } + + @Test + public void resolveProvidedArgFirst() { + this.resolvers.add(new StubArgumentResolver(1)); + this.resolvers.add(new StubArgumentResolver("value1")); + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + Object value = invokeAndBlock(new Handler(), method, 2, "value2"); + + assertEquals("2-value2", value); + } + + @Test + public void exceptionInResolvingArg() { + this.resolvers.add(new InvocableHandlerMethodTests.ExceptionRaisingArgumentResolver()); + try { + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + invokeAndBlock(new Handler(), method); + fail("Expected exception"); + } + catch (IllegalArgumentException ex) { + // expected - allow HandlerMethodArgumentResolver exceptions to propagate + } + } + + @Test + public void illegalArgumentException() { + this.resolvers.add(new StubArgumentResolver(Integer.class, "__not_an_int__")); + this.resolvers.add(new StubArgumentResolver("value")); + try { + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + invokeAndBlock(new Handler(), method); + fail("Expected exception"); + } + catch (IllegalStateException ex) { + assertNotNull("Exception not wrapped", ex.getCause()); + assertTrue(ex.getCause() instanceof IllegalArgumentException); + assertTrue(ex.getMessage().contains("Endpoint [")); + assertTrue(ex.getMessage().contains("Method [")); + assertTrue(ex.getMessage().contains("with argument values:")); + assertTrue(ex.getMessage().contains("[0] [type=java.lang.String] [value=__not_an_int__]")); + assertTrue(ex.getMessage().contains("[1] [type=java.lang.String] [value=value")); + } + } + + @Test + public void invocationTargetException() { + Method method = ResolvableMethod.on(Handler.class).argTypes(Throwable.class).resolveMethod(); + + Throwable expected = new Throwable("error"); + Mono result = invoke(new Handler(), method, expected); + StepVerifier.create(result).expectErrorSatisfies(actual -> assertSame(expected, actual)).verify(); + } + + @Test + public void voidMethod() { + this.resolvers.add(new StubArgumentResolver(double.class, 5.25)); + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0.0d)).method(); + Handler handler = new Handler(); + Object value = invokeAndBlock(handler, method); + + assertNull(value); + assertEquals(1, getStubResolver(0).getResolvedParameters().size()); + assertEquals("5.25", handler.getResult()); + assertEquals("amount", getStubResolver(0).getResolvedParameters().get(0).getParameterName()); + } + + @Test + public void voidMonoMethod() { + Method method = ResolvableMethod.on(Handler.class).mockCall(Handler::handleAsync).method(); + Handler handler = new Handler(); + Object value = invokeAndBlock(handler, method); + + assertNull(value); + assertEquals("success", handler.getResult()); + } + + + @Nullable + private Object invokeAndBlock(Object handler, Method method, Object... providedArgs) { + return invoke(handler, method, providedArgs).block(Duration.ofSeconds(5)); + } + + private Mono invoke(Object handler, Method method, Object... providedArgs) { + InvocableHandlerMethod handlerMethod = new InvocableHandlerMethod(handler, method); + handlerMethod.setArgumentResolvers(this.resolvers); + return handlerMethod.invoke(this.message, providedArgs); + } + + private StubArgumentResolver getStubResolver(int index) { + return (StubArgumentResolver) this.resolvers.get(index); + } + + + + @SuppressWarnings({"unused", "UnusedReturnValue", "SameParameterValue"}) + private static class Handler { + + private AtomicReference result = new AtomicReference<>(); + + + public String getResult() { + return this.result.get(); + } + + String handle(Integer intArg, String stringArg) { + return intArg + "-" + stringArg; + } + + void handle(double amount) { + this.result.set(String.valueOf(amount)); + } + + void handleWithException(Throwable ex) throws Throwable { + throw ex; + } + + Mono handleAsync() { + return Mono.delay(Duration.ofMillis(100)).thenEmpty(Mono.defer(() -> { + this.result.set("success"); + return Mono.empty(); + })); + } + } + + + private static class ExceptionRaisingArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return true; + } + + @Override + public Mono resolveArgument(MethodParameter parameter, Message message) { + return Mono.error(new IllegalArgumentException("oops, can't read")); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/StubArgumentResolver.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/StubArgumentResolver.java new file mode 100644 index 0000000000..66832e3670 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/StubArgumentResolver.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.util.ArrayList; +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * Stub resolver for a fixed value type and/or value. + * + * @author Rossen Stoyanchev + */ +public class StubArgumentResolver implements HandlerMethodArgumentResolver { + + private final Class valueType; + + @Nullable + private final Object value; + + private List resolvedParameters = new ArrayList<>(); + + + public StubArgumentResolver(Object value) { + this(value.getClass(), value); + } + + public StubArgumentResolver(Class valueType) { + this(valueType, null); + } + + public StubArgumentResolver(Class valueType, Object value) { + this.valueType = valueType; + this.value = value; + } + + + public List getResolvedParameters() { + return resolvedParameters; + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(this.valueType); + } + + @SuppressWarnings("unchecked") + @Override + public Mono resolveArgument(MethodParameter parameter, Message message) { + this.resolvedParameters.add(parameter); + return Mono.justOrEmpty(this.value); + } + +} From e3e1ffc98649dbab2fab34521b4cc9fad38e12d9 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Fri, 25 Jan 2019 17:47:57 -0500 Subject: [PATCH 02/17] Encoder/Decoder based payload serialization See gh-21987 --- spring-messaging/spring-messaging.gradle | 1 + .../PayloadMethodArgumentResolver.java | 299 ++++++++++++++++++ .../support/reactive/package-info.java | 10 + .../MethodArgumentResolutionException.java | 12 + ...stractEncoderMethodReturnValueHandler.java | 169 ++++++++++ .../PayloadMethodArgumentResolverTests.java | 208 ++++++++++++ .../InvocableHandlerMethodTests.java | 1 - .../invocation/MethodMessageHandlerTests.java | 2 +- .../{ => invocation}/ResolvableMethod.java | 14 +- .../EncoderMethodReturnValueHandlerTests.java | 154 +++++++++ .../reactive/InvocableHandlerMethodTests.java | 2 +- .../web/method/ResolvableMethod.java | 4 +- 12 files changed, 869 insertions(+), 7 deletions(-) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/package-info.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java rename spring-messaging/src/test/java/org/springframework/messaging/handler/{ => invocation}/ResolvableMethod.java (97%) create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java diff --git a/spring-messaging/spring-messaging.gradle b/spring-messaging/spring-messaging.gradle index 37e85a3d6e..ada7330be6 100644 --- a/spring-messaging/spring-messaging.gradle +++ b/spring-messaging/spring-messaging.gradle @@ -25,6 +25,7 @@ dependencies { } testCompile("org.apache.activemq:activemq-stomp:5.8.0") testCompile("io.projectreactor:reactor-test") + testCompile "io.reactivex.rxjava2:rxjava:${rxjava2Version}" testCompile("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}") testCompile("org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}") testCompile("org.xmlunit:xmlunit-matchers:2.6.2") diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java new file mode 100644 index 0000000000..6aad436572 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java @@ -0,0 +1,299 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation.support.reactive; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.Conventions; +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.DecodingException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException; +import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodArgumentResolver; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.SmartValidator; +import org.springframework.validation.Validator; +import org.springframework.validation.annotation.Validated; + +/** + * A resolver to extract and decode the payload of a message using a + * {@link Decoder}, where the payload is expected to be a {@link Publisher} of + * {@link DataBuffer DataBuffer}. + * + *

Validation is applied if the method argument is annotated with + * {@code @javax.validation.Valid} or + * {@link org.springframework.validation.annotation.Validated}. Validation + * failure results in an {@link MethodArgumentNotValidException}. + * + *

This resolver should be ordered last if {@link #useDefaultResolution} is + * set to {@code true} since in that case it supports all types and does not + * require the presence of {@link Payload}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class PayloadMethodArgumentResolver implements HandlerMethodArgumentResolver { + + protected final Log logger = LogFactory.getLog(getClass()); + + + private final List> decoders; + + @Nullable + private final Validator validator; + + private final ReactiveAdapterRegistry adapterRegistry; + + private final boolean useDefaultResolution; + + + public PayloadMethodArgumentResolver(List> decoders, @Nullable Validator validator, + @Nullable ReactiveAdapterRegistry registry, boolean useDefaultResolution) { + + Assert.isTrue(!CollectionUtils.isEmpty(decoders), "At least one Decoder is required."); + this.decoders = Collections.unmodifiableList(new ArrayList<>(decoders)); + this.validator = validator; + this.adapterRegistry = registry != null ? registry : ReactiveAdapterRegistry.getSharedInstance(); + this.useDefaultResolution = useDefaultResolution; + } + + + /** + * Return a read-only list of the configured decoders. + */ + public List> getDecoders() { + return this.decoders; + } + + /** + * Return the configured validator, if any. + */ + @Nullable + public Validator getValidator() { + return this.validator; + } + + /** + * Return the configured {@link ReactiveAdapterRegistry}. + */ + public ReactiveAdapterRegistry getAdapterRegistry() { + return this.adapterRegistry; + } + + /** + * Whether this resolver is configured to use default resolution, i.e. + * works for any argument type regardless of whether {@code @Payload} is + * present or not. + */ + public boolean isUseDefaultResolution() { + return this.useDefaultResolution; + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Payload.class) || this.useDefaultResolution; + } + + + /** + * Decode the content of the given message payload through a compatible + * {@link Decoder}. + * + *

Validation is applied if the method argument is annotated with + * {@code @javax.validation.Valid} or + * {@link org.springframework.validation.annotation.Validated}. Validation + * failure results in an {@link MethodArgumentNotValidException}. + * + * @param parameter the target method argument that we are decoding to + * @param message the message from which the content was extracted + * @return a Mono with the result of argument resolution + * + * @see #extractPayloadContent(MethodParameter, Message) + * @see #getMimeType(Message) + */ + @Override + public final Mono resolveArgument(MethodParameter parameter, Message message) { + Payload ann = parameter.getParameterAnnotation(Payload.class); + if (ann != null && StringUtils.hasText(ann.expression())) { + throw new IllegalStateException("@Payload SpEL expressions not supported by this resolver"); + } + Publisher content = extractPayloadContent(parameter, message); + return decodeContent(parameter, message, ann == null || ann.required(), content, getMimeType(message)); + } + + /** + * Extract the content to decode from the message. By default, the message + * payload is expected to be {@code Publisher}. Sub-classes can + * override this method to change that assumption. + * @param parameter the target method parameter we're decoding to + * @param message the input message with the content + * @return the content to decode + */ + @SuppressWarnings("unchecked") + protected Publisher extractPayloadContent(MethodParameter parameter, Message message) { + Publisher content; + try { + content = (Publisher) message.getPayload(); + } + catch (ClassCastException ex) { + throw new MethodArgumentResolutionException( + message, parameter, "Expected Publisher payload", ex); + } + return content; + } + + /** + * Return the mime type for the content. By default this method checks the + * {@link MessageHeaders#CONTENT_TYPE} header expecting to find a + * {@link MimeType} value or a String to parse to a {@link MimeType}. + * @param message the input message + */ + @Nullable + protected MimeType getMimeType(Message message) { + Object headerValue = message.getHeaders().get(MessageHeaders.CONTENT_TYPE); + if (headerValue == null) { + return null; + } + else if (headerValue instanceof String) { + return MimeTypeUtils.parseMimeType((String) headerValue); + } + else if (headerValue instanceof MimeType) { + return (MimeType) headerValue; + } + else { + throw new IllegalArgumentException("Unexpected MimeType value: " + headerValue); + } + } + + private Mono decodeContent(MethodParameter parameter, Message message, + boolean isContentRequired, Publisher content, @Nullable MimeType mimeType) { + + ResolvableType targetType = ResolvableType.forMethodParameter(parameter); + Class resolvedType = targetType.resolve(); + ReactiveAdapter adapter = (resolvedType != null ? getAdapterRegistry().getAdapter(resolvedType) : null); + ResolvableType elementType = (adapter != null ? targetType.getGeneric() : targetType); + isContentRequired = isContentRequired || (adapter != null && !adapter.supportsEmpty()); + Consumer validator = getValidator(message, parameter); + + if (logger.isDebugEnabled()) { + logger.debug("Mime type:" + mimeType); + } + mimeType = mimeType != null ? mimeType : MimeTypeUtils.APPLICATION_OCTET_STREAM; + + for (Decoder decoder : this.decoders) { + if (decoder.canDecode(elementType, mimeType)) { + if (adapter != null && adapter.isMultiValue()) { + if (logger.isDebugEnabled()) { + logger.debug("0..N [" + elementType + "]"); + } + Flux flux = decoder.decode(content, elementType, mimeType, Collections.emptyMap()); + flux = flux.onErrorResume(ex -> Flux.error(handleReadError(parameter, message, ex))); + if (isContentRequired) { + flux = flux.switchIfEmpty(Flux.error(() -> handleMissingBody(parameter, message))); + } + if (validator != null) { + flux = flux.doOnNext(validator::accept); + } + return Mono.just(adapter.fromPublisher(flux)); + } + else { + if (logger.isDebugEnabled()) { + logger.debug("0..1 [" + elementType + "]"); + } + // Single-value (with or without reactive type wrapper) + Mono mono = decoder.decodeToMono(content, targetType, mimeType, Collections.emptyMap()); + mono = mono.onErrorResume(ex -> Mono.error(handleReadError(parameter, message, ex))); + if (isContentRequired) { + mono = mono.switchIfEmpty(Mono.error(() -> handleMissingBody(parameter, message))); + } + if (validator != null) { + mono = mono.doOnNext(validator::accept); + } + return (adapter != null ? Mono.just(adapter.fromPublisher(mono)) : Mono.from(mono)); + } + } + } + + return Mono.error(new MethodArgumentResolutionException( + message, parameter, "Cannot decode to [" + targetType + "]" + message)); + } + + private Throwable handleReadError(MethodParameter parameter, Message message, Throwable ex) { + return ex instanceof DecodingException ? + new MethodArgumentResolutionException(message, parameter, "Failed to read HTTP message", ex) : ex; + } + + private MethodArgumentResolutionException handleMissingBody(MethodParameter param, Message message) { + return new MethodArgumentResolutionException(message, param, + "Payload content is missing: " + param.getExecutable().toGenericString()); + } + + @Nullable + private Consumer getValidator(Message message, MethodParameter parameter) { + if (this.validator == null) { + return null; + } + for (Annotation ann : parameter.getParameterAnnotations()) { + Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class); + if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) { + Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann)); + Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints}); + String name = Conventions.getVariableNameForParameter(parameter); + return target -> { + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, name); + if (!ObjectUtils.isEmpty(validationHints) && this.validator instanceof SmartValidator) { + ((SmartValidator) this.validator).validate(target, bindingResult, validationHints); + } + else { + this.validator.validate(target, bindingResult); + } + if (bindingResult.hasErrors()) { + throw new MethodArgumentNotValidException(message, parameter, bindingResult); + } + }; + } + } + return null; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/package-info.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/package-info.java new file mode 100644 index 0000000000..41f18a41cd --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/package-info.java @@ -0,0 +1,10 @@ +/** + * Support classes for working with annotated message-handling methods with + * non-blocking, reactive contracts. + */ +@NonNullApi +@NonNullFields +package org.springframework.messaging.handler.annotation.support.reactive; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/MethodArgumentResolutionException.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/MethodArgumentResolutionException.java index 0d03ea2c98..a8302bd48b 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/MethodArgumentResolutionException.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/MethodArgumentResolutionException.java @@ -17,6 +17,7 @@ package org.springframework.messaging.handler.invocation; import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.MessagingException; @@ -51,6 +52,17 @@ public class MethodArgumentResolutionException extends MessagingException { this.parameter = parameter; } + /** + * Create a new instance providing the invalid {@code MethodParameter}, + * prepared description, and a cause. + */ + public MethodArgumentResolutionException( + Message message, MethodParameter parameter, String description, @Nullable Throwable cause) { + + super(message, getMethodParameterMessage(parameter) + ": " + description, cause); + this.parameter = parameter; + } + /** * Return the MethodParameter that was rejected. diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java new file mode 100644 index 0000000000..d7a3aa96b4 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java @@ -0,0 +1,169 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessagingException; +import org.springframework.util.Assert; + +/** + * Base class for a return value handler that encodes the return value, possibly + * a {@link Publisher} of values, to a {@code Flux} through a + * compatible {@link Encoder}. + * + *

Sub-classes must implement the abstract method + * {@link #handleEncodedContent} to do something with the resulting encoded + * content. + * + *

This handler should be ordered last since its {@link #supportsReturnType} + * returns {@code true} for any method parameter type. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public abstract class AbstractEncoderMethodReturnValueHandler implements HandlerMethodReturnValueHandler { + + private static final ResolvableType VOID_RESOLVABLE_TYPE = ResolvableType.forClass(Void.class); + + private static final ResolvableType OBJECT_RESOLVABLE_TYPE = ResolvableType.forClass(Object.class); + + + protected final Log logger = LogFactory.getLog(getClass()); + + + private final List> encoders; + + private final ReactiveAdapterRegistry adapterRegistry; + + // TODO: configure or passed via MessageHeaders + private DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); + + + protected AbstractEncoderMethodReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { + Assert.notEmpty(encoders, "At least one Encoder is required"); + Assert.notNull(registry, "ReactiveAdapterRegistry is required"); + this.encoders = Collections.unmodifiableList(encoders); + this.adapterRegistry = registry; + } + + + /** + * The configured encoders. + */ + public List> getEncoders() { + return this.encoders; + } + + /** + * The configured adapter registry. + */ + public ReactiveAdapterRegistry getAdapterRegistry() { + return this.adapterRegistry; + } + + + @Override + public boolean supportsReturnType(MethodParameter returnType) { + return true; + } + + @Override + public Mono handleReturnValue(Object returnValue, MethodParameter returnType, Message message) { + Flux encodedContent = encodeContent(returnValue, returnType, this.bufferFactory); + return handleEncodedContent(encodedContent, returnType, message); + } + + @SuppressWarnings("unchecked") + private Flux encodeContent(@Nullable Object content, MethodParameter returnType, + DataBufferFactory bufferFactory) { + + ResolvableType bodyType = ResolvableType.forMethodParameter(returnType); + ReactiveAdapter adapter = getAdapterRegistry().getAdapter(bodyType.resolve(), content); + + Publisher publisher; + ResolvableType elementType; + if (adapter != null) { + publisher = adapter.toPublisher(content); + ResolvableType genericType = bodyType.getGeneric(); + elementType = getElementType(adapter, genericType); + } + else { + publisher = Mono.justOrEmpty(content); + elementType = (bodyType.toClass() == Object.class && content != null ? + ResolvableType.forInstance(content) : bodyType); + } + + if (elementType.resolve() == void.class || elementType.resolve() == Void.class) { + return Flux.from(publisher).cast(DataBuffer.class); + } + + if (logger.isDebugEnabled()) { + logger.debug((publisher instanceof Mono ? "0..1" : "0..N") + " [" + elementType + "]"); + } + + for (Encoder encoder : getEncoders()) { + if (encoder.canEncode(elementType, null)) { + Map hints = Collections.emptyMap(); + return encoder.encode((Publisher) publisher, bufferFactory, elementType, null, hints); + } + } + + return Flux.error(new MessagingException("No encoder for " + returnType)); + } + + private ResolvableType getElementType(ReactiveAdapter adapter, ResolvableType genericType) { + if (adapter.isNoValue()) { + return VOID_RESOLVABLE_TYPE; + } + else if (genericType != ResolvableType.NONE) { + return genericType; + } + else { + return OBJECT_RESOLVABLE_TYPE; + } + } + + /** + * Handle the encoded content in some way, e.g. wrapping it in a message and + * passing it on for further processing. + * @param encodedContent the result of data encoding + * @param returnType return type of the handler method that produced the data + * @param message the input message handled by the handler method + * @return completion {@code Mono} for the handling + */ + protected abstract Mono handleEncodedContent( + Flux encodedContent, MethodParameter returnType, Message message); + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java new file mode 100644 index 0000000000..0797440d06 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java @@ -0,0 +1,208 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation.support.reactive; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException; +import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException; +import org.springframework.messaging.handler.invocation.ResolvableMethod; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.util.MimeTypeUtils; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import org.springframework.validation.annotation.Validated; + +import static org.junit.Assert.*; + + +/** + * Unit tests for {@link PayloadMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + */ +public class PayloadMethodArgumentResolverTests { + + private final List> decoders = new ArrayList<>(); + + private final ResolvableMethod testMethod = ResolvableMethod.on(getClass()).named("handle").build(); + + + @Test + public void supportsParameter() { + + boolean useDefaultResolution = true; + PayloadMethodArgumentResolver resolver = createResolver(null, useDefaultResolution); + + assertTrue(resolver.supportsParameter(this.testMethod.annotPresent(Payload.class).arg())); + assertTrue(resolver.supportsParameter(this.testMethod.annotNotPresent(Payload.class).arg(String.class))); + + useDefaultResolution = false; + resolver = createResolver(null, useDefaultResolution); + + assertTrue(resolver.supportsParameter(this.testMethod.annotPresent(Payload.class).arg())); + assertFalse(resolver.supportsParameter(this.testMethod.annotNotPresent(Payload.class).arg(String.class))); + } + + @Test + public void emptyBodyWhenRequired() { + MethodParameter param = this.testMethod.arg(ResolvableType.forClassWithGenerics(Mono.class, String.class)); + Mono mono = resolveValue(param, Mono.empty(), null); + + StepVerifier.create(mono) + .consumeErrorWith(ex -> { + assertEquals(MethodArgumentResolutionException.class, ex.getClass()); + assertTrue(ex.getMessage(), ex.getMessage().contains("Payload content is missing")); + }) + .verify(); + } + + @Test + public void emptyBodyWhenNotRequired() { + MethodParameter param = this.testMethod.annotPresent(Payload.class).arg(); + assertNull(resolveValue(param, Mono.empty(), null)); + } + + @Test + public void stringMono() { + String body = "foo"; + MethodParameter param = this.testMethod.arg(ResolvableType.forClassWithGenerics(Mono.class, String.class)); + Mono value = Mono.delay(Duration.ofMillis(10)).map(aLong -> toDataBuffer(body)); + Mono mono = resolveValue(param, value, null); + + assertEquals(body, mono.block()); + } + + @Test + public void stringFlux() { + List body = Arrays.asList("foo", "bar"); + ResolvableType type = ResolvableType.forClassWithGenerics(Flux.class, String.class); + MethodParameter param = this.testMethod.arg(type); + Flux flux = resolveValue(param, Flux.fromIterable(body) + .delayElements(Duration.ofMillis(10)).map(value -> toDataBuffer(value + "\n")), null); + + assertEquals(body, flux.collectList().block()); + } + + @Test + public void string() { + String body = "foo"; + MethodParameter param = this.testMethod.annotNotPresent(Payload.class).arg(String.class); + Object value = resolveValue(param, Mono.just(toDataBuffer(body)), null); + + assertEquals(body, value); + } + + @Test + public void validateStringMono() { + ResolvableType type = ResolvableType.forClassWithGenerics(Mono.class, String.class); + MethodParameter param = this.testMethod.arg(type); + Mono mono = resolveValue(param, Mono.just(toDataBuffer("12345")), new TestValidator()); + + StepVerifier.create(mono).expectNextCount(0) + .expectError(MethodArgumentNotValidException.class).verify(); + } + + @Test + public void validateStringFlux() { + ResolvableType type = ResolvableType.forClassWithGenerics(Flux.class, String.class); + MethodParameter param = this.testMethod.arg(type); + Flux flux = resolveValue(param, Flux.just(toDataBuffer("12345678\n12345")), new TestValidator()); + + StepVerifier.create(flux) + .expectNext("12345678") + .expectError(MethodArgumentNotValidException.class) + .verify(); + } + + + private DataBuffer toDataBuffer(String value) { + return new DefaultDataBufferFactory().wrap(value.getBytes(StandardCharsets.UTF_8)); + } + + + @SuppressWarnings("unchecked") + @Nullable + private T resolveValue(MethodParameter param, Publisher content, Validator validator) { + + Message message = new GenericMessage<>(content, + Collections.singletonMap(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN)); + + Mono result = createResolver(validator, true).resolveArgument(param, message); + + Object value = result.block(Duration.ofSeconds(5)); + if (value != null) { + Class expectedType = param.getParameterType(); + assertTrue("Unexpected return value type: " + value, expectedType.isAssignableFrom(value.getClass())); + } + return (T) value; + } + + private PayloadMethodArgumentResolver createResolver(@Nullable Validator validator, boolean useDefaultResolution) { + if (this.decoders.isEmpty()) { + this.decoders.add(StringDecoder.allMimeTypes()); + } + List decoders = Collections.singletonList(StringDecoder.allMimeTypes()); + return new PayloadMethodArgumentResolver(decoders, validator, null, useDefaultResolution) {}; + } + + + @SuppressWarnings("unused") + private void handle( + @Validated Mono valueMono, + @Validated Flux valueFlux, + @Payload(required = false) String optionalValue, + String value) { + } + + + private static class TestValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return clazz.equals(String.class); + } + + @Override + public void validate(@Nullable Object target, Errors errors) { + if (target instanceof String && ((String) target).length() < 8) { + errors.reject("Invalid length"); + } + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethodTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethodTests.java index fbb0350d1b..e0c44543e6 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethodTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethodTests.java @@ -23,7 +23,6 @@ import org.junit.Test; import org.springframework.core.MethodParameter; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; -import org.springframework.messaging.handler.ResolvableMethod; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java index 54d6830be1..7dce680ee4 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java @@ -166,7 +166,7 @@ public class MethodMessageHandlerTests { this.method = "secondBestMatch"; } - public void illegalStateException(IllegalStateException exception) { + public void handleIllegalStateException(IllegalStateException exception) { this.method = "illegalStateException"; this.arguments.put("exception", exception); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/ResolvableMethod.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/ResolvableMethod.java similarity index 97% rename from spring-messaging/src/test/java/org/springframework/messaging/handler/ResolvableMethod.java rename to spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/ResolvableMethod.java index 7f4a367370..dd360bd6e0 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/ResolvableMethod.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/ResolvableMethod.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.messaging.handler; +package org.springframework.messaging.handler.invocation; import java.lang.annotation.Annotation; import java.lang.reflect.Method; @@ -57,7 +57,10 @@ import org.springframework.util.ReflectionUtils; import static java.util.stream.Collectors.*; /** - * Convenience class to resolve method parameters from hints. + * NOTE: This class is a replica of the same class in spring-web so it can + * be used for tests in spring-messaging. + * + *

Convenience class to resolve method parameters from hints. * *

Background

* @@ -120,7 +123,7 @@ import static java.util.stream.Collectors.*; * * * @author Rossen Stoyanchev - * @since 5.0 + * @since 5.2 */ public class ResolvableMethod { @@ -186,6 +189,7 @@ public class ResolvableMethod { /** * Filter on method arguments with annotation. + * See {@link org.springframework.web.method.MvcAnnotationPredicates}. */ @SafeVarargs public final ArgResolver annot(Predicate... filter) { @@ -298,6 +302,7 @@ public class ResolvableMethod { /** * Filter on annotated methods. + * See {@link org.springframework.web.method.MvcAnnotationPredicates}. */ @SafeVarargs public final Builder annot(Predicate... filters) { @@ -308,6 +313,7 @@ public class ResolvableMethod { /** * Filter on methods annotated with the given annotation type. * @see #annot(Predicate[]) + * See {@link org.springframework.web.method.MvcAnnotationPredicates}. */ @SafeVarargs public final Builder annotPresent(Class... annotationTypes) { @@ -524,6 +530,7 @@ public class ResolvableMethod { /** * Filter on method arguments with annotations. + * See {@link org.springframework.web.method.MvcAnnotationPredicates}. */ @SafeVarargs public final ArgResolver annot(Predicate... filters) { @@ -535,6 +542,7 @@ public class ResolvableMethod { * Filter on method arguments that have the given annotations. * @param annotationTypes the annotation types * @see #annot(Predicate[]) + * See {@link org.springframework.web.method.MvcAnnotationPredicates}. */ @SafeVarargs public final ArgResolver annotPresent(Class... annotationTypes) { diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java new file mode 100644 index 0000000000..b373d671c0 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.util.Collections; +import java.util.List; + +import io.reactivex.Completable; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.support.DataBufferTestUtils; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +import static java.nio.charset.StandardCharsets.*; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; +import static org.springframework.messaging.handler.invocation.ResolvableMethod.*; + +/** + * Unit tests for {@link AbstractEncoderMethodReturnValueHandler}. + * + * @author Rossen Stoyanchev + */ +public class EncoderMethodReturnValueHandlerTests { + + private final TestEncoderMethodReturnValueHandler handler = new TestEncoderMethodReturnValueHandler( + Collections.singletonList(CharSequenceEncoder.textPlainOnly()), + ReactiveAdapterRegistry.getSharedInstance()); + + private final Message message = mock(Message.class); + + + @Test + public void stringReturnValue() { + MethodParameter parameter = on(TestController.class).resolveReturnType(String.class); + this.handler.handleReturnValue("foo", parameter, message).block(); + Flux result = this.handler.encodedContent; + + StepVerifier.create(result) + .consumeNextWith(buffer -> assertEquals("foo", DataBufferTestUtils.dumpString(buffer, UTF_8))) + .verifyComplete(); + } + + @Test + public void objectReturnValue() { + MethodParameter parameter = on(TestController.class).resolveReturnType(Object.class); + this.handler.handleReturnValue("foo", parameter, message).block(); + Flux result = this.handler.encodedContent; + + StepVerifier.create(result) + .consumeNextWith(buffer -> assertEquals("foo", DataBufferTestUtils.dumpString(buffer, UTF_8))) + .verifyComplete(); + } + + @Test + public void fluxStringReturnValue() { + MethodParameter parameter = on(TestController.class).resolveReturnType(Flux.class, String.class); + this.handler.handleReturnValue(Flux.just("foo", "bar"), parameter, message).block(); + Flux result = this.handler.encodedContent; + + StepVerifier.create(result) + .consumeNextWith(buffer -> assertEquals("foo", DataBufferTestUtils.dumpString(buffer, UTF_8))) + .consumeNextWith(buffer -> assertEquals("bar", DataBufferTestUtils.dumpString(buffer, UTF_8))) + .verifyComplete(); + } + + @Test + public void voidReturnValue() { + testVoidReturnType(null, on(TestController.class).resolveReturnType(void.class)); + testVoidReturnType(Mono.empty(), on(TestController.class).resolveReturnType(Mono.class, Void.class)); + testVoidReturnType(Completable.complete(), on(TestController.class).resolveReturnType(Completable.class)); + + } + + private void testVoidReturnType(@Nullable Object value, MethodParameter bodyParameter) { + this.handler.handleReturnValue(value, bodyParameter, message).block(); + Flux result = this.handler.encodedContent; + StepVerifier.create(result).expectComplete().verify(); + } + + @Test + public void noEncoder() { + MethodParameter parameter = on(TestController.class).resolveReturnType(Object.class); + this.handler.handleReturnValue(new Object(), parameter, message).block(); + Flux result = this.handler.encodedContent; + + StepVerifier.create(result) + .expectErrorMessage("No encoder for method 'object' parameter -1") + .verify(); + } + + + @SuppressWarnings({"unused", "ConstantConditions"}) + private static class TestController { + + String string() { return null; } + + Object object() { return null; } + + Flux fluxString() { return null; } + + void voidReturn() { } + + Mono monoVoid() { return null; } + + Completable completable() { return null; } + } + + + private static class TestEncoderMethodReturnValueHandler extends AbstractEncoderMethodReturnValueHandler { + + private Flux encodedContent; + + + public Flux getEncodedContent() { + return this.encodedContent; + } + + protected TestEncoderMethodReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { + super(encoders, registry); + } + + @Override + protected Mono handleEncodedContent( + Flux encodedContent, MethodParameter returnType, Message message) { + + this.encodedContent = encodedContent; + return Mono.empty(); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java index c0e2d36f08..c41cae403b 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java @@ -29,8 +29,8 @@ import reactor.test.StepVerifier; import org.springframework.core.MethodParameter; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; -import org.springframework.messaging.handler.ResolvableMethod; import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException; +import org.springframework.messaging.handler.invocation.ResolvableMethod; import static org.junit.Assert.*; import static org.mockito.Mockito.*; diff --git a/spring-web/src/test/java/org/springframework/web/method/ResolvableMethod.java b/spring-web/src/test/java/org/springframework/web/method/ResolvableMethod.java index edf2ab4ba7..14e4f7631b 100644 --- a/spring-web/src/test/java/org/springframework/web/method/ResolvableMethod.java +++ b/spring-web/src/test/java/org/springframework/web/method/ResolvableMethod.java @@ -57,7 +57,9 @@ import org.springframework.util.ReflectionUtils; import static java.util.stream.Collectors.*; /** - * Convenience class to resolve method parameters from hints. + * Convenience class to resolve to a Method and method parameters. + * + *

Note that a replica of this class also exists in spring-messaging. * *

Background

* From 421090ca35941fef053eeedde5f84cc00cc22298 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Fri, 25 Jan 2019 11:49:06 -0500 Subject: [PATCH 03/17] Reactive AbstractMessageMethodHandler See gh-21987 --- .../messaging/ReactiveMessageHandler.java | 36 ++ .../AbstractMethodMessageHandler.java | 567 ++++++++++++++++++ .../reactive/ArgumentResolverConfigurer.java | 50 ++ .../ReturnValueHandlerConfigurer.java | 50 ++ .../invocation/MethodMessageHandlerTests.java | 56 +- .../invocation/TestExceptionResolver.java | 49 ++ .../reactive/MethodMessageHandlerTests.java | 261 ++++++++ .../reactive/TestReturnValueHandler.java | 51 ++ 8 files changed, 1076 insertions(+), 44 deletions(-) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageHandler.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ArgumentResolverConfigurer.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ReturnValueHandlerConfigurer.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/TestExceptionResolver.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java diff --git a/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageHandler.java new file mode 100644 index 0000000000..32c1d50e9e --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageHandler.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging; + +import reactor.core.publisher.Mono; + +/** + * Reactive contract for handling a {@link Message}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +@FunctionalInterface +public interface ReactiveMessageHandler { + + /** + * Handle the given message. + * @param message the message to be handled + * @return a completion {@link Mono} for the result of the message handling. + */ + Mono handleMessage(Message message); + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java new file mode 100644 index 0000000000..684ed8e04d --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java @@ -0,0 +1,567 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.ReactiveMessageHandler; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.messaging.handler.MessagingAdviceBean; +import org.springframework.messaging.handler.invocation.AbstractExceptionHandlerMethodResolver; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Abstract base class for reactive HandlerMethod-based message handling. + * Provides most of the logic required to discover handler methods at startup, + * find a matching handler method at runtime for a given message and invoke it. + * + *

Also supports discovering and invoking exception handling methods to process + * exceptions raised during message handling. + * + * @author Rossen Stoyanchev + * @since 5.2 + * @param the type of the Object that contains information mapping information + */ +public abstract class AbstractMethodMessageHandler + implements ReactiveMessageHandler, ApplicationContextAware, InitializingBean { + + /** + * Bean name prefix for target beans behind scoped proxies. Used to exclude those + * targets from handler method detection, in favor of the corresponding proxies. + *

We're not checking the autowire-candidate status here, which is how the + * proxy target filtering problem is being handled at the autowiring level, + * since autowire-candidate may have been turned to {@code false} for other + * reasons, while still expecting the bean to be eligible for handler methods. + *

Originally defined in {@link org.springframework.aop.scope.ScopedProxyUtils} + * but duplicated here to avoid a hard dependency on the spring-aop module. + */ + private static final String SCOPED_TARGET_NAME_PREFIX = "scopedTarget."; + + + protected final Log logger = LogFactory.getLog(getClass()); + + + private ArgumentResolverConfigurer argumentResolverConfigurer = new ArgumentResolverConfigurer(); + + private ReturnValueHandlerConfigurer returnValueHandlerConfigurer = new ReturnValueHandlerConfigurer(); + + private final HandlerMethodArgumentResolverComposite argumentResolvers = + new HandlerMethodArgumentResolverComposite(); + + private final HandlerMethodReturnValueHandlerComposite returnValueHandlers = + new HandlerMethodReturnValueHandlerComposite(); + + private ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance(); + + @Nullable + private ApplicationContext applicationContext; + + private final Map handlerMethods = new LinkedHashMap<>(64); + + private final MultiValueMap destinationLookup = new LinkedMultiValueMap<>(64); + + private final Map, AbstractExceptionHandlerMethodResolver> exceptionHandlerCache = + new ConcurrentHashMap<>(64); + + private final Map exceptionHandlerAdviceCache = + new LinkedHashMap<>(64); + + + /** + * Configure custom resolvers for handler method arguments. + */ + public void setArgumentResolverConfigurer(ArgumentResolverConfigurer configurer) { + Assert.notNull(configurer, "HandlerMethodArgumentResolver is required."); + this.argumentResolverConfigurer = configurer; + } + + /** + * Return the configured custom resolvers for handler method arguments. + */ + public ArgumentResolverConfigurer getArgumentResolverConfigurer() { + return this.argumentResolverConfigurer; + } + + /** + * Configure custom return value handlers for handler metohds. + */ + public void setReturnValueHandlerConfigurer(ReturnValueHandlerConfigurer configurer) { + Assert.notNull(configurer, "ReturnValueHandlerConfigurer is required."); + this.returnValueHandlerConfigurer = configurer; + } + + /** + * Return the configured return value handlers. + */ + public ReturnValueHandlerConfigurer getReturnValueHandlerConfigurer() { + return this.returnValueHandlerConfigurer; + } + + /** + * Configure the registry for adapting various reactive types. + *

By default this is an instance of {@link ReactiveAdapterRegistry} with + * default settings. + */ + public void setReactiveAdapterRegistry(ReactiveAdapterRegistry registry) { + Assert.notNull(registry, "ReactiveAdapterRegistry is required"); + this.reactiveAdapterRegistry = registry; + } + + /** + * Return the configured registry for adapting reactive types. + */ + public ReactiveAdapterRegistry getReactiveAdapterRegistry() { + return this.reactiveAdapterRegistry; + } + + @Override + public void setApplicationContext(@Nullable ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Nullable + public ApplicationContext getApplicationContext() { + return this.applicationContext; + } + + /** + * Subclasses can invoke this method to populate the MessagingAdviceBean cache + * (e.g. to support "global" {@code @MessageExceptionHandler}). + */ + protected void registerExceptionHandlerAdvice( + MessagingAdviceBean bean, AbstractExceptionHandlerMethodResolver resolver) { + + this.exceptionHandlerAdviceCache.put(bean, resolver); + } + + /** + * Return a read-only map with all handler methods and their mappings. + */ + public Map getHandlerMethods() { + return Collections.unmodifiableMap(this.handlerMethods); + } + + /** + * Return a read-only multi-value map with a direct lookup of mappings, + * (e.g. for non-pattern destinations). + */ + public MultiValueMap getDestinationLookup() { + return CollectionUtils.unmodifiableMultiValueMap(this.destinationLookup); + } + + + @Override + public void afterPropertiesSet() { + + List resolvers = initArgumentResolvers(); + if (resolvers.isEmpty()) { + resolvers = new ArrayList<>(this.argumentResolverConfigurer.getCustomResolvers()); + } + this.argumentResolvers.addResolvers(resolvers); + + List handlers = initReturnValueHandlers(); + if (handlers.isEmpty()) { + handlers = new ArrayList<>(this.returnValueHandlerConfigurer.getCustomHandlers()); + } + this.returnValueHandlers.addHandlers(handlers); + + initHandlerMethods(); + } + + /** + * Return the list of argument resolvers to use. + *

Subclasses should also take into account custom argument types configured via + * {@link #setArgumentResolverConfigurer}. + */ + protected abstract List initArgumentResolvers(); + + /** + * Return the list of return value handlers to use. + *

Subclasses should also take into account custom return value types configured + * via {@link #setReturnValueHandlerConfigurer}. + */ + protected abstract List initReturnValueHandlers(); + + + private void initHandlerMethods() { + if (this.applicationContext == null) { + logger.warn("No ApplicationContext available for detecting beans with message handling methods."); + return; + } + for (String beanName : this.applicationContext.getBeanNamesForType(Object.class)) { + if (!beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) { + Class beanType = null; + try { + beanType = this.applicationContext.getType(beanName); + } + catch (Throwable ex) { + // An unresolvable bean type, probably from a lazy bean - let's ignore it. + if (logger.isDebugEnabled()) { + logger.debug("Could not resolve target class for bean with name '" + beanName + "'", ex); + } + } + if (beanType != null && isHandler(beanType)) { + detectHandlerMethods(beanName); + } + } + } + } + + /** + * Whether the given bean could contain message handling methods. + */ + protected abstract boolean isHandler(Class beanType); + + /** + * Detect if the given handler has any methods that can handle messages and if + * so register it with the extracted mapping information. + * @param handler the handler to check, either an instance of a Spring bean name + */ + private void detectHandlerMethods(Object handler) { + Class handlerType; + if (handler instanceof String) { + ApplicationContext context = getApplicationContext(); + Assert.state(context != null, "ApplicationContext is required for resolving handler bean names"); + handlerType = context.getType((String) handler); + } + else { + handlerType = handler.getClass(); + } + if (handlerType != null) { + final Class userType = ClassUtils.getUserClass(handlerType); + Map methods = MethodIntrospector.selectMethods(userType, + (MethodIntrospector.MetadataLookup) method -> getMappingForMethod(method, userType)); + if (logger.isDebugEnabled()) { + logger.debug(methods.size() + " message handler methods found on " + userType + ": " + methods); + } + methods.forEach((key, value) -> registerHandlerMethod(handler, key, value)); + } + } + + /** + * Obtain the mapping for the given method, if any. + * @param method the method to check + * @param handlerType the handler type, possibly a sub-type of the method's declaring class + * @return the mapping, or {@code null} if the method is not mapped + */ + @Nullable + protected abstract T getMappingForMethod(Method method, Class handlerType); + + /** + * Register a handler method and its unique mapping, on startup. + * @param handler the bean name of the handler or the handler instance + * @param method the method to register + * @param mapping the mapping conditions associated with the handler method + * @throws IllegalStateException if another method was already registered + * under the same mapping + */ + protected void registerHandlerMethod(Object handler, Method method, T mapping) { + Assert.notNull(mapping, "Mapping must not be null"); + HandlerMethod newHandlerMethod = createHandlerMethod(handler, method); + HandlerMethod oldHandlerMethod = this.handlerMethods.get(mapping); + + if (oldHandlerMethod != null && !oldHandlerMethod.equals(newHandlerMethod)) { + throw new IllegalStateException("Ambiguous mapping found. Cannot map '" + newHandlerMethod.getBean() + + "' bean method \n" + newHandlerMethod + "\nto " + mapping + ": There is already '" + + oldHandlerMethod.getBean() + "' bean method\n" + oldHandlerMethod + " mapped."); + } + + this.handlerMethods.put(mapping, newHandlerMethod); + if (logger.isTraceEnabled()) { + logger.trace("Mapped \"" + mapping + "\" onto " + newHandlerMethod); + } + + for (String pattern : getDirectLookupMappings(mapping)) { + this.destinationLookup.add(pattern, mapping); + } + } + + /** + * Create a HandlerMethod instance from an Object handler that is either a handler + * instance or a String-based bean name. + */ + private HandlerMethod createHandlerMethod(Object handler, Method method) { + HandlerMethod handlerMethod; + if (handler instanceof String) { + ApplicationContext context = getApplicationContext(); + Assert.state(context != null, "ApplicationContext is required for resolving handler bean names"); + String beanName = (String) handler; + handlerMethod = new HandlerMethod(beanName, context.getAutowireCapableBeanFactory(), method); + } + else { + handlerMethod = new HandlerMethod(handler, method); + } + return handlerMethod; + } + + /** + * Return String-based destinations for the given mapping, if any, that can + * be used to find matches with a direct lookup (i.e. non-patterns). + *

Note: This is completely optional. The mapping + * metadata for a sub-class may support neither direct lookups, nor String + * based destinations. + */ + protected abstract Set getDirectLookupMappings(T mapping); + + + @Override + public Mono handleMessage(Message message) throws MessagingException { + Match match = getHandlerMethod(message); + if (match == null) { + return Mono.empty(); + } + HandlerMethod handlerMethod = match.getHandlerMethod().createWithResolvedBean(); + InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod); + invocable.setArgumentResolvers(this.argumentResolvers.getResolvers()); + if (logger.isDebugEnabled()) { + logger.debug("Invoking " + invocable.getShortLogMessage()); + } + return invocable.invoke(message) + .flatMap(value -> { + MethodParameter returnType = invocable.getReturnType(); + return this.returnValueHandlers.handleReturnValue(value, returnType, message); + }) + .onErrorResume(throwable -> { + Exception ex = (throwable instanceof Exception) ? (Exception) throwable : + new MessageHandlingException(message, "HandlerMethod invocation error", throwable); + return processHandlerException(message, handlerMethod, ex); + }); + } + + @Nullable + private Match getHandlerMethod(Message message) { + List> matches = new ArrayList<>(); + + String destination = getDestination(message); + List mappingsByUrl = destination != null ? this.destinationLookup.get(destination) : null; + if (mappingsByUrl != null) { + addMatchesToCollection(mappingsByUrl, message, matches); + } + if (matches.isEmpty()) { + // No direct hits, go through all mappings + Set allMappings = this.handlerMethods.keySet(); + addMatchesToCollection(allMappings, message, matches); + } + if (matches.isEmpty()) { + return null; + } + Comparator> comparator = new MatchComparator(getMappingComparator(message)); + matches.sort(comparator); + if (logger.isTraceEnabled()) { + logger.trace("Found " + matches.size() + " handler methods: " + matches); + } + Match bestMatch = matches.get(0); + if (matches.size() > 1) { + Match secondBestMatch = matches.get(1); + if (comparator.compare(bestMatch, secondBestMatch) == 0) { + Method m1 = bestMatch.handlerMethod.getMethod(); + Method m2 = secondBestMatch.handlerMethod.getMethod(); + throw new IllegalStateException("Ambiguous handler methods mapped for destination '" + + destination + "': {" + m1 + ", " + m2 + "}"); + } + } + return bestMatch; + } + + /** + * Extract a String-based destination, if any, that can be used to perform + * a direct look up into the registered mappings. + *

Note: This is completely optional. The mapping + * metadata for a sub-class may support neither direct lookups, nor String + * based destinations. + * @see #getDirectLookupMappings(Object) + */ + @Nullable + protected abstract String getDestination(Message message); + + private void addMatchesToCollection( + Collection mappingsToCheck, Message message, List> matches) { + + for (T mapping : mappingsToCheck) { + T match = getMatchingMapping(mapping, message); + if (match != null) { + matches.add(new Match(match, this.handlerMethods.get(mapping))); + } + } + } + + /** + * Check if a mapping matches the current message and return a possibly + * new mapping with conditions relevant to the current request. + * @param mapping the mapping to get a match for + * @param message the message being handled + * @return the match or {@code null} if there is no match + */ + @Nullable + protected abstract T getMatchingMapping(T mapping, Message message); + + /** + * Return a comparator for sorting matching mappings. + * The returned comparator should sort 'better' matches higher. + * @param message the current Message + * @return the comparator, never {@code null} + */ + protected abstract Comparator getMappingComparator(Message message); + + + private Mono processHandlerException(Message message, HandlerMethod handlerMethod, Exception ex) { + InvocableHandlerMethod exceptionInvocable = findExceptionHandler(handlerMethod, ex); + if (exceptionInvocable == null) { + logger.error("Unhandled exception from message handling method", ex); + return Mono.empty(); + } + exceptionInvocable.setArgumentResolvers(this.argumentResolvers.getResolvers()); + if (logger.isDebugEnabled()) { + logger.debug("Invoking " + exceptionInvocable.getShortLogMessage()); + } + return exceptionInvocable.invoke(message, ex) + .flatMap(value -> { + MethodParameter returnType = exceptionInvocable.getReturnType(); + return this.returnValueHandlers.handleReturnValue(value, returnType, message); + }); + } + + /** + * Find an exception handling method for the given exception. + *

The default implementation searches methods in the class hierarchy of + * the HandlerMethod first and if not found, it continues searching for + * additional handling methods registered via + * {@link #registerExceptionHandlerAdvice(MessagingAdviceBean, AbstractExceptionHandlerMethodResolver)}. + * @param handlerMethod the method where the exception was raised + * @param exception the raised exception + * @return a method to handle the exception, or {@code null} + */ + @Nullable + protected InvocableHandlerMethod findExceptionHandler(HandlerMethod handlerMethod, Exception exception) { + if (logger.isDebugEnabled()) { + logger.debug("Searching for methods to handle " + exception.getClass().getSimpleName()); + } + Class beanType = handlerMethod.getBeanType(); + AbstractExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.get(beanType); + if (resolver == null) { + resolver = createExceptionMethodResolverFor(beanType); + this.exceptionHandlerCache.put(beanType, resolver); + } + InvocableHandlerMethod exceptionHandlerMethod = null; + Method method = resolver.resolveMethod(exception); + if (method != null) { + exceptionHandlerMethod = new InvocableHandlerMethod(handlerMethod.getBean(), method); + } + else { + for (MessagingAdviceBean advice : this.exceptionHandlerAdviceCache.keySet()) { + if (advice.isApplicableToBeanType(beanType)) { + resolver = this.exceptionHandlerAdviceCache.get(advice); + method = resolver.resolveMethod(exception); + if (method != null) { + exceptionHandlerMethod = new InvocableHandlerMethod(advice.resolveBean(), method); + break; + } + } + } + } + if (exceptionHandlerMethod != null) { + exceptionHandlerMethod.setArgumentResolvers(this.argumentResolvers.getResolvers()); + } + return exceptionHandlerMethod; + } + + /** + * Create a concrete instance of {@link AbstractExceptionHandlerMethodResolver} + * that finds exception handling methods based on some criteria, e.g. based + * on the presence of {@code @MessageExceptionHandler}. + * @param beanType the class in which an exception occurred during handling + * @return the resolver to use + */ + protected abstract AbstractExceptionHandlerMethodResolver createExceptionMethodResolverFor(Class beanType); + + + /** + * Container for matched mapping and HandlerMethod. Used for best match + * comparison and for access to mapping information. + */ + private static class Match { + + private final T mapping; + + private final HandlerMethod handlerMethod; + + + Match(T mapping, HandlerMethod handlerMethod) { + this.mapping = mapping; + this.handlerMethod = handlerMethod; + } + + + public T getMapping() { + return this.mapping; + } + + public HandlerMethod getHandlerMethod() { + return this.handlerMethod; + } + + + @Override + public String toString() { + return this.mapping.toString(); + } + } + + + private class MatchComparator implements Comparator> { + + private final Comparator comparator; + + + MatchComparator(Comparator comparator) { + this.comparator = comparator; + } + + + @Override + public int compare(Match match1, Match match2) { + return this.comparator.compare(match1.mapping, match2.mapping); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ArgumentResolverConfigurer.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ArgumentResolverConfigurer.java new file mode 100644 index 0000000000..41c2fe77b9 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ArgumentResolverConfigurer.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * Assist with configuration for handler method argument resolvers. + * At present, it supports only providing a list of custom resolvers. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class ArgumentResolverConfigurer { + + private final List customResolvers = new ArrayList<>(8); + + + /** + * Configure resolvers for custom handler method arguments. + * @param resolver the resolvers to add + */ + public void addCustomResolver(HandlerMethodArgumentResolver... resolver) { + Assert.notNull(resolver, "'resolvers' must not be null"); + this.customResolvers.addAll(Arrays.asList(resolver)); + } + + + public List getCustomResolvers() { + return this.customResolvers; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ReturnValueHandlerConfigurer.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ReturnValueHandlerConfigurer.java new file mode 100644 index 0000000000..c466162635 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ReturnValueHandlerConfigurer.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * Assist with configuration for handler method return value handlers. + * At present, it supports only providing a list of custom handlers. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class ReturnValueHandlerConfigurer { + + private final List customHandlers = new ArrayList<>(8); + + + /** + * Configure custom return value handlers for handler methods. + * @param handlers the handlers to add + */ + public void addCustomHandler(HandlerMethodReturnValueHandler... handlers) { + Assert.notNull(handlers, "'handlers' must not be null"); + this.customHandlers.addAll(Arrays.asList(handlers)); + } + + + public List getCustomHandlers() { + return this.customHandlers; + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java index 7dce680ee4..b65cf64793 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java @@ -20,7 +20,6 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -32,7 +31,6 @@ import org.junit.Before; import org.junit.Test; import org.springframework.context.support.StaticApplicationContext; -import org.springframework.core.MethodIntrospector; import org.springframework.messaging.Message; import org.springframework.messaging.converter.SimpleMessageConverter; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; @@ -40,8 +38,8 @@ import org.springframework.messaging.handler.HandlerMethod; import org.springframework.messaging.handler.annotation.support.MessageMethodArgumentResolver; import org.springframework.messaging.support.MessageBuilder; import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; import org.springframework.util.PathMatcher; -import org.springframework.util.ReflectionUtils.MethodFilter; import static org.junit.Assert.*; @@ -90,7 +88,7 @@ public class MethodMessageHandlerTests { } @Test - public void antPatchMatchWildcard() throws Exception { + public void patternMatch() throws Exception { Method method = this.testController.getClass().getMethod("handlerPathMatchWildcard"); this.messageHandler.registerHandlerMethod(this.testController, method, "/handlerPathMatch*"); @@ -101,7 +99,7 @@ public class MethodMessageHandlerTests { } @Test - public void bestMatchWildcard() throws Exception { + public void bestMatch() throws Exception { Method method = this.testController.getClass().getMethod("bestMatch"); this.messageHandler.registerHandlerMethod(this.testController, method, "/bestmatch/{foo}/path"); @@ -124,7 +122,7 @@ public class MethodMessageHandlerTests { } @Test - public void exceptionHandled() { + public void handleException() { this.messageHandler.handleMessage(toDestination("/test/handlerThrowsExc")); @@ -186,6 +184,7 @@ public class MethodMessageHandlerTests { private PathMatcher pathMatcher = new AntPathMatcher(); + public void registerHandler(Object handler) { super.detectHandlerMethods(handler); } @@ -239,55 +238,24 @@ public class MethodMessageHandlerTests { @Override protected String getMatchingMapping(String mapping, Message message) { - String destination = getLookupDestination(getDestination(message)); - if (mapping.equals(destination) || this.pathMatcher.match(mapping, destination)) { - return mapping; - } - return null; + Assert.notNull(destination, "No destination"); + return mapping.equals(destination) || this.pathMatcher.match(mapping, destination) ? mapping : null; } @Override protected Comparator getMappingComparator(final Message message) { - return new Comparator() { - @Override - public int compare(String info1, String info2) { - DestinationPatternsMessageCondition cond1 = new DestinationPatternsMessageCondition(info1); - DestinationPatternsMessageCondition cond2 = new DestinationPatternsMessageCondition(info2); - return cond1.compareTo(cond2, message); - } + return (info1, info2) -> { + DestinationPatternsMessageCondition cond1 = new DestinationPatternsMessageCondition(info1); + DestinationPatternsMessageCondition cond2 = new DestinationPatternsMessageCondition(info2); + return cond1.compareTo(cond2, message); }; } @Override protected AbstractExceptionHandlerMethodResolver createExceptionHandlerMethodResolverFor(Class beanType) { - return new TestExceptionHandlerMethodResolver(beanType); + return new TestExceptionResolver(beanType); } } - - private static class TestExceptionHandlerMethodResolver extends AbstractExceptionHandlerMethodResolver { - - public TestExceptionHandlerMethodResolver(Class handlerType) { - super(initExceptionMappings(handlerType)); - } - - private static Map, Method> initExceptionMappings(Class handlerType) { - Map, Method> result = new HashMap<>(); - for (Method method : MethodIntrospector.selectMethods(handlerType, EXCEPTION_HANDLER_METHOD_FILTER)) { - for (Class exception : getExceptionsFromMethodSignature(method)) { - result.put(exception, method); - } - } - return result; - } - - public final static MethodFilter EXCEPTION_HANDLER_METHOD_FILTER = new MethodFilter() { - @Override - public boolean matches(Method method) { - return method.getName().contains("Exception"); - } - }; - } - } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/TestExceptionResolver.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/TestExceptionResolver.java new file mode 100644 index 0000000000..8be81d0ecc --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/TestExceptionResolver.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.invocation; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.core.MethodIntrospector; +import org.springframework.util.ReflectionUtils; + +/** + * Sub-class for {@link AbstractExceptionHandlerMethodResolver} for testing. + * @author Rossen Stoyanchev + */ +public class TestExceptionResolver extends AbstractExceptionHandlerMethodResolver { + + private final static ReflectionUtils.MethodFilter EXCEPTION_HANDLER_METHOD_FILTER = + method -> method.getName().matches("handle[\\w]*Exception"); + + + public TestExceptionResolver(Class handlerType) { + super(initExceptionMappings(handlerType)); + } + + private static Map, Method> initExceptionMappings(Class handlerType) { + Map, Method> result = new HashMap<>(); + for (Method method : MethodIntrospector.selectMethods(handlerType, EXCEPTION_HANDLER_METHOD_FILTER)) { + for (Class exception : getExceptionsFromMethodSignature(method)) { + result.put(exception, method); + } + } + return result; + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java new file mode 100644 index 0000000000..32c3cb2638 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java @@ -0,0 +1,261 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import org.hamcrest.Matchers; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.DestinationPatternsMessageCondition; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.messaging.handler.invocation.AbstractExceptionHandlerMethodResolver; +import org.springframework.messaging.handler.invocation.TestExceptionResolver; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.PathMatcher; + +import static org.junit.Assert.*; + +/** + * Unit tests for {@link AbstractMethodMessageHandler}. + * @author Rossen Stoyanchev + */ +public class MethodMessageHandlerTests { + + + @Test(expected = IllegalStateException.class) + public void duplicateMapping() { + initMethodMessageHandler(DuplicateMappingsController.class); + } + + @Test + public void registeredMappings() { + TestMethodMessageHandler messageHandler = initMethodMessageHandler(TestController.class); + Map mappings = messageHandler.getHandlerMethods(); + + assertEquals(5, mappings.keySet().size()); + assertThat(mappings.keySet(), Matchers.containsInAnyOrder( + "/handleMessage", "/handleMessageWithArgument", "/handleMessageAndThrow", + "/handleMessageMatch1", "/handleMessageMatch2")); + } + + @Test + public void bestMatch() throws NoSuchMethodException { + TestMethodMessageHandler handler = new TestMethodMessageHandler(); + TestController controller = new TestController(); + handler.register(controller, TestController.class.getMethod("handleMessageMatch1"), "/bestmatch/{foo}/path"); + handler.register(controller, TestController.class.getMethod("handleMessageMatch2"), "/bestmatch/*/*"); + handler.afterPropertiesSet(); + + Message message = new GenericMessage<>("body", Collections.singletonMap( + DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, "/bestmatch/bar/path")); + + handler.handleMessage(message).block(Duration.ofSeconds(5)); + + StepVerifier.create((Mono) handler.getLastReturnValue()) + .expectNext("handleMessageMatch1") + .verifyComplete(); + } + + @Test + public void argumentResolution() { + + ArgumentResolverConfigurer configurer = new ArgumentResolverConfigurer(); + configurer.addCustomResolver(new StubArgumentResolver(String.class, "foo")); + + TestMethodMessageHandler handler = initMethodMessageHandler( + theHandler -> theHandler.setArgumentResolverConfigurer(configurer), + TestController.class); + + Message message = new GenericMessage<>("body", Collections.singletonMap( + DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, "/handleMessageWithArgument")); + + handler.handleMessage(message).block(Duration.ofSeconds(5)); + + StepVerifier.create((Mono) handler.getLastReturnValue()) + .expectNext("handleMessageWithArgument,payload=foo") + .verifyComplete(); + } + + @Test + public void handleException() { + + TestMethodMessageHandler handler = initMethodMessageHandler(TestController.class); + + Message message = new GenericMessage<>("body", Collections.singletonMap( + DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, "/handleMessageAndThrow")); + + handler.handleMessage(message).block(Duration.ofSeconds(5)); + + StepVerifier.create((Mono) handler.getLastReturnValue()) + .expectNext("handleIllegalStateException,ex=rejected") + .verifyComplete(); + } + + + private TestMethodMessageHandler initMethodMessageHandler(Class... handlerTypes) { + return initMethodMessageHandler(handler -> {}, handlerTypes); + } + + private TestMethodMessageHandler initMethodMessageHandler( + Consumer customizer, Class... handlerTypes) { + + StaticApplicationContext context = new StaticApplicationContext(); + for (Class handlerType : handlerTypes) { + String beanName = ClassUtils.getShortNameAsProperty(handlerType); + context.registerPrototype(beanName, handlerType); + } + TestMethodMessageHandler messageHandler = new TestMethodMessageHandler(); + messageHandler.setApplicationContext(context); + customizer.accept(messageHandler); + messageHandler.afterPropertiesSet(); + return messageHandler; + } + + + @SuppressWarnings("unused") + private static class TestController { + + public Mono handleMessage() { + return delay("handleMessage"); + } + + @SuppressWarnings("rawtypes") + public Mono handleMessageWithArgument(String payload) { + return delay("handleMessageWithArgument,payload=" + payload); + } + + public Mono handleMessageAndThrow() { + return Mono.delay(Duration.ofMillis(10)) + .flatMap(aLong -> Mono.error(new IllegalStateException("rejected"))); + } + + public Mono handleMessageMatch1() { + return delay("handleMessageMatch1"); + } + + public Mono handleMessageMatch2() { + return delay("handleMessageMatch2"); + } + + public Mono handleIllegalStateException(IllegalStateException ex) { + return delay("handleIllegalStateException,ex=" + ex.getMessage()); + } + + private Mono delay(String value) { + return Mono.delay(Duration.ofMillis(10)).map(aLong -> value); + } + } + + + @SuppressWarnings("unused") + private static class DuplicateMappingsController { + + void handleMessageFoo() { } + + void handleMessageFoo(String foo) { } + } + + + private static class TestMethodMessageHandler extends AbstractMethodMessageHandler { + + private final TestReturnValueHandler returnValueHandler = new TestReturnValueHandler(); + + private PathMatcher pathMatcher = new AntPathMatcher(); + + + @Override + protected List initArgumentResolvers() { + return Collections.emptyList(); + } + + @Override + protected List initReturnValueHandlers() { + return Collections.singletonList(this.returnValueHandler); + } + + @Nullable + public Object getLastReturnValue() { + return this.returnValueHandler.getLastReturnValue(); + } + + public void register(Object handler, Method method, String mapping) { + super.registerHandlerMethod(handler, method, mapping); + } + + @Override + protected boolean isHandler(Class handlerType) { + return handlerType.getName().endsWith("Controller"); + } + + @Override + protected String getMappingForMethod(Method method, Class handlerType) { + String methodName = method.getName(); + if (methodName.startsWith("handleMessage")) { + return "/" + methodName; + } + return null; + } + + @Override + protected Set getDirectLookupMappings(String mapping) { + return Collections.singleton(mapping); + } + + @Override + @Nullable + protected String getDestination(Message message) { + return (String) message.getHeaders().get(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER); + } + + @Override + protected String getMatchingMapping(String mapping, Message message) { + String destination = getDestination(message); + Assert.notNull(destination, "No destination"); + return mapping.equals(destination) || this.pathMatcher.match(mapping, destination) ? mapping : null; + } + + @Override + protected Comparator getMappingComparator(Message message) { + return (info1, info2) -> { + DestinationPatternsMessageCondition cond1 = new DestinationPatternsMessageCondition(info1); + DestinationPatternsMessageCondition cond2 = new DestinationPatternsMessageCondition(info2); + return cond1.compareTo(cond2, message); + }; + } + + @Override + protected AbstractExceptionHandlerMethodResolver createExceptionMethodResolverFor(Class beanType) { + return new TestExceptionResolver(beanType); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java new file mode 100644 index 0000000000..449cec194b --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.invocation.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * Return value handler that simply stores the last return value. + * @author Rossen Stoyanchev + */ +public class TestReturnValueHandler implements HandlerMethodReturnValueHandler { + + @Nullable + private Object lastReturnValue; + + + @Nullable + public Object getLastReturnValue() { + return this.lastReturnValue; + } + + + @Override + public boolean supportsReturnType(MethodParameter returnType) { + return true; + } + + @Override + public Mono handleReturnValue(@Nullable Object value, MethodParameter returnType, Message message) { + this.lastReturnValue = value; + return Mono.empty(); + } + +} From dda40c1516eb328f67d507c85bfa1f3137bad978 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Fri, 25 Jan 2019 17:47:43 -0500 Subject: [PATCH 04/17] Reactive @MessageMapping See gh-21987 --- .../handler/CompositeMessageCondition.java | 160 ++++++++++++ .../MessageMappingMessageHandler.java | 246 ++++++++++++++++++ .../simp/SimpMessageMappingInfo.java | 53 ++-- .../MessageMappingMessageHandlerTests.java | 218 ++++++++++++++++ 4 files changed, 641 insertions(+), 36 deletions(-) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java new file mode 100644 index 0000000000..23c61faaaa --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java @@ -0,0 +1,160 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.messaging.Message; +import org.springframework.util.Assert; + +/** + * Composite {@link MessageCondition} that delegates to other message conditions. + * + *

For {@link #combine} and {@link #compareTo} it is expected that the "other" + * composite contains the same number, type, and order of message conditions. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class CompositeMessageCondition implements MessageCondition { + + private final List> messageConditions; + + + public CompositeMessageCondition(MessageCondition... messageConditions) { + this(Arrays.asList(messageConditions)); + } + + private CompositeMessageCondition(List> messageConditions) { + Assert.notEmpty(messageConditions, "No message conditions"); + this.messageConditions = messageConditions; + } + + + public List> getMessageConditions() { + return this.messageConditions; + } + + @SuppressWarnings("unchecked") + public > T getCondition(Class messageConditionType) { + for (MessageCondition condition : this.messageConditions) { + if (messageConditionType.isAssignableFrom(condition.getClass())) { + return (T) condition; + } + } + throw new IllegalStateException("No condition of type: " + messageConditionType); + } + + + @Override + public CompositeMessageCondition combine(CompositeMessageCondition other) { + checkCompatible(other); + List> result = new ArrayList<>(this.messageConditions.size()); + for (int i = 0; i < this.messageConditions.size(); i++) { + result.add(combine(getMessageConditions().get(i), other.getMessageConditions().get(i))); + } + return new CompositeMessageCondition(result); + } + + @SuppressWarnings("unchecked") + private > T combine(MessageCondition first, MessageCondition second) { + return ((T) first).combine((T) second); + } + + @Override + public CompositeMessageCondition getMatchingCondition(Message message) { + List> result = new ArrayList<>(this.messageConditions.size()); + for (MessageCondition condition : this.messageConditions) { + MessageCondition matchingCondition = (MessageCondition) condition.getMatchingCondition(message); + if (matchingCondition == null) { + return null; + } + result.add(matchingCondition); + } + return new CompositeMessageCondition(result); + } + + @Override + public int compareTo(CompositeMessageCondition other, Message message) { + checkCompatible(other); + List> otherConditions = other.getMessageConditions(); + for (int i = 0; i < this.messageConditions.size(); i++) { + int result = compare (this.messageConditions.get(i), otherConditions.get(i), message); + if (result != 0) { + return result; + } + } + return 0; + } + + @SuppressWarnings("unchecked") + private > int compare( + MessageCondition first, MessageCondition second, Message message) { + + return ((T) first).compareTo((T) second, message); + } + + private void checkCompatible(CompositeMessageCondition other) { + List> others = other.getMessageConditions(); + for (int i = 0; i < this.messageConditions.size(); i++) { + if (i < others.size()) { + if (this.messageConditions.get(i).getClass().equals(others.get(i).getClass())) { + continue; + } + } + throw new IllegalArgumentException("Mismatched CompositeMessageCondition: " + + this.messageConditions + " vs " + others); + } + } + + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof CompositeMessageCondition)) { + return false; + } + CompositeMessageCondition otherComposite = (CompositeMessageCondition) other; + checkCompatible(otherComposite); + List> otherConditions = otherComposite.getMessageConditions(); + for (int i = 0; i < this.messageConditions.size(); i++) { + if (!this.messageConditions.get(i).equals(otherConditions.get(i))) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + int hashCode = 0; + for (MessageCondition condition : this.messageConditions) { + hashCode += condition.hashCode() * 31; + } + return hashCode; + } + + @Override + public String toString() { + return this.messageConditions.stream().map(Object::toString).collect(Collectors.joining(",", "{", "}")); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java new file mode 100644 index 0000000000..4d8d8ba9d6 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java @@ -0,0 +1,246 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation.support.reactive; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.codec.Decoder; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.CompositeMessageCondition; +import org.springframework.messaging.handler.DestinationPatternsMessageCondition; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.support.AnnotationExceptionHandlerMethodResolver; +import org.springframework.messaging.handler.invocation.AbstractExceptionHandlerMethodResolver; +import org.springframework.messaging.handler.invocation.reactive.AbstractEncoderMethodReturnValueHandler; +import org.springframework.messaging.handler.invocation.reactive.AbstractMethodMessageHandler; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodArgumentResolver; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; +import org.springframework.stereotype.Controller; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; +import org.springframework.util.PathMatcher; +import org.springframework.util.StringValueResolver; +import org.springframework.validation.Validator; + +/** + * Extension of {@link AbstractMethodMessageHandler} for + * {@link MessageMapping @MessageMapping} methods. + * + *

The payload of incoming messages is decoded through + * {@link PayloadMethodArgumentResolver} using one of the configured + * {@link #setDecoders(List)} decoders. + * + *

The {@link #setEncoderReturnValueHandler encoderReturnValueHandler} + * property must be set to encode and handle return values from + * {@code @MessageMapping} methods. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class MessageMappingMessageHandler extends AbstractMethodMessageHandler + implements EmbeddedValueResolverAware { + + private PathMatcher pathMatcher = new AntPathMatcher(); + + private final List> decoders = new ArrayList<>(); + + @Nullable + private Validator validator; + + @Nullable + private HandlerMethodReturnValueHandler encoderReturnValueHandler; + + @Nullable + private StringValueResolver valueResolver; + + + /** + * Set the PathMatcher implementation to use for matching destinations + * against configured destination patterns. + *

By default, {@link AntPathMatcher} is used. + */ + public void setPathMatcher(PathMatcher pathMatcher) { + Assert.notNull(pathMatcher, "PathMatcher must not be null"); + this.pathMatcher = pathMatcher; + } + + /** + * Return the PathMatcher implementation to use for matching destinations. + */ + public PathMatcher getPathMatcher() { + return this.pathMatcher; + } + + /** + * Configure the decoders to user for incoming payloads. + */ + public void setDecoders(List> decoders) { + this.decoders.addAll(decoders); + } + + /** + * Return the configured decoders. + */ + public List> getDecoders() { + return this.decoders; + } + + /** + * Return the configured Validator instance. + */ + @Nullable + public Validator getValidator() { + return this.validator; + } + + /** + * Set the Validator instance used for validating {@code @Payload} arguments. + * @see org.springframework.validation.annotation.Validated + * @see PayloadMethodArgumentResolver + */ + public void setValidator(@Nullable Validator validator) { + this.validator = validator; + } + + /** + * Configure the return value handler that will encode response content. + * Consider extending {@link AbstractEncoderMethodReturnValueHandler} which + * provides the infrastructure to encode and all that's left is to somehow + * handle the encoded content, e.g. by wrapping as a message and passing it + * to something or sending it somewhere. + *

By default this is not configured in which case payload/content return + * values from {@code @MessageMapping} methods will remain unhandled. + * @param encoderReturnValueHandler the return value handler to use + * @see AbstractEncoderMethodReturnValueHandler + */ + public void setEncoderReturnValueHandler(@Nullable HandlerMethodReturnValueHandler encoderReturnValueHandler) { + this.encoderReturnValueHandler = encoderReturnValueHandler; + } + + /** + * Return the configured + * {@link #setEncoderReturnValueHandler encoderReturnValueHandler}. + */ + @Nullable + public HandlerMethodReturnValueHandler getEncoderReturnValueHandler() { + return this.encoderReturnValueHandler; + } + + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.valueResolver = resolver; + } + + + @Override + protected List initArgumentResolvers() { + List resolvers = new ArrayList<>(); + + // Custom resolvers + resolvers.addAll(getArgumentResolverConfigurer().getCustomResolvers()); + + // Catch-all + resolvers.add(new PayloadMethodArgumentResolver( + this.decoders, this.validator, getReactiveAdapterRegistry(), true)); + + return resolvers; + } + + @Override + protected List initReturnValueHandlers() { + List handlers = new ArrayList<>(); + handlers.addAll(getReturnValueHandlerConfigurer().getCustomHandlers()); + if (this.encoderReturnValueHandler != null) { + handlers.add(this.encoderReturnValueHandler); + } + return handlers; + } + + + @Override + protected boolean isHandler(Class beanType) { + return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class); + } + + @Override + protected CompositeMessageCondition getMappingForMethod(Method method, Class handlerType) { + CompositeMessageCondition methodCondition = getCondition(method); + if (methodCondition != null) { + CompositeMessageCondition typeCondition = getCondition(handlerType); + if (typeCondition != null) { + return typeCondition.combine(methodCondition); + } + } + return methodCondition; + } + + @Nullable + private CompositeMessageCondition getCondition(AnnotatedElement element) { + MessageMapping annot = AnnotatedElementUtils.findMergedAnnotation(element, MessageMapping.class); + if (annot == null || annot.value().length == 0) { + return null; + } + String[] destinations = annot.value(); + if (this.valueResolver != null) { + destinations = Arrays.stream(annot.value()) + .map(s -> this.valueResolver.resolveStringValue(s)) + .toArray(String[]::new); + } + return new CompositeMessageCondition(new DestinationPatternsMessageCondition(destinations, this.pathMatcher)); + } + + @Override + protected Set getDirectLookupMappings(CompositeMessageCondition mapping) { + Set result = new LinkedHashSet<>(); + for (String pattern : mapping.getCondition(DestinationPatternsMessageCondition.class).getPatterns()) { + if (!this.pathMatcher.isPattern(pattern)) { + result.add(pattern); + } + } + return result; + } + + @Override + protected String getDestination(Message message) { + return (String) message.getHeaders().get(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER); + } + + @Override + protected CompositeMessageCondition getMatchingMapping(CompositeMessageCondition mapping, Message message) { + return mapping.getMatchingCondition(message); + } + + @Override + protected Comparator getMappingComparator(Message message) { + return (info1, info2) -> info1.compareTo(info2, message); + } + + @Override + protected AbstractExceptionHandlerMethodResolver createExceptionMethodResolverFor(Class beanType) { + return new AnnotationExceptionHandlerMethodResolver(beanType); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageMappingInfo.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageMappingInfo.java index 317c24919b..e6d6b55994 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageMappingInfo.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageMappingInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -18,6 +18,7 @@ package org.springframework.messaging.simp; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; +import org.springframework.messaging.handler.CompositeMessageCondition; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; import org.springframework.messaging.handler.MessageCondition; @@ -34,62 +35,44 @@ import org.springframework.messaging.handler.MessageCondition; */ public class SimpMessageMappingInfo implements MessageCondition { - private final SimpMessageTypeMessageCondition messageTypeMessageCondition; - - private final DestinationPatternsMessageCondition destinationConditions; + private final CompositeMessageCondition delegate; public SimpMessageMappingInfo(SimpMessageTypeMessageCondition messageTypeMessageCondition, DestinationPatternsMessageCondition destinationConditions) { - this.messageTypeMessageCondition = messageTypeMessageCondition; - this.destinationConditions = destinationConditions; + this.delegate = new CompositeMessageCondition(messageTypeMessageCondition, destinationConditions); + } + + private SimpMessageMappingInfo(CompositeMessageCondition delegate) { + this.delegate = delegate; } public SimpMessageTypeMessageCondition getMessageTypeMessageCondition() { - return this.messageTypeMessageCondition; + return this.delegate.getCondition(SimpMessageTypeMessageCondition.class); } public DestinationPatternsMessageCondition getDestinationConditions() { - return this.destinationConditions; + return this.delegate.getCondition(DestinationPatternsMessageCondition.class); } @Override public SimpMessageMappingInfo combine(SimpMessageMappingInfo other) { - SimpMessageTypeMessageCondition typeCond = - this.getMessageTypeMessageCondition().combine(other.getMessageTypeMessageCondition()); - DestinationPatternsMessageCondition destCond = - this.destinationConditions.combine(other.getDestinationConditions()); - return new SimpMessageMappingInfo(typeCond, destCond); + return new SimpMessageMappingInfo(this.delegate.combine(other.delegate)); } @Override @Nullable public SimpMessageMappingInfo getMatchingCondition(Message message) { - SimpMessageTypeMessageCondition typeCond = this.messageTypeMessageCondition.getMatchingCondition(message); - if (typeCond == null) { - return null; - } - DestinationPatternsMessageCondition destCond = this.destinationConditions.getMatchingCondition(message); - if (destCond == null) { - return null; - } - return new SimpMessageMappingInfo(typeCond, destCond); + CompositeMessageCondition condition = this.delegate.getMatchingCondition(message); + return condition != null ? new SimpMessageMappingInfo(condition) : null; } @Override public int compareTo(SimpMessageMappingInfo other, Message message) { - int result = this.messageTypeMessageCondition.compareTo(other.messageTypeMessageCondition, message); - if (result != 0) { - return result; - } - result = this.destinationConditions.compareTo(other.destinationConditions, message); - if (result != 0) { - return result; - } - return 0; + return this.delegate.compareTo(other.delegate, message); } @@ -101,19 +84,17 @@ public class SimpMessageMappingInfo implements MessageCondition> decoders = Collections.singletonList(StringDecoder.allMimeTypes()); + List> encoders = Collections.singletonList(CharSequenceEncoder.allMimeTypes()); + + ReactiveAdapterRegistry registry = ReactiveAdapterRegistry.getSharedInstance(); + this.returnValueHandler = new TestEncoderReturnValueHandler(encoders, registry); + + PropertySource source = new MapPropertySource("test", Collections.singletonMap("path", "path123")); + + StaticApplicationContext context = new StaticApplicationContext(); + context.getEnvironment().getPropertySources().addFirst(source); + context.registerSingleton("testController", TestController.class); + context.refresh(); + + MessageMappingMessageHandler messageHandler = new MessageMappingMessageHandler(); + messageHandler.setApplicationContext(context); + messageHandler.setEmbeddedValueResolver(new EmbeddedValueResolver(context.getBeanFactory())); + messageHandler.setDecoders(decoders); + messageHandler.setEncoderReturnValueHandler(this.returnValueHandler); + messageHandler.afterPropertiesSet(); + + return messageHandler; + } + + private Message message(String destination, String... content) { + return new GenericMessage<>( + Flux.fromIterable(Arrays.stream(content).map(this::toDataBuffer).collect(Collectors.toList())), + Collections.singletonMap(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, destination)); + } + + private DataBuffer toDataBuffer(String payload) { + return bufferFactory.wrap(payload.getBytes(UTF_8)); + } + + private void verifyOutputContent(List expected) { + List buffers = this.returnValueHandler.getOutputContent(); + assertNotNull("No output: no matching handler method?", buffers); + List actual = buffers.stream().map(buffer -> dumpString(buffer, UTF_8)).collect(Collectors.toList()); + assertEquals(expected, actual); + } + + + @Controller + static class TestController { + + @MessageMapping("/string") + String handleString(String payload) { + return payload + "::response"; + } + + @MessageMapping("/monoString") + Mono handleMonoString(Mono payload) { + return payload.map(s -> s + "::response").delayElement(Duration.ofMillis(10)); + } + + @MessageMapping("/fluxString") + Flux handleFluxString(Flux payload) { + return payload.map(s -> s + "::response").delayElements(Duration.ofMillis(10)); + } + + @MessageMapping("/${path}") + String handleWithPlaceholder(String payload) { + return payload + "::response"; + } + + @MessageMapping("/exception") + String handleAndThrow() { + throw new IllegalArgumentException("rejected"); + } + + @MessageMapping("/errorSignal") + Mono handleAndSignalError() { + return Mono.delay(Duration.ofMillis(10)) + .flatMap(aLong -> Mono.error(new IllegalArgumentException("rejected"))); + } + + @MessageExceptionHandler + Mono handleException(IllegalArgumentException ex) { + return Mono.delay(Duration.ofMillis(10)).map(aLong -> ex.getMessage() + "::handled"); + } + } + + + private static class TestEncoderReturnValueHandler extends AbstractEncoderMethodReturnValueHandler { + + @Nullable + private volatile List outputContent; + + + TestEncoderReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { + super(encoders, registry); + } + + + @Nullable + public List getOutputContent() { + return this.outputContent; + } + + @Override + protected Mono handleEncodedContent( + Flux encodedContent, MethodParameter returnType, Message message) { + + return encodedContent.collectList().doOnNext(buffers -> this.outputContent = buffers).then(); + } + } + +} From 567c559da8434a43b2291d835717b23161adadca Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Mon, 28 Jan 2019 16:39:58 -0500 Subject: [PATCH 05/17] Resolvers for destination vars and headers See gh-21987 --- ...tractNamedValueMethodArgumentResolver.java | 235 ++++++++++++++++++ ...inationVariableMethodArgumentResolver.java | 84 +++++++ .../HeaderMethodArgumentResolver.java | 121 +++++++++ .../HeadersMethodArgumentResolver.java | 82 ++++++ .../MessageMappingMessageHandler.java | 33 +++ .../SyncHandlerMethodArgumentResolver.java | 52 ++++ .../annotation/MessagingPredicates.java | 128 ++++++++++ ...onVariableMethodArgumentResolverTests.java | 91 +++++++ .../HeaderMethodArgumentResolverTests.java | 176 +++++++++++++ .../HeadersMethodArgumentResolverTests.java | 121 +++++++++ 10 files changed, 1123 insertions(+) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/AbstractNamedValueMethodArgumentResolver.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolver.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolver.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolver.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/SyncHandlerMethodArgumentResolver.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/MessagingPredicates.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolverTests.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolverTests.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolverTests.java diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/AbstractNamedValueMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/AbstractNamedValueMethodArgumentResolver.java new file mode 100644 index 0000000000..9184e41f36 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/AbstractNamedValueMethodArgumentResolver.java @@ -0,0 +1,235 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation.support.reactive; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.BeanExpressionResolver; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.ValueConstants; +import org.springframework.messaging.handler.invocation.reactive.SyncHandlerMethodArgumentResolver; +import org.springframework.util.ClassUtils; + +/** + * Abstract base class to resolve method arguments from a named value, e.g. + * message headers or destination variables. Named values could have one or more + * of a name, a required flag, and a default value. + * + *

Subclasses only need to define specific steps such as how to obtain named + * value details from a method parameter, how to resolve to argument values, or + * how to handle missing values. + * + *

A default value string can contain ${...} placeholders and Spring + * Expression Language {@code #{...}} expressions which will be resolved if a + * {@link ConfigurableBeanFactory} is supplied to the class constructor. + * + *

A {@link ConversionService} is used to to convert resolved String argument + * value to the expected target method parameter type. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public abstract class AbstractNamedValueMethodArgumentResolver implements SyncHandlerMethodArgumentResolver { + + private final ConversionService conversionService; + + @Nullable + private final ConfigurableBeanFactory configurableBeanFactory; + + @Nullable + private final BeanExpressionContext expressionContext; + + private final Map namedValueInfoCache = new ConcurrentHashMap<>(256); + + + /** + * Constructor with a {@link ConversionService} and a {@link BeanFactory}. + * @param conversionService conversion service for converting String values + * to the target method parameter type + * @param beanFactory a bean factory for resolving {@code ${...}} + * placeholders and {@code #{...}} SpEL expressions in default values + */ + protected AbstractNamedValueMethodArgumentResolver(ConversionService conversionService, + @Nullable ConfigurableBeanFactory beanFactory) { + + this.conversionService = conversionService; + this.configurableBeanFactory = beanFactory; + this.expressionContext = (beanFactory != null ? new BeanExpressionContext(beanFactory, null) : null); + } + + + @Override + public Object resolveArgumentValue(MethodParameter parameter, Message message) { + + NamedValueInfo namedValueInfo = getNamedValueInfo(parameter); + MethodParameter nestedParameter = parameter.nestedIfOptional(); + + Object resolvedName = resolveEmbeddedValuesAndExpressions(namedValueInfo.name); + if (resolvedName == null) { + throw new IllegalArgumentException( + "Specified name must not resolve to null: [" + namedValueInfo.name + "]"); + } + + Object arg = resolveArgumentInternal(nestedParameter, message, resolvedName.toString()); + if (arg == null) { + if (namedValueInfo.defaultValue != null) { + arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); + } + else if (namedValueInfo.required && !nestedParameter.isOptional()) { + handleMissingValue(namedValueInfo.name, nestedParameter, message); + } + arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType()); + } + else if ("".equals(arg) && namedValueInfo.defaultValue != null) { + arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); + } + + if (parameter != nestedParameter || !ClassUtils.isAssignableValue(parameter.getParameterType(), arg)) { + arg = this.conversionService.convert(arg, TypeDescriptor.forObject(arg), new TypeDescriptor(parameter)); + } + + return arg; + } + + /** + * Obtain the named value for the given method parameter. + */ + private NamedValueInfo getNamedValueInfo(MethodParameter parameter) { + NamedValueInfo namedValueInfo = this.namedValueInfoCache.get(parameter); + if (namedValueInfo == null) { + namedValueInfo = createNamedValueInfo(parameter); + namedValueInfo = updateNamedValueInfo(parameter, namedValueInfo); + this.namedValueInfoCache.put(parameter, namedValueInfo); + } + return namedValueInfo; + } + + /** + * Create the {@link NamedValueInfo} object for the given method parameter. + * Implementations typically retrieve the method annotation by means of + * {@link MethodParameter#getParameterAnnotation(Class)}. + * @param parameter the method parameter + * @return the named value information + */ + protected abstract NamedValueInfo createNamedValueInfo(MethodParameter parameter); + + /** + * Fall back on the parameter name from the class file if necessary and + * replace {@link ValueConstants#DEFAULT_NONE} with null. + */ + private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) { + String name = info.name; + if (info.name.isEmpty()) { + name = parameter.getParameterName(); + if (name == null) { + Class type = parameter.getParameterType(); + throw new IllegalArgumentException( + "Name for argument of type [" + type.getName() + "] not specified, " + + "and parameter name information not found in class file either."); + } + } + return new NamedValueInfo(name, info.required, + ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue); + } + + /** + * Resolve the given annotation-specified value, + * potentially containing placeholders and expressions. + */ + @Nullable + private Object resolveEmbeddedValuesAndExpressions(String value) { + if (this.configurableBeanFactory == null || this.expressionContext == null) { + return value; + } + String placeholdersResolved = this.configurableBeanFactory.resolveEmbeddedValue(value); + BeanExpressionResolver exprResolver = this.configurableBeanFactory.getBeanExpressionResolver(); + if (exprResolver == null) { + return value; + } + return exprResolver.evaluate(placeholdersResolved, this.expressionContext); + } + + /** + * Resolves the given parameter type and value name into an argument value. + * @param parameter the method parameter to resolve to an argument value + * @param message the current request + * @param name the name of the value being resolved + * @return the resolved argument. May be {@code null} + */ + @Nullable + protected abstract Object resolveArgumentInternal(MethodParameter parameter, Message message, String name); + + /** + * Invoked when a value is required, but {@link #resolveArgumentInternal} + * returned {@code null} and there is no default value. Sub-classes can + * throw an appropriate exception for this case. + * @param name the name for the value + * @param parameter the target method parameter + * @param message the message being processed + */ + protected abstract void handleMissingValue(String name, MethodParameter parameter, Message message); + + /** + * One last chance to handle a possible null value. + * Specifically for booleans method parameters, use {@link Boolean#FALSE}. + * Also raise an ISE for primitive types. + */ + @Nullable + private Object handleNullValue(String name, @Nullable Object value, Class paramType) { + if (value == null) { + if (Boolean.TYPE.equals(paramType)) { + return Boolean.FALSE; + } + else if (paramType.isPrimitive()) { + throw new IllegalStateException("Optional " + paramType + " parameter '" + name + + "' is present but cannot be translated into a null value due to being " + + "declared as a primitive type. Consider declaring it as object wrapper " + + "for the corresponding primitive type."); + } + } + return value; + } + + + /** + * Represents a named value declaration. + */ + protected static class NamedValueInfo { + + private final String name; + + private final boolean required; + + @Nullable + private final String defaultValue; + + protected NamedValueInfo(String name, boolean required, @Nullable String defaultValue) { + this.name = name; + this.required = required; + this.defaultValue = defaultValue; + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolver.java new file mode 100644 index 0000000000..b8c413072d --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolver.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation.support.reactive; + +import java.util.Map; + +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.ValueConstants; +import org.springframework.util.Assert; + +/** + * Resolve for {@link DestinationVariable @DestinationVariable} method parameters. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class DestinationVariableMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + /** The name of the header used to for template variables. */ + public static final String DESTINATION_TEMPLATE_VARIABLES_HEADER = + DestinationVariableMethodArgumentResolver.class.getSimpleName() + ".templateVariables"; + + + public DestinationVariableMethodArgumentResolver(ConversionService conversionService) { + super(conversionService, null); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(DestinationVariable.class); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + DestinationVariable annot = parameter.getParameterAnnotation(DestinationVariable.class); + Assert.state(annot != null, "No DestinationVariable annotation"); + return new DestinationVariableNamedValueInfo(annot); + } + + @Override + @Nullable + @SuppressWarnings("unchecked") + protected Object resolveArgumentInternal(MethodParameter parameter, Message message, String name) { + MessageHeaders headers = message.getHeaders(); + Map vars = (Map) headers.get(DESTINATION_TEMPLATE_VARIABLES_HEADER); + return vars != null ? vars.get(name) : null; + } + + @Override + protected void handleMissingValue(String name, MethodParameter parameter, Message message) { + throw new MessageHandlingException(message, "Missing path template variable '" + name + "' " + + "for method parameter type [" + parameter.getParameterType() + "]"); + } + + + private static final class DestinationVariableNamedValueInfo extends NamedValueInfo { + + private DestinationVariableNamedValueInfo(DestinationVariable annotation) { + super(annotation.value(), true, ValueConstants.DEFAULT_NONE); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolver.java new file mode 100644 index 0000000000..a50d38e377 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolver.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation.support.reactive; + +import java.util.List; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.support.NativeMessageHeaderAccessor; +import org.springframework.util.Assert; + +/** + * Resolver for {@link Header @Header} arguments. Headers are resolved from + * either the top-level header map or the nested + * {@link NativeMessageHeaderAccessor native} header map. + * + * @author Rossen Stoyanchev + * @since 5.2 + * + * @see HeadersMethodArgumentResolver + * @see NativeMessageHeaderAccessor + */ +public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + private static final Log logger = LogFactory.getLog(HeaderMethodArgumentResolver.class); + + + public HeaderMethodArgumentResolver( + ConversionService conversionService, @Nullable ConfigurableBeanFactory beanFactory) { + + super(conversionService, beanFactory); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Header.class); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + Header annot = parameter.getParameterAnnotation(Header.class); + Assert.state(annot != null, "No Header annotation"); + return new HeaderNamedValueInfo(annot); + } + + @Override + @Nullable + protected Object resolveArgumentInternal(MethodParameter parameter, Message message, String name) { + + Object headerValue = message.getHeaders().get(name); + Object nativeHeaderValue = getNativeHeaderValue(message, name); + + if (headerValue != null && nativeHeaderValue != null) { + if (logger.isDebugEnabled()) { + logger.debug("A value was found for '" + name + "', in both the top level header map " + + "and also in the nested map for native headers. Using the value from top level map. " + + "Use 'nativeHeader.myHeader' to resolve the native header."); + } + } + + return (headerValue != null ? headerValue : nativeHeaderValue); + } + + @Nullable + private Object getNativeHeaderValue(Message message, String name) { + Map> nativeHeaders = getNativeHeaders(message); + if (name.startsWith("nativeHeaders.")) { + name = name.substring("nativeHeaders.".length()); + } + if (nativeHeaders == null || !nativeHeaders.containsKey(name)) { + return null; + } + List nativeHeaderValues = nativeHeaders.get(name); + return (nativeHeaderValues.size() == 1 ? nativeHeaderValues.get(0) : nativeHeaderValues); + } + + @SuppressWarnings("unchecked") + @Nullable + private Map> getNativeHeaders(Message message) { + return (Map>) message.getHeaders().get(NativeMessageHeaderAccessor.NATIVE_HEADERS); + } + + @Override + protected void handleMissingValue(String headerName, MethodParameter parameter, Message message) { + throw new MessageHandlingException(message, "Missing header '" + headerName + + "' for method parameter type [" + parameter.getParameterType() + "]"); + } + + + private static final class HeaderNamedValueInfo extends NamedValueInfo { + + private HeaderNamedValueInfo(Header annotation) { + super(annotation.name(), annotation.required(), annotation.defaultValue()); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolver.java new file mode 100644 index 0000000000..e2aa858540 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolver.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation.support.reactive; + +import java.lang.reflect.Method; +import java.util.Map; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.messaging.handler.invocation.reactive.SyncHandlerMethodArgumentResolver; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.util.ReflectionUtils; + +/** + * Argument resolver for headers. Resolves the following method parameters: + *

    + *
  • {@link Headers @Headers} {@link Map} + *
  • {@link MessageHeaders} + *
  • {@link MessageHeaderAccessor} + *
+ * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class HeadersMethodArgumentResolver implements SyncHandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + Class paramType = parameter.getParameterType(); + return ((parameter.hasParameterAnnotation(Headers.class) && Map.class.isAssignableFrom(paramType)) || + MessageHeaders.class == paramType || MessageHeaderAccessor.class.isAssignableFrom(paramType)); + } + + @Override + @Nullable + public Object resolveArgumentValue(MethodParameter parameter, Message message) { + Class paramType = parameter.getParameterType(); + if (Map.class.isAssignableFrom(paramType)) { + return message.getHeaders(); + } + else if (MessageHeaderAccessor.class == paramType) { + MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, MessageHeaderAccessor.class); + return accessor != null ? accessor : new MessageHeaderAccessor(message); + } + else if (MessageHeaderAccessor.class.isAssignableFrom(paramType)) { + MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, MessageHeaderAccessor.class); + if (accessor != null && paramType.isAssignableFrom(accessor.getClass())) { + return accessor; + } + else { + Method method = ReflectionUtils.findMethod(paramType, "wrap", Message.class); + if (method == null) { + throw new IllegalStateException( + "Cannot create accessor of type " + paramType + " for message " + message); + } + return ReflectionUtils.invokeMethod(method, null, message); + } + } + else { + throw new IllegalStateException("Unexpected parameter of type " + paramType + + " in method " + parameter.getMethod() + ". "); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java index 4d8d8ba9d6..25463e70f3 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java @@ -24,9 +24,14 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.EmbeddedValueResolverAware; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.codec.Decoder; +import org.springframework.core.convert.ConversionService; +import org.springframework.format.support.DefaultFormattingConversionService; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.handler.CompositeMessageCondition; @@ -73,6 +78,8 @@ public class MessageMappingMessageHandler extends AbstractMethodMessageHandlerBy default {@link DefaultFormattingConversionService} is used. + * @param conversionService the conversion service to use + */ + public void setConversionService(ConversionService conversionService) { + this.conversionService = conversionService; + } + + /** + * Return the configured ConversionService. + */ + public ConversionService getConversionService() { + return this.conversionService; + } + @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { this.valueResolver = resolver; @@ -159,6 +183,15 @@ public class MessageMappingMessageHandler extends AbstractMethodMessageHandler initArgumentResolvers() { List resolvers = new ArrayList<>(); + ApplicationContext context = getApplicationContext(); + ConfigurableBeanFactory beanFactory = (context instanceof ConfigurableApplicationContext ? + ((ConfigurableApplicationContext) context).getBeanFactory() : null); + + // Annotation-based resolvers + resolvers.add(new HeaderMethodArgumentResolver(this.conversionService, beanFactory)); + resolvers.add(new HeadersMethodArgumentResolver()); + resolvers.add(new DestinationVariableMethodArgumentResolver(this.conversionService)); + // Custom resolvers resolvers.addAll(getArgumentResolverConfigurer().getCustomResolvers()); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/SyncHandlerMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/SyncHandlerMethodArgumentResolver.java new file mode 100644 index 0000000000..01727c43de --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/SyncHandlerMethodArgumentResolver.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.invocation.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * An extension of {@link HandlerMethodArgumentResolver} for implementations + * that are synchronous in nature and do not block to resolve values. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface SyncHandlerMethodArgumentResolver extends HandlerMethodArgumentResolver { + + /** + * {@inheritDoc} + *

By default this simply delegates to {@link #resolveArgumentValue} for + * synchronous resolution. + */ + @Override + default Mono resolveArgument(MethodParameter parameter, Message message) { + return Mono.justOrEmpty(resolveArgumentValue(parameter, message)); + } + + /** + * Resolve the value for the method parameter synchronously. + * @param parameter the method parameter + * @param message the currently processed message + * @return the resolved value, if any + */ + @Nullable + Object resolveArgumentValue(MethodParameter parameter, Message message); + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/MessagingPredicates.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/MessagingPredicates.java new file mode 100644 index 0000000000..59c0100068 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/MessagingPredicates.java @@ -0,0 +1,128 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation; + +import java.util.function.Predicate; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; + +/** + * Predicates for messaging annotations. + * + * @author Rossen Stoyanchev + */ +public class MessagingPredicates { + + public static DestinationVariablePredicate destinationVar() { + return new DestinationVariablePredicate(); + } + + public static DestinationVariablePredicate destinationVar(String value) { + return new DestinationVariablePredicate().value(value); + } + + public static HeaderPredicate header() { + return new HeaderPredicate(); + } + + public static HeaderPredicate header(String name) { + return new HeaderPredicate().name(name); + } + + public static HeaderPredicate header(String name, String defaultValue) { + return new HeaderPredicate().name(name).defaultValue(defaultValue); + } + + public static HeaderPredicate headerPlain() { + return new HeaderPredicate().noAttributes(); + } + + + public static class DestinationVariablePredicate implements Predicate { + + @Nullable + private String value; + + + public DestinationVariablePredicate value(@Nullable String name) { + this.value = name; + return this; + } + + public DestinationVariablePredicate noValue() { + this.value = ""; + return this; + } + + @Override + public boolean test(MethodParameter parameter) { + DestinationVariable annotation = parameter.getParameterAnnotation(DestinationVariable.class); + return annotation != null && (this.value == null || annotation.value().equals(this.value)); + } + } + + + public static class HeaderPredicate implements Predicate { + + @Nullable + private String name; + + @Nullable + private Boolean required; + + @Nullable + private String defaultValue; + + + public HeaderPredicate name(@Nullable String name) { + this.name = name; + return this; + } + + public HeaderPredicate noName() { + this.name = ""; + return this; + } + + public HeaderPredicate required(boolean required) { + this.required = required; + return this; + } + + public HeaderPredicate defaultValue(@Nullable String value) { + this.defaultValue = value; + return this; + } + + public HeaderPredicate noAttributes() { + this.name = ""; + this.required = true; + this.defaultValue = ValueConstants.DEFAULT_NONE; + return this; + } + + @Override + public boolean test(MethodParameter parameter) { + Header annotation = parameter.getParameterAnnotation(Header.class); + return annotation != null && + (this.name == null || annotation.name().equals(this.name)) && + (this.required == null || annotation.required() == this.required) && + (this.defaultValue == null || annotation.defaultValue().equals(this.defaultValue)); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolverTests.java new file mode 100644 index 0000000000..e5aee3aa95 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolverTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation.support.reactive; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.invocation.ResolvableMethod; +import org.springframework.messaging.support.MessageBuilder; + +import static org.junit.Assert.*; +import static org.springframework.messaging.handler.annotation.MessagingPredicates.*; + +/** + * Test fixture for {@link DestinationVariableMethodArgumentResolver} tests. + * @author Rossen Stoyanchev + */ +public class DestinationVariableMethodArgumentResolverTests { + + private final DestinationVariableMethodArgumentResolver resolver = + new DestinationVariableMethodArgumentResolver(new DefaultConversionService()); + + private final ResolvableMethod resolvable = + ResolvableMethod.on(getClass()).named("handleMessage").build(); + + + @Test + public void supportsParameter() { + assertTrue(resolver.supportsParameter(this.resolvable.annot(destinationVar().noValue()).arg())); + assertFalse(resolver.supportsParameter(this.resolvable.annotNotPresent(DestinationVariable.class).arg())); + } + + @Test + public void resolveArgument() { + + Map vars = new HashMap<>(); + vars.put("foo", "bar"); + vars.put("name", "value"); + + Message message = MessageBuilder.withPayload(new byte[0]).setHeader( + DestinationVariableMethodArgumentResolver.DESTINATION_TEMPLATE_VARIABLES_HEADER, vars).build(); + + Object result = resolveArgument(this.resolvable.annot(destinationVar().noValue()).arg(), message); + assertEquals("bar", result); + + result = resolveArgument(this.resolvable.annot(destinationVar("name")).arg(), message); + assertEquals("value", result); + } + + @Test(expected = MessageHandlingException.class) + public void resolveArgumentNotFound() { + Message message = MessageBuilder.withPayload(new byte[0]).build(); + resolveArgument(this.resolvable.annot(destinationVar().noValue()).arg(), message); + } + + @SuppressWarnings({"unchecked", "ConstantConditions"}) + private T resolveArgument(MethodParameter param, Message message) { + return (T) this.resolver.resolveArgument(param, message).block(Duration.ofSeconds(5)); + } + + + @SuppressWarnings("unused") + private void handleMessage( + @DestinationVariable String foo, + @DestinationVariable(value = "name") String param1, + String param3) { + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolverTests.java new file mode 100644 index 0000000000..bc2422ad27 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolverTests.java @@ -0,0 +1,176 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation.support.reactive; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.invocation.ResolvableMethod; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.NativeMessageHeaderAccessor; + +import static org.junit.Assert.*; +import static org.springframework.messaging.handler.annotation.MessagingPredicates.*; + +/** + * Test fixture for {@link HeaderMethodArgumentResolver} tests. + * @author Rossen Stoyanchev + */ +public class HeaderMethodArgumentResolverTests { + + private HeaderMethodArgumentResolver resolver; + + private final ResolvableMethod resolvable = ResolvableMethod.on(getClass()).named("handleMessage").build(); + + + @Before + public void setup() { + GenericApplicationContext context = new GenericApplicationContext(); + context.refresh(); + this.resolver = new HeaderMethodArgumentResolver(new DefaultConversionService(), context.getBeanFactory()); + } + + + @Test + public void supportsParameter() { + assertTrue(this.resolver.supportsParameter(this.resolvable.annot(headerPlain()).arg())); + assertFalse(this.resolver.supportsParameter(this.resolvable.annotNotPresent(Header.class).arg())); + } + + @Test + public void resolveArgument() { + Message message = MessageBuilder.withPayload(new byte[0]).setHeader("param1", "foo").build(); + Object result = resolveArgument(this.resolvable.annot(headerPlain()).arg(), message); + assertEquals("foo", result); + } + + @Test // SPR-11326 + public void resolveArgumentNativeHeader() { + TestMessageHeaderAccessor headers = new TestMessageHeaderAccessor(); + headers.setNativeHeader("param1", "foo"); + Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); + assertEquals("foo", resolveArgument(this.resolvable.annot(headerPlain()).arg(), message)); + } + + @Test + public void resolveArgumentNativeHeaderAmbiguity() { + TestMessageHeaderAccessor headers = new TestMessageHeaderAccessor(); + headers.setHeader("param1", "foo"); + headers.setNativeHeader("param1", "native-foo"); + Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); + + assertEquals("foo", resolveArgument( + this.resolvable.annot(headerPlain()).arg(), message)); + + assertEquals("native-foo", resolveArgument( + this.resolvable.annot(header("nativeHeaders.param1")).arg(), message)); + } + + @Test(expected = MessageHandlingException.class) + public void resolveArgumentNotFound() { + Message message = MessageBuilder.withPayload(new byte[0]).build(); + resolveArgument(this.resolvable.annot(headerPlain()).arg(), message); + } + + @Test + public void resolveArgumentDefaultValue() { + Message message = MessageBuilder.withPayload(new byte[0]).build(); + Object result = resolveArgument(this.resolvable.annot(header("name", "bar")).arg(), message); + assertEquals("bar", result); + } + + @Test + public void resolveDefaultValueSystemProperty() { + System.setProperty("systemProperty", "sysbar"); + try { + Message message = MessageBuilder.withPayload(new byte[0]).build(); + MethodParameter param = this.resolvable.annot(header("name", "#{systemProperties.systemProperty}")).arg(); + Object result = resolveArgument(param, message); + assertEquals("sysbar", result); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @Test + public void resolveNameFromSystemProperty() { + System.setProperty("systemProperty", "sysbar"); + try { + Message message = MessageBuilder.withPayload(new byte[0]).setHeader("sysbar", "foo").build(); + MethodParameter param = this.resolvable.annot(header("#{systemProperties.systemProperty}")).arg(); + Object result = resolveArgument(param, message); + assertEquals("foo", result); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @Test + public void resolveOptionalHeaderWithValue() { + Message message = MessageBuilder.withPayload("foo").setHeader("foo", "bar").build(); + MethodParameter param = this.resolvable.annot(header("foo")).arg(Optional.class, String.class); + Object result = resolveArgument(param, message); + assertEquals(Optional.of("bar"), result); + } + + @Test + public void resolveOptionalHeaderAsEmpty() { + Message message = MessageBuilder.withPayload("foo").build(); + MethodParameter param = this.resolvable.annot(header("foo")).arg(Optional.class, String.class); + Object result = resolveArgument(param, message); + assertEquals(Optional.empty(), result); + } + + @SuppressWarnings({"unchecked", "ConstantConditions"}) + private T resolveArgument(MethodParameter param, Message message) { + return (T) this.resolver.resolveArgument(param, message).block(Duration.ofSeconds(5)); + } + + + @SuppressWarnings({"unused", "OptionalUsedAsFieldOrParameterType"}) + public void handleMessage( + @Header String param1, + @Header(name = "name", defaultValue = "bar") String param2, + @Header(name = "name", defaultValue = "#{systemProperties.systemProperty}") String param3, + @Header(name = "#{systemProperties.systemProperty}") String param4, + String param5, + @Header("foo") Optional param6, + @Header("nativeHeaders.param1") String nativeHeaderParam1) { + } + + + public static class TestMessageHeaderAccessor extends NativeMessageHeaderAccessor { + + TestMessageHeaderAccessor() { + super((Map>) null); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolverTests.java new file mode 100644 index 0000000000..bfbcd1e8db --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolverTests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.annotation.support.reactive; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.messaging.handler.invocation.ResolvableMethod; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.messaging.support.NativeMessageHeaderAccessor; + +import static org.junit.Assert.*; + +/** + * Test fixture for {@link HeadersMethodArgumentResolver} tests. + * @author Rossen Stoyanchev + */ +public class HeadersMethodArgumentResolverTests { + + private final HeadersMethodArgumentResolver resolver = new HeadersMethodArgumentResolver(); + + private Message message = + MessageBuilder.withPayload(new byte[0]).copyHeaders(Collections.singletonMap("foo", "bar")).build(); + + private final ResolvableMethod resolvable = ResolvableMethod.on(getClass()).named("handleMessage").build(); + + + @Test + public void supportsParameter() { + + assertTrue(this.resolver.supportsParameter( + this.resolvable.annotPresent(Headers.class).arg(Map.class, String.class, Object.class))); + + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(MessageHeaders.class))); + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(MessageHeaderAccessor.class))); + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(TestMessageHeaderAccessor.class))); + + assertFalse(this.resolver.supportsParameter(this.resolvable.annotPresent(Headers.class).arg(String.class))); + } + + @Test + @SuppressWarnings("unchecked") + public void resolveArgumentAnnotated() { + MethodParameter param = this.resolvable.annotPresent(Headers.class).arg(Map.class, String.class, Object.class); + Map headers = resolveArgument(param); + assertEquals("bar", headers.get("foo")); + } + + @Test(expected = IllegalStateException.class) + public void resolveArgumentAnnotatedNotMap() { + resolveArgument(this.resolvable.annotPresent(Headers.class).arg(String.class)); + } + + @Test + public void resolveArgumentMessageHeaders() { + MessageHeaders headers = resolveArgument(this.resolvable.arg(MessageHeaders.class)); + assertEquals("bar", headers.get("foo")); + } + + @Test + public void resolveArgumentMessageHeaderAccessor() { + MessageHeaderAccessor headers = resolveArgument(this.resolvable.arg(MessageHeaderAccessor.class)); + assertEquals("bar", headers.getHeader("foo")); + } + + @Test + public void resolveArgumentMessageHeaderAccessorSubclass() { + TestMessageHeaderAccessor headers = resolveArgument(this.resolvable.arg(TestMessageHeaderAccessor.class)); + assertEquals("bar", headers.getHeader("foo")); + } + + @SuppressWarnings({"unchecked", "ConstantConditions"}) + private T resolveArgument(MethodParameter param) { + return (T) this.resolver.resolveArgument(param, this.message).block(Duration.ofSeconds(5)); + } + + + @SuppressWarnings("unused") + private void handleMessage( + @Headers Map param1, + @Headers String param2, + MessageHeaders param3, + MessageHeaderAccessor param4, + TestMessageHeaderAccessor param5) { + } + + + public static class TestMessageHeaderAccessor extends NativeMessageHeaderAccessor { + + TestMessageHeaderAccessor(Message message) { + super(message); + } + + public static TestMessageHeaderAccessor wrap(Message message) { + return new TestMessageHeaderAccessor(message); + } + } + +} From 5b3b0b1a7b01b0ee752ee5e100d1238e9c6fdddf Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Mon, 28 Jan 2019 16:39:49 -0500 Subject: [PATCH 06/17] Polish The package o.s.messaging.handler.annotation.support was missing @NonnullApi and @NonNullFields. This commit corrects that and also adds @Nullable to methods and arguments as needed to address warnings. --- ...tractNamedValueMethodArgumentResolver.java | 21 ++++++++++++------- .../DefaultMessageHandlerMethodFactory.java | 15 ++++++++++--- ...inationVariableMethodArgumentResolver.java | 6 +++--- .../support/HeaderMethodArgumentResolver.java | 12 ++++++----- .../MessageMethodArgumentResolver.java | 3 ++- .../support/PayloadArgumentResolver.java | 5 +++-- .../annotation/support/package-info.java | 5 +++++ 7 files changed, 45 insertions(+), 22 deletions(-) diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java index dc8e877c58..5f9b64c20e 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-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. @@ -26,7 +26,6 @@ import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.core.MethodParameter; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; -import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.handler.annotation.ValueConstants; @@ -61,8 +60,10 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle private final ConversionService conversionService; + @Nullable private final ConfigurableBeanFactory configurableBeanFactory; + @Nullable private final BeanExpressionContext expressionContext; private final Map namedValueInfoCache = new ConcurrentHashMap<>(256); @@ -70,16 +71,16 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle /** * Constructor with a {@link ConversionService} and a {@link BeanFactory}. - * @param cs conversion service for converting values to match the + * @param conversionService conversion service for converting values to match the * target method parameter type * @param beanFactory a bean factory to use for resolving {@code ${...}} placeholder * and {@code #{...}} SpEL expressions in default values, or {@code null} if default * values are not expected to contain expressions */ - protected AbstractNamedValueMethodArgumentResolver(ConversionService cs, + protected AbstractNamedValueMethodArgumentResolver(ConversionService conversionService, @Nullable ConfigurableBeanFactory beanFactory) { - this.conversionService = (cs != null ? cs : DefaultConversionService.getSharedInstance()); + this.conversionService = conversionService; this.configurableBeanFactory = beanFactory; this.expressionContext = (beanFactory != null ? new BeanExpressionContext(beanFactory, null) : null); } @@ -161,8 +162,9 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle * Resolve the given annotation-specified value, * potentially containing placeholders and expressions. */ + @Nullable private Object resolveStringValue(String value) { - if (this.configurableBeanFactory == null) { + if (this.configurableBeanFactory == null || this.expressionContext == null) { return value; } String placeholdersResolved = this.configurableBeanFactory.resolveEmbeddedValue(value); @@ -199,6 +201,7 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle * A {@code null} results in a {@code false} value for {@code boolean}s or an * exception for other primitives. */ + @Nullable private Object handleNullValue(String name, @Nullable Object value, Class paramType) { if (value == null) { if (Boolean.TYPE.equals(paramType)) { @@ -221,7 +224,8 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle * @param parameter the argument parameter type * @param message the message */ - protected void handleResolvedValue(Object arg, String name, MethodParameter parameter, Message message) { + protected void handleResolvedValue( + @Nullable Object arg, String name, MethodParameter parameter, Message message) { } @@ -235,9 +239,10 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle private final boolean required; + @Nullable private final String defaultValue; - protected NamedValueInfo(String name, boolean required, String defaultValue) { + protected NamedValueInfo(String name, boolean required, @Nullable String defaultValue) { this.name = name; this.required = required; this.defaultValue = defaultValue; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DefaultMessageHandlerMethodFactory.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DefaultMessageHandlerMethodFactory.java index 3b33d97096..43d6cbd29c 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DefaultMessageHandlerMethodFactory.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DefaultMessageHandlerMethodFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -26,11 +26,13 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.core.convert.ConversionService; import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.lang.Nullable; import org.springframework.messaging.converter.GenericMessageConverter; import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolverComposite; import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.util.Assert; import org.springframework.validation.Validator; /** @@ -60,15 +62,19 @@ public class DefaultMessageHandlerMethodFactory private ConversionService conversionService = new DefaultFormattingConversionService(); + @Nullable private MessageConverter messageConverter; + @Nullable private Validator validator; + @Nullable private List customArgumentResolvers; private final HandlerMethodArgumentResolverComposite argumentResolvers = new HandlerMethodArgumentResolverComposite(); + @Nullable private BeanFactory beanFactory; @@ -114,6 +120,7 @@ public class DefaultMessageHandlerMethodFactory * the ones configured by default. This is an advanced option. For most use cases * it should be sufficient to use {@link #setCustomArgumentResolvers(java.util.List)}. */ + @SuppressWarnings("ConstantConditions") public void setArgumentResolvers(List argumentResolvers) { if (argumentResolvers == null) { this.argumentResolvers.clear(); @@ -151,11 +158,11 @@ public class DefaultMessageHandlerMethodFactory protected List initArgumentResolvers() { List resolvers = new ArrayList<>(); - ConfigurableBeanFactory cbf = (this.beanFactory instanceof ConfigurableBeanFactory ? + ConfigurableBeanFactory beanFactory = (this.beanFactory instanceof ConfigurableBeanFactory ? (ConfigurableBeanFactory) this.beanFactory : null); // Annotation-based argument resolution - resolvers.add(new HeaderMethodArgumentResolver(this.conversionService, cbf)); + resolvers.add(new HeaderMethodArgumentResolver(this.conversionService, beanFactory)); resolvers.add(new HeadersMethodArgumentResolver()); // Type-based argument resolution @@ -164,6 +171,8 @@ public class DefaultMessageHandlerMethodFactory if (this.customArgumentResolvers != null) { resolvers.addAll(this.customArgumentResolvers); } + + Assert.notNull(this.messageConverter, "MessageConverter not configured"); resolvers.add(new PayloadArgumentResolver(this.messageConverter, this.validator)); return resolvers; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java index b1c3b6b34d..d9bad3db43 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -43,8 +43,8 @@ public class DestinationVariableMethodArgumentResolver extends AbstractNamedValu DestinationVariableMethodArgumentResolver.class.getSimpleName() + ".templateVariables"; - public DestinationVariableMethodArgumentResolver(ConversionService cs) { - super(cs, null); + public DestinationVariableMethodArgumentResolver(ConversionService conversionService) { + super(conversionService, null); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java index 679db8c9b0..8e2e72d29a 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -43,8 +43,10 @@ public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgume private static final Log logger = LogFactory.getLog(HeaderMethodArgumentResolver.class); - public HeaderMethodArgumentResolver(ConversionService cs, ConfigurableBeanFactory beanFactory) { - super(cs, beanFactory); + public HeaderMethodArgumentResolver( + ConversionService conversionService, @Nullable ConfigurableBeanFactory beanFactory) { + + super(conversionService, beanFactory); } @@ -94,9 +96,9 @@ public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgume } @SuppressWarnings("unchecked") + @Nullable private Map> getNativeHeaders(Message message) { - return (Map>) message.getHeaders().get( - NativeMessageHeaderAccessor.NATIVE_HEADERS); + return (Map>) message.getHeaders().get(NativeMessageHeaderAccessor.NATIVE_HEADERS); } @Override diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java index d92af195ae..337d966bc8 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -43,6 +43,7 @@ import org.springframework.util.StringUtils; */ public class MessageMethodArgumentResolver implements HandlerMethodArgumentResolver { + @Nullable private final MessageConverter converter; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadArgumentResolver.java index 022441df4d..a8913b2752 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadArgumentResolver.java @@ -55,6 +55,7 @@ public class PayloadArgumentResolver implements HandlerMethodArgumentResolver { private final MessageConverter converter; + @Nullable private final Validator validator; private final boolean useDefaultResolution; @@ -76,7 +77,7 @@ public class PayloadArgumentResolver implements HandlerMethodArgumentResolver { * @param messageConverter the MessageConverter to use (required) * @param validator the Validator to use (optional) */ - public PayloadArgumentResolver(MessageConverter messageConverter, Validator validator) { + public PayloadArgumentResolver(MessageConverter messageConverter, @Nullable Validator validator) { this(messageConverter, validator, true); } @@ -89,7 +90,7 @@ public class PayloadArgumentResolver implements HandlerMethodArgumentResolver { * all parameters; if "false" then only arguments with the {@code @Payload} * annotation are supported. */ - public PayloadArgumentResolver(MessageConverter messageConverter, Validator validator, + public PayloadArgumentResolver(MessageConverter messageConverter, @Nullable Validator validator, boolean useDefaultResolution) { Assert.notNull(messageConverter, "MessageConverter must not be null"); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/package-info.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/package-info.java index 7b106d109c..494657e5c6 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/package-info.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/package-info.java @@ -1,4 +1,9 @@ /** * Support classes for working with annotated message-handling methods. */ +@NonNullApi +@NonNullFields package org.springframework.messaging.handler.annotation.support; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; From ceccd9fbee3f7e1f04afed42ac696e4ad126b7a4 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Tue, 29 Jan 2019 17:28:15 -0500 Subject: [PATCH 07/17] Polish Updates to synchronize with newly created reactive equivalents. --- ...tractNamedValueMethodArgumentResolver.java | 79 +++++++++--------- ...inationVariableMethodArgumentResolver.java | 30 +++---- .../support/HeaderMethodArgumentResolver.java | 20 +++-- .../HeadersMethodArgumentResolver.java | 18 ++-- ...onVariableMethodArgumentResolverTests.java | 44 ++++------ .../HeaderMethodArgumentResolverTests.java | 82 +++++++------------ .../HeadersMethodArgumentResolverTests.java | 67 ++++++--------- 7 files changed, 140 insertions(+), 200 deletions(-) diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java index 5f9b64c20e..4d51886797 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java @@ -33,24 +33,20 @@ import org.springframework.messaging.handler.invocation.HandlerMethodArgumentRes import org.springframework.util.ClassUtils; /** - * Abstract base class for resolving method arguments from a named value. Message headers, - * and path variables are examples of named values. Each may have a name, a required flag, - * and a default value. + * Abstract base class to resolve method arguments from a named value, e.g. + * message headers or destination variables. Named values could have one or more + * of a name, a required flag, and a default value. * - *

Subclasses define how to do the following: - *

    - *
  • Obtain named value information for a method parameter - *
  • Resolve names into argument values - *
  • Handle missing argument values when argument values are required - *
  • Optionally handle a resolved value - *
+ *

Subclasses only need to define specific steps such as how to obtain named + * value details from a method parameter, how to resolve to argument values, or + * how to handle missing values. * - *

A default value string can contain ${...} placeholders and Spring Expression - * Language {@code #{...}} expressions. For this to work a {@link ConfigurableBeanFactory} - * must be supplied to the class constructor. + *

A default value string can contain ${...} placeholders and Spring + * Expression Language {@code #{...}} expressions which will be resolved if a + * {@link ConfigurableBeanFactory} is supplied to the class constructor. * - *

A {@link ConversionService} may be used to apply type conversion to the resolved - * argument value if it doesn't match the method parameter type. + *

A {@link ConversionService} is used to to convert resolved String argument + * value to the expected target method parameter type. * * @author Rossen Stoyanchev * @author Juergen Hoeller @@ -71,11 +67,10 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle /** * Constructor with a {@link ConversionService} and a {@link BeanFactory}. - * @param conversionService conversion service for converting values to match the - * target method parameter type - * @param beanFactory a bean factory to use for resolving {@code ${...}} placeholder - * and {@code #{...}} SpEL expressions in default values, or {@code null} if default - * values are not expected to contain expressions + * @param conversionService conversion service for converting String values + * to the target method parameter type + * @param beanFactory a bean factory for resolving {@code ${...}} + * placeholders and {@code #{...}} SpEL expressions in default values */ protected AbstractNamedValueMethodArgumentResolver(ConversionService conversionService, @Nullable ConfigurableBeanFactory beanFactory) { @@ -87,12 +82,12 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle @Override - @Nullable public Object resolveArgument(MethodParameter parameter, Message message) throws Exception { + NamedValueInfo namedValueInfo = getNamedValueInfo(parameter); MethodParameter nestedParameter = parameter.nestedIfOptional(); - Object resolvedName = resolveStringValue(namedValueInfo.name); + Object resolvedName = resolveEmbeddedValuesAndExpressions(namedValueInfo.name); if (resolvedName == null) { throw new IllegalArgumentException( "Specified name must not resolve to null: [" + namedValueInfo.name + "]"); @@ -101,7 +96,7 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle Object arg = resolveArgumentInternal(nestedParameter, message, resolvedName.toString()); if (arg == null) { if (namedValueInfo.defaultValue != null) { - arg = resolveStringValue(namedValueInfo.defaultValue); + arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); } else if (namedValueInfo.required && !nestedParameter.isOptional()) { handleMissingValue(namedValueInfo.name, nestedParameter, message); @@ -109,7 +104,7 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType()); } else if ("".equals(arg) && namedValueInfo.defaultValue != null) { - arg = resolveStringValue(namedValueInfo.defaultValue); + arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); } if (parameter != nestedParameter || !ClassUtils.isAssignableValue(parameter.getParameterType(), arg)) { @@ -135,27 +130,31 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle } /** - * Create the {@link NamedValueInfo} object for the given method parameter. Implementations typically - * retrieve the method annotation by means of {@link MethodParameter#getParameterAnnotation(Class)}. + * Create the {@link NamedValueInfo} object for the given method parameter. + * Implementations typically retrieve the method annotation by means of + * {@link MethodParameter#getParameterAnnotation(Class)}. * @param parameter the method parameter * @return the named value information */ protected abstract NamedValueInfo createNamedValueInfo(MethodParameter parameter); /** - * Create a new NamedValueInfo based on the given NamedValueInfo with sanitized values. + * Fall back on the parameter name from the class file if necessary and + * replace {@link ValueConstants#DEFAULT_NONE} with null. */ private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) { String name = info.name; if (info.name.isEmpty()) { name = parameter.getParameterName(); if (name == null) { - throw new IllegalArgumentException("Name for argument type [" + parameter.getParameterType().getName() + - "] not available, and parameter name information not found in class file either."); + Class type = parameter.getParameterType(); + throw new IllegalArgumentException( + "Name for argument of type [" + type.getName() + "] not specified, " + + "and parameter name information not found in class file either."); } } - String defaultValue = (ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue); - return new NamedValueInfo(name, info.required, defaultValue); + return new NamedValueInfo(name, info.required, + ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue); } /** @@ -163,7 +162,7 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle * potentially containing placeholders and expressions. */ @Nullable - private Object resolveStringValue(String value) { + private Object resolveEmbeddedValuesAndExpressions(String value) { if (this.configurableBeanFactory == null || this.expressionContext == null) { return value; } @@ -188,18 +187,19 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle throws Exception; /** - * Invoked when a named value is required, but - * {@link #resolveArgumentInternal(MethodParameter, Message, String)} returned {@code null} and - * there is no default value. Subclasses typically throw an exception in this case. + * Invoked when a value is required, but {@link #resolveArgumentInternal} + * returned {@code null} and there is no default value. Sub-classes can + * throw an appropriate exception for this case. * @param name the name for the value - * @param parameter the method parameter + * @param parameter the target method parameter * @param message the message being processed */ protected abstract void handleMissingValue(String name, MethodParameter parameter, Message message); /** - * A {@code null} results in a {@code false} value for {@code boolean}s or an - * exception for other primitives. + * One last chance to handle a possible null value. + * Specifically for booleans method parameters, use {@link Boolean#FALSE}. + * Also raise an ISE for primitive types. */ @Nullable private Object handleNullValue(String name, @Nullable Object value, Class paramType) { @@ -230,8 +230,7 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle /** - * Represents the information about a named value, including name, whether it's - * required and a default value. + * Represents a named value declaration. */ protected static class NamedValueInfo { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java index d9bad3db43..6fccd661e1 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java @@ -23,22 +23,20 @@ import org.springframework.core.convert.ConversionService; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.ValueConstants; import org.springframework.util.Assert; /** - * Resolves method parameters annotated with - * {@link org.springframework.messaging.handler.annotation.DestinationVariable @DestinationVariable}. + * Resolve for {@link DestinationVariable @DestinationVariable} method parameters. * * @author Brian Clozel * @since 4.0 */ public class DestinationVariableMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { - /** - * The name of the header used to for template variables. - */ + /** The name of the header used to for template variables. */ public static final String DESTINATION_TEMPLATE_VARIABLES_HEADER = DestinationVariableMethodArgumentResolver.class.getSimpleName() + ".templateVariables"; @@ -55,26 +53,24 @@ public class DestinationVariableMethodArgumentResolver extends AbstractNamedValu @Override protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { - DestinationVariable annotation = parameter.getParameterAnnotation(DestinationVariable.class); - Assert.state(annotation != null, "No DestinationVariable annotation"); - return new DestinationVariableNamedValueInfo(annotation); + DestinationVariable annot = parameter.getParameterAnnotation(DestinationVariable.class); + Assert.state(annot != null, "No DestinationVariable annotation"); + return new DestinationVariableNamedValueInfo(annot); } @Override @Nullable - protected Object resolveArgumentInternal(MethodParameter parameter, Message message, String name) - throws Exception { - - @SuppressWarnings("unchecked") - Map vars = - (Map) message.getHeaders().get(DESTINATION_TEMPLATE_VARIABLES_HEADER); - return (vars != null ? vars.get(name) : null); + @SuppressWarnings("unchecked") + protected Object resolveArgumentInternal(MethodParameter parameter, Message message, String name) { + MessageHeaders headers = message.getHeaders(); + Map vars = (Map) headers.get(DESTINATION_TEMPLATE_VARIABLES_HEADER); + return vars != null ? vars.get(name) : null; } @Override protected void handleMissingValue(String name, MethodParameter parameter, Message message) { - throw new MessageHandlingException(message, "Missing path template variable '" + name + - "' for method parameter type [" + parameter.getParameterType() + "]"); + throw new MessageHandlingException(message, "Missing path template variable '" + name + "' " + + "for method parameter type [" + parameter.getParameterType() + "]"); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java index 8e2e72d29a..613991d739 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java @@ -33,10 +33,15 @@ import org.springframework.messaging.support.NativeMessageHeaderAccessor; import org.springframework.util.Assert; /** - * Resolves method parameters annotated with {@link Header @Header}. + * Resolver for {@link Header @Header} arguments. Headers are resolved from + * either the top-level header map or the nested + * {@link NativeMessageHeaderAccessor native} header map. * * @author Rossen Stoyanchev * @since 4.0 + * + * @see HeadersMethodArgumentResolver + * @see NativeMessageHeaderAccessor */ public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { @@ -57,9 +62,9 @@ public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgume @Override protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { - Header annotation = parameter.getParameterAnnotation(Header.class); - Assert.state(annotation != null, "No Header annotation"); - return new HeaderNamedValueInfo(annotation); + Header annot = parameter.getParameterAnnotation(Header.class); + Assert.state(annot != null, "No Header annotation"); + return new HeaderNamedValueInfo(annot); } @Override @@ -72,10 +77,9 @@ public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgume if (headerValue != null && nativeHeaderValue != null) { if (logger.isDebugEnabled()) { - logger.debug("Message headers contain two values for the same header '" + name + "', " + - "one in the top level header map and a second in the nested map with native headers. " + - "Using the value from top level map. " + - "Use 'nativeHeader.myHeader' to resolve to the value from the nested native header map."); + logger.debug("A value was found for '" + name + "', in both the top level header map " + + "and also in the nested map for native headers. Using the value from top level map. " + + "Use 'nativeHeader.myHeader' to resolve the native header."); } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolver.java index 2d4a517946..7d257f17ca 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -29,12 +29,11 @@ import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.util.ReflectionUtils; /** - * {@link HandlerMethodArgumentResolver} for header method parameters. Resolves the - * following method parameters: + * Argument resolver for headers. Resolves the following method parameters: *

    - *
  • Parameters assignable to {@link Map} annotated with {@link Headers @Headers} - *
  • Parameters of type {@link MessageHeaders} - *
  • Parameters assignable to {@link MessageHeaderAccessor} + *
  • {@link Headers @Headers} {@link Map} + *
  • {@link MessageHeaders} + *
  • {@link MessageHeaderAccessor} *
* * @author Rossen Stoyanchev @@ -58,7 +57,7 @@ public class HeadersMethodArgumentResolver implements HandlerMethodArgumentResol } else if (MessageHeaderAccessor.class == paramType) { MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, MessageHeaderAccessor.class); - return (accessor != null ? accessor : new MessageHeaderAccessor(message)); + return accessor != null ? accessor : new MessageHeaderAccessor(message); } else if (MessageHeaderAccessor.class.isAssignableFrom(paramType)) { MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, MessageHeaderAccessor.class); @@ -75,9 +74,8 @@ public class HeadersMethodArgumentResolver implements HandlerMethodArgumentResol } } else { - throw new IllegalStateException( - "Unexpected method parameter type " + paramType + "in method " + parameter.getMethod() + ". " - + "@Headers method arguments must be assignable to java.util.Map."); + throw new IllegalStateException("Unexpected parameter of type " + paramType + + " in method " + parameter.getMethod() + ". "); } } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolverTests.java index 3af92e3b2e..ea4b8559e2 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolverTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -16,23 +16,21 @@ package org.springframework.messaging.handler.annotation.support; -import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; -import org.junit.Before; import org.junit.Test; -import org.springframework.core.DefaultParameterNameDiscoverer; -import org.springframework.core.GenericTypeResolver; import org.springframework.core.MethodParameter; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHandlingException; import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.invocation.ResolvableMethod; import org.springframework.messaging.support.MessageBuilder; import static org.junit.Assert.*; +import static org.springframework.messaging.handler.annotation.MessagingPredicates.*; /** * Test fixture for {@link DestinationVariableMethodArgumentResolver} tests. @@ -41,33 +39,17 @@ import static org.junit.Assert.*; */ public class DestinationVariableMethodArgumentResolverTests { - private DestinationVariableMethodArgumentResolver resolver; + private final DestinationVariableMethodArgumentResolver resolver = + new DestinationVariableMethodArgumentResolver(new DefaultConversionService()); - private MethodParameter paramAnnotated; - private MethodParameter paramAnnotatedValue; - private MethodParameter paramNotAnnotated; + private final ResolvableMethod resolvable = + ResolvableMethod.on(getClass()).named("handleMessage").build(); - @Before - public void setup() throws Exception { - this.resolver = new DestinationVariableMethodArgumentResolver(new DefaultConversionService()); - - Method method = getClass().getDeclaredMethod("handleMessage", String.class, String.class, String.class); - this.paramAnnotated = new MethodParameter(method, 0); - this.paramAnnotatedValue = new MethodParameter(method, 1); - this.paramNotAnnotated = new MethodParameter(method, 2); - - this.paramAnnotated.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); - GenericTypeResolver.resolveParameterType(this.paramAnnotated, DestinationVariableMethodArgumentResolver.class); - this.paramAnnotatedValue.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); - GenericTypeResolver.resolveParameterType(this.paramAnnotatedValue, DestinationVariableMethodArgumentResolver.class); - } - @Test public void supportsParameter() { - assertTrue(resolver.supportsParameter(paramAnnotated)); - assertTrue(resolver.supportsParameter(paramAnnotatedValue)); - assertFalse(resolver.supportsParameter(paramNotAnnotated)); + assertTrue(resolver.supportsParameter(this.resolvable.annot(destinationVar().noValue()).arg())); + assertFalse(resolver.supportsParameter(this.resolvable.annotNotPresent(DestinationVariable.class).arg())); } @Test @@ -80,17 +62,19 @@ public class DestinationVariableMethodArgumentResolverTests { Message message = MessageBuilder.withPayload(new byte[0]).setHeader( DestinationVariableMethodArgumentResolver.DESTINATION_TEMPLATE_VARIABLES_HEADER, vars).build(); - Object result = this.resolver.resolveArgument(this.paramAnnotated, message); + MethodParameter param = this.resolvable.annot(destinationVar().noValue()).arg(); + Object result = this.resolver.resolveArgument(param, message); assertEquals("bar", result); - result = this.resolver.resolveArgument(this.paramAnnotatedValue, message); + param = this.resolvable.annot(destinationVar("name")).arg(); + result = this.resolver.resolveArgument(param, message); assertEquals("value", result); } @Test(expected = MessageHandlingException.class) public void resolveArgumentNotFound() throws Exception { Message message = MessageBuilder.withPayload(new byte[0]).build(); - this.resolver.resolveArgument(this.paramAnnotated, message); + this.resolver.resolveArgument(this.resolvable.annot(destinationVar().noValue()).arg(), message); } @SuppressWarnings("unused") diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java index 61bf8854f6..927809e4fe 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -16,7 +16,6 @@ package org.springframework.messaging.handler.annotation.support; -import java.lang.reflect.Method; import java.util.List; import java.util.Map; import java.util.Optional; @@ -25,19 +24,17 @@ import org.junit.Before; import org.junit.Test; import org.springframework.context.support.GenericApplicationContext; -import org.springframework.core.DefaultParameterNameDiscoverer; -import org.springframework.core.GenericTypeResolver; import org.springframework.core.MethodParameter; -import org.springframework.core.annotation.SynthesizingMethodParameter; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHandlingException; import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.invocation.ResolvableMethod; import org.springframework.messaging.support.MessageBuilder; import org.springframework.messaging.support.NativeMessageHeaderAccessor; -import org.springframework.util.ReflectionUtils; import static org.junit.Assert.*; +import static org.springframework.messaging.handler.annotation.MessagingPredicates.*; /** * Test fixture for {@link HeaderMethodArgumentResolver} tests. @@ -50,46 +47,27 @@ public class HeaderMethodArgumentResolverTests { private HeaderMethodArgumentResolver resolver; - private MethodParameter paramRequired; - private MethodParameter paramNamedDefaultValueStringHeader; - private MethodParameter paramSystemPropertyDefaultValue; - private MethodParameter paramSystemPropertyName; - private MethodParameter paramNotAnnotated; - private MethodParameter paramOptional; - private MethodParameter paramNativeHeader; + private final ResolvableMethod resolvable = ResolvableMethod.on(getClass()).named("handleMessage").build(); @Before public void setup() { - @SuppressWarnings("resource") - GenericApplicationContext cxt = new GenericApplicationContext(); - cxt.refresh(); - this.resolver = new HeaderMethodArgumentResolver(new DefaultConversionService(), cxt.getBeanFactory()); - - Method method = ReflectionUtils.findMethod(getClass(), "handleMessage", (Class[]) null); - this.paramRequired = new SynthesizingMethodParameter(method, 0); - this.paramNamedDefaultValueStringHeader = new SynthesizingMethodParameter(method, 1); - this.paramSystemPropertyDefaultValue = new SynthesizingMethodParameter(method, 2); - this.paramSystemPropertyName = new SynthesizingMethodParameter(method, 3); - this.paramNotAnnotated = new SynthesizingMethodParameter(method, 4); - this.paramOptional = new SynthesizingMethodParameter(method, 5); - this.paramNativeHeader = new SynthesizingMethodParameter(method, 6); - - this.paramRequired.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); - GenericTypeResolver.resolveParameterType(this.paramRequired, HeaderMethodArgumentResolver.class); + GenericApplicationContext context = new GenericApplicationContext(); + context.refresh(); + this.resolver = new HeaderMethodArgumentResolver(new DefaultConversionService(), context.getBeanFactory()); } @Test public void supportsParameter() { - assertTrue(resolver.supportsParameter(paramNamedDefaultValueStringHeader)); - assertFalse(resolver.supportsParameter(paramNotAnnotated)); + assertTrue(this.resolver.supportsParameter(this.resolvable.annot(headerPlain()).arg())); + assertFalse(this.resolver.supportsParameter(this.resolvable.annotNotPresent(Header.class).arg())); } @Test public void resolveArgument() throws Exception { Message message = MessageBuilder.withPayload(new byte[0]).setHeader("param1", "foo").build(); - Object result = this.resolver.resolveArgument(this.paramRequired, message); + Object result = this.resolver.resolveArgument(this.resolvable.annot(headerPlain()).arg(), message); assertEquals("foo", result); } @@ -98,7 +76,7 @@ public class HeaderMethodArgumentResolverTests { TestMessageHeaderAccessor headers = new TestMessageHeaderAccessor(); headers.setNativeHeader("param1", "foo"); Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); - assertEquals("foo", this.resolver.resolveArgument(this.paramRequired, message)); + assertEquals("foo", this.resolver.resolveArgument(this.resolvable.annot(headerPlain()).arg(), message)); } @Test @@ -108,20 +86,23 @@ public class HeaderMethodArgumentResolverTests { headers.setNativeHeader("param1", "native-foo"); Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); - assertEquals("foo", this.resolver.resolveArgument(this.paramRequired, message)); - assertEquals("native-foo", this.resolver.resolveArgument(this.paramNativeHeader, message)); + assertEquals("foo", this.resolver.resolveArgument( + this.resolvable.annot(headerPlain()).arg(), message)); + + assertEquals("native-foo", this.resolver.resolveArgument( + this.resolvable.annot(header("nativeHeaders.param1")).arg(), message)); } @Test(expected = MessageHandlingException.class) public void resolveArgumentNotFound() throws Exception { Message message = MessageBuilder.withPayload(new byte[0]).build(); - this.resolver.resolveArgument(this.paramRequired, message); + this.resolver.resolveArgument(this.resolvable.annot(headerPlain()).arg(), message); } @Test public void resolveArgumentDefaultValue() throws Exception { Message message = MessageBuilder.withPayload(new byte[0]).build(); - Object result = this.resolver.resolveArgument(this.paramNamedDefaultValueStringHeader, message); + Object result = this.resolver.resolveArgument(this.resolvable.annot(header("name", "bar")).arg(), message); assertEquals("bar", result); } @@ -130,7 +111,8 @@ public class HeaderMethodArgumentResolverTests { System.setProperty("systemProperty", "sysbar"); try { Message message = MessageBuilder.withPayload(new byte[0]).build(); - Object result = resolver.resolveArgument(paramSystemPropertyDefaultValue, message); + MethodParameter param = this.resolvable.annot(header("name", "#{systemProperties.systemProperty}")).arg(); + Object result = resolver.resolveArgument(param, message); assertEquals("sysbar", result); } finally { @@ -143,7 +125,8 @@ public class HeaderMethodArgumentResolverTests { System.setProperty("systemProperty", "sysbar"); try { Message message = MessageBuilder.withPayload(new byte[0]).setHeader("sysbar", "foo").build(); - Object result = resolver.resolveArgument(paramSystemPropertyName, message); + MethodParameter param = this.resolvable.annot(header("#{systemProperties.systemProperty}")).arg(); + Object result = resolver.resolveArgument(param, message); assertEquals("foo", result); } finally { @@ -153,31 +136,22 @@ public class HeaderMethodArgumentResolverTests { @Test public void resolveOptionalHeaderWithValue() throws Exception { - GenericApplicationContext cxt = new GenericApplicationContext(); - cxt.refresh(); - - HeaderMethodArgumentResolver resolver = - new HeaderMethodArgumentResolver(new DefaultConversionService(), cxt.getBeanFactory()); - Message message = MessageBuilder.withPayload("foo").setHeader("foo", "bar").build(); - Object result = resolver.resolveArgument(paramOptional, message); + MethodParameter param = this.resolvable.annot(header("foo")).arg(Optional.class, String.class); + Object result = resolver.resolveArgument(param, message); assertEquals(Optional.of("bar"), result); } @Test public void resolveOptionalHeaderAsEmpty() throws Exception { - GenericApplicationContext cxt = new GenericApplicationContext(); - cxt.refresh(); - - HeaderMethodArgumentResolver resolver = - new HeaderMethodArgumentResolver(new DefaultConversionService(), cxt.getBeanFactory()); - Message message = MessageBuilder.withPayload("foo").build(); - Object result = resolver.resolveArgument(paramOptional, message); + MethodParameter param = this.resolvable.annot(header("foo")).arg(Optional.class, String.class); + Object result = resolver.resolveArgument(param, message); assertEquals(Optional.empty(), result); } + @SuppressWarnings({"unused", "OptionalUsedAsFieldOrParameterType"}) public void handleMessage( @Header String param1, @Header(name = "name", defaultValue = "bar") String param2, @@ -191,7 +165,7 @@ public class HeaderMethodArgumentResolverTests { public static class TestMessageHeaderAccessor extends NativeMessageHeaderAccessor { - protected TestMessageHeaderAccessor() { + TestMessageHeaderAccessor() { super((Map>) null); } } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolverTests.java index e666d50787..592a01e4da 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolverTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-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. @@ -16,17 +16,16 @@ package org.springframework.messaging.handler.annotation.support; -import java.lang.reflect.Method; -import java.util.HashMap; +import java.util.Collections; import java.util.Map; -import org.junit.Before; import org.junit.Test; import org.springframework.core.MethodParameter; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.messaging.handler.invocation.ResolvableMethod; import org.springframework.messaging.support.MessageBuilder; import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.messaging.support.NativeMessageHeaderAccessor; @@ -41,47 +40,31 @@ import static org.junit.Assert.*; */ public class HeadersMethodArgumentResolverTests { - private HeadersMethodArgumentResolver resolver; + private final HeadersMethodArgumentResolver resolver = new HeadersMethodArgumentResolver(); - private MethodParameter paramAnnotated; - private MethodParameter paramAnnotatedNotMap; - private MethodParameter paramMessageHeaders; - private MethodParameter paramMessageHeaderAccessor; - private MethodParameter paramMessageHeaderAccessorSubclass; + private Message message = + MessageBuilder.withPayload(new byte[0]).copyHeaders(Collections.singletonMap("foo", "bar")).build(); - private Message message; + private final ResolvableMethod resolvable = ResolvableMethod.on(getClass()).named("handleMessage").build(); - @Before - public void setup() throws Exception { - this.resolver = new HeadersMethodArgumentResolver(); - - Method method = getClass().getDeclaredMethod("handleMessage", Map.class, String.class, - MessageHeaders.class, MessageHeaderAccessor.class, TestMessageHeaderAccessor.class); - - this.paramAnnotated = new MethodParameter(method, 0); - this.paramAnnotatedNotMap = new MethodParameter(method, 1); - this.paramMessageHeaders = new MethodParameter(method, 2); - this.paramMessageHeaderAccessor = new MethodParameter(method, 3); - this.paramMessageHeaderAccessorSubclass = new MethodParameter(method, 4); - - Map headers = new HashMap<>(); - headers.put("foo", "bar"); - this.message = MessageBuilder.withPayload(new byte[0]).copyHeaders(headers).build(); - } - @Test public void supportsParameter() { - assertTrue(this.resolver.supportsParameter(this.paramAnnotated)); - assertFalse(this.resolver.supportsParameter(this.paramAnnotatedNotMap)); - assertTrue(this.resolver.supportsParameter(this.paramMessageHeaders)); - assertTrue(this.resolver.supportsParameter(this.paramMessageHeaderAccessor)); - assertTrue(this.resolver.supportsParameter(this.paramMessageHeaderAccessorSubclass)); + + assertTrue(this.resolver.supportsParameter( + this.resolvable.annotPresent(Headers.class).arg(Map.class, String.class, Object.class))); + + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(MessageHeaders.class))); + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(MessageHeaderAccessor.class))); + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(TestMessageHeaderAccessor.class))); + + assertFalse(this.resolver.supportsParameter(this.resolvable.annotPresent(Headers.class).arg(String.class))); } @Test public void resolveArgumentAnnotated() throws Exception { - Object resolved = this.resolver.resolveArgument(this.paramAnnotated, this.message); + MethodParameter param = this.resolvable.annotPresent(Headers.class).arg(Map.class, String.class, Object.class); + Object resolved = this.resolver.resolveArgument(param, this.message); assertTrue(resolved instanceof Map); @SuppressWarnings("unchecked") @@ -91,12 +74,12 @@ public class HeadersMethodArgumentResolverTests { @Test(expected = IllegalStateException.class) public void resolveArgumentAnnotatedNotMap() throws Exception { - this.resolver.resolveArgument(this.paramAnnotatedNotMap, this.message); + this.resolver.resolveArgument(this.resolvable.annotPresent(Headers.class).arg(String.class), this.message); } @Test public void resolveArgumentMessageHeaders() throws Exception { - Object resolved = this.resolver.resolveArgument(this.paramMessageHeaders, this.message); + Object resolved = this.resolver.resolveArgument(this.resolvable.arg(MessageHeaders.class), this.message); assertTrue(resolved instanceof MessageHeaders); MessageHeaders headers = (MessageHeaders) resolved; @@ -105,7 +88,8 @@ public class HeadersMethodArgumentResolverTests { @Test public void resolveArgumentMessageHeaderAccessor() throws Exception { - Object resolved = this.resolver.resolveArgument(this.paramMessageHeaderAccessor, this.message); + MethodParameter param = this.resolvable.arg(MessageHeaderAccessor.class); + Object resolved = this.resolver.resolveArgument(param, this.message); assertTrue(resolved instanceof MessageHeaderAccessor); MessageHeaderAccessor headers = (MessageHeaderAccessor) resolved; @@ -114,7 +98,8 @@ public class HeadersMethodArgumentResolverTests { @Test public void resolveArgumentMessageHeaderAccessorSubclass() throws Exception { - Object resolved = this.resolver.resolveArgument(this.paramMessageHeaderAccessorSubclass, this.message); + MethodParameter param = this.resolvable.arg(TestMessageHeaderAccessor.class); + Object resolved = this.resolver.resolveArgument(param, this.message); assertTrue(resolved instanceof TestMessageHeaderAccessor); TestMessageHeaderAccessor headers = (TestMessageHeaderAccessor) resolved; @@ -124,7 +109,7 @@ public class HeadersMethodArgumentResolverTests { @SuppressWarnings("unused") private void handleMessage( - @Headers Map param1, + @Headers Map param1, @Headers String param2, MessageHeaders param3, MessageHeaderAccessor param4, @@ -134,7 +119,7 @@ public class HeadersMethodArgumentResolverTests { public static class TestMessageHeaderAccessor extends NativeMessageHeaderAccessor { - protected TestMessageHeaderAccessor(Message message) { + TestMessageHeaderAccessor(Message message) { super(message); } From 33682d74c24b5abbea667d444cc8b9819f4efab9 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Tue, 5 Feb 2019 21:26:12 -0500 Subject: [PATCH 08/17] ReactiveMessageChannel and ReactiveSubscribableChannel See gh-21987 --- .../messaging/ReactiveMessageChannel.java | 38 +++++++ .../ReactiveSubscribableChannel.java | 42 ++++++++ .../DefaultReactiveMessageChannel.java | 102 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageChannel.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/ReactiveSubscribableChannel.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/support/DefaultReactiveMessageChannel.java diff --git a/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageChannel.java b/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageChannel.java new file mode 100644 index 0000000000..08e6537e85 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageChannel.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging; + +import reactor.core.publisher.Mono; + +/** + * Contract for reactive, non-blocking sending of messages. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface ReactiveMessageChannel { + + /** + * Send a {@link Message} to this channel. If the message is sent + * successfully, return {@code true}. Or if not sent due to a non-fatal + * reason, return {@code false}. + * @param message the message to send + * @return completion {@link Mono} returning {@code true} on success, + * {@code false} if not sent, or an error signal. + */ + Mono send(Message message); + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/ReactiveSubscribableChannel.java b/spring-messaging/src/main/java/org/springframework/messaging/ReactiveSubscribableChannel.java new file mode 100644 index 0000000000..c3b792019e --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/ReactiveSubscribableChannel.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2013 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 + * + * http://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.messaging; + +/** + * {@link MessageChannel} that maintains a registry of subscribers to handle + * messages sent through this channel. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface ReactiveSubscribableChannel extends ReactiveMessageChannel { + + /** + * Register a message handler. + * @return {@code true} if the handler was subscribed or {@code false} if it + * was already subscribed. + */ + boolean subscribe(ReactiveMessageHandler handler); + + /** + * Un-register a message handler. + * @return {@code true} if the handler was un-registered, or {@code false} + * if was not registered. + */ + boolean unsubscribe(ReactiveMessageHandler handler); + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/support/DefaultReactiveMessageChannel.java b/spring-messaging/src/main/java/org/springframework/messaging/support/DefaultReactiveMessageChannel.java new file mode 100644 index 0000000000..a5283cdfe7 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/support/DefaultReactiveMessageChannel.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.support; + +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.BeanNameAware; +import org.springframework.messaging.Message; +import org.springframework.messaging.ReactiveMessageHandler; +import org.springframework.messaging.ReactiveSubscribableChannel; +import org.springframework.util.ObjectUtils; + +/** + * Default implementation of {@link ReactiveSubscribableChannel}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class DefaultReactiveMessageChannel implements ReactiveSubscribableChannel, BeanNameAware { + + private static final Mono SUCCESS_RESULT = Mono.just(true); + + private static Log logger = LogFactory.getLog(DefaultReactiveMessageChannel.class); + + + private final Set handlers = new CopyOnWriteArraySet<>(); + + private String beanName; + + + public DefaultReactiveMessageChannel() { + this.beanName = getClass().getSimpleName() + "@" + ObjectUtils.getIdentityHexString(this); + } + + + /** + * A message channel uses the bean name primarily for logging purposes. + */ + @Override + public void setBeanName(String name) { + this.beanName = name; + } + + /** + * Return the bean name for this message channel. + */ + public String getBeanName() { + return this.beanName; + } + + + @Override + public boolean subscribe(ReactiveMessageHandler handler) { + boolean result = this.handlers.add(handler); + if (result) { + if (logger.isDebugEnabled()) { + logger.debug(getBeanName() + " added " + handler); + } + } + return result; + } + + + @Override + public boolean unsubscribe(ReactiveMessageHandler handler) { + boolean result = this.handlers.remove(handler); + if (result) { + if (logger.isDebugEnabled()) { + logger.debug(getBeanName() + " removed " + handler); + } + } + return result; + } + + + @Override + public Mono send(Message message) { + return Flux.fromIterable(this.handlers) + .concatMap(handler -> handler.handleMessage(message)) + .then(SUCCESS_RESULT); + } + +} From f2bb95ba7bda8acae191c9673cca376fbc458901 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Thu, 14 Feb 2019 17:04:20 -0500 Subject: [PATCH 09/17] Payload encoding/decoding and handling refinements See gh-21987 --- .../messaging/handler/HandlerMethod.java | 2 +- .../MessageMappingMessageHandler.java | 134 ++++++++++-------- .../PayloadMethodArgumentResolver.java | 71 +++++----- ...stractEncoderMethodReturnValueHandler.java | 101 ++++++++----- .../AbstractMethodMessageHandler.java | 59 ++++++-- .../HandlerMethodReturnValueHandler.java | 4 + .../MessageMappingMessageHandlerTests.java | 89 +++++------- .../PayloadMethodArgumentResolverTests.java | 10 +- .../EncoderMethodReturnValueHandlerTests.java | 81 ++++------- .../reactive/MethodMessageHandlerTests.java | 10 +- .../TestEncoderMethodReturnValueHandler.java | 63 ++++++++ 11 files changed, 371 insertions(+), 253 deletions(-) create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java index 0be63540cd..30230a2c30 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java @@ -298,7 +298,7 @@ public class HandlerMethod { */ public String getShortLogMessage() { int args = this.method.getParameterCount(); - return getBeanType().getName() + "#" + this.method.getName() + "[" + args + " args]"; + return getBeanType().getSimpleName() + "#" + this.method.getName() + "[" + args + " args]"; } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java index 25463e70f3..6851883798 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java @@ -19,21 +19,25 @@ import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.function.Predicate; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.context.SmartLifecycle; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.codec.Decoder; import org.springframework.core.convert.ConversionService; import org.springframework.format.support.DefaultFormattingConversionService; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; +import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.CompositeMessageCondition; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -51,58 +55,57 @@ import org.springframework.util.StringValueResolver; import org.springframework.validation.Validator; /** - * Extension of {@link AbstractMethodMessageHandler} for - * {@link MessageMapping @MessageMapping} methods. + * Extension of {@link AbstractMethodMessageHandler} for reactive, non-blocking + * handling of messages via {@link MessageMapping @MessageMapping} methods. + * By default such methods are detected in {@code @Controller} Spring beans but + * that can be changed via {@link #setHandlerPredicate(Predicate)}. * - *

The payload of incoming messages is decoded through - * {@link PayloadMethodArgumentResolver} using one of the configured - * {@link #setDecoders(List)} decoders. + *

Payloads for incoming messages are decoded through the configured + * {@link #setDecoders(List)} decoders, with the help of + * {@link PayloadMethodArgumentResolver}. * - *

The {@link #setEncoderReturnValueHandler encoderReturnValueHandler} - * property must be set to encode and handle return values from - * {@code @MessageMapping} methods. + *

There is no default handling for return values but + * {@link #setReturnValueHandlerConfigurer} can be used to configure custom + * return value handlers. Sub-classes may also override + * {@link #initReturnValueHandlers()} to set up default return value handlers. * * @author Rossen Stoyanchev * @since 5.2 + * @see AbstractEncoderMethodReturnValueHandler */ public class MessageMappingMessageHandler extends AbstractMethodMessageHandler - implements EmbeddedValueResolverAware { + implements SmartLifecycle, EmbeddedValueResolverAware { - private PathMatcher pathMatcher = new AntPathMatcher(); + private final ReactiveSubscribableChannel inboundChannel; private final List> decoders = new ArrayList<>(); @Nullable private Validator validator; - @Nullable - private HandlerMethodReturnValueHandler encoderReturnValueHandler; + private PathMatcher pathMatcher; private ConversionService conversionService = new DefaultFormattingConversionService(); @Nullable private StringValueResolver valueResolver; + private volatile boolean running = false; - /** - * Set the PathMatcher implementation to use for matching destinations - * against configured destination patterns. - *

By default, {@link AntPathMatcher} is used. - */ - public void setPathMatcher(PathMatcher pathMatcher) { - Assert.notNull(pathMatcher, "PathMatcher must not be null"); - this.pathMatcher = pathMatcher; + private final Object lifecycleMonitor = new Object(); + + + public MessageMappingMessageHandler(ReactiveSubscribableChannel inboundChannel) { + Assert.notNull(inboundChannel, "`inboundChannel` is required"); + this.inboundChannel = inboundChannel; + this.pathMatcher = new AntPathMatcher(); + ((AntPathMatcher) this.pathMatcher).setPathSeparator("."); + setHandlerPredicate(beanType -> AnnotatedElementUtils.hasAnnotation(beanType, Controller.class)); } - /** - * Return the PathMatcher implementation to use for matching destinations. - */ - public PathMatcher getPathMatcher() { - return this.pathMatcher; - } /** - * Configure the decoders to user for incoming payloads. + * Configure the decoders to use for incoming payloads. */ public void setDecoders(List> decoders) { this.decoders.addAll(decoders); @@ -115,14 +118,6 @@ public class MessageMappingMessageHandler extends AbstractMethodMessageHandlerBy default this is not configured in which case payload/content return - * values from {@code @MessageMapping} methods will remain unhandled. - * @param encoderReturnValueHandler the return value handler to use - * @see AbstractEncoderMethodReturnValueHandler + * Return the configured Validator instance. */ - public void setEncoderReturnValueHandler(@Nullable HandlerMethodReturnValueHandler encoderReturnValueHandler) { - this.encoderReturnValueHandler = encoderReturnValueHandler; + @Nullable + public Validator getValidator() { + return this.validator; } /** - * Return the configured - * {@link #setEncoderReturnValueHandler encoderReturnValueHandler}. + * Set the PathMatcher implementation to use for matching destinations + * against configured destination patterns. + *

By default, {@link AntPathMatcher} is used with separator set to ".". */ - @Nullable - public HandlerMethodReturnValueHandler getEncoderReturnValueHandler() { - return this.encoderReturnValueHandler; + public void setPathMatcher(PathMatcher pathMatcher) { + Assert.notNull(pathMatcher, "PathMatcher must not be null"); + this.pathMatcher = pathMatcher; + } + + /** + * Return the PathMatcher implementation to use for matching destinations. + */ + public PathMatcher getPathMatcher() { + return this.pathMatcher; } /** @@ -204,20 +200,40 @@ public class MessageMappingMessageHandler extends AbstractMethodMessageHandler initReturnValueHandlers() { - List handlers = new ArrayList<>(); - handlers.addAll(getReturnValueHandlerConfigurer().getCustomHandlers()); - if (this.encoderReturnValueHandler != null) { - handlers.add(this.encoderReturnValueHandler); - } - return handlers; + return Collections.emptyList(); } @Override - protected boolean isHandler(Class beanType) { - return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class); + public final void start() { + synchronized (this.lifecycleMonitor) { + this.inboundChannel.subscribe(this); + this.running = true; + } } + @Override + public final void stop() { + synchronized (this.lifecycleMonitor) { + this.running = false; + this.inboundChannel.unsubscribe(this); + } + } + + @Override + public final void stop(Runnable callback) { + synchronized (this.lifecycleMonitor) { + stop(); + callback.run(); + } + } + + @Override + public final boolean isRunning() { + return this.running; + } + + @Override protected CompositeMessageCondition getMappingForMethod(Method method, Class handlerType) { CompositeMessageCondition methodCondition = getCondition(method); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java index 6aad436572..e6885e21ad 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java @@ -19,6 +19,7 @@ import java.lang.annotation.Annotation; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.function.Consumer; import org.apache.commons.logging.Log; @@ -148,38 +149,47 @@ public class PayloadMethodArgumentResolver implements HandlerMethodArgumentResol * @param message the message from which the content was extracted * @return a Mono with the result of argument resolution * - * @see #extractPayloadContent(MethodParameter, Message) + * @see #extractContent(MethodParameter, Message) * @see #getMimeType(Message) */ @Override public final Mono resolveArgument(MethodParameter parameter, Message message) { + Payload ann = parameter.getParameterAnnotation(Payload.class); if (ann != null && StringUtils.hasText(ann.expression())) { throw new IllegalStateException("@Payload SpEL expressions not supported by this resolver"); } - Publisher content = extractPayloadContent(parameter, message); - return decodeContent(parameter, message, ann == null || ann.required(), content, getMimeType(message)); + + MimeType mimeType = getMimeType(message); + mimeType = mimeType != null ? mimeType : MimeTypeUtils.APPLICATION_OCTET_STREAM; + + Flux content = extractContent(parameter, message); + return decodeContent(parameter, message, ann == null || ann.required(), content, mimeType); } - /** - * Extract the content to decode from the message. By default, the message - * payload is expected to be {@code Publisher}. Sub-classes can - * override this method to change that assumption. - * @param parameter the target method parameter we're decoding to - * @param message the input message with the content - * @return the content to decode - */ @SuppressWarnings("unchecked") - protected Publisher extractPayloadContent(MethodParameter parameter, Message message) { - Publisher content; - try { - content = (Publisher) message.getPayload(); + private Flux extractContent(MethodParameter parameter, Message message) { + Object payload = message.getPayload(); + if (payload instanceof DataBuffer) { + return Flux.just((DataBuffer) payload); } - catch (ClassCastException ex) { - throw new MethodArgumentResolutionException( - message, parameter, "Expected Publisher payload", ex); + if (payload instanceof Publisher) { + return Flux.from((Publisher) payload).map(value -> { + if (value instanceof DataBuffer) { + return (DataBuffer) value; + } + String className = value.getClass().getName(); + throw getUnexpectedPayloadError(message, parameter, "Publisher<" + className + ">"); + }); } - return content; + return Flux.error(getUnexpectedPayloadError(message, parameter, payload.getClass().getName())); + } + + private MethodArgumentResolutionException getUnexpectedPayloadError( + Message message, MethodParameter parameter, String actualType) { + + return new MethodArgumentResolutionException(message, parameter, + "Expected DataBuffer or Publisher for the Message payload, actual: " + actualType); } /** @@ -206,7 +216,7 @@ public class PayloadMethodArgumentResolver implements HandlerMethodArgumentResol } private Mono decodeContent(MethodParameter parameter, Message message, - boolean isContentRequired, Publisher content, @Nullable MimeType mimeType) { + boolean isContentRequired, Flux content, MimeType mimeType) { ResolvableType targetType = ResolvableType.forMethodParameter(parameter); Class resolvedType = targetType.resolve(); @@ -215,19 +225,14 @@ public class PayloadMethodArgumentResolver implements HandlerMethodArgumentResol isContentRequired = isContentRequired || (adapter != null && !adapter.supportsEmpty()); Consumer validator = getValidator(message, parameter); - if (logger.isDebugEnabled()) { - logger.debug("Mime type:" + mimeType); - } - mimeType = mimeType != null ? mimeType : MimeTypeUtils.APPLICATION_OCTET_STREAM; + Map hints = Collections.emptyMap(); for (Decoder decoder : this.decoders) { if (decoder.canDecode(elementType, mimeType)) { if (adapter != null && adapter.isMultiValue()) { - if (logger.isDebugEnabled()) { - logger.debug("0..N [" + elementType + "]"); - } - Flux flux = decoder.decode(content, elementType, mimeType, Collections.emptyMap()); - flux = flux.onErrorResume(ex -> Flux.error(handleReadError(parameter, message, ex))); + Flux flux = content + .concatMap(buffer -> decoder.decode(Mono.just(buffer), elementType, mimeType, hints)) + .onErrorResume(ex -> Flux.error(handleReadError(parameter, message, ex))); if (isContentRequired) { flux = flux.switchIfEmpty(Flux.error(() -> handleMissingBody(parameter, message))); } @@ -237,12 +242,10 @@ public class PayloadMethodArgumentResolver implements HandlerMethodArgumentResol return Mono.just(adapter.fromPublisher(flux)); } else { - if (logger.isDebugEnabled()) { - logger.debug("0..1 [" + elementType + "]"); - } // Single-value (with or without reactive type wrapper) - Mono mono = decoder.decodeToMono(content, targetType, mimeType, Collections.emptyMap()); - mono = mono.onErrorResume(ex -> Mono.error(handleReadError(parameter, message, ex))); + Mono mono = decoder + .decodeToMono(content.next(), targetType, mimeType, hints) + .onErrorResume(ex -> Mono.error(handleReadError(parameter, message, ex))); if (isContentRequired) { mono = mono.switchIfEmpty(Mono.error(() -> handleMissingBody(parameter, message))); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java index d7a3aa96b4..aa5916a414 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java @@ -32,20 +32,21 @@ import org.springframework.core.ResolvableType; import org.springframework.core.codec.Encoder; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.MessagingException; import org.springframework.util.Assert; +import org.springframework.util.MimeType; /** - * Base class for a return value handler that encodes the return value, possibly - * a {@link Publisher} of values, to a {@code Flux} through a - * compatible {@link Encoder}. + * Base class for a return value handler that encodes return values to + * {@code Flux} through the configured {@link Encoder}s. * *

Sub-classes must implement the abstract method - * {@link #handleEncodedContent} to do something with the resulting encoded - * content. + * {@link #handleEncodedContent} to handle the resulting encoded content. * *

This handler should be ordered last since its {@link #supportsReturnType} * returns {@code true} for any method parameter type. @@ -67,8 +68,7 @@ public abstract class AbstractEncoderMethodReturnValueHandler implements Handler private final ReactiveAdapterRegistry adapterRegistry; - // TODO: configure or passed via MessageHeaders - private DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); + private DataBufferFactory defaultBufferFactory = new DefaultDataBufferFactory(); protected AbstractEncoderMethodReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { @@ -96,69 +96,104 @@ public abstract class AbstractEncoderMethodReturnValueHandler implements Handler @Override public boolean supportsReturnType(MethodParameter returnType) { + // We could check canEncode but we're probably last in order anyway return true; } @Override - public Mono handleReturnValue(Object returnValue, MethodParameter returnType, Message message) { - Flux encodedContent = encodeContent(returnValue, returnType, this.bufferFactory); + public Mono handleReturnValue( + @Nullable Object returnValue, MethodParameter returnType, Message message) { + + DataBufferFactory bufferFactory = (DataBufferFactory) message.getHeaders() + .getOrDefault(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER, this.defaultBufferFactory); + + MimeType mimeType = (MimeType) message.getHeaders().get(MessageHeaders.CONTENT_TYPE); + + Flux encodedContent = encodeContent( + returnValue, returnType, bufferFactory, mimeType, Collections.emptyMap()); + return handleEncodedContent(encodedContent, returnType, message); } @SuppressWarnings("unchecked") - private Flux encodeContent(@Nullable Object content, MethodParameter returnType, - DataBufferFactory bufferFactory) { + private Flux encodeContent( + @Nullable Object content, MethodParameter returnType, DataBufferFactory bufferFactory, + @Nullable MimeType mimeType, Map hints) { - ResolvableType bodyType = ResolvableType.forMethodParameter(returnType); - ReactiveAdapter adapter = getAdapterRegistry().getAdapter(bodyType.resolve(), content); + ResolvableType returnValueType = ResolvableType.forMethodParameter(returnType); + ReactiveAdapter adapter = getAdapterRegistry().getAdapter(returnValueType.resolve(), content); Publisher publisher; ResolvableType elementType; if (adapter != null) { publisher = adapter.toPublisher(content); - ResolvableType genericType = bodyType.getGeneric(); + ResolvableType genericType = returnValueType.getGeneric(); elementType = getElementType(adapter, genericType); } else { publisher = Mono.justOrEmpty(content); - elementType = (bodyType.toClass() == Object.class && content != null ? - ResolvableType.forInstance(content) : bodyType); + elementType = returnValueType.toClass() == Object.class && content != null ? + ResolvableType.forInstance(content) : returnValueType; } if (elementType.resolve() == void.class || elementType.resolve() == Void.class) { return Flux.from(publisher).cast(DataBuffer.class); } - if (logger.isDebugEnabled()) { - logger.debug((publisher instanceof Mono ? "0..1" : "0..N") + " [" + elementType + "]"); - } + Encoder encoder = getEncoder(elementType, mimeType); - for (Encoder encoder : getEncoders()) { - if (encoder.canEncode(elementType, null)) { - Map hints = Collections.emptyMap(); - return encoder.encode((Publisher) publisher, bufferFactory, elementType, null, hints); - } - } - - return Flux.error(new MessagingException("No encoder for " + returnType)); + return Flux.from((Publisher) publisher).concatMap(value -> + encodeValue(value, elementType, encoder, bufferFactory, mimeType, hints)); } - private ResolvableType getElementType(ReactiveAdapter adapter, ResolvableType genericType) { + private ResolvableType getElementType(ReactiveAdapter adapter, ResolvableType type) { if (adapter.isNoValue()) { return VOID_RESOLVABLE_TYPE; } - else if (genericType != ResolvableType.NONE) { - return genericType; + else if (type != ResolvableType.NONE) { + return type; } else { return OBJECT_RESOLVABLE_TYPE; } } + @Nullable + @SuppressWarnings("unchecked") + private Encoder getEncoder(ResolvableType elementType, @Nullable MimeType mimeType) { + for (Encoder encoder : getEncoders()) { + if (encoder.canEncode(elementType, mimeType)) { + return (Encoder) encoder; + } + } + return null; + } + + @SuppressWarnings("unchecked") + private Mono encodeValue( + Object element, ResolvableType elementType, @Nullable Encoder encoder, + DataBufferFactory bufferFactory, @Nullable MimeType mimeType, + @Nullable Map hints) { + + if (encoder == null) { + encoder = getEncoder(ResolvableType.forInstance(element), mimeType); + if (encoder == null) { + return Mono.error(new MessagingException( + "No encoder for " + elementType + ", current value type is " + element.getClass())); + } + } + Mono mono = Mono.just((T) element); + Flux dataBuffers = encoder.encode(mono, bufferFactory, elementType, mimeType, hints); + return DataBufferUtils.join(dataBuffers); + } + /** - * Handle the encoded content in some way, e.g. wrapping it in a message and - * passing it on for further processing. - * @param encodedContent the result of data encoding + * Sub-classes implement this method to handle encoded values in some way + * such as creating and sending messages. + * + * @param encodedContent the encoded content; each {@code DataBuffer} + * represents the fully-aggregated, encoded content for one value + * (i.e. payload) returned from the HandlerMethod. * @param returnType return type of the handler method that produced the data * @param message the input message handled by the handler method * @return completion {@code Mono} for the handling diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java index 684ed8e04d..23824c3096 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -93,6 +94,9 @@ public abstract class AbstractMethodMessageHandler private ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance(); + @Nullable + private Predicate> handlerPredicate; + @Nullable private ApplicationContext applicationContext; @@ -137,6 +141,24 @@ public abstract class AbstractMethodMessageHandler return this.returnValueHandlerConfigurer; } + /** + * Configure a predicate to decide if which beans in the Spring context + * should be checked to see if they have message handling methods. + *

By default this is not set and sub-classes should configure it in + * order to enable auto-detection of message handling methods. + */ + public void setHandlerPredicate(@Nullable Predicate> handlerPredicate) { + this.handlerPredicate = handlerPredicate; + } + + /** + * Return the {@link #setHandlerPredicate configured} handler predicate. + */ + @Nullable + public Predicate> getHandlerPredicate() { + return this.handlerPredicate; + } + /** * Configure the registry for adapting various reactive types. *

By default this is an instance of {@link ReactiveAdapterRegistry} with @@ -228,6 +250,10 @@ public abstract class AbstractMethodMessageHandler logger.warn("No ApplicationContext available for detecting beans with message handling methods."); return; } + if (this.handlerPredicate == null) { + logger.warn("'handlerPredicate' not configured: no auto-detection of message handling methods."); + return; + } for (String beanName : this.applicationContext.getBeanNamesForType(Object.class)) { if (!beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) { Class beanType = null; @@ -240,24 +266,22 @@ public abstract class AbstractMethodMessageHandler logger.debug("Could not resolve target class for bean with name '" + beanName + "'", ex); } } - if (beanType != null && isHandler(beanType)) { + if (beanType != null && this.handlerPredicate.test(beanType)) { detectHandlerMethods(beanName); } } } } - /** - * Whether the given bean could contain message handling methods. - */ - protected abstract boolean isHandler(Class beanType); - /** * Detect if the given handler has any methods that can handle messages and if * so register it with the extracted mapping information. + *

Note: This method is protected and can be invoked by + * sub-classes, but this should be done on startup only as documented in + * {@link #registerHandlerMethod}. * @param handler the handler to check, either an instance of a Spring bean name */ - private void detectHandlerMethods(Object handler) { + protected final void detectHandlerMethods(Object handler) { Class handlerType; if (handler instanceof String) { ApplicationContext context = getApplicationContext(); @@ -288,14 +312,17 @@ public abstract class AbstractMethodMessageHandler protected abstract T getMappingForMethod(Method method, Class handlerType); /** - * Register a handler method and its unique mapping, on startup. + * Register a handler method and its unique mapping. + *

Note: This method is protected and can be invoked by + * sub-classes. Keep in mind however that the registration is not protected + * for concurrent use, and is expected to be done on startup. * @param handler the bean name of the handler or the handler instance * @param method the method to register * @param mapping the mapping conditions associated with the handler method * @throws IllegalStateException if another method was already registered * under the same mapping */ - protected void registerHandlerMethod(Object handler, Method method, T mapping) { + protected final void registerHandlerMethod(Object handler, Method method, T mapping) { Assert.notNull(mapping, "Mapping must not be null"); HandlerMethod newHandlerMethod = createHandlerMethod(handler, method); HandlerMethod oldHandlerMethod = this.handlerMethods.get(mapping); @@ -348,6 +375,7 @@ public abstract class AbstractMethodMessageHandler public Mono handleMessage(Message message) throws MessagingException { Match match = getHandlerMethod(message); if (match == null) { + // handleNoMatch would have been invoked already return Mono.empty(); } HandlerMethod handlerMethod = match.getHandlerMethod().createWithResolvedBean(); @@ -383,6 +411,7 @@ public abstract class AbstractMethodMessageHandler addMatchesToCollection(allMappings, message, matches); } if (matches.isEmpty()) { + handleNoMatch(destination, message); return null; } Comparator> comparator = new MatchComparator(getMappingComparator(message)); @@ -443,12 +472,22 @@ public abstract class AbstractMethodMessageHandler */ protected abstract Comparator getMappingComparator(Message message); + /** + * Invoked when no matching handler is found. + * @param destination the destination + * @param message the message + */ + @Nullable + protected void handleNoMatch(@Nullable String destination, Message message) { + logger.debug("No handlers for destination '" + destination + "'"); + } + private Mono processHandlerException(Message message, HandlerMethod handlerMethod, Exception ex) { InvocableHandlerMethod exceptionInvocable = findExceptionHandler(handlerMethod, ex); if (exceptionInvocable == null) { logger.error("Unhandled exception from message handling method", ex); - return Mono.empty(); + return Mono.error(ex); } exceptionInvocable.setArgumentResolvers(this.argumentResolvers.getResolvers()); if (logger.isDebugEnabled()) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java index 9cf6c894ce..7c72ba6332 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java @@ -31,6 +31,10 @@ import org.springframework.messaging.Message; */ public interface HandlerMethodReturnValueHandler { + /** Header containing a DataBufferFactory to use. */ + public static final String DATA_BUFFER_FACTORY_HEADER = "dataBufferFactoryHeader"; + + /** * Whether the given {@linkplain MethodParameter method return type} is * supported by this handler. diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java index 243beb8f8d..a34ddc8b0c 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java @@ -19,15 +19,14 @@ import java.time.Duration; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; import org.junit.Test; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; import org.springframework.beans.factory.config.EmbeddedValueResolver; import org.springframework.context.support.StaticApplicationContext; -import org.springframework.core.MethodParameter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.codec.CharSequenceEncoder; import org.springframework.core.codec.Decoder; @@ -38,18 +37,18 @@ import org.springframework.core.env.PropertySource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBufferFactory; -import org.springframework.lang.Nullable; import org.springframework.messaging.Message; +import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; import org.springframework.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.invocation.reactive.AbstractEncoderMethodReturnValueHandler; +import org.springframework.messaging.handler.invocation.reactive.TestEncoderMethodReturnValueHandler; import org.springframework.messaging.support.GenericMessage; import org.springframework.stereotype.Controller; import static java.nio.charset.StandardCharsets.*; import static org.junit.Assert.*; -import static org.springframework.core.io.buffer.support.DataBufferTestUtils.*; +import static org.mockito.Mockito.*; /** * Unit tests for {@link MessageMappingMessageHandler}. @@ -61,51 +60,64 @@ public class MessageMappingMessageHandlerTests { private static final DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); - private TestEncoderReturnValueHandler returnValueHandler; + private TestEncoderMethodReturnValueHandler returnValueHandler; @Test public void handleString() { MessageMappingMessageHandler messsageHandler = initMesssageHandler(); - messsageHandler.handleMessage(message("/string", "abcdef")).block(Duration.ofSeconds(5)); + messsageHandler.handleMessage(message("string", "abcdef")).block(Duration.ofSeconds(5)); verifyOutputContent(Collections.singletonList("abcdef::response")); } @Test public void handleMonoString() { MessageMappingMessageHandler messsageHandler = initMesssageHandler(); - messsageHandler.handleMessage(message("/monoString", "abcdef")).block(Duration.ofSeconds(5)); + messsageHandler.handleMessage(message("monoString", "abcdef")).block(Duration.ofSeconds(5)); verifyOutputContent(Collections.singletonList("abcdef::response")); } @Test public void handleFluxString() { MessageMappingMessageHandler messsageHandler = initMesssageHandler(); - messsageHandler.handleMessage(message("/fluxString", "abc\ndef\nghi")).block(Duration.ofSeconds(5)); + messsageHandler.handleMessage(message("fluxString", "abc\ndef\nghi")).block(Duration.ofSeconds(5)); verifyOutputContent(Arrays.asList("abc::response", "def::response", "ghi::response")); } @Test public void handleWithPlaceholderInMapping() { MessageMappingMessageHandler messsageHandler = initMesssageHandler(); - messsageHandler.handleMessage(message("/path123", "abcdef")).block(Duration.ofSeconds(5)); + messsageHandler.handleMessage(message("path123", "abcdef")).block(Duration.ofSeconds(5)); verifyOutputContent(Collections.singletonList("abcdef::response")); } @Test public void handleException() { MessageMappingMessageHandler messsageHandler = initMesssageHandler(); - messsageHandler.handleMessage(message("/exception", "abc")).block(Duration.ofSeconds(5)); + messsageHandler.handleMessage(message("exception", "abc")).block(Duration.ofSeconds(5)); verifyOutputContent(Collections.singletonList("rejected::handled")); } @Test public void handleErrorSignal() { MessageMappingMessageHandler messsageHandler = initMesssageHandler(); - messsageHandler.handleMessage(message("/errorSignal", "abc")).block(Duration.ofSeconds(5)); + messsageHandler.handleMessage(message("errorSignal", "abc")).block(Duration.ofSeconds(5)); verifyOutputContent(Collections.singletonList("rejected::handled")); } + @Test + public void unhandledExceptionShouldFlowThrough() { + + GenericMessage message = new GenericMessage<>(new Object(), + Collections.singletonMap(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, "string")); + + StepVerifier.create(initMesssageHandler().handleMessage(message)) + .expectErrorSatisfies(ex -> assertTrue( + "Actual: " + ex.getMessage(), + ex.getMessage().startsWith("Could not resolve method parameter at index 0"))) + .verify(Duration.ofSeconds(5)); + } + private MessageMappingMessageHandler initMesssageHandler() { @@ -113,7 +125,7 @@ public class MessageMappingMessageHandlerTests { List> encoders = Collections.singletonList(CharSequenceEncoder.allMimeTypes()); ReactiveAdapterRegistry registry = ReactiveAdapterRegistry.getSharedInstance(); - this.returnValueHandler = new TestEncoderReturnValueHandler(encoders, registry); + this.returnValueHandler = new TestEncoderMethodReturnValueHandler(encoders, registry); PropertySource source = new MapPropertySource("test", Collections.singletonMap("path", "path123")); @@ -122,11 +134,13 @@ public class MessageMappingMessageHandlerTests { context.registerSingleton("testController", TestController.class); context.refresh(); - MessageMappingMessageHandler messageHandler = new MessageMappingMessageHandler(); + ReactiveSubscribableChannel channel = mock(ReactiveSubscribableChannel.class); + + MessageMappingMessageHandler messageHandler = new MessageMappingMessageHandler(channel); + messageHandler.getReturnValueHandlerConfigurer().addCustomHandler(this.returnValueHandler); messageHandler.setApplicationContext(context); messageHandler.setEmbeddedValueResolver(new EmbeddedValueResolver(context.getBeanFactory())); messageHandler.setDecoders(decoders); - messageHandler.setEncoderReturnValueHandler(this.returnValueHandler); messageHandler.afterPropertiesSet(); return messageHandler; @@ -134,7 +148,7 @@ public class MessageMappingMessageHandlerTests { private Message message(String destination, String... content) { return new GenericMessage<>( - Flux.fromIterable(Arrays.stream(content).map(this::toDataBuffer).collect(Collectors.toList())), + Flux.fromIterable(Arrays.asList(content)).map(payload -> toDataBuffer(payload)), Collections.singletonMap(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, destination)); } @@ -143,42 +157,40 @@ public class MessageMappingMessageHandlerTests { } private void verifyOutputContent(List expected) { - List buffers = this.returnValueHandler.getOutputContent(); - assertNotNull("No output: no matching handler method?", buffers); - List actual = buffers.stream().map(buffer -> dumpString(buffer, UTF_8)).collect(Collectors.toList()); - assertEquals(expected, actual); + Flux result = this.returnValueHandler.getContentAsStrings(); + StepVerifier.create(result.collectList()).expectNext(expected).verifyComplete(); } @Controller static class TestController { - @MessageMapping("/string") + @MessageMapping("string") String handleString(String payload) { return payload + "::response"; } - @MessageMapping("/monoString") + @MessageMapping("monoString") Mono handleMonoString(Mono payload) { return payload.map(s -> s + "::response").delayElement(Duration.ofMillis(10)); } - @MessageMapping("/fluxString") + @MessageMapping("fluxString") Flux handleFluxString(Flux payload) { return payload.map(s -> s + "::response").delayElements(Duration.ofMillis(10)); } - @MessageMapping("/${path}") + @MessageMapping("${path}") String handleWithPlaceholder(String payload) { return payload + "::response"; } - @MessageMapping("/exception") + @MessageMapping("exception") String handleAndThrow() { throw new IllegalArgumentException("rejected"); } - @MessageMapping("/errorSignal") + @MessageMapping("errorSignal") Mono handleAndSignalError() { return Mono.delay(Duration.ofMillis(10)) .flatMap(aLong -> Mono.error(new IllegalArgumentException("rejected"))); @@ -190,29 +202,4 @@ public class MessageMappingMessageHandlerTests { } } - - private static class TestEncoderReturnValueHandler extends AbstractEncoderMethodReturnValueHandler { - - @Nullable - private volatile List outputContent; - - - TestEncoderReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { - super(encoders, registry); - } - - - @Nullable - public List getOutputContent() { - return this.outputContent; - } - - @Override - protected Mono handleEncodedContent( - Flux encodedContent, MethodParameter returnType, Message message) { - - return encodedContent.collectList().doOnNext(buffers -> this.outputContent = buffers).then(); - } - } - } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java index 0797440d06..0e1e05d08a 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java @@ -101,8 +101,8 @@ public class PayloadMethodArgumentResolverTests { public void stringMono() { String body = "foo"; MethodParameter param = this.testMethod.arg(ResolvableType.forClassWithGenerics(Mono.class, String.class)); - Mono value = Mono.delay(Duration.ofMillis(10)).map(aLong -> toDataBuffer(body)); - Mono mono = resolveValue(param, value, null); + Mono mono = resolveValue(param, + Mono.delay(Duration.ofMillis(10)).map(aLong -> toDataBuffer(body)), null); assertEquals(body, mono.block()); } @@ -112,8 +112,8 @@ public class PayloadMethodArgumentResolverTests { List body = Arrays.asList("foo", "bar"); ResolvableType type = ResolvableType.forClassWithGenerics(Flux.class, String.class); MethodParameter param = this.testMethod.arg(type); - Flux flux = resolveValue(param, Flux.fromIterable(body) - .delayElements(Duration.ofMillis(10)).map(value -> toDataBuffer(value + "\n")), null); + Flux flux = resolveValue(param, + Flux.fromIterable(body).delayElements(Duration.ofMillis(10)).map(this::toDataBuffer), null); assertEquals(body, flux.collectList().block()); } @@ -141,7 +141,7 @@ public class PayloadMethodArgumentResolverTests { public void validateStringFlux() { ResolvableType type = ResolvableType.forClassWithGenerics(Flux.class, String.class); MethodParameter param = this.testMethod.arg(type); - Flux flux = resolveValue(param, Flux.just(toDataBuffer("12345678\n12345")), new TestValidator()); + Flux flux = resolveValue(param, Mono.just(toDataBuffer("12345678\n12345")), new TestValidator()); StepVerifier.create(flux) .expectNext("12345678") diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java index b373d671c0..a8cb85b7ee 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java @@ -16,7 +16,6 @@ package org.springframework.messaging.handler.invocation.reactive; import java.util.Collections; -import java.util.List; import io.reactivex.Completable; import org.junit.Test; @@ -27,15 +26,10 @@ import reactor.test.StepVerifier; import org.springframework.core.MethodParameter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.codec.CharSequenceEncoder; -import org.springframework.core.codec.Encoder; -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.support.DataBufferTestUtils; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; -import static java.nio.charset.StandardCharsets.*; -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; import static org.springframework.messaging.handler.invocation.ResolvableMethod.*; /** @@ -49,41 +43,43 @@ public class EncoderMethodReturnValueHandlerTests { Collections.singletonList(CharSequenceEncoder.textPlainOnly()), ReactiveAdapterRegistry.getSharedInstance()); - private final Message message = mock(Message.class); + private final Message message = new GenericMessage<>("shouldn't matter"); @Test public void stringReturnValue() { MethodParameter parameter = on(TestController.class).resolveReturnType(String.class); - this.handler.handleReturnValue("foo", parameter, message).block(); - Flux result = this.handler.encodedContent; + this.handler.handleReturnValue("foo", parameter, this.message).block(); + Flux result = this.handler.getContentAsStrings(); - StepVerifier.create(result) - .consumeNextWith(buffer -> assertEquals("foo", DataBufferTestUtils.dumpString(buffer, UTF_8))) - .verifyComplete(); + StepVerifier.create(result).expectNext("foo").verifyComplete(); } @Test public void objectReturnValue() { MethodParameter parameter = on(TestController.class).resolveReturnType(Object.class); - this.handler.handleReturnValue("foo", parameter, message).block(); - Flux result = this.handler.encodedContent; + this.handler.handleReturnValue("foo", parameter, this.message).block(); + Flux result = this.handler.getContentAsStrings(); - StepVerifier.create(result) - .consumeNextWith(buffer -> assertEquals("foo", DataBufferTestUtils.dumpString(buffer, UTF_8))) - .verifyComplete(); + StepVerifier.create(result).expectNext("foo").verifyComplete(); } @Test public void fluxStringReturnValue() { MethodParameter parameter = on(TestController.class).resolveReturnType(Flux.class, String.class); - this.handler.handleReturnValue(Flux.just("foo", "bar"), parameter, message).block(); - Flux result = this.handler.encodedContent; + this.handler.handleReturnValue(Flux.just("foo", "bar"), parameter, this.message).block(); + Flux result = this.handler.getContentAsStrings(); - StepVerifier.create(result) - .consumeNextWith(buffer -> assertEquals("foo", DataBufferTestUtils.dumpString(buffer, UTF_8))) - .consumeNextWith(buffer -> assertEquals("bar", DataBufferTestUtils.dumpString(buffer, UTF_8))) - .verifyComplete(); + StepVerifier.create(result).expectNext("foo").expectNext("bar").verifyComplete(); + } + + @Test + public void fluxObjectReturnValue() { + MethodParameter parameter = on(TestController.class).resolveReturnType(Flux.class, Object.class); + this.handler.handleReturnValue(Flux.just("foo", "bar"), parameter, this.message).block(); + Flux result = this.handler.getContentAsStrings(); + + StepVerifier.create(result).expectNext("foo").expectNext("bar").verifyComplete(); } @Test @@ -91,23 +87,19 @@ public class EncoderMethodReturnValueHandlerTests { testVoidReturnType(null, on(TestController.class).resolveReturnType(void.class)); testVoidReturnType(Mono.empty(), on(TestController.class).resolveReturnType(Mono.class, Void.class)); testVoidReturnType(Completable.complete(), on(TestController.class).resolveReturnType(Completable.class)); - } private void testVoidReturnType(@Nullable Object value, MethodParameter bodyParameter) { - this.handler.handleReturnValue(value, bodyParameter, message).block(); - Flux result = this.handler.encodedContent; + this.handler.handleReturnValue(value, bodyParameter, this.message).block(); + Flux result = this.handler.getContentAsStrings(); StepVerifier.create(result).expectComplete().verify(); } @Test public void noEncoder() { MethodParameter parameter = on(TestController.class).resolveReturnType(Object.class); - this.handler.handleReturnValue(new Object(), parameter, message).block(); - Flux result = this.handler.encodedContent; - - StepVerifier.create(result) - .expectErrorMessage("No encoder for method 'object' parameter -1") + StepVerifier.create(this.handler.handleReturnValue(new Object(), parameter, this.message)) + .expectErrorMessage("No encoder for java.lang.Object, current value type is class java.lang.Object") .verify(); } @@ -121,6 +113,8 @@ public class EncoderMethodReturnValueHandlerTests { Flux fluxString() { return null; } + Flux fluxObject() { return null; } + void voidReturn() { } Mono monoVoid() { return null; } @@ -128,27 +122,4 @@ public class EncoderMethodReturnValueHandlerTests { Completable completable() { return null; } } - - private static class TestEncoderMethodReturnValueHandler extends AbstractEncoderMethodReturnValueHandler { - - private Flux encodedContent; - - - public Flux getEncodedContent() { - return this.encodedContent; - } - - protected TestEncoderMethodReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { - super(encoders, registry); - } - - @Override - protected Mono handleEncodedContent( - Flux encodedContent, MethodParameter returnType, Message message) { - - this.encodedContent = encodedContent; - return Mono.empty(); - } - } - } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java index 32c3cb2638..bcd7b6eec0 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java @@ -192,6 +192,11 @@ public class MethodMessageHandlerTests { private PathMatcher pathMatcher = new AntPathMatcher(); + public TestMethodMessageHandler() { + setHandlerPredicate(handlerType -> handlerType.getName().endsWith("Controller")); + } + + @Override protected List initArgumentResolvers() { return Collections.emptyList(); @@ -211,11 +216,6 @@ public class MethodMessageHandlerTests { super.registerHandlerMethod(handler, method, mapping); } - @Override - protected boolean isHandler(Class handlerType) { - return handlerType.getName().endsWith("Controller"); - } - @Override protected String getMappingForMethod(Method method, Class handlerType) { String methodName = method.getName(); diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java new file mode 100644 index 0000000000..3a47d53af9 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.util.List; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.support.DataBufferTestUtils; +import org.springframework.messaging.Message; + +import static java.nio.charset.StandardCharsets.*; + +/** + * Implementation of {@link AbstractEncoderMethodReturnValueHandler} for tests. + * "Handles" by storing encoded return values. + * + * @author Rossen Stoyanchev + */ +public class TestEncoderMethodReturnValueHandler extends AbstractEncoderMethodReturnValueHandler { + + private Flux encodedContent; + + + public TestEncoderMethodReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { + super(encoders, registry); + } + + + public Flux getContent() { + return this.encodedContent; + } + + public Flux getContentAsStrings() { + return this.encodedContent.map(buffer -> DataBufferTestUtils.dumpString(buffer, UTF_8)); + } + + @Override + protected Mono handleEncodedContent( + Flux encodedContent, MethodParameter returnType, Message message) { + + this.encodedContent = encodedContent.cache(); + return this.encodedContent.then(); + } +} From 4e78b5df2f3b4c20652fef2070763749fa2d205d Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Sun, 10 Feb 2019 14:45:16 -0500 Subject: [PATCH 10/17] RSocket @MessageMapping handling See gh-21987 --- spring-messaging/spring-messaging.gradle | 4 + .../messaging/rsocket/MessagingAcceptor.java | 114 +++++++ .../messaging/rsocket/MessagingRSocket.java | 165 ++++++++++ .../rsocket/RSocketMessageHandler.java | 97 ++++++ .../RSocketPayloadReturnValueHandler.java | 88 +++++ .../SendingRSocketMethodArgumentResolver.java | 58 ++++ .../messaging/rsocket/package-info.java | 9 + .../FireAndForgetCountingInterceptor.java | 78 +++++ ...RSocketClientToServerIntegrationTests.java | 235 ++++++++++++++ ...RSocketServerToClientIntegrationTests.java | 305 ++++++++++++++++++ 10 files changed, 1153 insertions(+) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/SendingRSocketMethodArgumentResolver.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/package-info.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/rsocket/FireAndForgetCountingInterceptor.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java diff --git a/spring-messaging/spring-messaging.gradle b/spring-messaging/spring-messaging.gradle index ada7330be6..9a4771279c 100644 --- a/spring-messaging/spring-messaging.gradle +++ b/spring-messaging/spring-messaging.gradle @@ -7,12 +7,15 @@ dependencyManagement { } } +def rsocketVersion = "0.11.15" + dependencies { compile(project(":spring-beans")) compile(project(":spring-core")) optional(project(":spring-context")) optional(project(":spring-oxm")) optional("io.projectreactor.netty:reactor-netty") + optional("io.rsocket:rsocket-core:${rsocketVersion}") optional("com.fasterxml.jackson.core:jackson-databind:${jackson2Version}") optional("javax.xml.bind:jaxb-api:2.3.1") testCompile("javax.inject:javax.inject-tck:1") @@ -26,6 +29,7 @@ dependencies { testCompile("org.apache.activemq:activemq-stomp:5.8.0") testCompile("io.projectreactor:reactor-test") testCompile "io.reactivex.rxjava2:rxjava:${rxjava2Version}" + testCompile("io.rsocket:rsocket-transport-netty:${rsocketVersion}") testCompile("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}") testCompile("org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}") testCompile("org.xmlunit:xmlunit-matchers:2.6.2") diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java new file mode 100644 index 0000000000..dc9c070b4d --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java @@ -0,0 +1,114 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.util.function.Function; +import java.util.function.Predicate; + +import io.netty.buffer.PooledByteBufAllocator; +import io.rsocket.ConnectionSetupPayload; +import io.rsocket.RSocket; +import io.rsocket.SocketAcceptor; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.ReactiveMessageChannel; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * RSocket acceptor for + * {@link io.rsocket.RSocketFactory.ClientRSocketFactory#acceptor(Function) client} or + * {@link io.rsocket.RSocketFactory.ServerRSocketFactory#acceptor(SocketAcceptor) server} + * side use. It wraps requests with a {@link Message} envelope and sends them + * to a {@link ReactiveMessageChannel} for handling, e.g. via + * {@code @MessageMapping} method. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public final class MessagingAcceptor implements SocketAcceptor, Function { + + private final ReactiveMessageChannel messageChannel; + + private NettyDataBufferFactory bufferFactory = new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT); + + @Nullable + private MimeType defaultDataMimeType; + + + /** + * Constructor with a message channel to send messages to. + * @param messageChannel the message channel to use + *

This assumes a Spring configuration setup with a + * {@code ReactiveMessageChannel} and an {@link RSocketMessageHandler} which + * by default auto-detects {@code @MessageMapping} methods in + * {@code @Controller} classes, but can also be configured with a + * {@link RSocketMessageHandler#setHandlerPredicate(Predicate) handlerPredicate} + * or with handler instances. + */ + public MessagingAcceptor(ReactiveMessageChannel messageChannel) { + Assert.notNull(messageChannel, "ReactiveMessageChannel is required"); + this.messageChannel = messageChannel; + } + + + /** + * Configure the default content type for data payloads. For server + * acceptors this is available from the {@link ConnectionSetupPayload} but + * for client acceptors it's not and must be provided here. + *

By default this is not set. + * @param defaultDataMimeType the MimeType to use + */ + public void setDefaultDataMimeType(@Nullable MimeType defaultDataMimeType) { + this.defaultDataMimeType = defaultDataMimeType; + } + + /** + * Configure the buffer factory to use. + *

By default this is initialized with the allocator instance + * {@link PooledByteBufAllocator#DEFAULT}. + * @param bufferFactory the bufferFactory to use + */ + public void setNettyDataBufferFactory(NettyDataBufferFactory bufferFactory) { + Assert.notNull(bufferFactory, "DataBufferFactory is required"); + this.bufferFactory = bufferFactory; + } + + + @Override + public Mono accept(ConnectionSetupPayload setupPayload, RSocket sendingRSocket) { + + MimeType mimeType = setupPayload.dataMimeType() != null ? + MimeTypeUtils.parseMimeType(setupPayload.dataMimeType()) : this.defaultDataMimeType; + + MessagingRSocket rsocket = createRSocket(sendingRSocket, mimeType); + return rsocket.afterConnectionEstablished(setupPayload).then(Mono.just(rsocket)); + } + + @Override + public RSocket apply(RSocket sendingRSocket) { + return createRSocket(sendingRSocket, this.defaultDataMimeType); + } + + private MessagingRSocket createRSocket(RSocket sendingRSocket, @Nullable MimeType dataMimeType) { + return new MessagingRSocket(this.messageChannel, this.bufferFactory, sendingRSocket, dataMimeType); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java new file mode 100644 index 0000000000..95b4a1ab5e --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java @@ -0,0 +1,165 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.util.function.Function; + +import io.rsocket.ConnectionSetupPayload; +import io.rsocket.Payload; +import io.rsocket.RSocket; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; + +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.NettyDataBuffer; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.core.io.buffer.PooledDataBuffer; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageDeliveryException; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.ReactiveMessageChannel; +import org.springframework.messaging.handler.DestinationPatternsMessageCondition; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; + +/** + * Package private implementation of {@link RSocket} used from + * {@link MessagingAcceptor}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +class MessagingRSocket implements RSocket { + + private final ReactiveMessageChannel messageChannel; + + private final NettyDataBufferFactory bufferFactory; + + private final RSocket sendingRSocket; + + @Nullable + private final MimeType dataMimeType; + + + MessagingRSocket(ReactiveMessageChannel messageChannel, NettyDataBufferFactory bufferFactory, + RSocket sendingRSocket, @Nullable MimeType dataMimeType) { + + Assert.notNull(messageChannel, "'messageChannel' is required"); + Assert.notNull(bufferFactory, "'bufferFactory' is required"); + Assert.notNull(sendingRSocket, "'sendingRSocket' is required"); + this.messageChannel = messageChannel; + this.bufferFactory = bufferFactory; + this.sendingRSocket = sendingRSocket; + this.dataMimeType = dataMimeType; + } + + + public Mono afterConnectionEstablished(ConnectionSetupPayload payload) { + return execute(payload).flatMap(flux -> flux.take(0).then()); + } + + + @Override + public Mono fireAndForget(Payload payload) { + return execute(payload).flatMap(flux -> flux.take(0).then()); + } + + @Override + public Mono requestResponse(Payload payload) { + return execute(payload).flatMap(Flux::next); + } + + @Override + public Flux requestStream(Payload payload) { + return execute(payload).flatMapMany(Function.identity()); + } + + @Override + public Flux requestChannel(Publisher payloads) { + return Flux.from(payloads) + .switchOnFirst((signal, inner) -> { + Payload first = signal.get(); + return first != null ? execute(first, inner).flatMapMany(Function.identity()) : inner; + }); + } + + @Override + public Mono metadataPush(Payload payload) { + return null; + } + + private Mono> execute(Payload payload) { + return execute(payload, Flux.just(payload)); + } + + private Mono> execute(Payload firstPayload, Flux payloads) { + + // TODO: + // Since we do retain(), we need to ensure buffers are released if not consumed, + // e.g. error before Flux subscribed to, no handler found, @MessageMapping ignores payload, etc. + + Flux payloadDataBuffers = payloads + .map(payload -> this.bufferFactory.wrap(payload.retain().sliceData())) + .doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release); + + MonoProcessor> replyMono = MonoProcessor.create(); + MessageHeaders headers = createHeaders(firstPayload, replyMono); + + Message message = MessageBuilder.createMessage(payloadDataBuffers, headers); + + return this.messageChannel.send(message).flatMap(result -> result ? + replyMono.isTerminated() ? replyMono : Mono.empty() : + Mono.error(new MessageDeliveryException("RSocket interaction not handled"))); + } + + private MessageHeaders createHeaders(Payload payload, MonoProcessor replyMono) { + + // For now treat the metadata as a simple string with routing information. + // We'll have to get more sophisticated once the routing extension is completed. + // https://github.com/rsocket/rsocket-java/issues/568 + + MessageHeaderAccessor headers = new MessageHeaderAccessor(); + + String destination = payload.getMetadataUtf8(); + headers.setHeader(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, destination); + + if (this.dataMimeType != null) { + headers.setContentType(this.dataMimeType); + } + + headers.setHeader(SendingRSocketMethodArgumentResolver.SENDING_RSOCKET_HEADER, this.sendingRSocket); + headers.setHeader(RSocketPayloadReturnValueHandler.RESPONSE_HEADER, replyMono); + headers.setHeader(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER, this.bufferFactory); + + return headers.getMessageHeaders(); + } + + @Override + public Mono onClose() { + return null; + } + + @Override + public void dispose() { + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java new file mode 100644 index 0000000000..1485f55035 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java @@ -0,0 +1,97 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.core.codec.Encoder; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageDeliveryException; +import org.springframework.messaging.ReactiveSubscribableChannel; +import org.springframework.messaging.handler.annotation.support.reactive.MessageMappingMessageHandler; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; +import org.springframework.util.StringUtils; + +/** + * RSocket-specific extension of {@link MessageMappingMessageHandler}. + * + *

The configured {@link #setEncoders(List) encoders} are used to encode the + * return values from handler methods, with the help of + * {@link RSocketPayloadReturnValueHandler}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class RSocketMessageHandler extends MessageMappingMessageHandler { + + private final List> encoders = new ArrayList<>(); + + + public RSocketMessageHandler(ReactiveSubscribableChannel inboundChannel) { + super(inboundChannel); + } + + public RSocketMessageHandler(ReactiveSubscribableChannel inboundChannel, List handlers) { + super(inboundChannel); + setHandlerPredicate(null); // disable auto-detection.. + for (Object handler : handlers) { + detectHandlerMethods(handler); + } + } + + + /** + * Configure the encoders to use for encoding handler method return values. + */ + public void setEncoders(List> encoders) { + this.encoders.addAll(encoders); + } + + /** + * Return the configured {@link #setEncoders(List) encoders}. + */ + public List> getEncoders() { + return this.encoders; + } + + + @Override + public void afterPropertiesSet() { + getArgumentResolverConfigurer().addCustomResolver(new SendingRSocketMethodArgumentResolver()); + super.afterPropertiesSet(); + } + + @Override + protected List initReturnValueHandlers() { + List handlers = new ArrayList<>(); + handlers.add(new RSocketPayloadReturnValueHandler(this.encoders, getReactiveAdapterRegistry())); + handlers.addAll(getReturnValueHandlerConfigurer().getCustomHandlers()); + return handlers; + } + + + @Override + protected void handleNoMatch(@Nullable String destination, Message message) { + // Ignore empty destination, probably the ConnectionSetupPayload + if (!StringUtils.isEmpty(destination)) { + super.handleNoMatch(destination, message); + throw new MessageDeliveryException("No handler for '" + destination + "'"); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java new file mode 100644 index 0000000000..9a7fa67775 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java @@ -0,0 +1,88 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.util.List; + +import io.rsocket.Payload; +import io.rsocket.util.ByteBufPayload; +import io.rsocket.util.DefaultPayload; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBuffer; +import org.springframework.core.io.buffer.NettyDataBuffer; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.reactive.AbstractEncoderMethodReturnValueHandler; +import org.springframework.util.Assert; + +/** + * Extension of {@link AbstractEncoderMethodReturnValueHandler} that + * {@link #handleEncodedContent handles} encoded content by wrapping data buffers + * as RSocket payloads and by passing those to the {@link MonoProcessor} + * from the {@link #RESPONSE_HEADER} header. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class RSocketPayloadReturnValueHandler extends AbstractEncoderMethodReturnValueHandler { + + /** + * Message header name that is expected to have a {@link MonoProcessor} + * which will receive the {@code Flux} that represents the response. + */ + public static final String RESPONSE_HEADER = "rsocketResponse"; + + + public RSocketPayloadReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { + super(encoders, registry); + } + + + @Override + @SuppressWarnings("unchecked") + protected Mono handleEncodedContent( + Flux encodedContent, MethodParameter returnType, Message message) { + + Object headerValue = message.getHeaders().get(RESPONSE_HEADER); + Assert.notNull(headerValue, "Missing '" + RESPONSE_HEADER + "'"); + Assert.isInstanceOf(MonoProcessor.class, headerValue, "Expected MonoProcessor"); + + MonoProcessor> monoProcessor = (MonoProcessor>) headerValue; + monoProcessor.onNext(encodedContent.map(this::toPayload)); + monoProcessor.onComplete(); + + return Mono.empty(); + } + + private Payload toPayload(DataBuffer dataBuffer) { + if (dataBuffer instanceof NettyDataBuffer) { + return ByteBufPayload.create(((NettyDataBuffer) dataBuffer).getNativeBuffer()); + } + else if (dataBuffer instanceof DefaultDataBuffer) { + return DefaultPayload.create(((DefaultDataBuffer) dataBuffer).getNativeBuffer()); + } + else { + return DefaultPayload.create(dataBuffer.asByteBuffer()); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/SendingRSocketMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/SendingRSocketMethodArgumentResolver.java new file mode 100644 index 0000000000..de791a462d --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/SendingRSocketMethodArgumentResolver.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import io.rsocket.RSocket; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodArgumentResolver; +import org.springframework.util.Assert; + +/** + * Resolves arguments of type {@link RSocket} that can be used for making + * requests to the remote peer. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class SendingRSocketMethodArgumentResolver implements HandlerMethodArgumentResolver { + + /** + * Message header name that is expected to have the {@link RSocket} to + * initiate new interactions to the remote peer with. + */ + public static final String SENDING_RSOCKET_HEADER = "sendingRSocket"; + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return RSocket.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Mono resolveArgument(MethodParameter parameter, Message message) { + + Object headerValue = message.getHeaders().get(SENDING_RSOCKET_HEADER); + Assert.notNull(headerValue, "Missing '" + SENDING_RSOCKET_HEADER + "'"); + Assert.isInstanceOf(RSocket.class, headerValue, "Expected header value of type io.rsocket.RSocket"); + + return Mono.just(headerValue); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/package-info.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/package-info.java new file mode 100644 index 0000000000..9cb5ed03ac --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/package-info.java @@ -0,0 +1,9 @@ +/** + * Support for the RSocket protocol. + */ +@NonNullApi +@NonNullFields +package org.springframework.messaging.rsocket; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/FireAndForgetCountingInterceptor.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/FireAndForgetCountingInterceptor.java new file mode 100644 index 0000000000..72a5d5906b --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/FireAndForgetCountingInterceptor.java @@ -0,0 +1,78 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import io.rsocket.AbstractRSocket; +import io.rsocket.Payload; +import io.rsocket.RSocket; +import io.rsocket.plugins.RSocketInterceptor; +import io.rsocket.util.RSocketProxy; +import reactor.core.publisher.Mono; + +/** + * Intercept received RSockets and count successfully completed requests seen + * on the server side. This is useful for verifying fire-and-forget + * interactions. + * + * @author Rossen Stoyanchev + */ +class FireAndForgetCountingInterceptor extends AbstractRSocket implements RSocketInterceptor { + + private final List rsockets = new CopyOnWriteArrayList<>(); + + + public int getRSocketCount() { + return this.rsockets.size(); + } + + public int getFireAndForgetCount(int index) { + return this.rsockets.get(index).getFireAndForgetCount(); + } + + + @Override + public RSocket apply(RSocket rsocket) { + CountingDecorator decorator = new CountingDecorator(rsocket); + this.rsockets.add(decorator); + return decorator; + } + + + private static class CountingDecorator extends RSocketProxy { + + private final AtomicInteger fireAndForget = new AtomicInteger(0); + + + CountingDecorator(RSocket delegate) { + super(delegate); + } + + + public int getFireAndForgetCount() { + return this.fireAndForget.get(); + } + + @Override + public Mono fireAndForget(Payload payload) { + return super.fireAndForget(payload).doOnSuccess(aVoid -> this.fireAndForget.incrementAndGet()); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java new file mode 100644 index 0000000000..e361e330b2 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java @@ -0,0 +1,235 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.time.Duration; +import java.util.Collections; + +import io.rsocket.Payload; +import io.rsocket.RSocket; +import io.rsocket.RSocketFactory; +import io.rsocket.transport.netty.client.TcpClientTransport; +import io.rsocket.transport.netty.server.CloseableChannel; +import io.rsocket.transport.netty.server.TcpServerTransport; +import io.rsocket.util.DefaultPayload; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.ReplayProcessor; +import reactor.test.StepVerifier; + +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.messaging.ReactiveMessageChannel; +import org.springframework.messaging.ReactiveSubscribableChannel; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.support.DefaultReactiveMessageChannel; +import org.springframework.stereotype.Controller; + +import static org.junit.Assert.*; + +/** + * Server-side handling of RSocket requests. + * + * @author Rossen Stoyanchev + */ +public class RSocketClientToServerIntegrationTests { + + private static AnnotationConfigApplicationContext context; + + private static CloseableChannel serverChannel; + + private static FireAndForgetCountingInterceptor interceptor = new FireAndForgetCountingInterceptor(); + + private static RSocket clientRsocket; + + + @BeforeClass + @SuppressWarnings("ConstantConditions") + public static void setupOnce() { + + context = new AnnotationConfigApplicationContext(ServerConfig.class); + + MessagingAcceptor acceptor = new MessagingAcceptor( + context.getBean("rsocketChannel", ReactiveMessageChannel.class)); + + serverChannel = RSocketFactory.receive() + .addServerPlugin(interceptor) + .acceptor(acceptor) + .transport(TcpServerTransport.create("localhost", 7000)) + .start() + .block(); + + clientRsocket = RSocketFactory.connect() + .dataMimeType("text/plain") + .transport(TcpClientTransport.create("localhost", 7000)) + .start() + .block(); + } + + @AfterClass + public static void tearDownOnce() { + clientRsocket.dispose(); + serverChannel.dispose(); + } + + + @Test + public void fireAndForget() { + + Flux.range(1, 3) + .concatMap(i -> clientRsocket.fireAndForget(payload("receive", "Hello " + i))) + .blockLast(); + + StepVerifier.create(context.getBean(ServerController.class).fireForgetPayloads) + .expectNext("Hello 1") + .expectNext("Hello 2") + .expectNext("Hello 3") + .thenCancel() + .verify(Duration.ofSeconds(5)); + + assertEquals(1, interceptor.getRSocketCount()); + assertEquals("Fire and forget requests did not actually complete handling on the server side", + 3, interceptor.getFireAndForgetCount(0)); + } + + @Test + public void echo() { + + Flux result = Flux.range(1, 3).concatMap(i -> + clientRsocket.requestResponse(payload("echo", "Hello " + i)).map(Payload::getDataUtf8)); + + StepVerifier.create(result) + .expectNext("Hello 1") + .expectNext("Hello 2") + .expectNext("Hello 3") + .verifyComplete(); + } + + @Test + public void echoAsync() { + + Flux result = Flux.range(1, 3).concatMap(i -> + clientRsocket.requestResponse(payload("echo-async", "Hello " + i)).map(Payload::getDataUtf8)); + + StepVerifier.create(result) + .expectNext("Hello 1 async") + .expectNext("Hello 2 async") + .expectNext("Hello 3 async") + .verifyComplete(); + } + + @Test + public void echoStream() { + + Flux result = clientRsocket.requestStream(payload("echo-stream", "Hello")) + .map(io.rsocket.Payload::getDataUtf8); + + StepVerifier.create(result) + .expectNext("Hello 0") + .expectNextCount(5) + .expectNext("Hello 6") + .expectNext("Hello 7") + .thenCancel() + .verify(); + } + + @Test + public void echoChannel() { + + Flux payloads = Flux.concat( + Flux.just(payload("echo-channel", "Hello 1")), + Flux.range(2, 9).map(i -> DefaultPayload.create("Hello " + i))); + + Flux result = clientRsocket.requestChannel(payloads).map(Payload::getDataUtf8); + + StepVerifier.create(result) + .expectNext("Hello 1 async") + .expectNextCount(7) + .expectNext("Hello 9 async") + .expectNext("Hello 10 async") + .verifyComplete(); + } + + + private static Payload payload(String destination, String data) { + return DefaultPayload.create(data, destination); + } + + + + @Controller + static class ServerController { + + final ReplayProcessor 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 echoAsync(String payload) { + return Mono.delay(Duration.ofMillis(10)).map(aLong -> payload + " async"); + } + + @MessageMapping("echo-stream") + Flux echoStream(String payload) { + return Flux.interval(Duration.ofMillis(10)).map(aLong -> payload + " " + aLong); + } + + @MessageMapping("echo-channel") + Flux echoChannel(Flux payloads) { + return payloads.delayElements(Duration.ofMillis(10)).map(payload -> payload + " async"); + } + + } + + + @Configuration + static class ServerConfig { + + @Bean + public ServerController controller() { + return new ServerController(); + } + + @Bean + public ReactiveSubscribableChannel rsocketChannel() { + return new DefaultReactiveMessageChannel(); + } + + @Bean + public RSocketMessageHandler rsocketMessageHandler() { + RSocketMessageHandler handler = new RSocketMessageHandler(rsocketChannel()); + handler.setDecoders(Collections.singletonList(StringDecoder.allMimeTypes())); + handler.setEncoders(Collections.singletonList(CharSequenceEncoder.allMimeTypes())); + return handler; + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java new file mode 100644 index 0000000000..aa91d88a09 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java @@ -0,0 +1,305 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; + +import io.rsocket.Payload; +import io.rsocket.RSocket; +import io.rsocket.RSocketFactory; +import io.rsocket.transport.netty.client.TcpClientTransport; +import io.rsocket.transport.netty.server.CloseableChannel; +import io.rsocket.transport.netty.server.TcpServerTransport; +import io.rsocket.util.DefaultPayload; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; +import reactor.core.publisher.ReplayProcessor; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; + +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.messaging.ReactiveMessageChannel; +import org.springframework.messaging.ReactiveSubscribableChannel; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.support.DefaultReactiveMessageChannel; +import org.springframework.stereotype.Controller; + +/** + * Client-side handling of requests initiated from the server side. + * + * @author Rossen Stoyanchev + */ +public class RSocketServerToClientIntegrationTests { + + private static AnnotationConfigApplicationContext context; + + private static CloseableChannel serverChannel; + + private static MessagingAcceptor clientAcceptor; + + + @BeforeClass + @SuppressWarnings("ConstantConditions") + public static void setupOnce() { + + context = new AnnotationConfigApplicationContext(ServerConfig.class); + + clientAcceptor = new MessagingAcceptor( + context.getBean("clientChannel", ReactiveMessageChannel.class)); + + MessagingAcceptor serverAcceptor = new MessagingAcceptor( + context.getBean("serverChannel", ReactiveMessageChannel.class)); + + serverChannel = RSocketFactory.receive() + .acceptor(serverAcceptor) + .transport(TcpServerTransport.create("localhost", 7000)) + .start() + .block(); + } + + @AfterClass + public static void tearDownOnce() { + serverChannel.dispose(); + } + + + @Test + public void echo() { + connectAndVerify("connect.echo"); + } + + @Test + public void echoAsync() { + connectAndVerify("connect.echo-async"); + } + + @Test + public void echoStream() { + connectAndVerify("connect.echo-stream"); + } + + @Test + public void echoChannel() { + connectAndVerify("connect.echo-channel"); + } + + + private static void connectAndVerify(String destination) { + + ServerController serverController = context.getBean(ServerController.class); + serverController.reset(); + + RSocket rsocket = null; + try { + rsocket = RSocketFactory.connect() + .setupPayload(DefaultPayload.create("", destination)) + .dataMimeType("text/plain") + .acceptor(clientAcceptor) + .transport(TcpClientTransport.create("localhost", 7000)) + .start() + .block(); + + serverController.await(Duration.ofSeconds(5)); + } + finally { + if (rsocket != null) { + rsocket.dispose(); + } + } + } + + + @Controller + @SuppressWarnings({"unused", "NullableProblems"}) + static class ServerController { + + // Must be initialized by @Test method... + volatile MonoProcessor result; + + + @MessageMapping("connect.echo") + void echo(RSocket rsocket) { + runTest(() -> { + Flux result = Flux.range(1, 3).concatMap(i -> + rsocket.requestResponse(payload("echo", "Hello " + i)).map(Payload::getDataUtf8)); + + StepVerifier.create(result) + .expectNext("Hello 1") + .expectNext("Hello 2") + .expectNext("Hello 3") + .verifyComplete(); + }); + } + + @MessageMapping("connect.echo-async") + void echoAsync(RSocket rsocket) { + runTest(() -> { + Flux result = Flux.range(1, 3).concatMap(i -> + rsocket.requestResponse(payload("echo-async", "Hello " + i)).map(Payload::getDataUtf8)); + + StepVerifier.create(result) + .expectNext("Hello 1 async") + .expectNext("Hello 2 async") + .expectNext("Hello 3 async") + .verifyComplete(); + }); + } + + @MessageMapping("connect.echo-stream") + void echoStream(RSocket rsocket) { + runTest(() -> { + Flux result = rsocket.requestStream(payload("echo-stream", "Hello")) + .map(io.rsocket.Payload::getDataUtf8); + + StepVerifier.create(result) + .expectNext("Hello 0") + .expectNextCount(5) + .expectNext("Hello 6") + .expectNext("Hello 7") + .thenCancel() + .verify(); + }); + } + + @MessageMapping("connect.echo-channel") + void echoChannel(RSocket rsocket) { + runTest(() -> { + Flux payloads = Flux.concat( + Flux.just(payload("echo-channel", "Hello 1")), + Flux.range(2, 9).map(i -> DefaultPayload.create("Hello " + i))); + + Flux result = rsocket.requestChannel(payloads).map(Payload::getDataUtf8); + + StepVerifier.create(result) + .expectNext("Hello 1 async") + .expectNextCount(7) + .expectNext("Hello 9 async") + .expectNext("Hello 10 async") + .verifyComplete(); + }); + } + + + private void runTest(Runnable testEcho) { + Mono.fromRunnable(testEcho) + .doOnError(ex -> result.onError(ex)) + .doOnSuccess(o -> result.onComplete()) + .subscribeOn(Schedulers.elastic()) + .subscribe(); + } + + private static Payload payload(String destination, String data) { + return DefaultPayload.create(data, destination); + } + + + public void reset() { + this.result = MonoProcessor.create(); + } + + public void await(Duration duration) { + this.result.block(duration); + } + } + + + private static class ClientController { + + final ReplayProcessor 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 echoAsync(String payload) { + return Mono.delay(Duration.ofMillis(10)).map(aLong -> payload + " async"); + } + + @MessageMapping("echo-stream") + Flux echoStream(String payload) { + return Flux.interval(Duration.ofMillis(10)).map(aLong -> payload + " " + aLong); + } + + @MessageMapping("echo-channel") + Flux echoChannel(Flux payloads) { + return payloads.delayElements(Duration.ofMillis(10)).map(payload -> payload + " async"); + } + } + + + @Configuration + static class ServerConfig { + + @Bean + public ClientController clientController() { + return new ClientController(); + } + + @Bean + public ServerController serverController() { + return new ServerController(); + } + + @Bean + public ReactiveSubscribableChannel clientChannel() { + return new DefaultReactiveMessageChannel(); + } + + @Bean + public ReactiveSubscribableChannel serverChannel() { + return new DefaultReactiveMessageChannel(); + } + + @Bean + public RSocketMessageHandler clientMessageHandler() { + List handlers = Collections.singletonList(clientController()); + RSocketMessageHandler handler = new RSocketMessageHandler(clientChannel(), handlers); + addDefaultCodecs(handler); + return handler; + } + + @Bean + public RSocketMessageHandler serverMessageHandler() { + RSocketMessageHandler handler = new RSocketMessageHandler(serverChannel()); + addDefaultCodecs(handler); + return handler; + } + + private void addDefaultCodecs(RSocketMessageHandler handler) { + handler.setDecoders(Collections.singletonList(StringDecoder.allMimeTypes())); + handler.setEncoders(Collections.singletonList(CharSequenceEncoder.allMimeTypes())); + } + } + +} From 8bdd709683e8de24e15787786348c14c7a557dd4 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Mon, 18 Feb 2019 16:41:29 -0500 Subject: [PATCH 11/17] RSocketRequester, RSocketStrategies, PayloadUtils See gh-21987 --- .../HandlerMethodReturnValueHandler.java | 4 +- .../rsocket/DefaultRSocketRequester.java | 267 +++++++++++++++++ .../rsocket/DefaultRSocketStrategies.java | 144 +++++++++ .../messaging/rsocket/MessagingAcceptor.java | 28 +- .../messaging/rsocket/MessagingRSocket.java | 34 ++- .../messaging/rsocket/PayloadUtils.java | 99 +++++++ .../rsocket/RSocketMessageHandler.java | 42 ++- .../RSocketPayloadReturnValueHandler.java | 18 +- .../messaging/rsocket/RSocketRequester.java | 166 +++++++++++ ...ocketRequesterMethodArgumentResolver.java} | 26 +- .../messaging/rsocket/RSocketStrategies.java | 160 ++++++++++ .../rsocket/DefaultRSocketRequesterTests.java | 275 ++++++++++++++++++ ...RSocketClientToServerIntegrationTests.java | 63 ++-- ...RSocketServerToClientIntegrationTests.java | 52 ++-- 14 files changed, 1263 insertions(+), 115 deletions(-) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java rename spring-messaging/src/main/java/org/springframework/messaging/rsocket/{SendingRSocketMethodArgumentResolver.java => RSocketRequesterMethodArgumentResolver.java} (60%) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java index 7c72ba6332..f220d81995 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java @@ -31,8 +31,8 @@ import org.springframework.messaging.Message; */ public interface HandlerMethodReturnValueHandler { - /** Header containing a DataBufferFactory to use. */ - public static final String DATA_BUFFER_FACTORY_HEADER = "dataBufferFactoryHeader"; + /** Header containing a DataBufferFactory for use in return value handling. */ + String DATA_BUFFER_FACTORY_HEADER = "dataBufferFactory"; /** diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java new file mode 100644 index 0000000000..978c353d3e --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java @@ -0,0 +1,267 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; + +import io.rsocket.Payload; +import io.rsocket.RSocket; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; + +/** + * Default, package-private {@link RSocketRequester} implementation. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +final class DefaultRSocketRequester implements RSocketRequester { + + private static final Map EMPTY_HINTS = Collections.emptyMap(); + + + private final RSocket rsocket; + + @Nullable + private final MimeType dataMimeType; + + private final RSocketStrategies strategies; + + private DataBuffer emptyDataBuffer; + + + DefaultRSocketRequester(RSocket rsocket, @Nullable MimeType dataMimeType, RSocketStrategies strategies) { + Assert.notNull(rsocket, "RSocket is required"); + Assert.notNull(strategies, "RSocketStrategies is required"); + this.rsocket = rsocket; + this.dataMimeType = dataMimeType; + this.strategies = strategies; + this.emptyDataBuffer = this.strategies.dataBufferFactory().wrap(new byte[0]); + } + + + @Override + public RSocket rsocket() { + return this.rsocket; + } + + @Override + public RequestSpec route(String route) { + return new DefaultRequestSpec(route); + } + + + private static boolean isVoid(ResolvableType elementType) { + return Void.class.equals(elementType.resolve()) || void.class.equals(elementType.resolve()); + } + + + private class DefaultRequestSpec implements RequestSpec { + + private final String route; + + + DefaultRequestSpec(String route) { + this.route = route; + } + + + @Override + public ResponseSpec data(Object data) { + Assert.notNull(data, "'data' must not be null"); + return toResponseSpec(data, ResolvableType.NONE); + } + + @Override + public > ResponseSpec data(P publisher, Class dataType) { + Assert.notNull(publisher, "'publisher' must not be null"); + Assert.notNull(dataType, "'dataType' must not be null"); + return toResponseSpec(publisher, ResolvableType.forClass(dataType)); + } + + @Override + public > ResponseSpec data(P publisher, ParameterizedTypeReference dataTypeRef) { + Assert.notNull(publisher, "'publisher' must not be null"); + Assert.notNull(dataTypeRef, "'dataTypeRef' must not be null"); + return toResponseSpec(publisher, ResolvableType.forType(dataTypeRef)); + } + + private ResponseSpec toResponseSpec(Object input, ResolvableType dataType) { + ReactiveAdapter adapter = strategies.reactiveAdapterRegistry().getAdapter(input.getClass()); + Publisher publisher; + if (input instanceof Publisher) { + publisher = (Publisher) input; + } + else if (adapter != null) { + publisher = adapter.toPublisher(input); + } + else { + Mono payloadMono = encodeValue(input, ResolvableType.forInstance(input), null) + .map(this::firstPayload) + .switchIfEmpty(emptyPayload()); + return new DefaultResponseSpec(payloadMono); + } + + if (isVoid(dataType) || (adapter != null && adapter.isNoValue())) { + Mono payloadMono = Mono.when(publisher).then(emptyPayload()); + return new DefaultResponseSpec(payloadMono); + } + + Encoder encoder = dataType != ResolvableType.NONE && !Object.class.equals(dataType.resolve()) ? + strategies.encoder(dataType, dataMimeType) : null; + + if (adapter != null && !adapter.isMultiValue()) { + Mono payloadMono = Mono.from(publisher) + .flatMap(value -> encodeValue(value, dataType, encoder)) + .map(this::firstPayload) + .switchIfEmpty(emptyPayload()); + return new DefaultResponseSpec(payloadMono); + } + + Flux payloadFlux = Flux.from(publisher) + .concatMap(value -> encodeValue(value, dataType, encoder)) + .switchOnFirst((signal, inner) -> { + DataBuffer data = signal.get(); + return data != null ? + Flux.concat(Mono.just(firstPayload(data)), inner.skip(1).map(PayloadUtils::asPayload)) : + inner.map(PayloadUtils::asPayload); + }) + .switchIfEmpty(emptyPayload()); + return new DefaultResponseSpec(payloadFlux); + } + + @SuppressWarnings("unchecked") + private Mono encodeValue(T value, ResolvableType valueType, @Nullable Encoder encoder) { + if (encoder == null) { + encoder = strategies.encoder(ResolvableType.forInstance(value), dataMimeType); + } + return DataBufferUtils.join(((Encoder) encoder).encode( + Mono.just(value), strategies.dataBufferFactory(), valueType, dataMimeType, EMPTY_HINTS)); + } + + private Payload firstPayload(DataBuffer data) { + return PayloadUtils.asPayload(getMetadata(), data); + } + + private Mono emptyPayload() { + return Mono.fromCallable(() -> firstPayload(emptyDataBuffer)); + } + + private DataBuffer getMetadata() { + return strategies.dataBufferFactory().wrap(this.route.getBytes(StandardCharsets.UTF_8)); + } + } + + + private class DefaultResponseSpec implements ResponseSpec { + + @Nullable + private final Mono payloadMono; + + @Nullable + private final Flux payloadFlux; + + + DefaultResponseSpec(Mono payloadMono) { + this.payloadMono = payloadMono; + this.payloadFlux = null; + } + + DefaultResponseSpec(Flux payloadFlux) { + this.payloadMono = null; + this.payloadFlux = payloadFlux; + } + + + @Override + public Mono send() { + Assert.notNull(this.payloadMono, "No RSocket interaction model for one-way send with Flux."); + return this.payloadMono.flatMap(rsocket::fireAndForget); + } + + @Override + public Mono retrieveMono(Class dataType) { + return retrieveMono(ResolvableType.forClass(dataType)); + } + + @Override + public Mono retrieveMono(ParameterizedTypeReference dataTypeRef) { + return retrieveMono(ResolvableType.forType(dataTypeRef)); + } + + @Override + public Flux retrieveFlux(Class dataType) { + return retrieveFlux(ResolvableType.forClass(dataType)); + } + + @Override + public Flux retrieveFlux(ParameterizedTypeReference dataTypeRef) { + return retrieveFlux(ResolvableType.forType(dataTypeRef)); + } + + @SuppressWarnings("unchecked") + private Mono retrieveMono(ResolvableType elementType) { + Assert.notNull(this.payloadMono, + "No RSocket interaction model for Flux request to Mono response."); + + Mono payloadMono = this.payloadMono.flatMap(rsocket::requestResponse); + + if (isVoid(elementType)) { + return (Mono) payloadMono.then(); + } + + Decoder decoder = strategies.decoder(elementType, dataMimeType); + return (Mono) decoder.decodeToMono( + payloadMono.map(this::asDataBuffer), elementType, dataMimeType, EMPTY_HINTS); + } + + @SuppressWarnings("unchecked") + private Flux retrieveFlux(ResolvableType elementType) { + + Flux payloadFlux = this.payloadMono != null ? + this.payloadMono.flatMapMany(rsocket::requestStream) : + rsocket.requestChannel(this.payloadFlux); + + if (isVoid(elementType)) { + return payloadFlux.thenMany(Flux.empty()); + } + + Decoder decoder = strategies.decoder(elementType, dataMimeType); + + return payloadFlux.map(this::asDataBuffer).concatMap(dataBuffer -> + (Mono) decoder.decodeToMono(Mono.just(dataBuffer), elementType, dataMimeType, EMPTY_HINTS)); + } + + private DataBuffer asDataBuffer(Payload payload) { + return PayloadUtils.asDataBuffer(payload, strategies.dataBufferFactory()); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java new file mode 100644 index 0000000000..271e06e285 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java @@ -0,0 +1,144 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import io.netty.buffer.PooledByteBufAllocator; + +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.lang.Nullable; + +/** + * Default, package-private {@link RSocketStrategies} implementation. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +final class DefaultRSocketStrategies implements RSocketStrategies { + + private final List> encoders; + + private final List> decoders; + + private final ReactiveAdapterRegistry adapterRegistry; + + private final DataBufferFactory bufferFactory; + + + private DefaultRSocketStrategies( + List> encoders, List> decoders, + ReactiveAdapterRegistry adapterRegistry, DataBufferFactory bufferFactory) { + + this.encoders = Collections.unmodifiableList(encoders); + this.decoders = Collections.unmodifiableList(decoders); + this.adapterRegistry = adapterRegistry; + this.bufferFactory = bufferFactory; + } + + + @Override + public List> encoders() { + return this.encoders; + } + + @Override + public List> decoders() { + return this.decoders; + } + + @Override + public ReactiveAdapterRegistry reactiveAdapterRegistry() { + return this.adapterRegistry; + } + + @Override + public DataBufferFactory dataBufferFactory() { + return this.bufferFactory; + } + + + /** + * Default RSocketStrategies.Builder implementation. + */ + static class DefaultRSocketStrategiesBuilder implements RSocketStrategies.Builder { + + private final List> encoders = new ArrayList<>(); + + private final List> decoders = new ArrayList<>(); + + @Nullable + private ReactiveAdapterRegistry adapterRegistry; + + @Nullable + private DataBufferFactory bufferFactory; + + + @Override + public Builder encoder(Encoder... encoders) { + this.encoders.addAll(Arrays.asList(encoders)); + return this; + } + + @Override + public Builder decoder(Decoder... decoder) { + this.decoders.addAll(Arrays.asList(decoder)); + return this; + } + + @Override + public Builder encoders(Consumer>> consumer) { + consumer.accept(this.encoders); + return this; + } + + @Override + public Builder decoders(Consumer>> consumer) { + consumer.accept(this.decoders); + return this; + } + + @Override + public Builder reactiveAdapterStrategy(ReactiveAdapterRegistry registry) { + this.adapterRegistry = registry; + return this; + } + + @Override + public Builder dataBufferFactory(DataBufferFactory bufferFactory) { + this.bufferFactory = bufferFactory; + return this; + } + + @Override + public RSocketStrategies build() { + return new DefaultRSocketStrategies(this.encoders, this.decoders, + this.adapterRegistry != null ? + this.adapterRegistry : ReactiveAdapterRegistry.getSharedInstance(), + this.bufferFactory != null ? this.bufferFactory : + new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT)); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java index dc9c070b4d..2cc7212834 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java @@ -18,13 +18,11 @@ package org.springframework.messaging.rsocket; import java.util.function.Function; import java.util.function.Predicate; -import io.netty.buffer.PooledByteBufAllocator; import io.rsocket.ConnectionSetupPayload; import io.rsocket.RSocket; import io.rsocket.SocketAcceptor; import reactor.core.publisher.Mono; -import org.springframework.core.io.buffer.NettyDataBufferFactory; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.ReactiveMessageChannel; @@ -47,7 +45,7 @@ public final class MessagingAcceptor implements SocketAcceptor, FunctionBy default this is initialized with the allocator instance - * {@link PooledByteBufAllocator#DEFAULT}. - * @param bufferFactory the bufferFactory to use - */ - public void setNettyDataBufferFactory(NettyDataBufferFactory bufferFactory) { - Assert.notNull(bufferFactory, "DataBufferFactory is required"); - this.bufferFactory = bufferFactory; - } - @Override public Mono accept(ConnectionSetupPayload setupPayload, RSocket sendingRSocket) { @@ -108,7 +106,7 @@ public final class MessagingAcceptor implements SocketAcceptor, Function payloadDataBuffers = payloads - .map(payload -> this.bufferFactory.wrap(payload.retain().sliceData())) + Flux payloadDataBuffers = payloads + .map(payload -> PayloadUtils.asDataBuffer(payload, this.strategies.dataBufferFactory())) .doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release); MonoProcessor> replyMono = MonoProcessor.create(); @@ -146,9 +146,11 @@ class MessagingRSocket implements RSocket { headers.setContentType(this.dataMimeType); } - headers.setHeader(SendingRSocketMethodArgumentResolver.SENDING_RSOCKET_HEADER, this.sendingRSocket); + headers.setHeader(RSocketRequesterMethodArgumentResolver.RSOCKET_REQUESTER_HEADER, this.requester); headers.setHeader(RSocketPayloadReturnValueHandler.RESPONSE_HEADER, replyMono); - headers.setHeader(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER, this.bufferFactory); + + DataBufferFactory bufferFactory = this.strategies.dataBufferFactory(); + headers.setHeader(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER, bufferFactory); return headers.getMessageHeaders(); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java new file mode 100644 index 0000000000..98fd9ae8c1 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java @@ -0,0 +1,99 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.nio.ByteBuffer; + +import io.netty.buffer.ByteBuf; +import io.rsocket.Payload; +import io.rsocket.util.ByteBufPayload; +import io.rsocket.util.DefaultPayload; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBuffer; +import org.springframework.core.io.buffer.NettyDataBuffer; +import org.springframework.core.io.buffer.NettyDataBufferFactory; + +/** + * Static utility methods to create {@link Payload} from {@link DataBuffer}s + * and vice versa. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +abstract class PayloadUtils { + + /** + * Return the Payload data wrapped as DataBuffer. If the bufferFactory is + * {@link NettyDataBufferFactory} the payload retained and sliced. + * @param payload the input payload + * @param bufferFactory the BufferFactory to use to wrap + * @return the DataBuffer wrapper + */ + public static DataBuffer asDataBuffer(Payload payload, DataBufferFactory bufferFactory) { + if (bufferFactory instanceof NettyDataBufferFactory) { + return ((NettyDataBufferFactory) bufferFactory).wrap(payload.retain().sliceData()); + } + else { + return bufferFactory.wrap(payload.getData()); + } + } + + /** + * Create a Payload from the given metadata and data. + * @param metadata the metadata part for the payload + * @param data the data part for the payload + * @return the created Payload + */ + public static Payload asPayload(DataBuffer metadata, DataBuffer data) { + if (metadata instanceof NettyDataBuffer && data instanceof NettyDataBuffer) { + return ByteBufPayload.create(getByteBuf(data), getByteBuf(metadata)); + } + else if (metadata instanceof DefaultDataBuffer && data instanceof DefaultDataBuffer) { + return DefaultPayload.create(getByteBuffer(data), getByteBuffer(metadata)); + } + else { + return DefaultPayload.create(data.asByteBuffer(), metadata.asByteBuffer()); + } + } + + /** + * Create a Payload from the given data. + * @param data the data part for the payload + * @return the created Payload + */ + public static Payload asPayload(DataBuffer data) { + if (data instanceof NettyDataBuffer) { + return ByteBufPayload.create(getByteBuf(data)); + } + else if (data instanceof DefaultDataBuffer) { + return DefaultPayload.create(getByteBuffer(data)); + } + else { + return DefaultPayload.create(data.asByteBuffer()); + } + } + + private static ByteBuf getByteBuf(DataBuffer dataBuffer) { + return ((NettyDataBuffer) dataBuffer).getNativeBuffer(); + } + + private static + ByteBuffer getByteBuffer(DataBuffer dataBuffer) { + return ((DefaultDataBuffer) dataBuffer).getNativeBuffer(); + } +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java index 1485f55035..a6f0030329 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java @@ -18,6 +18,7 @@ package org.springframework.messaging.rsocket; import java.util.ArrayList; import java.util.List; +import org.springframework.core.codec.Decoder; import org.springframework.core.codec.Encoder; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; @@ -25,6 +26,7 @@ import org.springframework.messaging.MessageDeliveryException; import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.annotation.support.reactive.MessageMappingMessageHandler; import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** @@ -41,6 +43,9 @@ public class RSocketMessageHandler extends MessageMappingMessageHandler { private final List> encoders = new ArrayList<>(); + @Nullable + private RSocketStrategies rsocketStrategies; + public RSocketMessageHandler(ReactiveSubscribableChannel inboundChannel) { super(inboundChannel); @@ -55,6 +60,7 @@ public class RSocketMessageHandler extends MessageMappingMessageHandler { } + /** * Configure the encoders to use for encoding handler method return values. */ @@ -69,10 +75,44 @@ public class RSocketMessageHandler extends MessageMappingMessageHandler { return this.encoders; } + /** + * Provide configuration in the form of {@link RSocketStrategies}. This is + * an alternative to using {@link #setEncoders(List)}, + * {@link #setDecoders(List)}, and others directly. It is convenient when + * you also need to configure an {@link RSocketRequester} in which case + * the strategies can be configured once and used in multiple places. + * @param rsocketStrategies the strategies to use + */ + public void setRSocketStrategies(RSocketStrategies rsocketStrategies) { + Assert.notNull(rsocketStrategies, "RSocketStrategies must not be null"); + this.rsocketStrategies = rsocketStrategies; + setDecoders(rsocketStrategies.decoders()); + setEncoders(rsocketStrategies.encoders()); + setReactiveAdapterRegistry(rsocketStrategies.reactiveAdapterRegistry()); + } + + /** + * Return the {@code RSocketStrategies} instance provided via + * {@link #setRSocketStrategies rsocketStrategies}, or + * otherwise a new instance populated with the configured + * {@link #setEncoders(List) encoders}, {@link #setDecoders(List) decoders} + * and others. + */ + public RSocketStrategies getRSocketStrategies() { + if (this.rsocketStrategies != null) { + return this.rsocketStrategies; + } + return RSocketStrategies.builder() + .decoder(getDecoders().toArray(new Decoder[0])) + .encoder(getEncoders().toArray(new Encoder[0])) + .reactiveAdapterStrategy(getReactiveAdapterRegistry()) + .build(); + } + @Override public void afterPropertiesSet() { - getArgumentResolverConfigurer().addCustomResolver(new SendingRSocketMethodArgumentResolver()); + getArgumentResolverConfigurer().addCustomResolver(new RSocketRequesterMethodArgumentResolver()); super.afterPropertiesSet(); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java index 9a7fa67775..83521683da 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java @@ -18,8 +18,6 @@ package org.springframework.messaging.rsocket; import java.util.List; import io.rsocket.Payload; -import io.rsocket.util.ByteBufPayload; -import io.rsocket.util.DefaultPayload; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.MonoProcessor; @@ -28,8 +26,6 @@ import org.springframework.core.MethodParameter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.codec.Encoder; import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DefaultDataBuffer; -import org.springframework.core.io.buffer.NettyDataBuffer; import org.springframework.messaging.Message; import org.springframework.messaging.handler.invocation.reactive.AbstractEncoderMethodReturnValueHandler; import org.springframework.util.Assert; @@ -67,22 +63,10 @@ public class RSocketPayloadReturnValueHandler extends AbstractEncoderMethodRetur Assert.isInstanceOf(MonoProcessor.class, headerValue, "Expected MonoProcessor"); MonoProcessor> monoProcessor = (MonoProcessor>) headerValue; - monoProcessor.onNext(encodedContent.map(this::toPayload)); + monoProcessor.onNext(encodedContent.map(PayloadUtils::asPayload)); monoProcessor.onComplete(); return Mono.empty(); } - private Payload toPayload(DataBuffer dataBuffer) { - if (dataBuffer instanceof NettyDataBuffer) { - return ByteBufPayload.create(((NettyDataBuffer) dataBuffer).getNativeBuffer()); - } - else if (dataBuffer instanceof DefaultDataBuffer) { - return DefaultPayload.create(((DefaultDataBuffer) dataBuffer).getNativeBuffer()); - } - else { - return DefaultPayload.create(dataBuffer.asByteBuffer()); - } - } - } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java new file mode 100644 index 0000000000..968f0e693d --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java @@ -0,0 +1,166 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import io.rsocket.RSocket; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.lang.Nullable; +import org.springframework.util.MimeType; + +/** + * A thin wrapper around a sending {@link RSocket} with a fluent API accepting + * and returning higher level Objects for input and for output, along with + * methods specify routing and other metadata. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface RSocketRequester { + + + /** + * Return the underlying RSocket used to make requests. + */ + RSocket rsocket(); + + + /** + * Create a new {@code RSocketRequester} from the given {@link RSocket} and + * strategies for encoding and decoding request and response payloads. + * @param rsocket the sending RSocket to use + * @param dataMimeType the MimeType for data (from the SETUP frame) + * @param strategies encoders, decoders, and others + * @return the created RSocketRequester wrapper + */ + static RSocketRequester create(RSocket rsocket, @Nullable MimeType dataMimeType, RSocketStrategies strategies) { + return new DefaultRSocketRequester(rsocket, dataMimeType, strategies); + } + + + // For now we treat metadata as a simple string that is the route. + // This will change after the resolution of: + // https://github.com/rsocket/rsocket-java/issues/568 + + /** + * Entry point to prepare a new request to the given route. + * + *

For requestChannel interactions, i.e. Flux-to-Flux the metadata is + * attached to the first request payload. + * + * @param route the routing destination + * @return a spec for further defining and executing the reuqest + */ + RequestSpec route(String route); + + + /** + * Contract to provide input data for an RSocket request. + */ + interface RequestSpec { + + /** + * Provide request payload data. The given Object may be a synchronous + * value, or a {@link Publisher} of values, or another async type that's + * registered in the configured {@link ReactiveAdapterRegistry}. + *

For multivalued Publishers, prefer using + * {@link #data(Publisher, Class)} or + * {@link #data(Publisher, ParameterizedTypeReference)} since that makes + * it possible to find a compatible {@code Encoder} up front vs looking + * it up on every value. + * @param data the Object to use for payload data + * @return spec for declaring the expected response + */ + ResponseSpec data(Object data); + + /** + * Provide a {@link Publisher} of value(s) for request payload data. + *

Publisher semantics determined through the configured + * {@link ReactiveAdapterRegistry} influence which of the 4 RSocket + * interactions to use. Publishers with unknown semantics are treated + * as multivalued. Consider registering a reactive type adapter, or + * passing {@code Mono.from(publisher)}. + *

If the publisher completes empty, possibly {@code Publisher}, + * the request will have an empty data Payload. + * @param publisher source of payload data value(s) + * @param dataType the type of values to be published + * @param the type of element values + * @param

the type of publisher + * @return spec for declaring the expected response + */ + > ResponseSpec data(P publisher, Class dataType); + + /** + * Variant of {@link #data(Publisher, Class)} for when the dataType has + * to have a generic type. See {@link ParameterizedTypeReference}. + */ + > ResponseSpec data(P publisher, ParameterizedTypeReference dataTypeRef); + } + + + /** + * Contract to declare the expected RSocket response. + */ + interface ResponseSpec { + + /** + * Perform {@link RSocket#fireAndForget fireAndForget}. + */ + Mono send(); + + /** + * Perform {@link RSocket#requestResponse requestResponse}. If the + * expected data type is {@code Void.class}, the returned {@code Mono} + * will complete after all data is consumed. + *

Note: Use of this method will raise an error if + * the request payload is a multivalued {@link Publisher} as + * determined through the configured {@link ReactiveAdapterRegistry}. + * @param dataType the expected data type for the response + * @param parameter for the expected data type + * @return the decoded response + */ + Mono retrieveMono(Class dataType); + + /** + * Variant of {@link #retrieveMono(Class)} for when the dataType has + * to have a generic type. See {@link ParameterizedTypeReference}. + */ + Mono retrieveMono(ParameterizedTypeReference dataTypeRef); + + /** + * Perform {@link RSocket#requestStream requestStream} or + * {@link RSocket#requestChannel requestChannel} depending on whether + * the request input consists of a single or multiple payloads. + * If the expected data type is {@code Void.class}, the returned + * {@code Flux} will complete after all data is consumed. + * @param dataType the expected type for values in the response + * @param parameterize the expected type of values + * @return the decoded response + */ + Flux retrieveFlux(Class dataType); + + /** + * Variant of {@link #retrieveFlux(Class)} for when the dataType has + * to have a generic type. See {@link ParameterizedTypeReference}. + */ + Flux retrieveFlux(ParameterizedTypeReference dataTypeRef); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/SendingRSocketMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequesterMethodArgumentResolver.java similarity index 60% rename from spring-messaging/src/main/java/org/springframework/messaging/rsocket/SendingRSocketMethodArgumentResolver.java rename to spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequesterMethodArgumentResolver.java index de791a462d..36fc075e46 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/SendingRSocketMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequesterMethodArgumentResolver.java @@ -31,28 +31,40 @@ import org.springframework.util.Assert; * @author Rossen Stoyanchev * @since 5.2 */ -public class SendingRSocketMethodArgumentResolver implements HandlerMethodArgumentResolver { +public class RSocketRequesterMethodArgumentResolver implements HandlerMethodArgumentResolver { /** * Message header name that is expected to have the {@link RSocket} to * initiate new interactions to the remote peer with. */ - public static final String SENDING_RSOCKET_HEADER = "sendingRSocket"; + public static final String RSOCKET_REQUESTER_HEADER = "rsocketRequester"; @Override public boolean supportsParameter(MethodParameter parameter) { - return RSocket.class.isAssignableFrom(parameter.getParameterType()); + Class type = parameter.getParameterType(); + return RSocketRequester.class.equals(type) || RSocket.class.isAssignableFrom(type); } @Override public Mono resolveArgument(MethodParameter parameter, Message message) { - Object headerValue = message.getHeaders().get(SENDING_RSOCKET_HEADER); - Assert.notNull(headerValue, "Missing '" + SENDING_RSOCKET_HEADER + "'"); - Assert.isInstanceOf(RSocket.class, headerValue, "Expected header value of type io.rsocket.RSocket"); + Object headerValue = message.getHeaders().get(RSOCKET_REQUESTER_HEADER); + Assert.notNull(headerValue, "Missing '" + RSOCKET_REQUESTER_HEADER + "'"); + Assert.isInstanceOf(RSocketRequester.class, headerValue, "Expected header value of type RSocketRequester"); - return Mono.just(headerValue); + RSocketRequester requester = (RSocketRequester) headerValue; + + Class type = parameter.getParameterType(); + if (RSocketRequester.class.equals(type)) { + return Mono.just(requester); + } + else if (RSocket.class.isAssignableFrom(type)) { + return Mono.just(requester.rsocket()); + } + else { + return Mono.error(new IllegalArgumentException("Unexpected parameter type: " + parameter)); + } } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java new file mode 100644 index 0000000000..a2a6d64951 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java @@ -0,0 +1,160 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.util.List; +import java.util.function.Consumer; + +import io.netty.buffer.PooledByteBufAllocator; + +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.util.MimeType; + +/** + * Access to strategies for use by RSocket requester and responder components. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface RSocketStrategies { + + /** + * Return the configured {@link Builder#encoder(Encoder[]) encoders}. + * @see #encoder(ResolvableType, MimeType) + */ + List> encoders(); + + /** + * Find a compatible Encoder for the given element type. + * @param elementType the element type to match + * @param mimeType the MimeType to match + * @param for casting the Encoder to the expected element type + * @return the matching Encoder + * @throws IllegalArgumentException if no matching Encoder is found + */ + @SuppressWarnings("unchecked") + default Encoder encoder(ResolvableType elementType, @Nullable MimeType mimeType) { + for (Encoder encoder : encoders()) { + if (encoder.canEncode(elementType, mimeType)) { + return (Encoder) encoder; + } + } + throw new IllegalArgumentException("No encoder for " + elementType); + } + + /** + * Return the configured {@link Builder#decoder(Decoder[]) decoders}. + * @see #decoder(ResolvableType, MimeType) + */ + List> decoders(); + + /** + * Find a compatible Decoder for the given element type. + * @param elementType the element type to match + * @param mimeType the MimeType to match + * @param for casting the Decoder to the expected element type + * @return the matching Decoder + * @throws IllegalArgumentException if no matching Decoder is found + */ + @SuppressWarnings("unchecked") + default Decoder decoder(ResolvableType elementType, @Nullable MimeType mimeType) { + for (Decoder decoder : decoders()) { + if (decoder.canDecode(elementType, mimeType)) { + return (Decoder) decoder; + } + } + throw new IllegalArgumentException("No decoder for " + elementType); + } + + /** + * Return the configured + * {@link Builder#reactiveAdapterStrategy(ReactiveAdapterRegistry) reactiveAdapterRegistry}. + */ + ReactiveAdapterRegistry reactiveAdapterRegistry(); + + /** + * Return the configured + * {@link Builder#dataBufferFactory(DataBufferFactory) dataBufferFactory}. + */ + DataBufferFactory dataBufferFactory(); + + + /** + * Return a builder to build a new {@code RSocketStrategies} instance. + */ + static Builder builder() { + return new DefaultRSocketStrategies.DefaultRSocketStrategiesBuilder(); + } + + + /** + * The builder options for creating {@code RSocketStrategies}. + */ + interface Builder { + + /** + * Add encoders to use for serializing Objects. + *

By default this is empty. + */ + Builder encoder(Encoder... encoder); + + /** + * Add decoders for de-serializing Objects. + *

By default this is empty. + */ + Builder decoder(Decoder... decoder); + + /** + * Access and manipulate the list of configured {@link #encoder encoders}. + */ + Builder encoders(Consumer>> consumer); + + /** + * Access and manipulate the list of configured {@link #encoder decoders}. + */ + Builder decoders(Consumer>> consumer); + + /** + * Configure the registry for reactive type support. This can be used to + * to adapt to, and/or determine the semantics of a given + * {@link org.reactivestreams.Publisher Publisher}. + *

By default this {@link ReactiveAdapterRegistry#sharedInstance}. + * @param registry the registry to use + */ + Builder reactiveAdapterStrategy(ReactiveAdapterRegistry registry); + + /** + * Configure the DataBufferFactory to use for the allocation of buffers + * when creating or responding requests. + *

By default this is an instance of + * {@link org.springframework.core.io.buffer.NettyDataBufferFactory + * NettyDataBufferFactory} with {@link PooledByteBufAllocator#DEFAULT}. + * @param bufferFactory the buffer factory to use + */ + Builder dataBufferFactory(DataBufferFactory bufferFactory); + + /** + * Builder the {@code RSocketStrategies} instance. + */ + RSocketStrategies build(); + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java new file mode 100644 index 0000000000..a11ff90237 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java @@ -0,0 +1,275 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +import io.reactivex.Completable; +import io.reactivex.Observable; +import io.reactivex.Single; +import io.rsocket.AbstractRSocket; +import io.rsocket.Payload; +import org.junit.Before; +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.messaging.rsocket.RSocketRequester.RequestSpec; +import org.springframework.messaging.rsocket.RSocketRequester.ResponseSpec; +import org.springframework.util.MimeTypeUtils; + +import static java.util.concurrent.TimeUnit.*; +import static org.junit.Assert.*; + +/** + * Unit tests for {@link DefaultRSocketRequester}. + * + * @author Rossen Stoyanchev + */ +public class DefaultRSocketRequesterTests { + + private static final Duration MILLIS_10 = Duration.ofMillis(10); + + + private TestRSocket rsocket; + + private RSocketRequester requester; + + private final DefaultDataBufferFactory bufferFactory = new DefaultDataBufferFactory(); + + + @Before + public void setUp() { + RSocketStrategies strategies = RSocketStrategies.builder() + .decoder(StringDecoder.allMimeTypes()) + .encoder(CharSequenceEncoder.allMimeTypes()) + .build(); + this.rsocket = new TestRSocket(); + this.requester = RSocketRequester.create(rsocket, MimeTypeUtils.TEXT_PLAIN, strategies); + } + + + @Test + public void singlePayload() { + + // data(Object) + testSinglePayload(spec -> spec.data("bodyA"), "bodyA"); + testSinglePayload(spec -> spec.data(Mono.delay(MILLIS_10).map(l -> "bodyA")), "bodyA"); + testSinglePayload(spec -> spec.data(Mono.delay(MILLIS_10).then()), ""); + testSinglePayload(spec -> spec.data(Single.timer(10, MILLISECONDS).map(l -> "bodyA")), "bodyA"); + testSinglePayload(spec -> spec.data(Completable.complete()), ""); + + // data(Publisher, Class) + testSinglePayload(spec -> spec.data(Mono.delay(MILLIS_10).map(l -> "bodyA"), String.class), "bodyA"); + testSinglePayload(spec -> spec.data(Mono.delay(MILLIS_10).map(l -> "bodyA"), Object.class), "bodyA"); + testSinglePayload(spec -> spec.data(Mono.delay(MILLIS_10).then(), Void.class), ""); + } + + private void testSinglePayload(Function mapper, String expectedValue) { + mapper.apply(this.requester.route("toA")).send().block(Duration.ofSeconds(5)); + + assertEquals("fireAndForget", this.rsocket.getSavedMethodName()); + assertEquals("toA", this.rsocket.getSavedPayload().getMetadataUtf8()); + assertEquals(expectedValue, this.rsocket.getSavedPayload().getDataUtf8()); + } + + @Test + public void multiPayload() { + String[] values = new String[] {"bodyA", "bodyB", "bodyC"}; + Flux stringFlux = Flux.fromArray(values).delayElements(MILLIS_10); + + // data(Object) + testMultiPayload(spec -> spec.data(stringFlux), values); + testMultiPayload(spec -> spec.data(Flux.empty()), ""); + testMultiPayload(spec -> spec.data(Observable.fromArray(values).delay(10, MILLISECONDS)), values); + testMultiPayload(spec -> spec.data(Observable.empty()), ""); + + // data(Publisher, Class) + testMultiPayload(spec -> spec.data(stringFlux, String.class), values); + testMultiPayload(spec -> spec.data(stringFlux.cast(Object.class), Object.class), values); + } + + private void testMultiPayload(Function mapper, String... expectedValues) { + this.rsocket.reset(); + mapper.apply(this.requester.route("toA")).retrieveFlux(String.class).blockLast(Duration.ofSeconds(5)); + + assertEquals("requestChannel", this.rsocket.getSavedMethodName()); + List payloads = this.rsocket.getSavedPayloadFlux().collectList().block(Duration.ofSeconds(5)); + assertNotNull(payloads); + + if (Arrays.equals(new String[] {""}, expectedValues)) { + assertEquals(1, payloads.size()); + assertEquals("toA", payloads.get(0).getMetadataUtf8()); + assertEquals("", payloads.get(0).getDataUtf8()); + } + else { + assertArrayEquals(new String[] {"toA", "", ""}, + payloads.stream().map(Payload::getMetadataUtf8).toArray(String[]::new)); + assertArrayEquals(expectedValues, + payloads.stream().map(Payload::getDataUtf8).toArray(String[]::new)); + } + } + + @Test + public void send() { + String value = "bodyA"; + this.requester.route("toA").data(value).send().block(Duration.ofSeconds(5)); + + assertEquals("fireAndForget", this.rsocket.getSavedMethodName()); + assertEquals("toA", this.rsocket.getSavedPayload().getMetadataUtf8()); + assertEquals("bodyA", this.rsocket.getSavedPayload().getDataUtf8()); + } + + @Test + public void retrieveMono() { + String value = "bodyA"; + this.rsocket.setPayloadMonoToReturn(Mono.delay(MILLIS_10).thenReturn(toPayload(value))); + Mono response = this.requester.route("").data("").retrieveMono(String.class); + + StepVerifier.create(response).expectNext(value).expectComplete().verify(Duration.ofSeconds(5)); + assertEquals("requestResponse", this.rsocket.getSavedMethodName()); + } + + @Test + public void retrieveMonoVoid() { + AtomicBoolean consumed = new AtomicBoolean(false); + Mono mono = Mono.delay(MILLIS_10).thenReturn(toPayload("bodyA")).doOnSuccess(p -> consumed.set(true)); + this.rsocket.setPayloadMonoToReturn(mono); + this.requester.route("").data("").retrieveMono(Void.class).block(Duration.ofSeconds(5)); + + assertTrue(consumed.get()); + assertEquals("requestResponse", this.rsocket.getSavedMethodName()); + } + + @Test + public void retrieveFlux() { + String[] values = new String[] {"bodyA", "bodyB", "bodyC"}; + this.rsocket.setPayloadFluxToReturn(Flux.fromArray(values).delayElements(MILLIS_10).map(this::toPayload)); + Flux response = this.requester.route("").data("").retrieveFlux(String.class); + + StepVerifier.create(response).expectNext(values).expectComplete().verify(Duration.ofSeconds(5)); + assertEquals("requestStream", this.rsocket.getSavedMethodName()); + } + + @Test + public void retrieveFluxVoid() { + AtomicBoolean consumed = new AtomicBoolean(false); + Flux flux = Flux.just("bodyA", "bodyB") + .delayElements(MILLIS_10).map(this::toPayload).doOnComplete(() -> consumed.set(true)); + this.rsocket.setPayloadFluxToReturn(flux); + this.requester.route("").data("").retrieveFlux(Void.class).blockLast(Duration.ofSeconds(5)); + + assertTrue(consumed.get()); + assertEquals("requestStream", this.rsocket.getSavedMethodName()); + } + + @Test + public void rejectFluxToMono() { + try { + this.requester.route("").data(Flux.just("a", "b")).retrieveMono(String.class); + fail(); + } + catch (IllegalArgumentException ex) { + assertEquals("No RSocket interaction model for Flux request to Mono response.", ex.getMessage()); + } + } + + private Payload toPayload(String value) { + return PayloadUtils.asPayload(bufferFactory.wrap(value.getBytes(StandardCharsets.UTF_8))); + } + + + private static class TestRSocket extends AbstractRSocket { + + private Mono payloadMonoToReturn = Mono.empty(); + private Flux payloadFluxToReturn = Flux.empty(); + + @Nullable private volatile String savedMethodName; + @Nullable private volatile Payload savedPayload; + @Nullable private volatile Flux savedPayloadFlux; + + + void setPayloadMonoToReturn(Mono payloadMonoToReturn) { + this.payloadMonoToReturn = payloadMonoToReturn; + } + + void setPayloadFluxToReturn(Flux payloadFluxToReturn) { + this.payloadFluxToReturn = payloadFluxToReturn; + } + + @Nullable + String getSavedMethodName() { + return this.savedMethodName; + } + + @Nullable + Payload getSavedPayload() { + return this.savedPayload; + } + + @Nullable + Flux getSavedPayloadFlux() { + return this.savedPayloadFlux; + } + + public void reset() { + this.savedMethodName = null; + this.savedPayload = null; + this.savedPayloadFlux = null; + } + + + @Override + public Mono fireAndForget(Payload payload) { + this.savedMethodName = "fireAndForget"; + this.savedPayload = payload; + return Mono.empty(); + } + + @Override + public Mono requestResponse(Payload payload) { + this.savedMethodName = "requestResponse"; + this.savedPayload = payload; + return this.payloadMonoToReturn; + } + + @Override + public Flux requestStream(Payload payload) { + this.savedMethodName = "requestStream"; + this.savedPayload = payload; + return this.payloadFluxToReturn; + } + + @Override + public Flux requestChannel(Publisher publisher) { + this.savedMethodName = "requestChannel"; + this.savedPayloadFlux = Flux.from(publisher); + return this.payloadFluxToReturn; + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java index e361e330b2..39c27d8cc2 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java @@ -16,15 +16,12 @@ package org.springframework.messaging.rsocket; import java.time.Duration; -import java.util.Collections; -import io.rsocket.Payload; import io.rsocket.RSocket; import io.rsocket.RSocketFactory; import io.rsocket.transport.netty.client.TcpClientTransport; import io.rsocket.transport.netty.server.CloseableChannel; import io.rsocket.transport.netty.server.TcpServerTransport; -import io.rsocket.util.DefaultPayload; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -43,6 +40,7 @@ import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.support.DefaultReactiveMessageChannel; import org.springframework.stereotype.Controller; +import org.springframework.util.MimeTypeUtils; import static org.junit.Assert.*; @@ -55,11 +53,13 @@ public class RSocketClientToServerIntegrationTests { private static AnnotationConfigApplicationContext context; - private static CloseableChannel serverChannel; + private static CloseableChannel server; private static FireAndForgetCountingInterceptor interceptor = new FireAndForgetCountingInterceptor(); - private static RSocket clientRsocket; + private static RSocket client; + + private static RSocketRequester requester; @BeforeClass @@ -68,27 +68,30 @@ public class RSocketClientToServerIntegrationTests { context = new AnnotationConfigApplicationContext(ServerConfig.class); - MessagingAcceptor acceptor = new MessagingAcceptor( - context.getBean("rsocketChannel", ReactiveMessageChannel.class)); + ReactiveMessageChannel messageChannel = context.getBean(ReactiveMessageChannel.class); + RSocketStrategies rsocketStrategies = context.getBean(RSocketStrategies.class); - serverChannel = RSocketFactory.receive() + server = RSocketFactory.receive() .addServerPlugin(interceptor) - .acceptor(acceptor) + .acceptor(new MessagingAcceptor(messageChannel)) .transport(TcpServerTransport.create("localhost", 7000)) .start() .block(); - clientRsocket = RSocketFactory.connect() - .dataMimeType("text/plain") + client = RSocketFactory.connect() + .dataMimeType(MimeTypeUtils.TEXT_PLAIN_VALUE) .transport(TcpClientTransport.create("localhost", 7000)) .start() .block(); + + requester = RSocketRequester.create( + client, MimeTypeUtils.TEXT_PLAIN, rsocketStrategies); } @AfterClass public static void tearDownOnce() { - clientRsocket.dispose(); - serverChannel.dispose(); + client.dispose(); + server.dispose(); } @@ -96,7 +99,7 @@ public class RSocketClientToServerIntegrationTests { public void fireAndForget() { Flux.range(1, 3) - .concatMap(i -> clientRsocket.fireAndForget(payload("receive", "Hello " + i))) + .concatMap(i -> requester.route("receive").data("Hello " + i).send()) .blockLast(); StepVerifier.create(context.getBean(ServerController.class).fireForgetPayloads) @@ -115,7 +118,7 @@ public class RSocketClientToServerIntegrationTests { public void echo() { Flux result = Flux.range(1, 3).concatMap(i -> - clientRsocket.requestResponse(payload("echo", "Hello " + i)).map(Payload::getDataUtf8)); + requester.route("echo").data("Hello " + i).retrieveMono(String.class)); StepVerifier.create(result) .expectNext("Hello 1") @@ -128,7 +131,7 @@ public class RSocketClientToServerIntegrationTests { public void echoAsync() { Flux result = Flux.range(1, 3).concatMap(i -> - clientRsocket.requestResponse(payload("echo-async", "Hello " + i)).map(Payload::getDataUtf8)); + requester.route("echo-async").data("Hello " + i).retrieveMono(String.class)); StepVerifier.create(result) .expectNext("Hello 1 async") @@ -140,8 +143,7 @@ public class RSocketClientToServerIntegrationTests { @Test public void echoStream() { - Flux result = clientRsocket.requestStream(payload("echo-stream", "Hello")) - .map(io.rsocket.Payload::getDataUtf8); + Flux result = requester.route("echo-stream").data("Hello").retrieveFlux(String.class); StepVerifier.create(result) .expectNext("Hello 0") @@ -155,11 +157,9 @@ public class RSocketClientToServerIntegrationTests { @Test public void echoChannel() { - Flux payloads = Flux.concat( - Flux.just(payload("echo-channel", "Hello 1")), - Flux.range(2, 9).map(i -> DefaultPayload.create("Hello " + i))); - - Flux result = clientRsocket.requestChannel(payloads).map(Payload::getDataUtf8); + Flux result = requester.route("echo-channel") + .data(Flux.range(1, 10).map(i -> "Hello " + i), String.class) + .retrieveFlux(String.class); StepVerifier.create(result) .expectNext("Hello 1 async") @@ -170,12 +170,6 @@ public class RSocketClientToServerIntegrationTests { } - private static Payload payload(String destination, String data) { - return DefaultPayload.create(data, destination); - } - - - @Controller static class ServerController { @@ -226,10 +220,17 @@ public class RSocketClientToServerIntegrationTests { @Bean public RSocketMessageHandler rsocketMessageHandler() { RSocketMessageHandler handler = new RSocketMessageHandler(rsocketChannel()); - handler.setDecoders(Collections.singletonList(StringDecoder.allMimeTypes())); - handler.setEncoders(Collections.singletonList(CharSequenceEncoder.allMimeTypes())); + handler.setRSocketStrategies(rsocketStrategies()); return handler; } + + @Bean + public RSocketStrategies rsocketStrategies() { + return RSocketStrategies.builder() + .decoder(StringDecoder.allMimeTypes()) + .encoder(CharSequenceEncoder.allMimeTypes()) + .build(); + } } } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java index aa91d88a09..9da6689610 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java @@ -19,11 +19,11 @@ import java.time.Duration; import java.util.Collections; import java.util.List; +import io.rsocket.Closeable; import io.rsocket.Payload; import io.rsocket.RSocket; import io.rsocket.RSocketFactory; import io.rsocket.transport.netty.client.TcpClientTransport; -import io.rsocket.transport.netty.server.CloseableChannel; import io.rsocket.transport.netty.server.TcpServerTransport; import io.rsocket.util.DefaultPayload; import org.junit.AfterClass; @@ -56,7 +56,7 @@ public class RSocketServerToClientIntegrationTests { private static AnnotationConfigApplicationContext context; - private static CloseableChannel serverChannel; + private static Closeable server; private static MessagingAcceptor clientAcceptor; @@ -67,14 +67,14 @@ public class RSocketServerToClientIntegrationTests { context = new AnnotationConfigApplicationContext(ServerConfig.class); + ReactiveMessageChannel messageChannel = context.getBean("serverChannel", ReactiveMessageChannel.class); + RSocketStrategies rsocketStrategies = context.getBean(RSocketStrategies.class); + clientAcceptor = new MessagingAcceptor( context.getBean("clientChannel", ReactiveMessageChannel.class)); - MessagingAcceptor serverAcceptor = new MessagingAcceptor( - context.getBean("serverChannel", ReactiveMessageChannel.class)); - - serverChannel = RSocketFactory.receive() - .acceptor(serverAcceptor) + server = RSocketFactory.receive() + .acceptor(new MessagingAcceptor(messageChannel, rsocketStrategies)) .transport(TcpServerTransport.create("localhost", 7000)) .start() .block(); @@ -82,7 +82,7 @@ public class RSocketServerToClientIntegrationTests { @AfterClass public static void tearDownOnce() { - serverChannel.dispose(); + server.dispose(); } @@ -141,10 +141,10 @@ public class RSocketServerToClientIntegrationTests { @MessageMapping("connect.echo") - void echo(RSocket rsocket) { + void echo(RSocketRequester requester) { runTest(() -> { Flux result = Flux.range(1, 3).concatMap(i -> - rsocket.requestResponse(payload("echo", "Hello " + i)).map(Payload::getDataUtf8)); + requester.route("echo").data("Hello " + i).retrieveMono(String.class)); StepVerifier.create(result) .expectNext("Hello 1") @@ -155,10 +155,10 @@ public class RSocketServerToClientIntegrationTests { } @MessageMapping("connect.echo-async") - void echoAsync(RSocket rsocket) { + void echoAsync(RSocketRequester requester) { runTest(() -> { Flux result = Flux.range(1, 3).concatMap(i -> - rsocket.requestResponse(payload("echo-async", "Hello " + i)).map(Payload::getDataUtf8)); + requester.route("echo-async").data("Hello " + i).retrieveMono(String.class)); StepVerifier.create(result) .expectNext("Hello 1 async") @@ -169,10 +169,9 @@ public class RSocketServerToClientIntegrationTests { } @MessageMapping("connect.echo-stream") - void echoStream(RSocket rsocket) { + void echoStream(RSocketRequester requester) { runTest(() -> { - Flux result = rsocket.requestStream(payload("echo-stream", "Hello")) - .map(io.rsocket.Payload::getDataUtf8); + Flux result = requester.route("echo-stream").data("Hello").retrieveFlux(String.class); StepVerifier.create(result) .expectNext("Hello 0") @@ -185,13 +184,11 @@ public class RSocketServerToClientIntegrationTests { } @MessageMapping("connect.echo-channel") - void echoChannel(RSocket rsocket) { + void echoChannel(RSocketRequester requester) { runTest(() -> { - Flux payloads = Flux.concat( - Flux.just(payload("echo-channel", "Hello 1")), - Flux.range(2, 9).map(i -> DefaultPayload.create("Hello " + i))); - - Flux result = rsocket.requestChannel(payloads).map(Payload::getDataUtf8); + Flux result = requester.route("echo-channel") + .data(Flux.range(1, 10).map(i -> "Hello " + i), String.class) + .retrieveFlux(String.class); StepVerifier.create(result) .expectNext("Hello 1 async") @@ -285,20 +282,23 @@ public class RSocketServerToClientIntegrationTests { public RSocketMessageHandler clientMessageHandler() { List handlers = Collections.singletonList(clientController()); RSocketMessageHandler handler = new RSocketMessageHandler(clientChannel(), handlers); - addDefaultCodecs(handler); + handler.setRSocketStrategies(rsocketStrategies()); return handler; } @Bean public RSocketMessageHandler serverMessageHandler() { RSocketMessageHandler handler = new RSocketMessageHandler(serverChannel()); - addDefaultCodecs(handler); + handler.setRSocketStrategies(rsocketStrategies()); return handler; } - private void addDefaultCodecs(RSocketMessageHandler handler) { - handler.setDecoders(Collections.singletonList(StringDecoder.allMimeTypes())); - handler.setEncoders(Collections.singletonList(CharSequenceEncoder.allMimeTypes())); + @Bean + public RSocketStrategies rsocketStrategies() { + return RSocketStrategies.builder() + .decoder(StringDecoder.allMimeTypes()) + .encoder(CharSequenceEncoder.allMimeTypes()) + .build(); } } From d6f4ec8c33147f30e34280e048f15ad7c4da1530 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Mon, 25 Feb 2019 12:56:32 -0500 Subject: [PATCH 12/17] MessagingAcceptor/RSocket refinements + upgrade to 0.11.17 See gh-21987 --- spring-messaging/spring-messaging.gradle | 2 +- .../rsocket/DefaultRSocketRequester.java | 21 +++-- .../messaging/rsocket/MessagingAcceptor.java | 23 ++--- .../messaging/rsocket/MessagingRSocket.java | 89 ++++++++++--------- .../messaging/rsocket/PayloadUtils.java | 29 +++--- .../rsocket/RSocketMessageHandler.java | 13 --- .../RSocketPayloadReturnValueHandler.java | 2 +- .../rsocket/DefaultRSocketRequesterTests.java | 2 +- ...RSocketClientToServerIntegrationTests.java | 7 ++ ...RSocketServerToClientIntegrationTests.java | 39 ++++---- 10 files changed, 108 insertions(+), 119 deletions(-) diff --git a/spring-messaging/spring-messaging.gradle b/spring-messaging/spring-messaging.gradle index 9a4771279c..fd469281c6 100644 --- a/spring-messaging/spring-messaging.gradle +++ b/spring-messaging/spring-messaging.gradle @@ -7,7 +7,7 @@ dependencyManagement { } } -def rsocketVersion = "0.11.15" +def rsocketVersion = "0.11.17" dependencies { compile(project(":spring-beans")) diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java index 978c353d3e..94ed888f25 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java @@ -149,9 +149,14 @@ final class DefaultRSocketRequester implements RSocketRequester { .concatMap(value -> encodeValue(value, dataType, encoder)) .switchOnFirst((signal, inner) -> { DataBuffer data = signal.get(); - return data != null ? - Flux.concat(Mono.just(firstPayload(data)), inner.skip(1).map(PayloadUtils::asPayload)) : - inner.map(PayloadUtils::asPayload); + if (data != null) { + return Flux.concat( + Mono.just(firstPayload(data)), + inner.skip(1).map(PayloadUtils::createPayload)); + } + else { + return inner.map(PayloadUtils::createPayload); + } }) .switchIfEmpty(emptyPayload()); return new DefaultResponseSpec(payloadFlux); @@ -167,7 +172,7 @@ final class DefaultRSocketRequester implements RSocketRequester { } private Payload firstPayload(DataBuffer data) { - return PayloadUtils.asPayload(getMetadata(), data); + return PayloadUtils.createPayload(getMetadata(), data); } private Mono emptyPayload() { @@ -239,7 +244,7 @@ final class DefaultRSocketRequester implements RSocketRequester { Decoder decoder = strategies.decoder(elementType, dataMimeType); return (Mono) decoder.decodeToMono( - payloadMono.map(this::asDataBuffer), elementType, dataMimeType, EMPTY_HINTS); + payloadMono.map(this::wrapPayloadData), elementType, dataMimeType, EMPTY_HINTS); } @SuppressWarnings("unchecked") @@ -255,12 +260,12 @@ final class DefaultRSocketRequester implements RSocketRequester { Decoder decoder = strategies.decoder(elementType, dataMimeType); - return payloadFlux.map(this::asDataBuffer).concatMap(dataBuffer -> + return payloadFlux.map(this::wrapPayloadData).concatMap(dataBuffer -> (Mono) decoder.decodeToMono(Mono.just(dataBuffer), elementType, dataMimeType, EMPTY_HINTS)); } - private DataBuffer asDataBuffer(Payload payload) { - return PayloadUtils.asDataBuffer(payload, strategies.dataBufferFactory()); + private DataBuffer wrapPayloadData(Payload payload) { + return PayloadUtils.wrapPayloadData(payload, strategies.dataBufferFactory()); } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java index 2cc7212834..e4b7e44ad7 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java @@ -28,7 +28,6 @@ import org.springframework.messaging.Message; import org.springframework.messaging.ReactiveMessageChannel; import org.springframework.util.Assert; import org.springframework.util.MimeType; -import org.springframework.util.MimeTypeUtils; /** * RSocket acceptor for @@ -79,10 +78,9 @@ public final class MessagingAcceptor implements SocketAcceptor, FunctionBy default this is not set. + * Configure the default content type to use for data payloads. + *

By default this is not set. However a server acceptor will use the + * content type from the {@link ConnectionSetupPayload}. * @param defaultDataMimeType the MimeType to use */ public void setDefaultDataMimeType(@Nullable MimeType defaultDataMimeType) { @@ -92,21 +90,18 @@ public final class MessagingAcceptor implements SocketAcceptor, Function accept(ConnectionSetupPayload setupPayload, RSocket sendingRSocket) { - - MimeType mimeType = setupPayload.dataMimeType() != null ? - MimeTypeUtils.parseMimeType(setupPayload.dataMimeType()) : this.defaultDataMimeType; - - MessagingRSocket rsocket = createRSocket(sendingRSocket, mimeType); - return rsocket.afterConnectionEstablished(setupPayload).then(Mono.just(rsocket)); + MessagingRSocket rsocket = createRSocket(sendingRSocket); + rsocket.handleConnectionSetupPayload(setupPayload).subscribe(); + return Mono.just(rsocket); } @Override public RSocket apply(RSocket sendingRSocket) { - return createRSocket(sendingRSocket, this.defaultDataMimeType); + return createRSocket(sendingRSocket); } - private MessagingRSocket createRSocket(RSocket sendingRSocket, @Nullable MimeType dataMimeType) { - return new MessagingRSocket(this.messageChannel, sendingRSocket, dataMimeType, this.rsocketStrategies); + private MessagingRSocket createRSocket(RSocket rsocket) { + return new MessagingRSocket(this.messageChannel, rsocket, this.defaultDataMimeType, this.rsocketStrategies); } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java index e1c983df1c..824ff3901d 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java @@ -17,6 +17,7 @@ package org.springframework.messaging.rsocket; import java.util.function.Function; +import io.rsocket.AbstractRSocket; import io.rsocket.ConnectionSetupPayload; import io.rsocket.Payload; import io.rsocket.RSocket; @@ -40,6 +41,8 @@ import org.springframework.messaging.support.MessageBuilder; import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.util.Assert; import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.StringUtils; /** * Package private implementation of {@link RSocket} that is is hooked into an @@ -49,90 +52,96 @@ import org.springframework.util.MimeType; * @author Rossen Stoyanchev * @since 5.2 */ -class MessagingRSocket implements RSocket { +class MessagingRSocket extends AbstractRSocket { private final ReactiveMessageChannel messageChannel; private final RSocketRequester requester; @Nullable - private final MimeType dataMimeType; + private MimeType dataMimeType; private final RSocketStrategies strategies; MessagingRSocket(ReactiveMessageChannel messageChannel, - RSocket sendingRSocket, @Nullable MimeType dataMimeType, RSocketStrategies strategies) { + RSocket sendingRSocket, @Nullable MimeType defaultDataMimeType, RSocketStrategies strategies) { Assert.notNull(messageChannel, "'messageChannel' is required"); Assert.notNull(sendingRSocket, "'sendingRSocket' is required"); this.messageChannel = messageChannel; - this.requester = RSocketRequester.create(sendingRSocket, dataMimeType, strategies); - this.dataMimeType = dataMimeType; + this.requester = RSocketRequester.create(sendingRSocket, defaultDataMimeType, strategies); + this.dataMimeType = defaultDataMimeType; this.strategies = strategies; } - public Mono afterConnectionEstablished(ConnectionSetupPayload payload) { - return execute(payload).flatMap(flux -> flux.take(0).then()); + + public Mono handleConnectionSetupPayload(ConnectionSetupPayload payload) { + if (StringUtils.hasText(payload.dataMimeType())) { + this.dataMimeType = MimeTypeUtils.parseMimeType(payload.dataMimeType()); + } + return handle(payload); } @Override public Mono fireAndForget(Payload payload) { - return execute(payload).flatMap(flux -> flux.take(0).then()); + return handle(payload); } @Override public Mono requestResponse(Payload payload) { - return execute(payload).flatMap(Flux::next); + return handleAndReply(payload, Flux.just(payload)).next(); } @Override public Flux requestStream(Payload payload) { - return execute(payload).flatMapMany(Function.identity()); + return handleAndReply(payload, Flux.just(payload)); } @Override public Flux requestChannel(Publisher payloads) { return Flux.from(payloads) - .switchOnFirst((signal, inner) -> { - Payload first = signal.get(); - return first != null ? execute(first, inner).flatMapMany(Function.identity()) : inner; + .switchOnFirst((signal, innerFlux) -> { + Payload firstPayload = signal.get(); + return firstPayload == null ? innerFlux : handleAndReply(firstPayload, innerFlux); }); } @Override public Mono metadataPush(Payload payload) { - return null; + // This won't be very useful until createHeaders starting doing something more with metadata.. + return handle(payload); } - private Mono> execute(Payload payload) { - return execute(payload, Flux.just(payload)); - } - private Mono> execute(Payload firstPayload, Flux payloads) { + private Mono handle(Payload payload) { - // TODO: - // Since we do retain(), we need to ensure buffers are released if not consumed, - // e.g. error before Flux subscribed to, no handler found, @MessageMapping ignores payload, etc. - - Flux payloadDataBuffers = payloads - .map(payload -> PayloadUtils.asDataBuffer(payload, this.strategies.dataBufferFactory())) - .doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release); - - MonoProcessor> replyMono = MonoProcessor.create(); - MessageHeaders headers = createHeaders(firstPayload, replyMono); - - Message message = MessageBuilder.createMessage(payloadDataBuffers, headers); + Message message = MessageBuilder.createMessage( + Mono.fromCallable(() -> wrapPayloadData(payload)), + createHeaders(payload, null)); return this.messageChannel.send(message).flatMap(result -> result ? - replyMono.isTerminated() ? replyMono : Mono.empty() : - Mono.error(new MessageDeliveryException("RSocket interaction not handled"))); + Mono.empty() : Mono.error(new MessageDeliveryException("RSocket request not handled"))); } - private MessageHeaders createHeaders(Payload payload, MonoProcessor replyMono) { + private Flux handleAndReply(Payload firstPayload, Flux payloads) { + MonoProcessor> replyMono = MonoProcessor.create(); + + Message message = MessageBuilder.createMessage( + payloads.map(this::wrapPayloadData).doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release), + createHeaders(firstPayload, replyMono)); + + return this.messageChannel.send(message).flatMapMany(result -> + result && replyMono.isTerminated() ? replyMono.flatMapMany(Function.identity()) : + Mono.error(new MessageDeliveryException("RSocket request not handled"))); + } + + private MessageHeaders createHeaders(Payload payload, @Nullable MonoProcessor replyMono) { + + // TODO: // For now treat the metadata as a simple string with routing information. // We'll have to get more sophisticated once the routing extension is completed. // https://github.com/rsocket/rsocket-java/issues/568 @@ -147,7 +156,10 @@ class MessagingRSocket implements RSocket { } headers.setHeader(RSocketRequesterMethodArgumentResolver.RSOCKET_REQUESTER_HEADER, this.requester); - headers.setHeader(RSocketPayloadReturnValueHandler.RESPONSE_HEADER, replyMono); + + if (replyMono != null) { + headers.setHeader(RSocketPayloadReturnValueHandler.RESPONSE_HEADER, replyMono); + } DataBufferFactory bufferFactory = this.strategies.dataBufferFactory(); headers.setHeader(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER, bufferFactory); @@ -155,13 +167,8 @@ class MessagingRSocket implements RSocket { return headers.getMessageHeaders(); } - @Override - public Mono onClose() { - return null; - } - - @Override - public void dispose() { + private DataBuffer wrapPayloadData(Payload payload) { + return PayloadUtils.wrapPayloadData(payload, this.strategies.dataBufferFactory()); } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java index 98fd9ae8c1..8e3e87c6e1 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java @@ -15,9 +15,6 @@ */ package org.springframework.messaging.rsocket; -import java.nio.ByteBuffer; - -import io.netty.buffer.ByteBuf; import io.rsocket.Payload; import io.rsocket.util.ByteBufPayload; import io.rsocket.util.DefaultPayload; @@ -44,7 +41,7 @@ abstract class PayloadUtils { * @param bufferFactory the BufferFactory to use to wrap * @return the DataBuffer wrapper */ - public static DataBuffer asDataBuffer(Payload payload, DataBufferFactory bufferFactory) { + public static DataBuffer wrapPayloadData(Payload payload, DataBufferFactory bufferFactory) { if (bufferFactory instanceof NettyDataBufferFactory) { return ((NettyDataBufferFactory) bufferFactory).wrap(payload.retain().sliceData()); } @@ -59,12 +56,16 @@ abstract class PayloadUtils { * @param data the data part for the payload * @return the created Payload */ - public static Payload asPayload(DataBuffer metadata, DataBuffer data) { + public static Payload createPayload(DataBuffer metadata, DataBuffer data) { if (metadata instanceof NettyDataBuffer && data instanceof NettyDataBuffer) { - return ByteBufPayload.create(getByteBuf(data), getByteBuf(metadata)); + return ByteBufPayload.create( + ((NettyDataBuffer) data).getNativeBuffer(), + ((NettyDataBuffer) metadata).getNativeBuffer()); } else if (metadata instanceof DefaultDataBuffer && data instanceof DefaultDataBuffer) { - return DefaultPayload.create(getByteBuffer(data), getByteBuffer(metadata)); + return DefaultPayload.create( + ((DefaultDataBuffer) data).getNativeBuffer(), + ((DefaultDataBuffer) metadata).getNativeBuffer()); } else { return DefaultPayload.create(data.asByteBuffer(), metadata.asByteBuffer()); @@ -76,24 +77,16 @@ abstract class PayloadUtils { * @param data the data part for the payload * @return the created Payload */ - public static Payload asPayload(DataBuffer data) { + public static Payload createPayload(DataBuffer data) { if (data instanceof NettyDataBuffer) { - return ByteBufPayload.create(getByteBuf(data)); + return ByteBufPayload.create(((NettyDataBuffer) data).getNativeBuffer()); } else if (data instanceof DefaultDataBuffer) { - return DefaultPayload.create(getByteBuffer(data)); + return DefaultPayload.create(((DefaultDataBuffer) data).getNativeBuffer()); } else { return DefaultPayload.create(data.asByteBuffer()); } } - private static ByteBuf getByteBuf(DataBuffer dataBuffer) { - return ((NettyDataBuffer) dataBuffer).getNativeBuffer(); - } - - private static - ByteBuffer getByteBuffer(DataBuffer dataBuffer) { - return ((DefaultDataBuffer) dataBuffer).getNativeBuffer(); - } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java index a6f0030329..93d5fb43a6 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java @@ -21,13 +21,10 @@ import java.util.List; import org.springframework.core.codec.Decoder; import org.springframework.core.codec.Encoder; import org.springframework.lang.Nullable; -import org.springframework.messaging.Message; -import org.springframework.messaging.MessageDeliveryException; import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.annotation.support.reactive.MessageMappingMessageHandler; import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; /** * RSocket-specific extension of {@link MessageMappingMessageHandler}. @@ -124,14 +121,4 @@ public class RSocketMessageHandler extends MessageMappingMessageHandler { return handlers; } - - @Override - protected void handleNoMatch(@Nullable String destination, Message message) { - // Ignore empty destination, probably the ConnectionSetupPayload - if (!StringUtils.isEmpty(destination)) { - super.handleNoMatch(destination, message); - throw new MessageDeliveryException("No handler for '" + destination + "'"); - } - } - } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java index 83521683da..c841736a7c 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java @@ -63,7 +63,7 @@ public class RSocketPayloadReturnValueHandler extends AbstractEncoderMethodRetur Assert.isInstanceOf(MonoProcessor.class, headerValue, "Expected MonoProcessor"); MonoProcessor> monoProcessor = (MonoProcessor>) headerValue; - monoProcessor.onNext(encodedContent.map(PayloadUtils::asPayload)); + monoProcessor.onNext(encodedContent.map(PayloadUtils::createPayload)); monoProcessor.onComplete(); return Mono.empty(); diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java index a11ff90237..13c3693f72 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java @@ -199,7 +199,7 @@ public class DefaultRSocketRequesterTests { } private Payload toPayload(String value) { - return PayloadUtils.asPayload(bufferFactory.wrap(value.getBytes(StandardCharsets.UTF_8))); + return PayloadUtils.createPayload(bufferFactory.wrap(value.getBytes(StandardCharsets.UTF_8))); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java index 39c27d8cc2..b6e0f8f38c 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java @@ -35,6 +35,7 @@ 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.messaging.MessageDeliveryException; import org.springframework.messaging.ReactiveMessageChannel; import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -169,6 +170,12 @@ public class RSocketClientToServerIntegrationTests { .verifyComplete(); } + @Test + public void noMatchingRoute() { + Mono result = requester.route("invalid").data("anything").retrieveMono(String.class); + StepVerifier.create(result).verifyErrorMessage("RSocket request not handled"); + } + @Controller static class ServerController { diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java index 9da6689610..6a31f906dc 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java @@ -20,7 +20,6 @@ import java.util.Collections; import java.util.List; import io.rsocket.Closeable; -import io.rsocket.Payload; import io.rsocket.RSocket; import io.rsocket.RSocketFactory; import io.rsocket.transport.netty.client.TcpClientTransport; @@ -140,13 +139,22 @@ public class RSocketServerToClientIntegrationTests { volatile MonoProcessor result; + public void reset() { + this.result = MonoProcessor.create(); + } + + public void await(Duration duration) { + this.result.block(duration); + } + + @MessageMapping("connect.echo") void echo(RSocketRequester requester) { runTest(() -> { - Flux result = Flux.range(1, 3).concatMap(i -> + Flux flux = Flux.range(1, 3).concatMap(i -> requester.route("echo").data("Hello " + i).retrieveMono(String.class)); - StepVerifier.create(result) + StepVerifier.create(flux) .expectNext("Hello 1") .expectNext("Hello 2") .expectNext("Hello 3") @@ -157,10 +165,10 @@ public class RSocketServerToClientIntegrationTests { @MessageMapping("connect.echo-async") void echoAsync(RSocketRequester requester) { runTest(() -> { - Flux result = Flux.range(1, 3).concatMap(i -> + Flux flux = Flux.range(1, 3).concatMap(i -> requester.route("echo-async").data("Hello " + i).retrieveMono(String.class)); - StepVerifier.create(result) + StepVerifier.create(flux) .expectNext("Hello 1 async") .expectNext("Hello 2 async") .expectNext("Hello 3 async") @@ -171,9 +179,9 @@ public class RSocketServerToClientIntegrationTests { @MessageMapping("connect.echo-stream") void echoStream(RSocketRequester requester) { runTest(() -> { - Flux result = requester.route("echo-stream").data("Hello").retrieveFlux(String.class); + Flux flux = requester.route("echo-stream").data("Hello").retrieveFlux(String.class); - StepVerifier.create(result) + StepVerifier.create(flux) .expectNext("Hello 0") .expectNextCount(5) .expectNext("Hello 6") @@ -186,11 +194,11 @@ public class RSocketServerToClientIntegrationTests { @MessageMapping("connect.echo-channel") void echoChannel(RSocketRequester requester) { runTest(() -> { - Flux result = requester.route("echo-channel") + Flux flux = requester.route("echo-channel") .data(Flux.range(1, 10).map(i -> "Hello " + i), String.class) .retrieveFlux(String.class); - StepVerifier.create(result) + StepVerifier.create(flux) .expectNext("Hello 1 async") .expectNextCount(7) .expectNext("Hello 9 async") @@ -207,19 +215,6 @@ public class RSocketServerToClientIntegrationTests { .subscribeOn(Schedulers.elastic()) .subscribe(); } - - private static Payload payload(String destination, String data) { - return DefaultPayload.create(data, destination); - } - - - public void reset() { - this.result = MonoProcessor.create(); - } - - public void await(Duration duration) { - this.result.block(duration); - } } From 4e1c0c682600a093fc76b14d26bcf3707a937229 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Thu, 21 Feb 2019 18:08:30 -0500 Subject: [PATCH 13/17] @MessageExceptionHandler supports error signal Before this change if a controller method returned a Publisher whose first signal was an error, the error signal would not be delegated to a @MessageExceptionHandler method as expected. To make this work for now we use a package private local copy of the ChannelSendOperator from spring-web. See gh-21987 --- ...stractEncoderMethodReturnValueHandler.java | 3 +- .../reactive/ChannelSendOperator.java | 410 ++++++++++++++++++ .../reactive/MethodMessageHandlerTests.java | 15 +- .../reactive/TestReturnValueHandler.java | 8 + ...RSocketClientToServerIntegrationTests.java | 36 +- 5 files changed, 463 insertions(+), 9 deletions(-) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ChannelSendOperator.java diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java index aa5916a414..5c185ce45f 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java @@ -112,7 +112,8 @@ public abstract class AbstractEncoderMethodReturnValueHandler implements Handler Flux encodedContent = encodeContent( returnValue, returnType, bufferFactory, mimeType, Collections.emptyMap()); - return handleEncodedContent(encodedContent, returnType, message); + return new ChannelSendOperator<>(encodedContent, publisher -> + handleEncodedContent(Flux.from(publisher), returnType, message)); } @SuppressWarnings("unchecked") diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ChannelSendOperator.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ChannelSendOperator.java new file mode 100644 index 0000000000..b89f5f96fe --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ChannelSendOperator.java @@ -0,0 +1,410 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.util.function.Function; + +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import reactor.core.CoreSubscriber; +import reactor.core.Scannable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Operators; +import reactor.util.context.Context; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * ---------------------- + *

NOTE: This class was copied from + * {@code org.springframework.http.server.reactive.ChannelSendOperator} and is + * identical to it. It's used for the same purpose, i.e. the ability to switch to + * alternate handling via annotated exception handler methods if the output + * publisher starts with an error. + *

----------------------
+ * + *

Given a write function that accepts a source {@code Publisher} to write + * with and returns {@code Publisher} for the result, this operator helps + * to defer the invocation of the write function, until we know if the source + * publisher will begin publishing without an error. If the first emission is + * an error, the write function is bypassed, and the error is sent directly + * through the result publisher. Otherwise the write function is invoked. + * + * @author Rossen Stoyanchev + * @author Stephane Maldini + * @since 5.2 + * @param the type of element signaled + */ +class ChannelSendOperator extends Mono implements Scannable { + + private final Function, Publisher> writeFunction; + + private final Flux source; + + + public ChannelSendOperator(Publisher source, Function, Publisher> writeFunction) { + this.source = Flux.from(source); + this.writeFunction = writeFunction; + } + + + @Override + @Nullable + @SuppressWarnings("rawtypes") + public Object scanUnsafe(Attr key) { + if (key == Attr.PREFETCH) { + return Integer.MAX_VALUE; + } + if (key == Attr.PARENT) { + return this.source; + } + return null; + } + + @Override + public void subscribe(CoreSubscriber actual) { + this.source.subscribe(new WriteBarrier(actual)); + } + + + private enum State { + + /** No emissions from the upstream source yet. */ + NEW, + + /** + * At least one signal of any kind has been received; we're ready to + * call the write function and proceed with actual writing. + */ + FIRST_SIGNAL_RECEIVED, + + /** + * The write subscriber has subscribed and requested; we're going to + * emit the cached signals. + */ + EMITTING_CACHED_SIGNALS, + + /** + * The write subscriber has subscribed, and cached signals have been + * emitted to it; we're ready to switch to a simple pass-through mode + * for all remaining signals. + **/ + READY_TO_WRITE + + } + + + /** + * A barrier inserted between the write source and the write subscriber + * (i.e. the HTTP server adapter) that pre-fetches and waits for the first + * signal before deciding whether to hook in to the write subscriber. + * + *

Acts as: + *

    + *
  • Subscriber to the write source. + *
  • Subscription to the write subscriber. + *
  • Publisher to the write subscriber. + *
+ * + *

Also uses {@link WriteCompletionBarrier} to communicate completion + * and detect cancel signals from the completion subscriber. + */ + private class WriteBarrier implements CoreSubscriber, Subscription, Publisher { + + /* Bridges signals to and from the completionSubscriber */ + private final WriteCompletionBarrier writeCompletionBarrier; + + /* Upstream write source subscription */ + @Nullable + private Subscription subscription; + + /** Cached data item before readyToWrite. */ + @Nullable + private T item; + + /** Cached error signal before readyToWrite. */ + @Nullable + private Throwable error; + + /** Cached onComplete signal before readyToWrite. */ + private boolean completed = false; + + /** Recursive demand while emitting cached signals. */ + private long demandBeforeReadyToWrite; + + /** Current state. */ + private State state = State.NEW; + + /** The actual writeSubscriber from the HTTP server adapter. */ + @Nullable + private Subscriber writeSubscriber; + + + WriteBarrier(CoreSubscriber completionSubscriber) { + this.writeCompletionBarrier = new WriteCompletionBarrier(completionSubscriber, this); + } + + + // Subscriber methods (we're the subscriber to the write source).. + + @Override + public final void onSubscribe(Subscription s) { + if (Operators.validate(this.subscription, s)) { + this.subscription = s; + this.writeCompletionBarrier.connect(); + s.request(1); + } + } + + @Override + public final void onNext(T item) { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onNext(item); + return; + } + //FIXME revisit in case of reentrant sync deadlock + synchronized (this) { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onNext(item); + } + else if (this.state == State.NEW) { + this.item = item; + this.state = State.FIRST_SIGNAL_RECEIVED; + writeFunction.apply(this).subscribe(this.writeCompletionBarrier); + } + else { + if (this.subscription != null) { + this.subscription.cancel(); + } + this.writeCompletionBarrier.onError(new IllegalStateException("Unexpected item.")); + } + } + } + + private Subscriber requiredWriteSubscriber() { + Assert.state(this.writeSubscriber != null, "No write subscriber"); + return this.writeSubscriber; + } + + @Override + public final void onError(Throwable ex) { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onError(ex); + return; + } + synchronized (this) { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onError(ex); + } + else if (this.state == State.NEW) { + this.state = State.FIRST_SIGNAL_RECEIVED; + this.writeCompletionBarrier.onError(ex); + } + else { + this.error = ex; + } + } + } + + @Override + public final void onComplete() { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onComplete(); + return; + } + synchronized (this) { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onComplete(); + } + else if (this.state == State.NEW) { + this.completed = true; + this.state = State.FIRST_SIGNAL_RECEIVED; + writeFunction.apply(this).subscribe(this.writeCompletionBarrier); + } + else { + this.completed = true; + } + } + } + + @Override + public Context currentContext() { + return this.writeCompletionBarrier.currentContext(); + } + + + // Subscription methods (we're the Subscription to the writeSubscriber).. + + @Override + public void request(long n) { + Subscription s = this.subscription; + if (s == null) { + return; + } + if (this.state == State.READY_TO_WRITE) { + s.request(n); + return; + } + synchronized (this) { + if (this.writeSubscriber != null) { + if (this.state == State.EMITTING_CACHED_SIGNALS) { + this.demandBeforeReadyToWrite = n; + return; + } + try { + this.state = State.EMITTING_CACHED_SIGNALS; + if (emitCachedSignals()) { + return; + } + n = n + this.demandBeforeReadyToWrite - 1; + if (n == 0) { + return; + } + } + finally { + this.state = State.READY_TO_WRITE; + } + } + } + s.request(n); + } + + private boolean emitCachedSignals() { + if (this.item != null) { + requiredWriteSubscriber().onNext(this.item); + } + if (this.error != null) { + requiredWriteSubscriber().onError(this.error); + return true; + } + if (this.completed) { + requiredWriteSubscriber().onComplete(); + return true; + } + return false; + } + + @Override + public void cancel() { + Subscription s = this.subscription; + if (s != null) { + this.subscription = null; + s.cancel(); + } + } + + + // Publisher methods (we're the Publisher to the writeSubscriber).. + + @Override + public void subscribe(Subscriber writeSubscriber) { + synchronized (this) { + Assert.state(this.writeSubscriber == null, "Only one write subscriber supported"); + this.writeSubscriber = writeSubscriber; + if (this.error != null || this.completed) { + this.writeSubscriber.onSubscribe(Operators.emptySubscription()); + emitCachedSignals(); + } + else { + this.writeSubscriber.onSubscribe(this); + } + } + } + } + + + /** + * We need an extra barrier between the WriteBarrier itself and the actual + * completion subscriber. + * + *

The completionSubscriber is subscribed initially to the WriteBarrier. + * Later after the first signal is received, we need one more subscriber + * instance (per spec can only subscribe once) to subscribe to the write + * function and switch to delegating completion signals from it. + */ + private class WriteCompletionBarrier implements CoreSubscriber, Subscription { + + /* Downstream write completion subscriber */ + private final CoreSubscriber completionSubscriber; + + private final WriteBarrier writeBarrier; + + @Nullable + private Subscription subscription; + + + public WriteCompletionBarrier(CoreSubscriber subscriber, WriteBarrier writeBarrier) { + this.completionSubscriber = subscriber; + this.writeBarrier = writeBarrier; + } + + + /** + * Connect the underlying completion subscriber to this barrier in order + * to track cancel signals and pass them on to the write barrier. + */ + public void connect() { + this.completionSubscriber.onSubscribe(this); + } + + // Subscriber methods (we're the subscriber to the write function).. + + @Override + public void onSubscribe(Subscription subscription) { + this.subscription = subscription; + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Void aVoid) { + } + + @Override + public void onError(Throwable ex) { + this.completionSubscriber.onError(ex); + } + + @Override + public void onComplete() { + this.completionSubscriber.onComplete(); + } + + @Override + public Context currentContext() { + return this.completionSubscriber.currentContext(); + } + + + @Override + public void request(long n) { + // Ignore: we don't produce data + } + + @Override + public void cancel() { + this.writeBarrier.cancel(); + Subscription subscription = this.subscription; + if (subscription != null) { + subscription.cancel(); + } + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java index bcd7b6eec0..82a448e14f 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -26,6 +26,7 @@ import java.util.function.Consumer; import org.hamcrest.Matchers; import org.junit.Test; +import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -63,7 +64,7 @@ public class MethodMessageHandlerTests { assertEquals(5, mappings.keySet().size()); assertThat(mappings.keySet(), Matchers.containsInAnyOrder( - "/handleMessage", "/handleMessageWithArgument", "/handleMessageAndThrow", + "/handleMessage", "/handleMessageWithArgument", "/handleMessageWithError", "/handleMessageMatch1", "/handleMessageMatch2")); } @@ -80,7 +81,7 @@ public class MethodMessageHandlerTests { handler.handleMessage(message).block(Duration.ofSeconds(5)); - StepVerifier.create((Mono) handler.getLastReturnValue()) + StepVerifier.create((Publisher) handler.getLastReturnValue()) .expectNext("handleMessageMatch1") .verifyComplete(); } @@ -100,7 +101,7 @@ public class MethodMessageHandlerTests { handler.handleMessage(message).block(Duration.ofSeconds(5)); - StepVerifier.create((Mono) handler.getLastReturnValue()) + StepVerifier.create((Publisher) handler.getLastReturnValue()) .expectNext("handleMessageWithArgument,payload=foo") .verifyComplete(); } @@ -111,11 +112,11 @@ public class MethodMessageHandlerTests { TestMethodMessageHandler handler = initMethodMessageHandler(TestController.class); Message message = new GenericMessage<>("body", Collections.singletonMap( - DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, "/handleMessageAndThrow")); + DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, "/handleMessageWithError")); handler.handleMessage(message).block(Duration.ofSeconds(5)); - StepVerifier.create((Mono) handler.getLastReturnValue()) + StepVerifier.create((Publisher) handler.getLastReturnValue()) .expectNext("handleIllegalStateException,ex=rejected") .verifyComplete(); } @@ -153,7 +154,7 @@ public class MethodMessageHandlerTests { return delay("handleMessageWithArgument,payload=" + payload); } - public Mono handleMessageAndThrow() { + public Mono handleMessageWithError() { return Mono.delay(Duration.ofMillis(10)) .flatMap(aLong -> Mono.error(new IllegalStateException("rejected"))); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java index 449cec194b..1133c13d73 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java @@ -15,6 +15,7 @@ */ package org.springframework.messaging.handler.invocation.reactive; +import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; import org.springframework.core.MethodParameter; @@ -43,7 +44,14 @@ public class TestReturnValueHandler implements HandlerMethodReturnValueHandler { } @Override + @SuppressWarnings("unchecked") public Mono handleReturnValue(@Nullable Object value, MethodParameter returnType, Message message) { + return value instanceof Publisher ? + new ChannelSendOperator((Publisher) value, this::saveValue) : + saveValue(value); + } + + private Mono saveValue(@Nullable Object value) { this.lastReturnValue = value; return Mono.empty(); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java index b6e0f8f38c..ff13b07100 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java @@ -35,9 +35,9 @@ 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.messaging.MessageDeliveryException; import org.springframework.messaging.ReactiveMessageChannel; import org.springframework.messaging.ReactiveSubscribableChannel; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.support.DefaultReactiveMessageChannel; import org.springframework.stereotype.Controller; @@ -170,6 +170,26 @@ public class RSocketClientToServerIntegrationTests { .verifyComplete(); } + @Test + public void handleWithThrownException() { + + Mono result = requester.route("thrown-exception").data("a").retrieveMono(String.class); + + StepVerifier.create(result) + .expectNext("Invalid input error handled") + .verifyComplete(); + } + + @Test + public void handleWithErrorSignal() { + + Mono result = requester.route("error-signal").data("a").retrieveMono(String.class); + + StepVerifier.create(result) + .expectNext("Invalid input error handled") + .verifyComplete(); + } + @Test public void noMatchingRoute() { Mono result = requester.route("invalid").data("anything").retrieveMono(String.class); @@ -208,6 +228,20 @@ public class RSocketClientToServerIntegrationTests { return payloads.delayElements(Duration.ofMillis(10)).map(payload -> payload + " async"); } + @MessageMapping("thrown-exception") + Mono handleAndThrow(String payload) { + throw new IllegalArgumentException("Invalid input error"); + } + + @MessageMapping("error-signal") + Mono handleAndReturnError(String payload) { + return Mono.error(new IllegalArgumentException("Invalid input error")); + } + + @MessageExceptionHandler + Mono handleException(IllegalArgumentException ex) { + return Mono.delay(Duration.ofMillis(10)).map(aLong -> ex.getMessage() + " handled"); + } } From 555dca9aff020d59a5af9f158829901432bc2eae Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Wed, 27 Feb 2019 09:42:53 -0500 Subject: [PATCH 14/17] Refactoring in AbstractMethodMessageHandler Split out the mechanics of invoking a HandlerMethod and handling the result into a separate helper class. See gh-21987 --- ...bstractExceptionHandlerMethodResolver.java | 4 +- .../AbstractMethodMessageHandler.java | 146 +++---------- .../invocation/reactive/InvocableHelper.java | 206 ++++++++++++++++++ 3 files changed, 235 insertions(+), 121 deletions(-) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java index 82d07e260d..836458adbe 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -85,7 +85,7 @@ public abstract class AbstractExceptionHandlerMethodResolver { * @return a Method to handle the exception, or {@code null} if none found */ @Nullable - public Method resolveMethod(Exception exception) { + public Method resolveMethod(Throwable exception) { Method method = resolveMethodByExceptionType(exception.getClass()); if (method == null) { Throwable cause = exception.getCause(); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java index 23824c3096..0241100714 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -24,7 +24,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; import org.apache.commons.logging.Log; @@ -35,11 +34,9 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.core.MethodIntrospector; -import org.springframework.core.MethodParameter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; -import org.springframework.messaging.MessageHandlingException; import org.springframework.messaging.MessagingException; import org.springframework.messaging.ReactiveMessageHandler; import org.springframework.messaging.handler.HandlerMethod; @@ -82,20 +79,14 @@ public abstract class AbstractMethodMessageHandler protected final Log logger = LogFactory.getLog(getClass()); + @Nullable + private Predicate> handlerPredicate; + private ArgumentResolverConfigurer argumentResolverConfigurer = new ArgumentResolverConfigurer(); private ReturnValueHandlerConfigurer returnValueHandlerConfigurer = new ReturnValueHandlerConfigurer(); - private final HandlerMethodArgumentResolverComposite argumentResolvers = - new HandlerMethodArgumentResolverComposite(); - - private final HandlerMethodReturnValueHandlerComposite returnValueHandlers = - new HandlerMethodReturnValueHandlerComposite(); - - private ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance(); - - @Nullable - private Predicate> handlerPredicate; + private final InvocableHelper invocableHelper = new InvocableHelper(this::createExceptionMethodResolverFor); @Nullable private ApplicationContext applicationContext; @@ -104,12 +95,24 @@ public abstract class AbstractMethodMessageHandler private final MultiValueMap destinationLookup = new LinkedMultiValueMap<>(64); - private final Map, AbstractExceptionHandlerMethodResolver> exceptionHandlerCache = - new ConcurrentHashMap<>(64); - private final Map exceptionHandlerAdviceCache = - new LinkedHashMap<>(64); + /** + * Configure a predicate to decide if which beans in the Spring context + * should be checked to see if they have message handling methods. + *

By default this is not set and sub-classes should configure it in + * order to enable auto-detection of message handling methods. + */ + public void setHandlerPredicate(@Nullable Predicate> handlerPredicate) { + this.handlerPredicate = handlerPredicate; + } + /** + * Return the {@link #setHandlerPredicate configured} handler predicate. + */ + @Nullable + public Predicate> getHandlerPredicate() { + return this.handlerPredicate; + } /** * Configure custom resolvers for handler method arguments. @@ -141,39 +144,20 @@ public abstract class AbstractMethodMessageHandler return this.returnValueHandlerConfigurer; } - /** - * Configure a predicate to decide if which beans in the Spring context - * should be checked to see if they have message handling methods. - *

By default this is not set and sub-classes should configure it in - * order to enable auto-detection of message handling methods. - */ - public void setHandlerPredicate(@Nullable Predicate> handlerPredicate) { - this.handlerPredicate = handlerPredicate; - } - - /** - * Return the {@link #setHandlerPredicate configured} handler predicate. - */ - @Nullable - public Predicate> getHandlerPredicate() { - return this.handlerPredicate; - } - /** * Configure the registry for adapting various reactive types. *

By default this is an instance of {@link ReactiveAdapterRegistry} with * default settings. */ public void setReactiveAdapterRegistry(ReactiveAdapterRegistry registry) { - Assert.notNull(registry, "ReactiveAdapterRegistry is required"); - this.reactiveAdapterRegistry = registry; + this.invocableHelper.setReactiveAdapterRegistry(registry); } /** * Return the configured registry for adapting reactive types. */ public ReactiveAdapterRegistry getReactiveAdapterRegistry() { - return this.reactiveAdapterRegistry; + return this.invocableHelper.getReactiveAdapterRegistry(); } @Override @@ -193,7 +177,7 @@ public abstract class AbstractMethodMessageHandler protected void registerExceptionHandlerAdvice( MessagingAdviceBean bean, AbstractExceptionHandlerMethodResolver resolver) { - this.exceptionHandlerAdviceCache.put(bean, resolver); + this.invocableHelper.registerExceptionHandlerAdvice(bean, resolver); } /** @@ -219,13 +203,13 @@ public abstract class AbstractMethodMessageHandler if (resolvers.isEmpty()) { resolvers = new ArrayList<>(this.argumentResolverConfigurer.getCustomResolvers()); } - this.argumentResolvers.addResolvers(resolvers); + this.invocableHelper.addArgumentResolvers(resolvers); List handlers = initReturnValueHandlers(); if (handlers.isEmpty()) { handlers = new ArrayList<>(this.returnValueHandlerConfigurer.getCustomHandlers()); } - this.returnValueHandlers.addHandlers(handlers); + this.invocableHelper.addReturnValueHandlers(handlers); initHandlerMethods(); } @@ -379,21 +363,7 @@ public abstract class AbstractMethodMessageHandler return Mono.empty(); } HandlerMethod handlerMethod = match.getHandlerMethod().createWithResolvedBean(); - InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod); - invocable.setArgumentResolvers(this.argumentResolvers.getResolvers()); - if (logger.isDebugEnabled()) { - logger.debug("Invoking " + invocable.getShortLogMessage()); - } - return invocable.invoke(message) - .flatMap(value -> { - MethodParameter returnType = invocable.getReturnType(); - return this.returnValueHandlers.handleReturnValue(value, returnType, message); - }) - .onErrorResume(throwable -> { - Exception ex = (throwable instanceof Exception) ? (Exception) throwable : - new MessageHandlingException(message, "HandlerMethod invocation error", throwable); - return processHandlerException(message, handlerMethod, ex); - }); + return this.invocableHelper.handleMessage(handlerMethod, message); } @Nullable @@ -482,68 +452,6 @@ public abstract class AbstractMethodMessageHandler logger.debug("No handlers for destination '" + destination + "'"); } - - private Mono processHandlerException(Message message, HandlerMethod handlerMethod, Exception ex) { - InvocableHandlerMethod exceptionInvocable = findExceptionHandler(handlerMethod, ex); - if (exceptionInvocable == null) { - logger.error("Unhandled exception from message handling method", ex); - return Mono.error(ex); - } - exceptionInvocable.setArgumentResolvers(this.argumentResolvers.getResolvers()); - if (logger.isDebugEnabled()) { - logger.debug("Invoking " + exceptionInvocable.getShortLogMessage()); - } - return exceptionInvocable.invoke(message, ex) - .flatMap(value -> { - MethodParameter returnType = exceptionInvocable.getReturnType(); - return this.returnValueHandlers.handleReturnValue(value, returnType, message); - }); - } - - /** - * Find an exception handling method for the given exception. - *

The default implementation searches methods in the class hierarchy of - * the HandlerMethod first and if not found, it continues searching for - * additional handling methods registered via - * {@link #registerExceptionHandlerAdvice(MessagingAdviceBean, AbstractExceptionHandlerMethodResolver)}. - * @param handlerMethod the method where the exception was raised - * @param exception the raised exception - * @return a method to handle the exception, or {@code null} - */ - @Nullable - protected InvocableHandlerMethod findExceptionHandler(HandlerMethod handlerMethod, Exception exception) { - if (logger.isDebugEnabled()) { - logger.debug("Searching for methods to handle " + exception.getClass().getSimpleName()); - } - Class beanType = handlerMethod.getBeanType(); - AbstractExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.get(beanType); - if (resolver == null) { - resolver = createExceptionMethodResolverFor(beanType); - this.exceptionHandlerCache.put(beanType, resolver); - } - InvocableHandlerMethod exceptionHandlerMethod = null; - Method method = resolver.resolveMethod(exception); - if (method != null) { - exceptionHandlerMethod = new InvocableHandlerMethod(handlerMethod.getBean(), method); - } - else { - for (MessagingAdviceBean advice : this.exceptionHandlerAdviceCache.keySet()) { - if (advice.isApplicableToBeanType(beanType)) { - resolver = this.exceptionHandlerAdviceCache.get(advice); - method = resolver.resolveMethod(exception); - if (method != null) { - exceptionHandlerMethod = new InvocableHandlerMethod(advice.resolveBean(), method); - break; - } - } - } - } - if (exceptionHandlerMethod != null) { - exceptionHandlerMethod.setArgumentResolvers(this.argumentResolvers.getResolvers()); - } - return exceptionHandlerMethod; - } - /** * Create a concrete instance of {@link AbstractExceptionHandlerMethodResolver} * that finds exception handling methods based on some criteria, e.g. based diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java new file mode 100644 index 0000000000..9bdfe47f12 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java @@ -0,0 +1,206 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.handler.invocation.reactive; + +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.messaging.handler.MessagingAdviceBean; +import org.springframework.messaging.handler.invocation.AbstractExceptionHandlerMethodResolver; +import org.springframework.util.Assert; + +/** + * Help to initialize and invoke an {@link InvocableHandlerMethod}, and to then + * apply return value handling and exception handling. Holds all necessary + * configuration necessary to do so. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +class InvocableHelper { + + private static Log logger = LogFactory.getLog(InvocableHelper.class); + + + private final HandlerMethodArgumentResolverComposite argumentResolvers = + new HandlerMethodArgumentResolverComposite(); + + private final HandlerMethodReturnValueHandlerComposite returnValueHandlers = + new HandlerMethodReturnValueHandlerComposite(); + + private ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance(); + + private final Function, AbstractExceptionHandlerMethodResolver> exceptionMethodResolverFactory; + + private final Map, AbstractExceptionHandlerMethodResolver> exceptionHandlerCache = + new ConcurrentHashMap<>(64); + + private final Map exceptionHandlerAdviceCache = + new LinkedHashMap<>(64); + + + public InvocableHelper( + Function, AbstractExceptionHandlerMethodResolver> exceptionMethodResolverFactory) { + + this.exceptionMethodResolverFactory = exceptionMethodResolverFactory; + } + + /** + * Add the arguments resolvers to use for message handling and exception + * handling methods. + */ + public void addArgumentResolvers(List resolvers) { + this.argumentResolvers.addResolvers(resolvers); + } + + /** + * Add the return value handlers to use for message handling and exception + * handling methods. + */ + public void addReturnValueHandlers(List handlers) { + this.returnValueHandlers.addHandlers(handlers); + } + + /** + * Configure the registry for adapting various reactive types. + *

By default this is an instance of {@link ReactiveAdapterRegistry} with + * default settings. + */ + public void setReactiveAdapterRegistry(ReactiveAdapterRegistry registry) { + Assert.notNull(registry, "ReactiveAdapterRegistry is required"); + this.reactiveAdapterRegistry = registry; + } + + /** + * Return the configured registry for adapting reactive types. + */ + public ReactiveAdapterRegistry getReactiveAdapterRegistry() { + return this.reactiveAdapterRegistry; + } + + /** + * Method to populate the MessagingAdviceBean cache (e.g. to support "global" + * {@code @MessageExceptionHandler}). + */ + public void registerExceptionHandlerAdvice( + MessagingAdviceBean bean, AbstractExceptionHandlerMethodResolver resolver) { + + this.exceptionHandlerAdviceCache.put(bean, resolver); + } + + + /** + * Create {@link InvocableHandlerMethod} with the configured arg resolvers. + * @param handlerMethod the target handler method to invoke + * @return the created instance + */ + + public InvocableHandlerMethod initMessageMappingMethod(HandlerMethod handlerMethod) { + InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod); + invocable.setArgumentResolvers(this.argumentResolvers.getResolvers()); + return invocable; + } + + /** + * Find an exception handling method for the given exception. + *

The default implementation searches methods in the class hierarchy of + * the HandlerMethod first and if not found, it continues searching for + * additional handling methods registered via + * {@link #registerExceptionHandlerAdvice}. + * @param handlerMethod the method where the exception was raised + * @param ex the exception raised or signaled + * @return a method to handle the exception, or {@code null} + */ + @Nullable + public InvocableHandlerMethod initExceptionHandlerMethod(HandlerMethod handlerMethod, Throwable ex) { + if (logger.isDebugEnabled()) { + logger.debug("Searching for methods to handle " + ex.getClass().getSimpleName()); + } + Class beanType = handlerMethod.getBeanType(); + AbstractExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.get(beanType); + if (resolver == null) { + resolver = this.exceptionMethodResolverFactory.apply(beanType); + this.exceptionHandlerCache.put(beanType, resolver); + } + InvocableHandlerMethod exceptionHandlerMethod = null; + Method method = resolver.resolveMethod(ex); + if (method != null) { + exceptionHandlerMethod = new InvocableHandlerMethod(handlerMethod.getBean(), method); + } + else { + for (MessagingAdviceBean advice : this.exceptionHandlerAdviceCache.keySet()) { + if (advice.isApplicableToBeanType(beanType)) { + resolver = this.exceptionHandlerAdviceCache.get(advice); + method = resolver.resolveMethod(ex); + if (method != null) { + exceptionHandlerMethod = new InvocableHandlerMethod(advice.resolveBean(), method); + break; + } + } + } + } + if (exceptionHandlerMethod != null) { + logger.debug("Found exception handler " + exceptionHandlerMethod.getShortLogMessage()); + exceptionHandlerMethod.setArgumentResolvers(this.argumentResolvers.getResolvers()); + } + else { + logger.error("No exception handling method", ex); + } + return exceptionHandlerMethod; + } + + + public Mono handleMessage(HandlerMethod handlerMethod, Message message) { + InvocableHandlerMethod invocable = initMessageMappingMethod(handlerMethod); + if (logger.isDebugEnabled()) { + logger.debug("Invoking " + invocable.getShortLogMessage()); + } + return invocable.invoke(message) + .flatMap(returnValue -> handleReturnValue(returnValue, invocable, message)) + .onErrorResume(ex -> { + InvocableHandlerMethod exHandler = initExceptionHandlerMethod(handlerMethod, ex); + if (exHandler == null) { + return Mono.error(ex); + } + if (logger.isDebugEnabled()) { + logger.debug("Invoking " + exHandler.getShortLogMessage()); + } + return exHandler.invoke(message, ex) + .flatMap(returnValue -> handleReturnValue(returnValue, exHandler, message)); + }); + } + + private Mono handleReturnValue( + @Nullable Object returnValue, HandlerMethod handlerMethod, Message message) { + + MethodParameter returnType = handlerMethod.getReturnType(); + return this.returnValueHandlers.handleReturnValue(returnValue, returnType, message); + } + +} From fa95b010cbcdb9b53672f0e226cafe2e30e1615e Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Tue, 26 Feb 2019 19:10:02 -0500 Subject: [PATCH 15/17] Direct delegation to RSocketMessageHandler Simplify handling by eliminating the use of a message channel. Instead MessageHandlerAcceptor now extends from RSocketMessageHandler and delegates directly to it. See gh-21987 --- .../messaging/ReactiveMessageChannel.java | 38 ------- .../ReactiveSubscribableChannel.java | 42 ------- .../MessageMappingMessageHandler.java | 96 +++++++++------- .../AbstractMethodMessageHandler.java | 53 +++++---- .../rsocket/MessageHandlerAcceptor.java | 77 +++++++++++++ .../messaging/rsocket/MessagingAcceptor.java | 107 ------------------ .../messaging/rsocket/MessagingRSocket.java | 44 ++++--- .../rsocket/RSocketMessageHandler.java | 38 ++----- .../DefaultReactiveMessageChannel.java | 102 ----------------- .../MessageMappingMessageHandlerTests.java | 6 +- .../reactive/MethodMessageHandlerTests.java | 11 +- ...RSocketClientToServerIntegrationTests.java | 23 +--- ...RSocketServerToClientIntegrationTests.java | 53 +++------ 13 files changed, 223 insertions(+), 467 deletions(-) delete mode 100644 spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageChannel.java delete mode 100644 spring-messaging/src/main/java/org/springframework/messaging/ReactiveSubscribableChannel.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessageHandlerAcceptor.java delete mode 100644 spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java delete mode 100644 spring-messaging/src/main/java/org/springframework/messaging/support/DefaultReactiveMessageChannel.java diff --git a/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageChannel.java b/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageChannel.java deleted file mode 100644 index 08e6537e85..0000000000 --- a/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageChannel.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2002-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 - * - * http://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.messaging; - -import reactor.core.publisher.Mono; - -/** - * Contract for reactive, non-blocking sending of messages. - * - * @author Rossen Stoyanchev - * @since 5.2 - */ -public interface ReactiveMessageChannel { - - /** - * Send a {@link Message} to this channel. If the message is sent - * successfully, return {@code true}. Or if not sent due to a non-fatal - * reason, return {@code false}. - * @param message the message to send - * @return completion {@link Mono} returning {@code true} on success, - * {@code false} if not sent, or an error signal. - */ - Mono send(Message message); - -} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/ReactiveSubscribableChannel.java b/spring-messaging/src/main/java/org/springframework/messaging/ReactiveSubscribableChannel.java deleted file mode 100644 index c3b792019e..0000000000 --- a/spring-messaging/src/main/java/org/springframework/messaging/ReactiveSubscribableChannel.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2002-2013 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 - * - * http://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.messaging; - -/** - * {@link MessageChannel} that maintains a registry of subscribers to handle - * messages sent through this channel. - * - * @author Rossen Stoyanchev - * @since 5.2 - */ -public interface ReactiveSubscribableChannel extends ReactiveMessageChannel { - - /** - * Register a message handler. - * @return {@code true} if the handler was subscribed or {@code false} if it - * was already subscribed. - */ - boolean subscribe(ReactiveMessageHandler handler); - - /** - * Un-register a message handler. - * @return {@code true} if the handler was un-registered, or {@code false} - * if was not registered. - */ - boolean unsubscribe(ReactiveMessageHandler handler); - -} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java index 6851883798..7bdad79d5a 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java @@ -30,14 +30,12 @@ import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.EmbeddedValueResolverAware; -import org.springframework.context.SmartLifecycle; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.codec.Decoder; import org.springframework.core.convert.ConversionService; import org.springframework.format.support.DefaultFormattingConversionService; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; -import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.CompositeMessageCondition; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -74,9 +72,11 @@ import org.springframework.validation.Validator; * @see AbstractEncoderMethodReturnValueHandler */ public class MessageMappingMessageHandler extends AbstractMethodMessageHandler - implements SmartLifecycle, EmbeddedValueResolverAware { + implements EmbeddedValueResolverAware { - private final ReactiveSubscribableChannel inboundChannel; + @Nullable + private Predicate> handlerPredicate = + beanType -> AnnotatedElementUtils.hasAnnotation(beanType, Controller.class); private final List> decoders = new ArrayList<>(); @@ -90,20 +90,63 @@ public class MessageMappingMessageHandler extends AbstractMethodMessageHandler AnnotatedElementUtils.hasAnnotation(beanType, Controller.class)); } + /** + * Manually configure handlers to check for {@code @MessageMapping} methods. + *

Note: the given handlers are not required to be + * annotated with {@code @Controller}. Consider also using + * {@link #setAutoDetectDisabled()} if the intent is to use these handlers + * instead of, and not in addition to {@code @Controller} classes. Or + * alternatively use {@link #setHandlerPredicate(Predicate)} to select a + * different set of beans based on a different criteria. + * @param handlers the handlers to register + * @see #setAutoDetectDisabled() + * @see #setHandlerPredicate(Predicate) + */ + public void setHandlers(List handlers) { + for (Object handler : handlers) { + detectHandlerMethods(handler); + } + // Disable auto-detection.. + this.handlerPredicate = null; + } + + /** + * Configure the predicate to use for selecting which Spring beans to check + * for {@code @MessageMapping} methods. When set to {@code null}, + * auto-detection is turned off which is what + * {@link #setAutoDetectDisabled()} does internally. + *

The predicate used by default selects {@code @Controller} classes. + * @see #setHandlers(List) + * @see #setAutoDetectDisabled() + */ + public void setHandlerPredicate(@Nullable Predicate> handlerPredicate) { + this.handlerPredicate = handlerPredicate; + } + + /** + * Return the {@link #setHandlerPredicate configured} handler predicate. + */ + @Nullable + public Predicate> getHandlerPredicate() { + return this.handlerPredicate; + } + + /** + * Disable auto-detection of {@code @MessageMapping} methods, e.g. in + * {@code @Controller}s, by setting {@link #setHandlerPredicate(Predicate) + * setHandlerPredicate(null)}. + */ + public void setAutoDetectDisabled() { + this.handlerPredicate = null; + } + /** * Configure the decoders to use for incoming payloads. */ @@ -203,34 +246,9 @@ public class MessageMappingMessageHandler extends AbstractMethodMessageHandler> initHandlerPredicate() { + return this.handlerPredicate; } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java index 0241100714..971648562e 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java @@ -30,6 +30,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import reactor.core.publisher.Mono; +import org.springframework.beans.factory.BeanNameAware; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; @@ -47,6 +48,7 @@ import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; /** * Abstract base class for reactive HandlerMethod-based message handling. @@ -61,7 +63,7 @@ import org.springframework.util.MultiValueMap; * @param the type of the Object that contains information mapping information */ public abstract class AbstractMethodMessageHandler - implements ReactiveMessageHandler, ApplicationContextAware, InitializingBean { + implements ReactiveMessageHandler, ApplicationContextAware, InitializingBean, BeanNameAware { /** * Bean name prefix for target beans behind scoped proxies. Used to exclude those @@ -79,9 +81,6 @@ public abstract class AbstractMethodMessageHandler protected final Log logger = LogFactory.getLog(getClass()); - @Nullable - private Predicate> handlerPredicate; - private ArgumentResolverConfigurer argumentResolverConfigurer = new ArgumentResolverConfigurer(); private ReturnValueHandlerConfigurer returnValueHandlerConfigurer = new ReturnValueHandlerConfigurer(); @@ -91,29 +90,14 @@ public abstract class AbstractMethodMessageHandler @Nullable private ApplicationContext applicationContext; + @Nullable + private String beanName; + private final Map handlerMethods = new LinkedHashMap<>(64); private final MultiValueMap destinationLookup = new LinkedMultiValueMap<>(64); - /** - * Configure a predicate to decide if which beans in the Spring context - * should be checked to see if they have message handling methods. - *

By default this is not set and sub-classes should configure it in - * order to enable auto-detection of message handling methods. - */ - public void setHandlerPredicate(@Nullable Predicate> handlerPredicate) { - this.handlerPredicate = handlerPredicate; - } - - /** - * Return the {@link #setHandlerPredicate configured} handler predicate. - */ - @Nullable - public Predicate> getHandlerPredicate() { - return this.handlerPredicate; - } - /** * Configure custom resolvers for handler method arguments. */ @@ -170,6 +154,16 @@ public abstract class AbstractMethodMessageHandler return this.applicationContext; } + @Override + public void setBeanName(String name) { + this.beanName = name; + } + + public String getBeanName() { + return this.beanName != null ? this.beanName : + getClass().getSimpleName() + "@" + ObjectUtils.getIdentityHexString(this); + } + /** * Subclasses can invoke this method to populate the MessagingAdviceBean cache * (e.g. to support "global" {@code @MessageExceptionHandler}). @@ -234,8 +228,9 @@ public abstract class AbstractMethodMessageHandler logger.warn("No ApplicationContext available for detecting beans with message handling methods."); return; } - if (this.handlerPredicate == null) { - logger.warn("'handlerPredicate' not configured: no auto-detection of message handling methods."); + Predicate> handlerPredicate = initHandlerPredicate(); + if (handlerPredicate == null) { + logger.warn("[" + getBeanName() + "] No auto-detection of handler methods (e.g. in @Controller)."); return; } for (String beanName : this.applicationContext.getBeanNamesForType(Object.class)) { @@ -250,13 +245,21 @@ public abstract class AbstractMethodMessageHandler logger.debug("Could not resolve target class for bean with name '" + beanName + "'", ex); } } - if (beanType != null && this.handlerPredicate.test(beanType)) { + if (beanType != null && handlerPredicate.test(beanType)) { detectHandlerMethods(beanName); } } } } + /** + * Return the predicate to use to check whether a given Spring bean should + * be introspected for message handling methods. If {@code null} is + * returned, auto-detection is effectively disabled. + */ + @Nullable + protected abstract Predicate> initHandlerPredicate(); + /** * Detect if the given handler has any methods that can handle messages and if * so register it with the extracted mapping information. diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessageHandlerAcceptor.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessageHandlerAcceptor.java new file mode 100644 index 0000000000..7ff4ec20ba --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessageHandlerAcceptor.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.util.function.Function; + +import io.rsocket.ConnectionSetupPayload; +import io.rsocket.RSocket; +import io.rsocket.SocketAcceptor; +import reactor.core.publisher.Mono; + +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.util.MimeType; + +/** + * Extension of {@link RSocketMessageHandler} that can be plugged directly into + * RSocket to receive connections either on the + * {@link io.rsocket.RSocketFactory.ClientRSocketFactory#acceptor(Function) client} or on the + * {@link io.rsocket.RSocketFactory.ServerRSocketFactory#acceptor(SocketAcceptor) server} + * side. Requests are handled by delegating to the "super" {@link #handleMessage(Message)}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public final class MessageHandlerAcceptor extends RSocketMessageHandler + implements SocketAcceptor, Function { + + @Nullable + private MimeType defaultDataMimeType; + + + /** + * Configure the default content type to use for data payloads. + *

By default this is not set. However a server acceptor will use the + * content type from the {@link ConnectionSetupPayload}, so this is typically + * required for clients but can also be used on servers as a fallback. + * @param defaultDataMimeType the MimeType to use + */ + public void setDefaultDataMimeType(@Nullable MimeType defaultDataMimeType) { + this.defaultDataMimeType = defaultDataMimeType; + } + + + @Override + public Mono accept(ConnectionSetupPayload setupPayload, RSocket sendingRSocket) { + MessagingRSocket rsocket = createRSocket(sendingRSocket); + // Allow handling of the ConnectionSetupPayload via @MessageMapping methods. + // However, if the handling is to make requests to the client, it's expected + // it will do so decoupled from the handling, e.g. via .subscribe(). + return rsocket.handleConnectionSetupPayload(setupPayload).then(Mono.just(rsocket)); + } + + @Override + public RSocket apply(RSocket sendingRSocket) { + return createRSocket(sendingRSocket); + } + + private MessagingRSocket createRSocket(RSocket rsocket) { + return new MessagingRSocket( + this::handleMessage, rsocket, this.defaultDataMimeType, getRSocketStrategies()); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java deleted file mode 100644 index e4b7e44ad7..0000000000 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingAcceptor.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2002-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 - * - * http://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.messaging.rsocket; - -import java.util.function.Function; -import java.util.function.Predicate; - -import io.rsocket.ConnectionSetupPayload; -import io.rsocket.RSocket; -import io.rsocket.SocketAcceptor; -import reactor.core.publisher.Mono; - -import org.springframework.lang.Nullable; -import org.springframework.messaging.Message; -import org.springframework.messaging.ReactiveMessageChannel; -import org.springframework.util.Assert; -import org.springframework.util.MimeType; - -/** - * RSocket acceptor for - * {@link io.rsocket.RSocketFactory.ClientRSocketFactory#acceptor(Function) client} or - * {@link io.rsocket.RSocketFactory.ServerRSocketFactory#acceptor(SocketAcceptor) server} - * side use. It wraps requests with a {@link Message} envelope and sends them - * to a {@link ReactiveMessageChannel} for handling, e.g. via - * {@code @MessageMapping} method. - * - * @author Rossen Stoyanchev - * @since 5.2 - */ -public final class MessagingAcceptor implements SocketAcceptor, Function { - - private final ReactiveMessageChannel messageChannel; - - private final RSocketStrategies rsocketStrategies; - - @Nullable - private MimeType defaultDataMimeType; - - - /** - * Constructor with a message channel to send messages to. - * @param messageChannel the message channel to use - *

This assumes a Spring configuration setup with a - * {@code ReactiveMessageChannel} and an {@link RSocketMessageHandler} which - * by default auto-detects {@code @MessageMapping} methods in - * {@code @Controller} classes, but can also be configured with a - * {@link RSocketMessageHandler#setHandlerPredicate(Predicate) handlerPredicate} - * or with handler instances. - */ - public MessagingAcceptor(ReactiveMessageChannel messageChannel) { - this(messageChannel, RSocketStrategies.builder().build()); - } - - /** - * Variant of {@link #MessagingAcceptor(ReactiveMessageChannel)} with an - * {@link RSocketStrategies} for wrapping the sending {@link RSocket} as - * {@link RSocketRequester}. - */ - public MessagingAcceptor(ReactiveMessageChannel messageChannel, RSocketStrategies rsocketStrategies) { - Assert.notNull(messageChannel, "ReactiveMessageChannel is required"); - Assert.notNull(rsocketStrategies, "RSocketStrategies is required"); - this.messageChannel = messageChannel; - this.rsocketStrategies = rsocketStrategies; - } - - - /** - * Configure the default content type to use for data payloads. - *

By default this is not set. However a server acceptor will use the - * content type from the {@link ConnectionSetupPayload}. - * @param defaultDataMimeType the MimeType to use - */ - public void setDefaultDataMimeType(@Nullable MimeType defaultDataMimeType) { - this.defaultDataMimeType = defaultDataMimeType; - } - - - @Override - public Mono accept(ConnectionSetupPayload setupPayload, RSocket sendingRSocket) { - MessagingRSocket rsocket = createRSocket(sendingRSocket); - rsocket.handleConnectionSetupPayload(setupPayload).subscribe(); - return Mono.just(rsocket); - } - - @Override - public RSocket apply(RSocket sendingRSocket) { - return createRSocket(sendingRSocket); - } - - private MessagingRSocket createRSocket(RSocket rsocket) { - return new MessagingRSocket(this.messageChannel, rsocket, this.defaultDataMimeType, this.rsocketStrategies); - } - -} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java index 824ff3901d..6395393739 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java @@ -34,7 +34,6 @@ import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.MessageDeliveryException; import org.springframework.messaging.MessageHeaders; -import org.springframework.messaging.ReactiveMessageChannel; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; import org.springframework.messaging.support.MessageBuilder; @@ -45,16 +44,16 @@ import org.springframework.util.MimeTypeUtils; import org.springframework.util.StringUtils; /** - * Package private implementation of {@link RSocket} that is is hooked into an - * RSocket client or server via {@link MessagingAcceptor} to accept and handle - * requests. + * Implementation of {@link RSocket} that wraps incoming requests with a + * {@link Message}, delegates to a {@link Function} for handling, and then + * obtains the response from a "reply" header. * * @author Rossen Stoyanchev * @since 5.2 */ class MessagingRSocket extends AbstractRSocket { - private final ReactiveMessageChannel messageChannel; + private final Function, Mono> handler; private final RSocketRequester requester; @@ -64,19 +63,24 @@ class MessagingRSocket extends AbstractRSocket { private final RSocketStrategies strategies; - MessagingRSocket(ReactiveMessageChannel messageChannel, - RSocket sendingRSocket, @Nullable MimeType defaultDataMimeType, RSocketStrategies strategies) { + MessagingRSocket(Function, Mono> handler, RSocket sendingRSocket, + @Nullable MimeType defaultDataMimeType, RSocketStrategies strategies) { - Assert.notNull(messageChannel, "'messageChannel' is required"); + Assert.notNull(handler, "'handler' is required"); Assert.notNull(sendingRSocket, "'sendingRSocket' is required"); - this.messageChannel = messageChannel; + this.handler = handler; this.requester = RSocketRequester.create(sendingRSocket, defaultDataMimeType, strategies); this.dataMimeType = defaultDataMimeType; this.strategies = strategies; } - + /** + * Wrap the {@link ConnectionSetupPayload} with a {@link Message} and + * delegate to {@link #handle(Payload)} for handling. + * @param payload the connection payload + * @return completion handle for success or error + */ public Mono handleConnectionSetupPayload(ConnectionSetupPayload payload) { if (StringUtils.hasText(payload.dataMimeType())) { this.dataMimeType = MimeTypeUtils.parseMimeType(payload.dataMimeType()); @@ -111,32 +115,26 @@ class MessagingRSocket extends AbstractRSocket { @Override public Mono metadataPush(Payload payload) { - // This won't be very useful until createHeaders starting doing something more with metadata.. + // Not very useful until createHeaders does more with metadata return handle(payload); } private Mono handle(Payload payload) { - Message message = MessageBuilder.createMessage( - Mono.fromCallable(() -> wrapPayloadData(payload)), - createHeaders(payload, null)); - - return this.messageChannel.send(message).flatMap(result -> result ? - Mono.empty() : Mono.error(new MessageDeliveryException("RSocket request not handled"))); + Mono.fromCallable(() -> wrapPayloadData(payload)), createHeaders(payload, null)); + return this.handler.apply(message); } private Flux handleAndReply(Payload firstPayload, Flux payloads) { - MonoProcessor> replyMono = MonoProcessor.create(); - Message message = MessageBuilder.createMessage( payloads.map(this::wrapPayloadData).doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release), createHeaders(firstPayload, replyMono)); - - return this.messageChannel.send(message).flatMapMany(result -> - result && replyMono.isTerminated() ? replyMono.flatMapMany(Function.identity()) : - Mono.error(new MessageDeliveryException("RSocket request not handled"))); + return this.handler.apply(message) + .thenMany(Flux.defer(() -> replyMono.isTerminated() ? + replyMono.flatMapMany(Function.identity()) : + Mono.error(new MessageDeliveryException("RSocket request not handled")))); } private MessageHeaders createHeaders(Payload payload, @Nullable MonoProcessor replyMono) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java index 93d5fb43a6..cac3ee2f4a 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java @@ -21,7 +21,6 @@ import java.util.List; import org.springframework.core.codec.Decoder; import org.springframework.core.codec.Encoder; import org.springframework.lang.Nullable; -import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.annotation.support.reactive.MessageMappingMessageHandler; import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; import org.springframework.util.Assert; @@ -44,20 +43,6 @@ public class RSocketMessageHandler extends MessageMappingMessageHandler { private RSocketStrategies rsocketStrategies; - public RSocketMessageHandler(ReactiveSubscribableChannel inboundChannel) { - super(inboundChannel); - } - - public RSocketMessageHandler(ReactiveSubscribableChannel inboundChannel, List handlers) { - super(inboundChannel); - setHandlerPredicate(null); // disable auto-detection.. - for (Object handler : handlers) { - detectHandlerMethods(handler); - } - } - - - /** * Configure the encoders to use for encoding handler method return values. */ @@ -76,8 +61,8 @@ public class RSocketMessageHandler extends MessageMappingMessageHandler { * Provide configuration in the form of {@link RSocketStrategies}. This is * an alternative to using {@link #setEncoders(List)}, * {@link #setDecoders(List)}, and others directly. It is convenient when - * you also need to configure an {@link RSocketRequester} in which case - * the strategies can be configured once and used in multiple places. + * you also configuring an {@link RSocketRequester} in which case the + * {@link RSocketStrategies} encapsulates required configuration for re-use. * @param rsocketStrategies the strategies to use */ public void setRSocketStrategies(RSocketStrategies rsocketStrategies) { @@ -91,19 +76,18 @@ public class RSocketMessageHandler extends MessageMappingMessageHandler { /** * Return the {@code RSocketStrategies} instance provided via * {@link #setRSocketStrategies rsocketStrategies}, or - * otherwise a new instance populated with the configured - * {@link #setEncoders(List) encoders}, {@link #setDecoders(List) decoders} - * and others. + * otherwise initialize it with the configured {@link #setEncoders(List) + * encoders}, {@link #setDecoders(List) decoders}, and others. */ public RSocketStrategies getRSocketStrategies() { - if (this.rsocketStrategies != null) { - return this.rsocketStrategies; + if (this.rsocketStrategies == null) { + this.rsocketStrategies = RSocketStrategies.builder() + .decoder(getDecoders().toArray(new Decoder[0])) + .encoder(getEncoders().toArray(new Encoder[0])) + .reactiveAdapterStrategy(getReactiveAdapterRegistry()) + .build(); } - return RSocketStrategies.builder() - .decoder(getDecoders().toArray(new Decoder[0])) - .encoder(getEncoders().toArray(new Encoder[0])) - .reactiveAdapterStrategy(getReactiveAdapterRegistry()) - .build(); + return this.rsocketStrategies; } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/support/DefaultReactiveMessageChannel.java b/spring-messaging/src/main/java/org/springframework/messaging/support/DefaultReactiveMessageChannel.java deleted file mode 100644 index a5283cdfe7..0000000000 --- a/spring-messaging/src/main/java/org/springframework/messaging/support/DefaultReactiveMessageChannel.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2002-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 - * - * http://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.messaging.support; - -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -import org.springframework.beans.factory.BeanNameAware; -import org.springframework.messaging.Message; -import org.springframework.messaging.ReactiveMessageHandler; -import org.springframework.messaging.ReactiveSubscribableChannel; -import org.springframework.util.ObjectUtils; - -/** - * Default implementation of {@link ReactiveSubscribableChannel}. - * - * @author Rossen Stoyanchev - * @since 5.2 - */ -public class DefaultReactiveMessageChannel implements ReactiveSubscribableChannel, BeanNameAware { - - private static final Mono SUCCESS_RESULT = Mono.just(true); - - private static Log logger = LogFactory.getLog(DefaultReactiveMessageChannel.class); - - - private final Set handlers = new CopyOnWriteArraySet<>(); - - private String beanName; - - - public DefaultReactiveMessageChannel() { - this.beanName = getClass().getSimpleName() + "@" + ObjectUtils.getIdentityHexString(this); - } - - - /** - * A message channel uses the bean name primarily for logging purposes. - */ - @Override - public void setBeanName(String name) { - this.beanName = name; - } - - /** - * Return the bean name for this message channel. - */ - public String getBeanName() { - return this.beanName; - } - - - @Override - public boolean subscribe(ReactiveMessageHandler handler) { - boolean result = this.handlers.add(handler); - if (result) { - if (logger.isDebugEnabled()) { - logger.debug(getBeanName() + " added " + handler); - } - } - return result; - } - - - @Override - public boolean unsubscribe(ReactiveMessageHandler handler) { - boolean result = this.handlers.remove(handler); - if (result) { - if (logger.isDebugEnabled()) { - logger.debug(getBeanName() + " removed " + handler); - } - } - return result; - } - - - @Override - public Mono send(Message message) { - return Flux.fromIterable(this.handlers) - .concatMap(handler -> handler.handleMessage(message)) - .then(SUCCESS_RESULT); - } - -} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java index a34ddc8b0c..af1a643de5 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java @@ -38,7 +38,6 @@ import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.messaging.Message; -import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; import org.springframework.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -48,7 +47,6 @@ import org.springframework.stereotype.Controller; import static java.nio.charset.StandardCharsets.*; import static org.junit.Assert.*; -import static org.mockito.Mockito.*; /** * Unit tests for {@link MessageMappingMessageHandler}. @@ -134,9 +132,7 @@ public class MessageMappingMessageHandlerTests { context.registerSingleton("testController", TestController.class); context.refresh(); - ReactiveSubscribableChannel channel = mock(ReactiveSubscribableChannel.class); - - MessageMappingMessageHandler messageHandler = new MessageMappingMessageHandler(channel); + MessageMappingMessageHandler messageHandler = new MessageMappingMessageHandler(); messageHandler.getReturnValueHandlerConfigurer().addCustomHandler(this.returnValueHandler); messageHandler.setApplicationContext(context); messageHandler.setEmbeddedValueResolver(new EmbeddedValueResolver(context.getBeanFactory())); diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java index 82a448e14f..6022876719 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Predicate; import org.hamcrest.Matchers; import org.junit.Test; @@ -193,11 +194,6 @@ public class MethodMessageHandlerTests { private PathMatcher pathMatcher = new AntPathMatcher(); - public TestMethodMessageHandler() { - setHandlerPredicate(handlerType -> handlerType.getName().endsWith("Controller")); - } - - @Override protected List initArgumentResolvers() { return Collections.emptyList(); @@ -208,6 +204,11 @@ public class MethodMessageHandlerTests { return Collections.singletonList(this.returnValueHandler); } + @Override + protected Predicate> initHandlerPredicate() { + return handlerType -> handlerType.getName().endsWith("Controller"); + } + @Nullable public Object getLastReturnValue() { return this.returnValueHandler.getLastReturnValue(); diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java index ff13b07100..a37ce6b276 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java @@ -35,11 +35,8 @@ 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.messaging.ReactiveMessageChannel; -import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.support.DefaultReactiveMessageChannel; import org.springframework.stereotype.Controller; import org.springframework.util.MimeTypeUtils; @@ -69,12 +66,9 @@ public class RSocketClientToServerIntegrationTests { context = new AnnotationConfigApplicationContext(ServerConfig.class); - ReactiveMessageChannel messageChannel = context.getBean(ReactiveMessageChannel.class); - RSocketStrategies rsocketStrategies = context.getBean(RSocketStrategies.class); - server = RSocketFactory.receive() .addServerPlugin(interceptor) - .acceptor(new MessagingAcceptor(messageChannel)) + .acceptor(context.getBean(MessageHandlerAcceptor.class)) .transport(TcpServerTransport.create("localhost", 7000)) .start() .block(); @@ -86,7 +80,7 @@ public class RSocketClientToServerIntegrationTests { .block(); requester = RSocketRequester.create( - client, MimeTypeUtils.TEXT_PLAIN, rsocketStrategies); + client, MimeTypeUtils.TEXT_PLAIN, context.getBean(RSocketStrategies.class)); } @AfterClass @@ -254,15 +248,10 @@ public class RSocketClientToServerIntegrationTests { } @Bean - public ReactiveSubscribableChannel rsocketChannel() { - return new DefaultReactiveMessageChannel(); - } - - @Bean - public RSocketMessageHandler rsocketMessageHandler() { - RSocketMessageHandler handler = new RSocketMessageHandler(rsocketChannel()); - handler.setRSocketStrategies(rsocketStrategies()); - return handler; + public MessageHandlerAcceptor messageHandlerAcceptor() { + MessageHandlerAcceptor acceptor = new MessageHandlerAcceptor(); + acceptor.setRSocketStrategies(rsocketStrategies()); + return acceptor; } @Bean diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java index 6a31f906dc..3a05e01cc8 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java @@ -17,7 +17,6 @@ package org.springframework.messaging.rsocket; import java.time.Duration; import java.util.Collections; -import java.util.List; import io.rsocket.Closeable; import io.rsocket.RSocket; @@ -40,10 +39,7 @@ 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.messaging.ReactiveMessageChannel; -import org.springframework.messaging.ReactiveSubscribableChannel; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.support.DefaultReactiveMessageChannel; import org.springframework.stereotype.Controller; /** @@ -57,23 +53,15 @@ public class RSocketServerToClientIntegrationTests { private static Closeable server; - private static MessagingAcceptor clientAcceptor; - @BeforeClass @SuppressWarnings("ConstantConditions") public static void setupOnce() { - context = new AnnotationConfigApplicationContext(ServerConfig.class); - - ReactiveMessageChannel messageChannel = context.getBean("serverChannel", ReactiveMessageChannel.class); - RSocketStrategies rsocketStrategies = context.getBean(RSocketStrategies.class); - - clientAcceptor = new MessagingAcceptor( - context.getBean("clientChannel", ReactiveMessageChannel.class)); + context = new AnnotationConfigApplicationContext(RSocketConfig.class); server = RSocketFactory.receive() - .acceptor(new MessagingAcceptor(messageChannel, rsocketStrategies)) + .acceptor(context.getBean("serverAcceptor", MessageHandlerAcceptor.class)) .transport(TcpServerTransport.create("localhost", 7000)) .start() .block(); @@ -116,7 +104,7 @@ public class RSocketServerToClientIntegrationTests { rsocket = RSocketFactory.connect() .setupPayload(DefaultPayload.create("", destination)) .dataMimeType("text/plain") - .acceptor(clientAcceptor) + .acceptor(context.getBean("clientAcceptor", MessageHandlerAcceptor.class)) .transport(TcpClientTransport.create("localhost", 7000)) .start() .block(); @@ -212,13 +200,13 @@ public class RSocketServerToClientIntegrationTests { Mono.fromRunnable(testEcho) .doOnError(ex -> result.onError(ex)) .doOnSuccess(o -> result.onComplete()) - .subscribeOn(Schedulers.elastic()) + .subscribeOn(Schedulers.elastic()) // StepVerifier will block .subscribe(); } } - private static class ClientController { + private static class ClientHandler { final ReplayProcessor fireForgetPayloads = ReplayProcessor.create(); @@ -251,11 +239,11 @@ public class RSocketServerToClientIntegrationTests { @Configuration - static class ServerConfig { + static class RSocketConfig { @Bean - public ClientController clientController() { - return new ClientController(); + public ClientHandler clientHandler() { + return new ClientHandler(); } @Bean @@ -264,26 +252,17 @@ public class RSocketServerToClientIntegrationTests { } @Bean - public ReactiveSubscribableChannel clientChannel() { - return new DefaultReactiveMessageChannel(); + public MessageHandlerAcceptor clientAcceptor() { + MessageHandlerAcceptor acceptor = new MessageHandlerAcceptor(); + acceptor.setHandlers(Collections.singletonList(clientHandler())); + acceptor.setAutoDetectDisabled(); + acceptor.setRSocketStrategies(rsocketStrategies()); + return acceptor; } @Bean - public ReactiveSubscribableChannel serverChannel() { - return new DefaultReactiveMessageChannel(); - } - - @Bean - public RSocketMessageHandler clientMessageHandler() { - List handlers = Collections.singletonList(clientController()); - RSocketMessageHandler handler = new RSocketMessageHandler(clientChannel(), handlers); - handler.setRSocketStrategies(rsocketStrategies()); - return handler; - } - - @Bean - public RSocketMessageHandler serverMessageHandler() { - RSocketMessageHandler handler = new RSocketMessageHandler(serverChannel()); + public MessageHandlerAcceptor serverAcceptor() { + MessageHandlerAcceptor handler = new MessageHandlerAcceptor(); handler.setRSocketStrategies(rsocketStrategies()); return handler; } From 23b39ad27be8fa1c8bb058d4e99903dd9ae4a39a Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Wed, 27 Feb 2019 12:08:51 -0500 Subject: [PATCH 16/17] Explicit handling of void return values Do give HandlerMethodReturnValueHandler's a chance to handle return values so the RSocket reply header is always set. See gh-21987 --- ...stractEncoderMethodReturnValueHandler.java | 13 ++++ .../invocation/reactive/InvocableHelper.java | 2 + .../messaging/rsocket/MessagingRSocket.java | 5 +- .../rsocket/RSocketMessageHandler.java | 17 ++++++ .../RSocketPayloadReturnValueHandler.java | 30 +++++++--- .../TestEncoderMethodReturnValueHandler.java | 6 ++ ...RSocketClientToServerIntegrationTests.java | 60 ++++++++++--------- 7 files changed, 94 insertions(+), 39 deletions(-) diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java index 5c185ce45f..28387c292d 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java @@ -104,6 +104,10 @@ public abstract class AbstractEncoderMethodReturnValueHandler implements Handler public Mono handleReturnValue( @Nullable Object returnValue, MethodParameter returnType, Message message) { + if (returnValue == null) { + return handleNoContent(returnType, message); + } + DataBufferFactory bufferFactory = (DataBufferFactory) message.getHeaders() .getOrDefault(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER, this.defaultBufferFactory); @@ -202,4 +206,13 @@ public abstract class AbstractEncoderMethodReturnValueHandler implements Handler protected abstract Mono handleEncodedContent( Flux encodedContent, MethodParameter returnType, Message message); + /** + * Invoked for a {@code null} return value, which could mean a void method + * or method returning an async type parameterized by void. + * @param returnType return type of the handler method that produced the data + * @param message the input message handled by the handler method + * @return completion {@code Mono} for the handling + */ + protected abstract Mono handleNoContent(MethodParameter returnType, Message message); + } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java index 9bdfe47f12..84e5e766e4 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java @@ -182,6 +182,7 @@ class InvocableHelper { logger.debug("Invoking " + invocable.getShortLogMessage()); } return invocable.invoke(message) + .switchIfEmpty(Mono.defer(() -> handleReturnValue(null, invocable, message))) .flatMap(returnValue -> handleReturnValue(returnValue, invocable, message)) .onErrorResume(ex -> { InvocableHandlerMethod exHandler = initExceptionHandlerMethod(handlerMethod, ex); @@ -192,6 +193,7 @@ class InvocableHelper { logger.debug("Invoking " + exHandler.getShortLogMessage()); } return exHandler.invoke(message, ex) + .switchIfEmpty(Mono.defer(() -> handleReturnValue(null, exHandler, message))) .flatMap(returnValue -> handleReturnValue(returnValue, exHandler, message)); }); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java index 6395393739..2cfde5aa41 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java @@ -32,7 +32,6 @@ import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.PooledDataBuffer; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; -import org.springframework.messaging.MessageDeliveryException; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; @@ -123,6 +122,7 @@ class MessagingRSocket extends AbstractRSocket { private Mono handle(Payload payload) { Message message = MessageBuilder.createMessage( Mono.fromCallable(() -> wrapPayloadData(payload)), createHeaders(payload, null)); + return this.handler.apply(message); } @@ -131,10 +131,11 @@ class MessagingRSocket extends AbstractRSocket { Message message = MessageBuilder.createMessage( payloads.map(this::wrapPayloadData).doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release), createHeaders(firstPayload, replyMono)); + return this.handler.apply(message) .thenMany(Flux.defer(() -> replyMono.isTerminated() ? replyMono.flatMapMany(Function.identity()) : - Mono.error(new MessageDeliveryException("RSocket request not handled")))); + Mono.error(new IllegalStateException("Something went wrong: reply Mono not set")))); } private MessageHeaders createHeaders(Payload payload, @Nullable MonoProcessor replyMono) { diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java index cac3ee2f4a..3e038598ac 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java @@ -21,9 +21,12 @@ import java.util.List; import org.springframework.core.codec.Decoder; import org.springframework.core.codec.Encoder; import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageDeliveryException; import org.springframework.messaging.handler.annotation.support.reactive.MessageMappingMessageHandler; import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * RSocket-specific extension of {@link MessageMappingMessageHandler}. @@ -105,4 +108,18 @@ public class RSocketMessageHandler extends MessageMappingMessageHandler { return handlers; } + @Override + protected void handleNoMatch(@Nullable String destination, Message message) { + + // MessagingRSocket will raise an error anyway if reply Mono is expected + // Here we raise a more helpful message a destination is present + + // It is OK if some messages (ConnectionSetupPayload, metadataPush) are not handled + // We need a better way to avoid raising errors for those + + if (StringUtils.hasText(destination)) { + throw new MessageDeliveryException("No handler for destination '" + destination + "'"); + } + } + } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java index c841736a7c..4c671c4e13 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java @@ -26,6 +26,7 @@ import org.springframework.core.MethodParameter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.codec.Encoder; import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.handler.invocation.reactive.AbstractEncoderMethodReturnValueHandler; import org.springframework.util.Assert; @@ -58,15 +59,28 @@ public class RSocketPayloadReturnValueHandler extends AbstractEncoderMethodRetur protected Mono handleEncodedContent( Flux encodedContent, MethodParameter returnType, Message message) { - Object headerValue = message.getHeaders().get(RESPONSE_HEADER); - Assert.notNull(headerValue, "Missing '" + RESPONSE_HEADER + "'"); - Assert.isInstanceOf(MonoProcessor.class, headerValue, "Expected MonoProcessor"); - - MonoProcessor> monoProcessor = (MonoProcessor>) headerValue; - monoProcessor.onNext(encodedContent.map(PayloadUtils::createPayload)); - monoProcessor.onComplete(); - + MonoProcessor> replyMono = getReplyMono(message); + Assert.notNull(replyMono, "Missing '" + RESPONSE_HEADER + "'"); + replyMono.onNext(encodedContent.map(PayloadUtils::createPayload)); + replyMono.onComplete(); return Mono.empty(); } + @Override + protected Mono handleNoContent(MethodParameter returnType, Message message) { + MonoProcessor> replyMono = getReplyMono(message); + if (replyMono != null) { + replyMono.onComplete(); + } + return Mono.empty(); + } + + @Nullable + @SuppressWarnings("unchecked") + private MonoProcessor> getReplyMono(Message message) { + Object headerValue = message.getHeaders().get(RESPONSE_HEADER); + Assert.state(headerValue == null || headerValue instanceof MonoProcessor, "Expected MonoProcessor"); + return (MonoProcessor>) headerValue; + } + } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java index 3a47d53af9..2d07a4ad86 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java @@ -60,4 +60,10 @@ public class TestEncoderMethodReturnValueHandler extends AbstractEncoderMethodRe this.encodedContent = encodedContent.cache(); return this.encodedContent.then(); } + + @Override + protected Mono handleNoContent(MethodParameter returnType, Message message) { + this.encodedContent = Flux.empty(); + return Mono.empty(); + } } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java index a37ce6b276..6e189e241e 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java @@ -111,83 +111,73 @@ public class RSocketClientToServerIntegrationTests { @Test public void echo() { - Flux result = Flux.range(1, 3).concatMap(i -> requester.route("echo").data("Hello " + i).retrieveMono(String.class)); StepVerifier.create(result) - .expectNext("Hello 1") - .expectNext("Hello 2") - .expectNext("Hello 3") + .expectNext("Hello 1").expectNext("Hello 2").expectNext("Hello 3") .verifyComplete(); } @Test public void echoAsync() { - Flux result = Flux.range(1, 3).concatMap(i -> requester.route("echo-async").data("Hello " + i).retrieveMono(String.class)); StepVerifier.create(result) - .expectNext("Hello 1 async") - .expectNext("Hello 2 async") - .expectNext("Hello 3 async") + .expectNext("Hello 1 async").expectNext("Hello 2 async").expectNext("Hello 3 async") .verifyComplete(); } @Test public void echoStream() { - Flux result = requester.route("echo-stream").data("Hello").retrieveFlux(String.class); StepVerifier.create(result) - .expectNext("Hello 0") - .expectNextCount(5) - .expectNext("Hello 6") - .expectNext("Hello 7") + .expectNext("Hello 0").expectNextCount(6).expectNext("Hello 7") .thenCancel() .verify(); } @Test public void echoChannel() { - Flux result = requester.route("echo-channel") .data(Flux.range(1, 10).map(i -> "Hello " + i), String.class) .retrieveFlux(String.class); StepVerifier.create(result) - .expectNext("Hello 1 async") - .expectNextCount(7) - .expectNext("Hello 9 async") - .expectNext("Hello 10 async") + .expectNext("Hello 1 async").expectNextCount(8).expectNext("Hello 10 async") .verifyComplete(); } + @Test + public void voidReturnValue() { + Flux result = requester.route("void-return-value").data("Hello").retrieveFlux(String.class); + StepVerifier.create(result).verifyComplete(); + } + + @Test + public void voidReturnValueFromExceptionHandler() { + Flux result = requester.route("void-return-value").data("bad").retrieveFlux(String.class); + StepVerifier.create(result).verifyComplete(); + } + @Test public void handleWithThrownException() { - Mono result = requester.route("thrown-exception").data("a").retrieveMono(String.class); - - StepVerifier.create(result) - .expectNext("Invalid input error handled") - .verifyComplete(); + StepVerifier.create(result).expectNext("Invalid input error handled").verifyComplete(); } @Test public void handleWithErrorSignal() { - Mono result = requester.route("error-signal").data("a").retrieveMono(String.class); - - StepVerifier.create(result) - .expectNext("Invalid input error handled") - .verifyComplete(); + StepVerifier.create(result).expectNext("Invalid input error handled").verifyComplete(); } @Test public void noMatchingRoute() { Mono result = requester.route("invalid").data("anything").retrieveMono(String.class); - StepVerifier.create(result).verifyErrorMessage("RSocket request not handled"); + StepVerifier.create(result).verifyErrorMessage("No handler for destination 'invalid'"); } @@ -232,10 +222,22 @@ public class RSocketClientToServerIntegrationTests { return Mono.error(new IllegalArgumentException("Invalid input error")); } + @MessageMapping("void-return-value") + Mono voidReturnValue(String payload) { + return !payload.equals("bad") ? + Mono.delay(Duration.ofMillis(10)).then(Mono.empty()) : + Mono.error(new IllegalStateException("bad")); + } + @MessageExceptionHandler Mono handleException(IllegalArgumentException ex) { return Mono.delay(Duration.ofMillis(10)).map(aLong -> ex.getMessage() + " handled"); } + + @MessageExceptionHandler + Mono handleExceptionWithVoidReturnValue(IllegalStateException ex) { + return Mono.delay(Duration.ofMillis(10)).then(Mono.empty()); + } } From 9e7f557b4ac5ebd72ba4409a962fa3cb0b026799 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Mon, 4 Mar 2019 23:34:53 -0500 Subject: [PATCH 17/17] Updates for buffer management in RSocket - Integration tests run with zero copy configuration. - RSocketBufferLeakTests has been added. - Updates in MessagingRSocket to ensure proper release See gh-21987 --- .../AbstractDataBufferAllocatingTestCase.java | 8 +- .../AbstractMethodMessageHandler.java | 6 +- .../rsocket/DefaultRSocketRequester.java | 8 +- .../rsocket/DefaultRSocketStrategies.java | 20 +- .../messaging/rsocket/MessagingRSocket.java | 62 ++- .../messaging/rsocket/PayloadUtils.java | 35 +- .../messaging/rsocket/RSocketStrategies.java | 21 +- .../rsocket/RSocketBufferLeakTests.java | 466 ++++++++++++++++++ ...RSocketClientToServerIntegrationTests.java | 6 + ...RSocketServerToClientIntegrationTests.java | 6 + 10 files changed, 581 insertions(+), 57 deletions(-) create mode 100644 spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketBufferLeakTests.java diff --git a/spring-core/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java b/spring-core/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java index 27bd39d756..8a66e528c2 100644 --- a/spring-core/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java +++ b/spring-core/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-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. @@ -105,13 +105,15 @@ public abstract class AbstractDataBufferAllocatingTestCase { */ protected void waitForDataBufferRelease(Duration duration) throws InterruptedException { Instant start = Instant.now(); - while (Instant.now().isBefore(start.plus(duration))) { + while (true) { try { verifyAllocations(); break; } catch (AssertionError ex) { - // ignore; + if (Instant.now().isAfter(start.plus(duration))) { + throw ex; + } } Thread.sleep(50); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java index 971648562e..f4231151f0 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java @@ -396,10 +396,10 @@ public abstract class AbstractMethodMessageHandler if (matches.size() > 1) { Match secondBestMatch = matches.get(1); if (comparator.compare(bestMatch, secondBestMatch) == 0) { - Method m1 = bestMatch.handlerMethod.getMethod(); - Method m2 = secondBestMatch.handlerMethod.getMethod(); + HandlerMethod m1 = bestMatch.handlerMethod; + HandlerMethod m2 = secondBestMatch.handlerMethod; throw new IllegalStateException("Ambiguous handler methods mapped for destination '" + - destination + "': {" + m1 + ", " + m2 + "}"); + destination + "': {" + m1.getShortLogMessage() + ", " + m2.getShortLogMessage() + "}"); } } return bestMatch; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java index 94ed888f25..c59e99386f 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java @@ -244,7 +244,7 @@ final class DefaultRSocketRequester implements RSocketRequester { Decoder decoder = strategies.decoder(elementType, dataMimeType); return (Mono) decoder.decodeToMono( - payloadMono.map(this::wrapPayloadData), elementType, dataMimeType, EMPTY_HINTS); + payloadMono.map(this::retainDataAndReleasePayload), elementType, dataMimeType, EMPTY_HINTS); } @SuppressWarnings("unchecked") @@ -260,12 +260,12 @@ final class DefaultRSocketRequester implements RSocketRequester { Decoder decoder = strategies.decoder(elementType, dataMimeType); - return payloadFlux.map(this::wrapPayloadData).concatMap(dataBuffer -> + return payloadFlux.map(this::retainDataAndReleasePayload).concatMap(dataBuffer -> (Mono) decoder.decodeToMono(Mono.just(dataBuffer), elementType, dataMimeType, EMPTY_HINTS)); } - private DataBuffer wrapPayloadData(Payload payload) { - return PayloadUtils.wrapPayloadData(payload, strategies.dataBufferFactory()); + private DataBuffer retainDataAndReleasePayload(Payload payload) { + return PayloadUtils.retainDataAndReleasePayload(payload, strategies.dataBufferFactory()); } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java index 271e06e285..a2a12293e9 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java @@ -21,14 +21,13 @@ import java.util.Collections; import java.util.List; import java.util.function.Consumer; -import io.netty.buffer.PooledByteBufAllocator; - import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.codec.Decoder; import org.springframework.core.codec.Encoder; import org.springframework.core.io.buffer.DataBufferFactory; -import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; /** * Default, package-private {@link RSocketStrategies} implementation. @@ -88,11 +87,10 @@ final class DefaultRSocketStrategies implements RSocketStrategies { private final List> decoders = new ArrayList<>(); - @Nullable - private ReactiveAdapterRegistry adapterRegistry; + private ReactiveAdapterRegistry adapterRegistry = ReactiveAdapterRegistry.getSharedInstance(); @Nullable - private DataBufferFactory bufferFactory; + private DataBufferFactory dataBufferFactory; @Override @@ -121,23 +119,21 @@ final class DefaultRSocketStrategies implements RSocketStrategies { @Override public Builder reactiveAdapterStrategy(ReactiveAdapterRegistry registry) { + Assert.notNull(registry, "ReactiveAdapterRegistry is required"); this.adapterRegistry = registry; return this; } @Override public Builder dataBufferFactory(DataBufferFactory bufferFactory) { - this.bufferFactory = bufferFactory; + this.dataBufferFactory = bufferFactory; return this; } @Override public RSocketStrategies build() { - return new DefaultRSocketStrategies(this.encoders, this.decoders, - this.adapterRegistry != null ? - this.adapterRegistry : ReactiveAdapterRegistry.getSharedInstance(), - this.bufferFactory != null ? this.bufferFactory : - new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT)); + return new DefaultRSocketStrategies(this.encoders, this.decoders, this.adapterRegistry, + this.dataBufferFactory != null ? this.dataBufferFactory : new DefaultDataBufferFactory()); } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java index 2cfde5aa41..a941566904 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java @@ -15,6 +15,7 @@ */ package org.springframework.messaging.rsocket; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import io.rsocket.AbstractRSocket; @@ -29,7 +30,7 @@ import reactor.core.publisher.MonoProcessor; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.core.io.buffer.PooledDataBuffer; +import org.springframework.core.io.buffer.NettyDataBuffer; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; @@ -84,6 +85,9 @@ class MessagingRSocket extends AbstractRSocket { if (StringUtils.hasText(payload.dataMimeType())) { this.dataMimeType = MimeTypeUtils.parseMimeType(payload.dataMimeType()); } + // frameDecoder does not apply to connectionSetupPayload + // so retain here since handle expects it.. + payload.retain(); return handle(payload); } @@ -120,54 +124,72 @@ class MessagingRSocket extends AbstractRSocket { private Mono handle(Payload payload) { - Message message = MessageBuilder.createMessage( - Mono.fromCallable(() -> wrapPayloadData(payload)), createHeaders(payload, null)); + String destination = getDestination(payload); + MessageHeaders headers = createHeaders(destination, null); + DataBuffer dataBuffer = retainDataAndReleasePayload(payload); + int refCount = refCount(dataBuffer); + Message message = MessageBuilder.createMessage(dataBuffer, headers); + return Mono.defer(() -> this.handler.apply(message)) + .doFinally(s -> { + if (refCount(dataBuffer) == refCount) { + DataBufferUtils.release(dataBuffer); + } + }); + } - return this.handler.apply(message); + private int refCount(DataBuffer dataBuffer) { + return dataBuffer instanceof NettyDataBuffer ? + ((NettyDataBuffer) dataBuffer).getNativeBuffer().refCnt() : 1; } private Flux handleAndReply(Payload firstPayload, Flux payloads) { MonoProcessor> replyMono = MonoProcessor.create(); - Message message = MessageBuilder.createMessage( - payloads.map(this::wrapPayloadData).doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release), - createHeaders(firstPayload, replyMono)); + String destination = getDestination(firstPayload); + MessageHeaders headers = createHeaders(destination, replyMono); - return this.handler.apply(message) + AtomicBoolean read = new AtomicBoolean(); + Flux buffers = payloads.map(this::retainDataAndReleasePayload).doOnSubscribe(s -> read.set(true)); + Message> message = MessageBuilder.createMessage(buffers, headers); + + return Mono.defer(() -> this.handler.apply(message)) + .doFinally(s -> { + // Subscription should have happened by now due to ChannelSendOperator + if (!read.get()) { + buffers.subscribe(DataBufferUtils::release); + } + }) .thenMany(Flux.defer(() -> replyMono.isTerminated() ? replyMono.flatMapMany(Function.identity()) : Mono.error(new IllegalStateException("Something went wrong: reply Mono not set")))); } - private MessageHeaders createHeaders(Payload payload, @Nullable MonoProcessor replyMono) { + private String getDestination(Payload payload) { // TODO: // For now treat the metadata as a simple string with routing information. // We'll have to get more sophisticated once the routing extension is completed. // https://github.com/rsocket/rsocket-java/issues/568 + return payload.getMetadataUtf8(); + } + + private DataBuffer retainDataAndReleasePayload(Payload payload) { + return PayloadUtils.retainDataAndReleasePayload(payload, this.strategies.dataBufferFactory()); + } + + private MessageHeaders createHeaders(String destination, @Nullable MonoProcessor replyMono) { MessageHeaderAccessor headers = new MessageHeaderAccessor(); - - String destination = payload.getMetadataUtf8(); headers.setHeader(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, destination); - if (this.dataMimeType != null) { headers.setContentType(this.dataMimeType); } - headers.setHeader(RSocketRequesterMethodArgumentResolver.RSOCKET_REQUESTER_HEADER, this.requester); - if (replyMono != null) { headers.setHeader(RSocketPayloadReturnValueHandler.RESPONSE_HEADER, replyMono); } - DataBufferFactory bufferFactory = this.strategies.dataBufferFactory(); headers.setHeader(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER, bufferFactory); - return headers.getMessageHeaders(); } - private DataBuffer wrapPayloadData(Payload payload) { - return PayloadUtils.wrapPayloadData(payload, this.strategies.dataBufferFactory()); - } - } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java index 8e3e87c6e1..ee25110161 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java @@ -15,6 +15,8 @@ */ package org.springframework.messaging.rsocket; +import io.netty.buffer.ByteBuf; +import io.rsocket.Frame; import io.rsocket.Payload; import io.rsocket.util.ByteBufPayload; import io.rsocket.util.DefaultPayload; @@ -24,6 +26,7 @@ import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBuffer; import org.springframework.core.io.buffer.NettyDataBuffer; import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.util.Assert; /** * Static utility methods to create {@link Payload} from {@link DataBuffer}s @@ -35,19 +38,31 @@ import org.springframework.core.io.buffer.NettyDataBufferFactory; abstract class PayloadUtils { /** - * Return the Payload data wrapped as DataBuffer. If the bufferFactory is - * {@link NettyDataBufferFactory} the payload retained and sliced. - * @param payload the input payload - * @param bufferFactory the BufferFactory to use to wrap - * @return the DataBuffer wrapper + * Use this method to slice, retain and wrap the data portion of the + * {@code Payload}, and also to release the {@code Payload}. This assumes + * the Payload metadata has been read by now and ensures downstream code + * need only be aware of {@code DataBuffer}s. + * @param payload the payload to process + * @param bufferFactory the DataBufferFactory to wrap with + * @return the created {@code DataBuffer} instance */ - public static DataBuffer wrapPayloadData(Payload payload, DataBufferFactory bufferFactory) { - if (bufferFactory instanceof NettyDataBufferFactory) { - return ((NettyDataBufferFactory) bufferFactory).wrap(payload.retain().sliceData()); - } - else { + public static DataBuffer retainDataAndReleasePayload(Payload payload, DataBufferFactory bufferFactory) { + try { + if (bufferFactory instanceof NettyDataBufferFactory) { + ByteBuf byteBuf = payload.sliceData().retain(); + return ((NettyDataBufferFactory) bufferFactory).wrap(byteBuf); + } + + Assert.isTrue(!(payload instanceof ByteBufPayload) && !(payload instanceof Frame), + "NettyDataBufferFactory expected, actual: " + bufferFactory.getClass().getSimpleName()); + return bufferFactory.wrap(payload.getData()); } + finally { + if (payload.refCnt() > 0) { + payload.release(); + } + } } /** diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java index a2a6d64951..7d6f64f560 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java @@ -142,12 +142,23 @@ public interface RSocketStrategies { Builder reactiveAdapterStrategy(ReactiveAdapterRegistry registry); /** - * Configure the DataBufferFactory to use for the allocation of buffers - * when creating or responding requests. - *

By default this is an instance of + * Configure the DataBufferFactory to use for allocating buffers, for + * example when preparing requests or when responding. The choice here + * must be aligned with the frame decoder configured in + * {@link io.rsocket.RSocketFactory}. + *

By default this property is an instance of + * {@link org.springframework.core.io.buffer.DefaultDataBufferFactory + * DefaultDataBufferFactory} matching to the default frame decoder in + * {@link io.rsocket.RSocketFactory} which copies the payload. This + * comes at cost to performance but does not require reference counting + * and eliminates possibility for memory leaks. + *

To switch to a zero-copy strategy, + * configure RSocket + * accordingly, and then configure this property with an instance of * {@link org.springframework.core.io.buffer.NettyDataBufferFactory - * NettyDataBufferFactory} with {@link PooledByteBufAllocator#DEFAULT}. - * @param bufferFactory the buffer factory to use + * NettyDataBufferFactory} with a pooled allocator such as + * {@link PooledByteBufAllocator#DEFAULT}. + * @param bufferFactory the DataBufferFactory to use */ Builder dataBufferFactory(DataBufferFactory bufferFactory); diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketBufferLeakTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketBufferLeakTests.java new file mode 100644 index 0000000000..f12656ed1b --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketBufferLeakTests.java @@ -0,0 +1,466 @@ +/* + * Copyright 2002-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 + * + * http://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.messaging.rsocket; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.buffer.Unpooled; +import io.netty.util.ReferenceCounted; +import io.rsocket.AbstractRSocket; +import io.rsocket.Frame; +import io.rsocket.RSocket; +import io.rsocket.RSocketFactory; +import io.rsocket.plugins.RSocketInterceptor; +import io.rsocket.transport.netty.client.TcpClientTransport; +import io.rsocket.transport.netty.server.CloseableChannel; +import io.rsocket.transport.netty.server.TcpServerTransport; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.ReplayProcessor; +import reactor.test.StepVerifier; + +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.Resource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.NettyDataBuffer; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.core.io.buffer.PooledDataBuffer; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.ObjectUtils; + +import static org.junit.Assert.*; + +/** + * Tests for scenarios that could lead to Payload and/or DataBuffer leaks. + * + * @author Rossen Stoyanchev + */ +public class RSocketBufferLeakTests { + + private static AnnotationConfigApplicationContext context; + + private static final PayloadInterceptor payloadInterceptor = new PayloadInterceptor(); + + private static CloseableChannel server; + + private static RSocket client; + + private static RSocketRequester requester; + + + @BeforeClass + @SuppressWarnings("ConstantConditions") + public static void setupOnce() { + + context = new AnnotationConfigApplicationContext(ServerConfig.class); + + server = RSocketFactory.receive() + .frameDecoder(Frame::retain) // zero copy + .addServerPlugin(payloadInterceptor) // intercept responding + .acceptor(context.getBean(MessageHandlerAcceptor.class)) + .transport(TcpServerTransport.create("localhost", 7000)) + .start() + .block(); + + client = RSocketFactory.connect() + .frameDecoder(Frame::retain) // zero copy + .addClientPlugin(payloadInterceptor) // intercept outgoing requests + .dataMimeType(MimeTypeUtils.TEXT_PLAIN_VALUE) + .transport(TcpClientTransport.create("localhost", 7000)) + .start() + .block(); + + requester = RSocketRequester.create( + client, MimeTypeUtils.TEXT_PLAIN, context.getBean(RSocketStrategies.class)); + } + + @AfterClass + public static void tearDownOnce() { + client.dispose(); + server.dispose(); + } + + + @Before + public void setUp() { + getLeakAwareNettyDataBufferFactory().reset(); + payloadInterceptor.reset(); + } + + @After + public void tearDown() throws InterruptedException { + getLeakAwareNettyDataBufferFactory().checkForLeaks(Duration.ofSeconds(5)); + payloadInterceptor.checkForLeaks(); + } + + private LeakAwareNettyDataBufferFactory getLeakAwareNettyDataBufferFactory() { + return (LeakAwareNettyDataBufferFactory) context.getBean(RSocketStrategies.class).dataBufferFactory(); + } + + + @Test + public void assemblyTimeErrorForHandleAndReply() { + Mono result = requester.route("A.B").data("foo").retrieveMono(String.class); + StepVerifier.create(result).expectErrorMatches(ex -> { + String prefix = "Ambiguous handler methods mapped for destination 'A.B':"; + return ex.getMessage().startsWith(prefix); + }).verify(); + } + + @Test + public void subscriptionTimeErrorForHandleAndReply() { + Mono result = requester.route("not-decodable").data("foo").retrieveMono(String.class); + StepVerifier.create(result).expectErrorMatches(ex -> { + String prefix = "Cannot decode to [org.springframework.core.io.Resource]"; + return ex.getMessage().contains(prefix); + }).verify(); + } + + @Test + public void errorSignalWithExceptionHandler() { + Mono result = requester.route("error-signal").data("foo").retrieveMono(String.class); + StepVerifier.create(result).expectNext("Handled 'bad input'").verifyComplete(); + } + + @Test + public void ignoreInput() { + Flux result = requester.route("ignore-input").data("a").retrieveFlux(String.class); + StepVerifier.create(result).expectNext("bar").verifyComplete(); + } + + @Test + public void retrieveMonoFromFluxResponderMethod() { + Mono result = requester.route("request-stream").data("foo").retrieveMono(String.class); + StepVerifier.create(result).expectNext("foo-1").verifyComplete(); + } + + + @Controller + static class ServerController { + + @MessageMapping("A.*") + void ambiguousMatchA(String payload) { + throw new IllegalStateException("Unexpected call"); + } + + @MessageMapping("*.B") + void ambiguousMatchB(String payload) { + throw new IllegalStateException("Unexpected call"); + } + + @MessageMapping("not-decodable") + void notDecodable(@Payload Resource resource) { + throw new IllegalStateException("Unexpected call"); + } + + @MessageMapping("error-signal") + public Flux errorSignal(String payload) { + return Flux.error(new IllegalArgumentException("bad input")) + .delayElements(Duration.ofMillis(10)) + .cast(String.class); + } + + @MessageExceptionHandler + public String handleIllegalArgument(IllegalArgumentException ex) { + return "Handled '" + ex.getMessage() + "'"; + } + + @MessageMapping("ignore-input") + Mono ignoreInput() { + return Mono.delay(Duration.ofMillis(10)).map(l -> "bar"); + } + + @MessageMapping("request-stream") + Flux stream(String payload) { + return Flux.range(1,100).delayElements(Duration.ofMillis(10)).map(idx -> payload + "-" + idx); + } + } + + + @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 LeakAwareNettyDataBufferFactory(PooledByteBufAllocator.DEFAULT)) + .build(); + } + } + + + /** + * Similar {@link org.springframework.core.io.buffer.LeakAwareDataBufferFactory} + * but extends {@link NettyDataBufferFactory} rather than rely on + * decoration, since {@link PayloadUtils} does instanceof checks. + */ + private static class LeakAwareNettyDataBufferFactory extends NettyDataBufferFactory { + + private final List created = new ArrayList<>(); + + + LeakAwareNettyDataBufferFactory(ByteBufAllocator byteBufAllocator) { + super(byteBufAllocator); + } + + + void checkForLeaks(Duration duration) throws InterruptedException { + Instant start = Instant.now(); + while (true) { + try { + this.created.forEach(info -> { + if (((PooledDataBuffer) info.getDataBuffer()).isAllocated()) { + throw info.getError(); + } + }); + break; + } + catch (AssertionError ex) { + if (Instant.now().isAfter(start.plus(duration))) { + throw ex; + } + } + Thread.sleep(50); + } + } + + void reset() { + this.created.clear(); + } + + + @Override + public NettyDataBuffer allocateBuffer() { + return (NettyDataBuffer) record(super.allocateBuffer()); + } + + @Override + public NettyDataBuffer allocateBuffer(int initialCapacity) { + return (NettyDataBuffer) record(super.allocateBuffer(initialCapacity)); + } + + @Override + public NettyDataBuffer wrap(ByteBuf byteBuf) { + NettyDataBuffer dataBuffer = super.wrap(byteBuf); + if (byteBuf != Unpooled.EMPTY_BUFFER) { + record(dataBuffer); + } + return dataBuffer; + } + + @Override + public DataBuffer join(List dataBuffers) { + return record(super.join(dataBuffers)); + } + + private DataBuffer record(DataBuffer buffer) { + this.created.add(new DataBufferLeakInfo(buffer, new AssertionError(String.format( + "DataBuffer leak: {%s} {%s} not released.%nStacktrace at buffer creation: ", buffer, + ObjectUtils.getIdentityHexString(((NettyDataBuffer) buffer).getNativeBuffer()))))); + return buffer; + } + } + + + private static class DataBufferLeakInfo { + + private final DataBuffer dataBuffer; + + private final AssertionError error; + + + DataBufferLeakInfo(DataBuffer dataBuffer, AssertionError error) { + this.dataBuffer = dataBuffer; + this.error = error; + } + + DataBuffer getDataBuffer() { + return this.dataBuffer; + } + + AssertionError getError() { + return this.error; + } + } + + + /** + * Store all intercepted incoming and outgoing payloads and then use + * {@link #checkForLeaks()} at the end to check reference counts. + */ + private static class PayloadInterceptor extends AbstractRSocket implements RSocketInterceptor { + + private final List rsockets = new CopyOnWriteArrayList<>(); + + + void checkForLeaks() { + this.rsockets.stream().map(PayloadSavingDecorator::getPayloads) + .forEach(payloadInfoProcessor -> { + payloadInfoProcessor.onComplete(); + payloadInfoProcessor + .doOnNext(this::checkForLeak) + .blockLast(); + }); + } + + private void checkForLeak(PayloadLeakInfo info) { + Instant start = Instant.now(); + while (true) { + try { + int count = info.getReferenceCount(); + assertTrue("Leaked payload (refCnt=" + count + "): " + info, count == 0); + break; + } + catch (AssertionError ex) { + if (Instant.now().isAfter(start.plus(Duration.ofSeconds(5)))) { + throw ex; + } + } + try { + Thread.sleep(50); + } + catch (InterruptedException ex) { + // ignore + } + } + } + + public void reset() { + this.rsockets.forEach(PayloadSavingDecorator::reset); + } + + + @Override + public RSocket apply(RSocket rsocket) { + PayloadSavingDecorator decorator = new PayloadSavingDecorator(rsocket); + this.rsockets.add(decorator); + return decorator; + } + + + private static class PayloadSavingDecorator extends AbstractRSocket { + + private final RSocket delegate; + + private ReplayProcessor payloads = ReplayProcessor.create(); + + + PayloadSavingDecorator(RSocket delegate) { + this.delegate = delegate; + } + + + ReplayProcessor getPayloads() { + return this.payloads; + } + + void reset() { + this.payloads = ReplayProcessor.create(); + } + + @Override + public Mono fireAndForget(io.rsocket.Payload payload) { + return this.delegate.fireAndForget(addPayload(payload)); + } + + @Override + public Mono requestResponse(io.rsocket.Payload payload) { + return this.delegate.requestResponse(addPayload(payload)).doOnSuccess(this::addPayload); + } + + @Override + public Flux requestStream(io.rsocket.Payload payload) { + return this.delegate.requestStream(addPayload(payload)).doOnNext(this::addPayload); + } + + @Override + public Flux requestChannel(Publisher payloads) { + return this.delegate + .requestChannel(Flux.from(payloads).doOnNext(this::addPayload)) + .doOnNext(this::addPayload); + } + + private io.rsocket.Payload addPayload(io.rsocket.Payload payload) { + this.payloads.onNext(new PayloadLeakInfo(payload)); + return payload; + } + + @Override + public Mono metadataPush(io.rsocket.Payload payload) { + return this.delegate.metadataPush(addPayload(payload)); + } + } + } + + + private static class PayloadLeakInfo { + + private final String description; + + private final ReferenceCounted referenceCounted; + + + PayloadLeakInfo(io.rsocket.Payload payload) { + this.description = payload.toString(); + this.referenceCounted = payload; + } + + + int getReferenceCount() { + return this.referenceCounted.refCnt(); + } + + @Override + public String toString() { + return this.description; + } + } +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java index 6e189e241e..0e3b65a15c 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java @@ -17,6 +17,8 @@ package org.springframework.messaging.rsocket; import java.time.Duration; +import io.netty.buffer.PooledByteBufAllocator; +import io.rsocket.Frame; import io.rsocket.RSocket; import io.rsocket.RSocketFactory; import io.rsocket.transport.netty.client.TcpClientTransport; @@ -35,6 +37,7 @@ 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.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.stereotype.Controller; @@ -68,6 +71,7 @@ public class RSocketClientToServerIntegrationTests { server = RSocketFactory.receive() .addServerPlugin(interceptor) + .frameDecoder(Frame::retain) // as per https://github.com/rsocket/rsocket-java#zero-copy .acceptor(context.getBean(MessageHandlerAcceptor.class)) .transport(TcpServerTransport.create("localhost", 7000)) .start() @@ -75,6 +79,7 @@ public class RSocketClientToServerIntegrationTests { client = RSocketFactory.connect() .dataMimeType(MimeTypeUtils.TEXT_PLAIN_VALUE) + .frameDecoder(Frame::retain) // as per https://github.com/rsocket/rsocket-java#zero-copy .transport(TcpClientTransport.create("localhost", 7000)) .start() .block(); @@ -261,6 +266,7 @@ public class RSocketClientToServerIntegrationTests { return RSocketStrategies.builder() .decoder(StringDecoder.allMimeTypes()) .encoder(CharSequenceEncoder.allMimeTypes()) + .dataBufferFactory(new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT)) .build(); } } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java index 3a05e01cc8..e55ef09820 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java @@ -18,7 +18,9 @@ package org.springframework.messaging.rsocket; import java.time.Duration; import java.util.Collections; +import io.netty.buffer.PooledByteBufAllocator; import io.rsocket.Closeable; +import io.rsocket.Frame; import io.rsocket.RSocket; import io.rsocket.RSocketFactory; import io.rsocket.transport.netty.client.TcpClientTransport; @@ -39,6 +41,7 @@ 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.messaging.handler.annotation.MessageMapping; import org.springframework.stereotype.Controller; @@ -61,6 +64,7 @@ public class RSocketServerToClientIntegrationTests { context = new AnnotationConfigApplicationContext(RSocketConfig.class); server = RSocketFactory.receive() + .frameDecoder(Frame::retain) // as per https://github.com/rsocket/rsocket-java#zero-copy .acceptor(context.getBean("serverAcceptor", MessageHandlerAcceptor.class)) .transport(TcpServerTransport.create("localhost", 7000)) .start() @@ -104,6 +108,7 @@ public class RSocketServerToClientIntegrationTests { rsocket = RSocketFactory.connect() .setupPayload(DefaultPayload.create("", destination)) .dataMimeType("text/plain") + .frameDecoder(Frame::retain) // as per https://github.com/rsocket/rsocket-java#zero-copy .acceptor(context.getBean("clientAcceptor", MessageHandlerAcceptor.class)) .transport(TcpClientTransport.create("localhost", 7000)) .start() @@ -272,6 +277,7 @@ public class RSocketServerToClientIntegrationTests { return RSocketStrategies.builder() .decoder(StringDecoder.allMimeTypes()) .encoder(CharSequenceEncoder.allMimeTypes()) + .dataBufferFactory(new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT)) .build(); } }