Jak zmusić AspectJ/Spring AOP do działania z korutynami? Zobaczmy jak połączyć programowanie aspektowe z asynchronicznością.
Programowanie aspektowe (aspect-oriented programming) jest od dłuższego czasu popularne w świecie javowym. Biblioteki takie jak AspectJ czy Spring AOP pozwalają w łatwy sposób obsłużyć cross-cutting concerns. Niestety, nie wspierają one oficjalnie Kotlin Coroutines, które świetnie sprawdzają się w pisaniu asynchronicznego, nieblokującego kodu. Czy jesteśmy w stanie zmusić aspekty do działania z korutynami, pomimo braku oficjalnego wsparcia? Okazuje się, że tak.
Problemy w łączeniu AOP z asynchronicznym kodem
Tradycyjnie w tych bibliotekach mamy dostępne rady (advices) umożliwiające zdefiniowanie kodu, który ma zostać wykonany na przykład przed wywołaniem metody (before), po zwróceniu wartości przez metodę (after returning) lub po rzuceniu wyjątku (after throwing).
class Foo {
@Logging
fun add(a: Int, b: Int): Int = a + b
}
@Aspect
class LoggingAspect {
@AfterReturning(
pointcut = "@annotation(Logging)",
returning = "result"
)
fun logResult(joinPoint: JoinPoint, result: Any?) {
logger.debug("`${joinPoint.signature}` returned `${result}`")
}
}
[DEBUG] `int Foo.add(int,int)` returned `5`
O ile w tradycyjnym modelu równoległości opartym o wątki i blokujące wywołania sprawdza się to dobrze, o tyle w nowszych podejściach opartych o asynchroniczność jest już gorzej. Jeśli używamy Reactive Streams, często będziemy mieli metody, które zamiast bezpośrednio zwracać wartości i rzucać wyjątki, zwracają leniwe strumienie Publisher<T>, które to emitują wartości lub błędy (onNext/onError). Pisanie aspektów staje się trochę uciążliwe, lecz wciąż wykonalne:
class ReactiveFoo {
@ReactiveLogging
fun add(a: Int, b: Int): Mono<Int> = Mono.just(a + b)
}
@Aspect
class ReactiveLoggingAspect {
@Around("""
@annotation(ReactiveLogging) &&
execution(reactor.core.publisher.Mono *(..))
""")
fun logResult(joinPoint: ProceedingJoinPoint): Mono<Any> =
(joinPoint.proceed() as Mono<Any>)
.doOnNext { result ->
logger.debug(
"`${joinPoint.signature}` returned `${result}`"
)
}
}
[DEBUG] `Mono ReactiveFoo.add(int,int)` returned `5`
Gorzej jest w przypadku Kotlin Coroutines. AspectJ/Spring AOP oficjalnie nie wspierają suspending functions. Na pierwszy rzut oka asynchroniczny kod oparty o korutyny niewiele różni się od klasycznego kodu synchronicznego. Widzimy słowo kluczowe suspend i niewiele poza tym. Kompilator Kotlina jednak dość mocno przekształca ten kod i na poziomie bytecodu otrzymujemy zmienione sygnatury metod, które wykorzystują Continuation Passing Style i implementują maszyny stanów.
Spróbujmy zaaplikować standardowy aspekt do suspend fun:
class CoroutineFoo {
@Logging
suspend fun add(a: Int, b: Int): Int {
delay(100)
return a + b
}
}
Po uruchomieniu sygnatura funkcji, jak i zwracana wartość mogą się wydawać inne od oczekiwanych:
[DEBUG] `Object CoroutineFoo.add(int,int,Continuation)` returned `COROUTINE_SUSPENDED`
Każda suspend funkcja na poziomie bytecodu JVM ma dodatkowy parametr Continuation. Jej zwracany typ to Object, ponieważ oprócz typu zadeklarowanego w Kotlinie, może też zwrócić magiczną wartość COROUTINE_SUSPENDED.
Zainstaluj teraz i czytaj tylko dobre teksty!
Jak zmusić aspekty do działania z korutynami?
Zauważmy, że sygnatury funkcji suspend fun f(): T oraz fun f(c: Continuation): Any? z punktu widzenia JVM są identyczne. Z punktu widzenia Kotlina, w pierwszej Continuation jest przekazywane implicite, w drugiej explicite. Pierwsza nie jest wspierana przez AspectJ, ale druga już jest.
Do zdefiniowania aspektu wykorzystamy punkt cięcia (pointcut) args(.., kotlin.coroutines.Continuation) oraz radę (advice) around:
@Around("""
@annotation(CoroutineLogging) &&
args(.., kotlin.coroutines.Continuation)
""")
fun logResult(joinPoint: ProceedingJoinPoint): Any? { }
Z ProceedingJoinPoint potrzebujemy wyciągnąć argumenty, z którymi została wywołana funkcja, w szczególności ostatni argument typu Continuation:
val ProceedingJoinPoint.coroutineContinuation: Continuation<Any?>
get() = this.args.last() as Continuation<Any?>
val ProceedingJoinPoint.coroutineArgs: Array<Any?>
get() = this.args.sliceArray(0 until this.args.size - 1)
Pozwala nam to na zaimplementowanie odpowiednika ProceedingJoinPoint.proceed() zdefiniowanego jako suspend fun:
import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn
suspend fun ProceedingJoinPoint.proceedCoroutine(
args: Array<Any?> = this.coroutineArgs
): Any? =
suspendCoroutineUninterceptedOrReturn { continuation ->
this.proceed(args + continuation)
}
Wykorzystując ProceedingJoinPoint.coroutineContinuation, które zdefiniowaliśmy chwilę wcześniej, możemy wywołać dowolną funkcję suspend:
import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn
fun ProceedingJoinPoint.runCoroutine(
block: suspend () -> Any?
): Any? =
block.startCoroutineUninterceptedOrReturn(this.coroutineContinuation)
Składając to wszystko razem, otrzymujemy aspekt działający z korutynami:
@Aspect
class CoroutineLoggingAspect {
@Around("""
@annotation(CoroutineLogging) &&
args(.., kotlin.coroutines.Continuation)
""")
fun logResult(joinPoint: ProceedingJoinPoint): Any? =
joinPoint.runCoroutine {
val result = joinPoint.proceedCoroutine()
logger.debug("`${joinPoint.signature}` returned `${result}`")
result
}
}
[DEBUG] `Object CoroutineFoo.add(int,int,Continuation)` returned `5`
Pełny kod źródłowy znajdziecie tutaj.
Zainstaluj teraz i czytaj tylko dobre teksty!
Podsumowanie
Używanie aspektów z korutynami jest jak najbardziej możliwe. Pomimo braku oficjalnego wsparcia przy odrobinie dodatkowego kodu jesteśmy w stanie połączyć programowanie aspektowe z modelem asynchronicznym. Aspekty mogą nam się przydać przy implementowaniu cross-cutting concerns takich jak logging, tracing i tym podobne.