From 421090ca35941fef053eeedde5f84cc00cc22298 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Fri, 25 Jan 2019 11:49:06 -0500 Subject: [PATCH] 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 00000000000..32c1d50e9e5 --- /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 00000000000..684ed8e04d6 --- /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 00000000000..41c2fe77b90 --- /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 00000000000..c4661626358 --- /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 7dce680ee48..b65cf64793d 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 00000000000..8be81d0ecc9 --- /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 00000000000..32c3cb2638e --- /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 00000000000..449cec194b6 --- /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(); + } + +}