Skip to content

Commit c8169e5

Browse files
committed
Add Coroutines support to Spring AOP
This commit adds support for Kotlin Coroutines to Spring AOP by leveraging CoroutinesUtils#invokeSuspendingFunction in AopUtils#invokeJoinpointUsingReflection to convert it to the equivalent Publisher return value, like in other parts of Spring Framework. That allows method interceptors with Reactive support to process related return values. CglibAopProxy#processReturnType and JdkDynamicAopProxy#invoke take care of the conversion from the Publisher return value to Kotlin Coroutines. Reactive transactional and HTTP service interface support have been refined to leverage those new generic capabilities. Closes gh-22462
1 parent 9b3f456 commit c8169e5

File tree

11 files changed

+278
-51
lines changed

11 files changed

+278
-51
lines changed

integration-tests/integration-tests.gradle

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
plugins {
22
id 'org.springframework.build.runtimehints-agent'
3+
id 'kotlin'
34
}
45

56
description = "Spring Integration Tests"
@@ -26,6 +27,7 @@ dependencies {
2627
testImplementation("org.aspectj:aspectjweaver")
2728
testImplementation("org.hsqldb:hsqldb")
2829
testImplementation("org.hibernate:hibernate-core-jakarta")
30+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
2931
}
3032

3133
normalization {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package org.springframework.aop.framework.autoproxy
2+
3+
import kotlinx.coroutines.delay
4+
import kotlinx.coroutines.runBlocking
5+
import org.aopalliance.intercept.MethodInterceptor
6+
import org.aopalliance.intercept.MethodInvocation
7+
import org.assertj.core.api.Assertions.assertThat
8+
import org.junit.jupiter.api.Test
9+
import org.springframework.aop.framework.autoproxy.AspectJAutoProxyInterceptorKotlinIntegrationTests.InterceptorConfig
10+
import org.springframework.aop.support.StaticMethodMatcherPointcutAdvisor
11+
import org.springframework.beans.factory.annotation.Autowired
12+
import org.springframework.context.annotation.Bean
13+
import org.springframework.context.annotation.Configuration
14+
import org.springframework.context.annotation.EnableAspectJAutoProxy
15+
import org.springframework.test.annotation.DirtiesContext
16+
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig
17+
import reactor.core.publisher.Mono
18+
import java.lang.reflect.Method
19+
20+
21+
/**
22+
* Integration tests for interceptors with Kotlin (with and without Coroutines) configured
23+
* via AspectJ auto-proxy support.
24+
*/
25+
@SpringJUnitConfig(InterceptorConfig::class)
26+
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
27+
class AspectJAutoProxyInterceptorKotlinIntegrationTests(
28+
@Autowired val echo: Echo,
29+
@Autowired val firstAdvisor: TestPointcutAdvisor,
30+
@Autowired val secondAdvisor: TestPointcutAdvisor) {
31+
32+
@Test
33+
fun `Multiple interceptors with regular function`() {
34+
assertThat(firstAdvisor.interceptor.invocations).isEmpty()
35+
assertThat(secondAdvisor.interceptor.invocations).isEmpty()
36+
val value = "Hello!"
37+
assertThat(echo.echo(value)).isEqualTo(value)
38+
assertThat(firstAdvisor.interceptor.invocations).singleElement().matches { String::class.java.isAssignableFrom(it) }
39+
assertThat(secondAdvisor.interceptor.invocations).singleElement().matches { String::class.java.isAssignableFrom(it) }
40+
}
41+
42+
@Test
43+
fun `Multiple interceptors with suspending function`() {
44+
assertThat(firstAdvisor.interceptor.invocations).isEmpty()
45+
assertThat(secondAdvisor.interceptor.invocations).isEmpty()
46+
val value = "Hello!"
47+
runBlocking {
48+
assertThat(echo.suspendingEcho(value)).isEqualTo(value)
49+
}
50+
assertThat(firstAdvisor.interceptor.invocations).singleElement().matches { Mono::class.java.isAssignableFrom(it) }
51+
assertThat(secondAdvisor.interceptor.invocations).singleElement().matches { Mono::class.java.isAssignableFrom(it) }
52+
}
53+
54+
@Configuration
55+
@EnableAspectJAutoProxy
56+
open class InterceptorConfig {
57+
58+
@Bean
59+
open fun firstAdvisor() = TestPointcutAdvisor().apply { order = 0 }
60+
61+
@Bean
62+
open fun secondAdvisor() = TestPointcutAdvisor().apply { order = 1 }
63+
64+
65+
@Bean
66+
open fun echo(): Echo {
67+
return Echo()
68+
}
69+
}
70+
71+
class TestMethodInterceptor: MethodInterceptor {
72+
73+
var invocations: MutableList<Class<*>> = mutableListOf()
74+
75+
@Suppress("RedundantNullableReturnType")
76+
override fun invoke(invocation: MethodInvocation): Any? {
77+
val result = invocation.proceed()
78+
invocations.add(result!!.javaClass)
79+
return result
80+
}
81+
82+
}
83+
84+
class TestPointcutAdvisor : StaticMethodMatcherPointcutAdvisor(TestMethodInterceptor()) {
85+
86+
val interceptor: TestMethodInterceptor
87+
get() = advice as TestMethodInterceptor
88+
89+
override fun matches(method: Method, targetClass: Class<*>): Boolean {
90+
return targetClass == Echo::class.java && method.name.lowercase().endsWith("echo")
91+
}
92+
}
93+
94+
open class Echo {
95+
96+
open fun echo(value: String): String {
97+
return value;
98+
}
99+
100+
open suspend fun suspendingEcho(value: String): String {
101+
delay(1)
102+
return value;
103+
}
104+
105+
}
106+
107+
}

spring-aop/spring-aop.gradle

+4
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
description = "Spring AOP"
22

3+
apply plugin: "kotlin"
4+
35
dependencies {
46
api(project(":spring-beans"))
57
api(project(":spring-core"))
68
optional("org.apache.commons:commons-pool2")
79
optional("org.aspectj:aspectjweaver")
10+
optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
811
testFixturesImplementation(testFixtures(project(":spring-beans")))
912
testFixturesImplementation(testFixtures(project(":spring-core")))
1013
testFixturesImplementation("com.google.code.findbugs:jsr305")
1114
testImplementation(project(":spring-core-test"))
1215
testImplementation(testFixtures(project(":spring-beans")))
1316
testImplementation(testFixtures(project(":spring-core")))
17+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
1418
}

spring-aop/src/main/java/org/springframework/aop/framework/CglibAopProxy.java

+17-7
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import org.springframework.cglib.proxy.MethodProxy;
4848
import org.springframework.cglib.proxy.NoOp;
4949
import org.springframework.core.KotlinDetector;
50+
import org.springframework.core.MethodParameter;
5051
import org.springframework.core.SmartClassLoader;
5152
import org.springframework.lang.Nullable;
5253
import org.springframework.util.Assert;
@@ -75,6 +76,7 @@
7576
* @author Ramnivas Laddad
7677
* @author Chris Beams
7778
* @author Dave Syer
79+
* @author Sebastien Deleuze
7880
* @see org.springframework.cglib.proxy.Enhancer
7981
* @see AdvisedSupport#setProxyTargetClass
8082
* @see DefaultAopProxyFactory
@@ -98,6 +100,8 @@ class CglibAopProxy implements AopProxy, Serializable {
98100
/** Keeps track of the Classes that we have validated for final methods. */
99101
private static final Map<Class<?>, Boolean> validatedClasses = new WeakHashMap<>();
100102

103+
private static final String COROUTINES_FLOW_CLASS_NAME = "kotlinx.coroutines.flow.Flow";
104+
101105

102106
/** The configuration used to configure this proxy. */
103107
protected final AdvisedSupport advised;
@@ -399,10 +403,11 @@ private static boolean implementsInterface(Method method, Set<Class<?>> ifcs) {
399403
/**
400404
* Process a return value. Wraps a return of {@code this} if necessary to be the
401405
* {@code proxy} and also verifies that {@code null} is not returned as a primitive.
406+
* Also takes care of the conversion from {@code Mono} to Kotlin Coroutines if needed.
402407
*/
403408
@Nullable
404409
private static Object processReturnType(
405-
Object proxy, @Nullable Object target, Method method, @Nullable Object returnValue) {
410+
Object proxy, @Nullable Object target, Method method, Object[] arguments, @Nullable Object returnValue) {
406411

407412
// Massage return value if necessary
408413
if (returnValue != null && returnValue == target &&
@@ -416,6 +421,11 @@ private static Object processReturnType(
416421
throw new AopInvocationException(
417422
"Null return value from advice does not match primitive return type for: " + method);
418423
}
424+
if (KotlinDetector.isSuspendingFunction(method)) {
425+
return COROUTINES_FLOW_CLASS_NAME.equals(new MethodParameter(method, -1).getParameterType().getName()) ?
426+
CoroutinesUtils.asFlow(returnValue) :
427+
CoroutinesUtils.awaitSingleOrNull(returnValue, arguments[arguments.length - 1]);
428+
}
419429
return returnValue;
420430
}
421431

@@ -446,7 +456,7 @@ public StaticUnadvisedInterceptor(@Nullable Object target) {
446456
@Nullable
447457
public Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
448458
Object retVal = AopUtils.invokeJoinpointUsingReflection(this.target, method, args);
449-
return processReturnType(proxy, this.target, method, retVal);
459+
return processReturnType(proxy, this.target, method, args, retVal);
450460
}
451461
}
452462

@@ -471,7 +481,7 @@ public Object intercept(Object proxy, Method method, Object[] args, MethodProxy
471481
try {
472482
oldProxy = AopContext.setCurrentProxy(proxy);
473483
Object retVal = AopUtils.invokeJoinpointUsingReflection(this.target, method, args);
474-
return processReturnType(proxy, this.target, method, retVal);
484+
return processReturnType(proxy, this.target, method, args, retVal);
475485
}
476486
finally {
477487
AopContext.setCurrentProxy(oldProxy);
@@ -499,7 +509,7 @@ public Object intercept(Object proxy, Method method, Object[] args, MethodProxy
499509
Object target = this.targetSource.getTarget();
500510
try {
501511
Object retVal = AopUtils.invokeJoinpointUsingReflection(target, method, args);
502-
return processReturnType(proxy, target, method, retVal);
512+
return processReturnType(proxy, target, method, args, retVal);
503513
}
504514
finally {
505515
if (target != null) {
@@ -529,7 +539,7 @@ public Object intercept(Object proxy, Method method, Object[] args, MethodProxy
529539
try {
530540
oldProxy = AopContext.setCurrentProxy(proxy);
531541
Object retVal = AopUtils.invokeJoinpointUsingReflection(target, method, args);
532-
return processReturnType(proxy, target, method, retVal);
542+
return processReturnType(proxy, target, method, args, retVal);
533543
}
534544
finally {
535545
AopContext.setCurrentProxy(oldProxy);
@@ -656,7 +666,7 @@ public Object intercept(Object proxy, Method method, Object[] args, MethodProxy
656666
proxy, this.target, method, args, this.targetClass, this.adviceChain, methodProxy);
657667
// If we get here, we need to create a MethodInvocation.
658668
Object retVal = invocation.proceed();
659-
retVal = processReturnType(proxy, this.target, method, retVal);
669+
retVal = processReturnType(proxy, this.target, method, args, retVal);
660670
return retVal;
661671
}
662672
}
@@ -706,7 +716,7 @@ public Object intercept(Object proxy, Method method, Object[] args, MethodProxy
706716
// We need to create a method invocation...
707717
retVal = new CglibMethodInvocation(proxy, target, method, args, targetClass, chain, methodProxy).proceed();
708718
}
709-
return processReturnType(proxy, target, method, retVal);
719+
return processReturnType(proxy, target, method, args, retVal);
710720
}
711721
finally {
712722
if (target != null && !targetSource.isStatic()) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.aop.framework;
18+
19+
import kotlin.coroutines.Continuation;
20+
import kotlinx.coroutines.reactive.ReactiveFlowKt;
21+
import kotlinx.coroutines.reactor.MonoKt;
22+
import org.reactivestreams.Publisher;
23+
import reactor.core.publisher.Mono;
24+
25+
import org.springframework.lang.Nullable;
26+
27+
/**
28+
* Package-visible class designed to avoid a hard dependency on Kotlin and Coroutines dependency at runtime.
29+
*
30+
* @author Sebastien Deleuze
31+
* @since 6.1.0
32+
*/
33+
abstract class CoroutinesUtils {
34+
35+
static Object asFlow(Object publisher) {
36+
return ReactiveFlowKt.asFlow((Publisher<?>) publisher);
37+
}
38+
39+
@SuppressWarnings("unchecked")
40+
@Nullable
41+
static Object awaitSingleOrNull(Object mono, Object continuation) {
42+
return MonoKt.awaitSingleOrNull((Mono<?>) mono, (Continuation<Object>) continuation);
43+
}
44+
45+
}

spring-aop/src/main/java/org/springframework/aop/framework/JdkDynamicAopProxy.java

+9
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
import org.springframework.aop.TargetSource;
3232
import org.springframework.aop.support.AopUtils;
3333
import org.springframework.core.DecoratingProxy;
34+
import org.springframework.core.KotlinDetector;
35+
import org.springframework.core.MethodParameter;
3436
import org.springframework.lang.Nullable;
3537
import org.springframework.util.Assert;
3638
import org.springframework.util.ClassUtils;
@@ -58,6 +60,7 @@
5860
* @author Rob Harrop
5961
* @author Dave Syer
6062
* @author Sergey Tsypanov
63+
* @author Sebastien Deleuze
6164
* @see java.lang.reflect.Proxy
6265
* @see AdvisedSupport
6366
* @see ProxyFactory
@@ -80,6 +83,8 @@ final class JdkDynamicAopProxy implements AopProxy, InvocationHandler, Serializa
8083
/** We use a static Log to avoid serialization issues. */
8184
private static final Log logger = LogFactory.getLog(JdkDynamicAopProxy.class);
8285

86+
private static final String COROUTINES_FLOW_CLASS_NAME = "kotlinx.coroutines.flow.Flow";
87+
8388
/** Config used to configure this proxy. */
8489
private final AdvisedSupport advised;
8590

@@ -258,6 +263,10 @@ else if (retVal == null && returnType != Void.TYPE && returnType.isPrimitive())
258263
throw new AopInvocationException(
259264
"Null return value from advice does not match primitive return type for: " + method);
260265
}
266+
if (KotlinDetector.isSuspendingFunction(method)) {
267+
return COROUTINES_FLOW_CLASS_NAME.equals(new MethodParameter(method, -1).getParameterType().getName()) ?
268+
CoroutinesUtils.asFlow(retVal) : CoroutinesUtils.awaitSingleOrNull(retVal, args[args.length - 1]);
269+
}
261270
return retVal;
262271
}
263272
finally {

spring-aop/src/main/java/org/springframework/aop/support/AopUtils.java

+24-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -25,6 +25,11 @@
2525
import java.util.List;
2626
import java.util.Set;
2727

28+
import kotlin.coroutines.Continuation;
29+
import kotlin.coroutines.CoroutineContext;
30+
import kotlinx.coroutines.Job;
31+
import org.reactivestreams.Publisher;
32+
2833
import org.springframework.aop.Advisor;
2934
import org.springframework.aop.AopInvocationException;
3035
import org.springframework.aop.IntroductionAdvisor;
@@ -35,6 +40,8 @@
3540
import org.springframework.aop.SpringProxy;
3641
import org.springframework.aop.TargetClassAware;
3742
import org.springframework.core.BridgeMethodResolver;
43+
import org.springframework.core.CoroutinesUtils;
44+
import org.springframework.core.KotlinDetector;
3845
import org.springframework.core.MethodIntrospector;
3946
import org.springframework.lang.Nullable;
4047
import org.springframework.util.Assert;
@@ -53,6 +60,7 @@
5360
* @author Rod Johnson
5461
* @author Juergen Hoeller
5562
* @author Rob Harrop
63+
* @author Sebastien Deleuze
5664
* @see org.springframework.aop.framework.AopProxyUtils
5765
*/
5866
public abstract class AopUtils {
@@ -340,7 +348,8 @@ public static Object invokeJoinpointUsingReflection(@Nullable Object target, Met
340348
// Use reflection to invoke the method.
341349
try {
342350
ReflectionUtils.makeAccessible(method);
343-
return method.invoke(target, args);
351+
return KotlinDetector.isSuspendingFunction(method) ?
352+
KotlinDelegate.invokeSuspendingFunction(method, target, args) : method.invoke(target, args);
344353
}
345354
catch (InvocationTargetException ex) {
346355
// Invoked method threw a checked exception.
@@ -356,4 +365,17 @@ public static Object invokeJoinpointUsingReflection(@Nullable Object target, Met
356365
}
357366
}
358367

368+
/**
369+
* Inner class to avoid a hard dependency on Kotlin at runtime.
370+
*/
371+
private static class KotlinDelegate {
372+
373+
public static Publisher<?> invokeSuspendingFunction(Method method, Object target, Object... args) {
374+
Continuation<?> continuation = (Continuation<?>) args[args.length -1];
375+
CoroutineContext context = continuation.getContext().minusKey(Job.Key);
376+
return CoroutinesUtils.invokeSuspendingFunction(context, method, target, args);
377+
}
378+
379+
}
380+
359381
}

0 commit comments

Comments
 (0)