Support suspending handler methods in Spring MVC

This commit adds support for Kotlin Coroutines suspending functions to
Spring MVC, by converting those to a Mono that can then be handled by
the asynchronous request processing feature.

It also optimizes Coroutines detection with the introduction of an
optimized KotlinDetector.isSuspendingFunction() method that does not
require kotlin-reflect.

Closes gh-23611
This commit is contained in:
Sébastien Deleuze 2020-10-09 12:22:15 +02:00
parent fad7243733
commit 94a42a3086
13 changed files with 110 additions and 29 deletions

View File

@ -51,14 +51,6 @@ internal fun <T: Any> deferredToMono(source: Deferred<T>) =
internal fun <T: Any> monoToDeferred(source: Mono<T>) =
GlobalScope.async(Dispatchers.Unconfined) { source.awaitFirstOrNull() }
/**
* Return {@code true} if the method is a suspending function.
*
* @author Sebastien Deleuze
* @since 5.2.2
*/
internal fun isSuspendingFunction(method: Method) = method.kotlinFunction!!.isSuspend
/**
* Invoke a suspending function and converts it to [Mono] or [reactor.core.publisher.Flux].
*
@ -66,7 +58,7 @@ internal fun isSuspendingFunction(method: Method) = method.kotlinFunction!!.isSu
* @since 5.2
*/
@Suppress("UNCHECKED_CAST")
internal fun invokeSuspendingFunction(method: Method, bean: Any, vararg args: Any?): Publisher<*> {
fun invokeSuspendingFunction(method: Method, bean: Any, vararg args: Any?): Publisher<*> {
val function = method.kotlinFunction!!
val mono = mono(Dispatchers.Unconfined) {
function.callSuspend(bean, *args.sliceArray(0..(args.size-2))).let { if (it == Unit) null else it }

View File

@ -17,6 +17,7 @@
package org.springframework.core;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import org.springframework.lang.Nullable;
import org.springframework.util.ClassUtils;
@ -74,4 +75,20 @@ public abstract class KotlinDetector {
return (kotlinMetadata != null && clazz.getDeclaredAnnotation(kotlinMetadata) != null);
}
/**
* Return {@code true} if the method is a suspending function.
*
* @author Sebastien Deleuze
* @since 5.3
*/
public static boolean isSuspendingFunction(Method method) {
if (KotlinDetector.isKotlinType(method.getDeclaringClass())) {
Class<?>[] types = method.getParameterTypes();
if ((types.length > 0) && "kotlin.coroutines.Continuation".equals(types[types.length - 1].getName())) {
return true;
}
}
return false;
}
}

View File

@ -131,8 +131,7 @@ public class InvocableHandlerMethod extends HandlerMethod {
try {
Method method = getBridgedMethod();
ReflectionUtils.makeAccessible(method);
if (KotlinDetector.isKotlinReflectPresent() && KotlinDetector.isKotlinType(method.getDeclaringClass())
&& CoroutinesUtils.isSuspendingFunction(method)) {
if (KotlinDetector.isSuspendingFunction(method)) {
isSuspendingFunction = true;
value = CoroutinesUtils.invokeSuspendingFunction(method, getBean(), args);
}

View File

@ -6,6 +6,7 @@ apply plugin: "kotlinx-serialization"
dependencies {
compile(project(":spring-beans"))
compile(project(":spring-core"))
compileOnly(project(":kotlin-coroutines"))
optional(project(":spring-aop"))
optional(project(":spring-context"))
optional(project(":spring-oxm"))

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2019 the original author or authors.
* Copyright 2002-2020 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.
@ -20,7 +20,9 @@ import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import org.springframework.core.CoroutinesUtils;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.KotlinDetector;
import org.springframework.core.MethodParameter;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.lang.Nullable;
@ -39,6 +41,7 @@ import org.springframework.web.method.HandlerMethod;
*
* @author Rossen Stoyanchev
* @author Juergen Hoeller
* @author Sebastien Deleuze
* @since 3.1
*/
public class InvocableHandlerMethod extends HandlerMethod {
@ -185,12 +188,16 @@ public class InvocableHandlerMethod extends HandlerMethod {
*/
@Nullable
protected Object doInvoke(Object... args) throws Exception {
ReflectionUtils.makeAccessible(getBridgedMethod());
Method method = getBridgedMethod();
ReflectionUtils.makeAccessible(method);
try {
return getBridgedMethod().invoke(getBean(), args);
if (KotlinDetector.isSuspendingFunction(method)) {
return CoroutinesUtils.invokeSuspendingFunction(method, getBean(), args);
}
return method.invoke(getBean(), args);
}
catch (IllegalArgumentException ex) {
assertTargetBean(getBridgedMethod(), getBean(), args);
assertTargetBean(method, getBean(), args);
String text = (ex.getMessage() != null ? ex.getMessage() : "Illegal argument");
throw new IllegalStateException(formatInvokeError(text, args), ex);
}

View File

@ -139,8 +139,7 @@ public class InvocableHandlerMethod extends HandlerMethod {
try {
ReflectionUtils.makeAccessible(getBridgedMethod());
Method method = getBridgedMethod();
if (KotlinDetector.isKotlinReflectPresent() && KotlinDetector.isKotlinType(method.getDeclaringClass())
&& CoroutinesUtils.isSuspendingFunction(method)) {
if (KotlinDetector.isSuspendingFunction(method)) {
value = CoroutinesUtils.invokeSuspendingFunction(method, getBean(), args);
}
else {

View File

@ -36,6 +36,8 @@ dependencies {
optional("org.jetbrains.kotlin:kotlin-reflect")
optional("org.jetbrains.kotlin:kotlin-stdlib")
optional("org.reactivestreams:reactive-streams")
optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
testCompile(project(":kotlin-coroutines"))
testCompile(testFixtures(project(":spring-beans")))
testCompile(testFixtures(project(":spring-core")))
testCompile(testFixtures(project(":spring-context")))
@ -62,7 +64,6 @@ dependencies {
testCompile("io.reactivex.rxjava3:rxjava")
testCompile("org.jetbrains.kotlin:kotlin-script-runtime")
testRuntime("org.jetbrains.kotlin:kotlin-scripting-jsr223")
testRuntime("org.jetbrains.kotlinx:kotlinx-coroutines-core") // https://github.com/gradle/gradle/issues/14017
testRuntime("org.jruby:jruby")
testRuntime("org.python:jython-standalone")
testRuntime("org.webjars:underscorejs")

View File

@ -38,7 +38,6 @@ import javax.servlet.http.HttpServletRequest;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.KotlinDetector;
import org.springframework.core.MethodIntrospector;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@ -606,13 +605,6 @@ public abstract class AbstractHandlerMethodMapping<T> extends AbstractHandlerMap
}
public void register(T mapping, Object handler, Method method) {
// Assert that the handler method is not a suspending one.
if (KotlinDetector.isKotlinType(method.getDeclaringClass())) {
Class<?>[] types = method.getParameterTypes();
if ((types.length > 0) && "kotlin.coroutines.Continuation".equals(types[types.length - 1].getName())) {
throw new IllegalStateException("Unsupported suspending handler method detected: " + method);
}
}
this.readWriteLock.writeLock().lock();
try {
HandlerMethod handlerMethod = createHandlerMethod(handler, method);

View File

@ -0,0 +1,42 @@
/*
* Copyright 2002-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.servlet.mvc.method.annotation;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
/**
* No-op resolver for method arguments of type {@link kotlin.coroutines.Continuation}.
*
* @author Sebastien Deleuze
* @since 5.3
*/
public class ContinuationHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return "kotlin.coroutines.Continuation".equals(parameter.getParameterType().getName());
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return null;
}
}

View File

@ -34,6 +34,7 @@ import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.KotlinDetector;
import org.springframework.core.MethodIntrospector;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.core.ReactiveAdapterRegistry;
@ -670,6 +671,9 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
resolvers.add(new ErrorsMethodArgumentResolver());
resolvers.add(new SessionStatusMethodArgumentResolver());
resolvers.add(new UriComponentsBuilderMethodArgumentResolver());
if (KotlinDetector.isKotlinPresent()) {
resolvers.add(new ContinuationHandlerMethodArgumentResolver());
}
// Custom arguments
if (getCustomArgumentResolvers() != null) {

View File

@ -25,6 +25,7 @@ import java.util.concurrent.Callable;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.core.KotlinDetector;
import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.http.HttpHeaders;
@ -271,6 +272,8 @@ public class ServletInvocableHandlerMethod extends InvocableHandlerMethod {
this.returnValue = returnValue;
this.returnType = (returnValue instanceof ReactiveTypeHandler.CollectedValuesList ?
((ReactiveTypeHandler.CollectedValuesList) returnValue).getReturnType() :
KotlinDetector.isSuspendingFunction(super.getMethod()) ?
ResolvableType.forMethodParameter(getReturnType()) :
ResolvableType.forType(super.getGenericParameterType()).getGeneric());
}

View File

@ -16,9 +16,11 @@
package org.springframework.web.servlet.mvc.method.annotation
import kotlinx.coroutines.delay
import org.assertj.core.api.Assertions.assertThat
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.async.WebAsyncUtils
import org.springframework.web.servlet.handler.PathPatternsParameterizedTest
import org.springframework.web.testfixture.servlet.MockHttpServletRequest
import org.springframework.web.testfixture.servlet.MockHttpServletResponse
@ -71,6 +73,17 @@ class ServletAnnotationControllerHandlerMethodKotlinTests : AbstractServletHandl
assertThat(response.contentAsString).isEqualTo("value1-12")
}
@PathPatternsParameterizedTest
fun suspendingMethod(usePathPatterns: Boolean) {
initDispatcherServlet(CoroutinesController::class.java, usePathPatterns)
val request = MockHttpServletRequest("GET", "/suspending")
request.isAsyncSupported = true
val response = MockHttpServletResponse()
servlet.service(request, response)
assertThat(WebAsyncUtils.getAsyncManager(request).concurrentResult).isEqualTo("foo")
}
data class DataClass(val param1: String, val param2: Int)
@ -86,4 +99,15 @@ class ServletAnnotationControllerHandlerMethodKotlinTests : AbstractServletHandl
fun handle(data: DataClassWithOptionalParameter) = "${data.param1}-${data.param2}"
}
@RestController
class CoroutinesController {
@Suppress("RedundantSuspendModifier")
@RequestMapping("/suspending")
suspend fun handle(): String {
return "foo"
}
}
}

View File

@ -412,8 +412,8 @@ and types like https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-co
Spring Framework provides support for Coroutines on the following scope:
* https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-deferred/index.html[Deferred] and https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/index.html[Flow] return values support in Spring WebFlux annotated `@Controller`
* Suspending function support in Spring WebFlux annotated `@Controller`
* https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-deferred/index.html[Deferred] and https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/index.html[Flow] return values support in Spring MVC and WebFlux annotated `@Controller`
* Suspending function support in Spring MVC and WebFlux annotated `@Controller`
* Extensions for WebFlux {doc-root}/spring-framework/docs/{spring-version}/kdoc-api/spring-framework/org.springframework.web.reactive.function.client/index.html[client] and {doc-root}/spring-framework/docs/{spring-version}/kdoc-api/spring-framework/org.springframework.web.reactive.function.server/index.html[server] functional API.
* WebFlux.fn {doc-root}/spring-framework/docs/{spring-version}/kdoc-api/spring-framework/org.springframework.web.reactive.function.server/co-router.html[coRouter { }] DSL
* Suspending function and `Flow` support in RSocket `@MessageMapping` annotated methods
@ -434,7 +434,7 @@ dependencies {
}
----
Version `1.3.0` and above are supported.
Version `1.3.9` and above are supported.
=== How Reactive translates to Coroutines?