Spring Boot Annotations: Behind the Scenes and the Self-Invocation Problem
Spring uses proxies to add extra logic to methods marked with annotations like @Cacheable and others. But using proxies could lead to issues like self-invocation problem
Join the DZone community and get the full member experience.
Join For FreeWhen developing Spring Boot applications, we often use annotations such as @Transactional
, @Cacheable
, @Retryable
, @Validated
, @Async
, and so on. Through these annotations, we imbue our beans with supplementary logic, such as encapsulating database operations within transactions or implementing caching mechanisms.
However, not everyone wonders how they work under the hood and what issues may arise from using them. In this article, let's embark on a journey to explore how the most popular annotations work and what the self-invocation problem is.
Spring Beans and Proxies
When some annotations, like @Transactional
, are applied to methods in beans. Spring creates proxy objects that intercept the actual method calls and execute additional logic before or after that.
Spring utilizes AOP (Aspect-Oriented Programming) through these proxies to address cross-cutting concerns like logging, security, and transactions, distinct from the main logic. This allows us to keep the core business logic clean and concentrate solely on it without being distracted by other matters.
Understanding things is often easier when we look at an example. So, let's consider the service below:
@Service
public class CacheableService {
@Cacheable(cacheNames = "cache")
public String getFromCache() {
return "This value will be moved to cache and next time used from there";
}
}
In order to make CacheableService
start using a cache, we marked the method getFromCache()
with @Cacheable
annotation. Spring undertakes the task of enhancing the bean by introducing supplementary logic:
- If the value is absent in the cache, it has to be retrieved and placed into the cache.
- If the value is already presents in the cache, it has to be directly retrieved from there.
This process involves the utilization of proxies. In the runtime, Spring not only creates a new instance of the CacheableService
, but also generates a proxy class to accompany it.
The simplified sequence appears as follows:
So, Spring generates a proxy object, which mirrors the existing methods while incorporating supplementary logic.
If the class implements an interface, the proxy object also implements it, and dynamic proxy is used in this case. Otherwise, the proxy extends the target class using CGLib.
Examples of How the Most Popular Annotations Work Under the Hood
Classes for proxies are generated at runtime. In the case of our example, CacheableService
, the generated proxy looks like the following:
public class CacheableService$$SpringCGLIB$$0 extends CacheableService implements SpringProxy, Advised, Factory {
...
private MethodInterceptor CGLIB$CALLBACK_0;
...
@Override
public final String getFromCache() {
//execute using method interceptor
}
...
}
We can see that CGLib was used because CacheableService
implements no interfaces, and the generated proxy simply extends it. If CacheableService
implemented some interface, a dynamic proxy would be used instead, and it would implement the same interface.
Spring employs MethodInterceptors within the proxies, and these interceptors are responsible for adding supplementary logic around the actual method invocations
Let's take a look at what happens inside method interceptors, which are used with the most common annotations:
@Cacheable
Let's begin with the @Cacheable
annotation, as demonstrated in this article with the CacheableService
example. In this scenario, the proxy that's created employs the CacheInterceptor:
if (cacheHit != null && !hasCachePut(contexts)) {
// If there are no put requests, just use the cache hit
cacheValue = cacheHit.get();
returnValue = wrapCacheValue(method, cacheValue);
} else {
// Invoke the method if we don't have a cache hit
returnValue = invokeOperation(invoker);
cacheValue = unwrapReturnValue(returnValue);
}
The method interceptor operates as follows: it examines the cache for a specific value and directly returns it if it's found. In the event that the value is not present in the cache, the interceptor orchestrates its computation through the invokeOperation
(the target method call). Following the computation, the interceptor stores the value in the cache and subsequently returns it.
@Transactional
In the case of Transactional annotation, the proxy uses another MethodInterceptor implementation — TransactionInterceptor:
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
Object retVal;
try {
// Target method invocation
retVal = invocation.proceedWithInvocation();
} catch (Throwable ex) {
// target invocation exception -> transaction rollback
completeTransactionAfterThrowing(txInfo, ex); // leads to transactionManager.rollback(...);
throw ex;
} finally {
cleanupTransactionInfo(txInfo);
}
We can see that, actually, this is not that difficult. A new transaction is initiated prior to the method invocation, and it is committed if no errors occur; otherwise, a transaction rollback takes place.
@Retryable
When it comes to the @Retryable
annotation, the proxy that's generated utilizes the RetryOperationsInterceptor along with the RetryTemplate behind the scenes:
while (canRetry(retryPolicy, context) && !context.isExhaustedOnly()) {
try {
...
T result = retryCallback.doWithRetry(context);
doOnSuccessInterceptors(retryCallback, context, result);
return result;
} catch (Throwable e) {
registerThrowable(retryPolicy, state, context, e);
...
}
...
}
The actual target method is executed in a loop until success or the error limit is exceeded.
@Validated
If we annotate certain components with the @Validated annotation, and some method arguments are also marked with validation annotations like @NotBlank
, @Min
, @Email
, the MethodValidationInterceptor will be added to a generated proxy. The interceptor works in the following way:
try {
result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
} catch (IllegalArgumentException ex) {
...
result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
}
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
@Async
In the case of this annotation, the proxy invokes the target method using AsyncExecutionInterceptor and AsyncTaskExecutor:
if (CompletableFuture.class.isAssignableFrom(returnType)) {
return executor.submitCompletable(task);
} else if (... some other cases ...){
} else if (Future.class.isAssignableFrom(returnType)) {
return executor.submit(task);
} else if (void.class == returnType) {
executor.submit(task);
return null;
} else {
throw new IllegalArgumentException("Invalid return type for async method (only Future and void supported): " + returnType);
}
Self-Invocation Problem
We have already learned that Spring creates proxy objects that mirror the existing bean methods and execute supplementary logic before or after the target method invocation.
But what would happen if we called the method marked with an annotation from another method in the same bean? Let's explore this case using an example:
@Service
public class CacheableService {
public String selfInvoke() {
return getFromCache();
}
@Cacheable(cacheNames = "cache")
public String getFromCache() {
return "This value will be moved to cache and next time used from there";
}
}
Now, the CacheableService
includes an additional method called selfInvoke()
, which is not marked with any annotation. This method simply calls getFromCache()
, a method that is marked with the @Cacheable
annotation and enhanced in the proxy to utilize the cache.
So, the question arises: will the selfInvoke()
method retrieve a value from the cache?
Many developers expect that this should work and the cache will be utilized. However, in reality, it doesn't function as expected. To grasp this, let's analyze a sequence diagram that encompasses the updated CacheableService
, its proxy, and the invocation of the selfInvoke()
method.
So, when we call the selfInvoke()
method, we only access the getFromCache()
method in the target bean. However, all the enhancements made to enable caching for this method exist in the overridden getFromCache()
method within the proxy, which, unfortunately, remains unvisited.
The same issue applies to other annotations as well, such as @Transactional
, @Retryable
, @Validated
, @Async
, and others.
Indeed, several options exist to fix this issue. One approach involves refactoring the code to prevent self-invocation of methods marked with annotations and to call them from other beans only. Alternatively, transitioning to AspectJ for proxy creation (compile-time weaving) instead of relying on CGLib and dynamic proxies can be considered. Additionally, a technique known as self-injection could be employed, where the bean is injected into itself, and the target method is invoked on the injected bean. However, it's important to note that this latter solution is essentially a workaround.
Conclusion
In this article, we've explained how Spring uses proxies to add extra logic to methods marked with annotations like @Transactional
, @Cacheable
, @Retryable
, @Validated
, @Async
, and others.
We've also dived into how these annotations work behind the scenes with some code examples.
However, using proxies can sometimes cause issues like the self-invocation problem. We've discussed what it is and ways to fix it.
It's important to get a grip on these concepts, and I really hope you found this article interesting.
Thanks for reading!
Opinions expressed by DZone contributors are their own.
Comments