Another round of Spring problems! This time, I have the need to disable Spring Caching per request.
The methods which should be cached looks like this:
@Cacheable("name-of-the-cache")
public String doSomething(String input) {
// Costly operation here
}
I don’t want to write a second version of these methods without the @Cacheable
annotation, but instead introduce some Spring black magic to disable the cache per request.
First, we need a @RequestScopedBean
:
@Component
@RequestScope(proxyMode = ScopedProxyMode.NO)
public class CacheDisabler {
private boolean disabled = false;
public void disableCache() {
disabled = true;
}
public void enableCache() {
disabled = false;
}
boolean isCacheDisabled() {
return disabled;
}
}
You need to set proxyMode = ScopedProxyMode.NO
, otherwise Spring will create a proxy around that object. The method calls on this proxy fail if there is no request going on. That’s bad, because
every method call on disableCache
or enableCache
or isCacheDisabled
can then throw exceptions. When setting proxyMode
to ScopedProxyMode.NO
, the bean lookup will fail if there is
no request going on instead of exception throwing on method level. The next class we write will deal correctly with this situation.
This CacheDisabler
bean is now a Singleton per request, which means that changing the boolean variable disabled
inside the bean affects only the current request.
The next piece of the puzzle is a Spring Caching Cache implementation, which honors the value of the boolean disabled
field from the CacheDisabler
:
/**
* A cache implementation which can be switched off.
*/
public class DeactivatableCache implements Cache {
private final Cache delegate;
private final NoOpCache noOpCache;
private final ObjectFactory<CacheDisabler> cacheDisabler;
private final boolean disabledByDefault;
public DeactivatableCache(ObjectFactory<CacheDisabler> cacheDisabler, Cache delegate, boolean disabledByDefault) {
this.delegate = delegate;
this.cacheDisabler = cacheDisabler;
this.disabledByDefault = disabledByDefault;
this.noOpCache = new NoOpCache(delegate.getName());
}
// Some boring methods omitted - they just call the same method on delegate cache
@Override
public ValueWrapper get(Object key) {
if (isCacheDisabled()) {
return noOpCache.get(key);
}
return delegate.get(key);
}
@Override
public <T> T get(Object key, Class<T> type) {
if (isCacheDisabled()) {
return noOpCache.get(key, type);
}
return delegate.get(key, type);
}
@Override
public <T> T get(Object key, Callable<T> valueLoader) {
if (isCacheDisabled()) {
return noOpCache.get(key, valueLoader);
}
return delegate.get(key, valueLoader);
}
@Override
public void put(Object key, Object value) {
if (isCacheDisabled()) {
noOpCache.put(key, value);
return;
}
delegate.put(key, value);
}
@Override
public ValueWrapper putIfAbsent(Object key, Object value) {
if (isCacheDisabled()) {
return noOpCache.putIfAbsent(key, value);
}
return delegate.putIfAbsent(key, value);
}
@Override
public void evict(Object key) {
if (isCacheDisabled()) {
noOpCache.evict(key);
return;
}
delegate.evict(key);
}
@Override
public void clear() {
if (isCacheDisabled()) {
noOpCache.clear();
return;
}
delegate.clear();
}
private boolean isCacheDisabled() {
CacheDisabler currentCacheDisabler;
try {
currentCacheDisabler = this.cacheDisabler.getObject();
} catch (BeansException e) {
// We ignore the exception on intent
LOGGER.trace("No CacheDisabler found, using default = {}", disabledByDefault);
return disabledByDefault;
}
if (currentCacheDisabler == null) {
LOGGER.trace("No CacheDisabler found, using default = {}", disabledByDefault);
return disabledByDefault;
}
boolean disabled = currentCacheDisabler.isCacheDisabled();
LOGGER.trace("CacheDisabler: Cache disabled = {}", disabled);
return disabled;
}
}
This cache wraps an existing cache (decorator pattern) and uses an ObjectFactory
from Spring to get the current request-scoped CacheDisabler
. The pattern is the same in every method: ask the current CacheDisabler
if the cache is enabled. If the
cache is enabled, delegate to the wrapped cache (delegate
field). Otherwise delegate to the NoOpCache
from Spring cache. It, as the name suggests, doesn’t cache anything.
The isCacheDisabled
method has to deal with three cases:
- We can’t get the current
CacheDisabler
, because no request is going on. This is the case if you access a@Cachable
method in aCommandlineRunner
, etc. In this case we use a default value. - The
getObject
fromObjectFactory
has returnednull
. The JavaDoc states that “[it] should never be {@code null})”, but hey, I don’t trust the word “should”. Better null-safe than sorry! In this case we use a default value, too. - We get the current
CacheDisabler
and can ask it if the cache is disabled.
Now we have to register the DeactivatableCache
with Spring. Some more Spring magic:
@Configuration
@EnableCaching
public class CachingConfiguration {
private final ObjectFactory<CacheDisabler> cacheDisabler;
public CachingConfiguration(ObjectFactory<CacheDisabler> cacheDisabler) {
this.cacheDisabler = cacheDisabler;
}
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
Cache cache = new CaffeineCache("name-of-the-cache", Caffeine.newBuilder()
.recordStats()
.expireAfterWrite(1, TimeUnit.HOURS)
.maximumSize(10_000)
.build());
cacheManager.setCaches(Collections.singletonList(new DeactivatableCache(cacheDisabler, cache, false));
return cacheManager;
}
}
This creates a CaffeineCache
, wraps a DeactivatableCache
around it and then registers it with Spring.
Now the DeactivatableCache
is used by Spring Caching. But how to disable the cache per request? One way would be to inject ObjectFactory<CacheDisabler>
in your controller (or service or whatever Spring bean) and call the getObject().disableCache()
method.
But I want to introduce even more black magic and write an annotation @DisableCache
, which can be applied to the Spring MVC controller class / method. If this annotation is present, the cache is disabled for the current request.
First, create the annotation:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface DisableCache {
}
Now we need some piece of code which detects the annotation and disables the cache. For that I use a HandlerInterceptor
, which are called by Spring MVC before any controller method is invoked:
@Component
@Import(CacheDisabler.class)
public class DisableCacheInterceptor extends HandlerInterceptorAdapter {
private final ObjectFactory<CacheDisabler> cacheDisabler;
@Autowired
public DisableCacheInterceptor(ObjectFactory<CacheDisabler> cacheDisabler) {
this.cacheDisabler = cacheDisabler;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod && hasDisableCacheAnnotation((HandlerMethod) handler)) {
disableCacheForRequest();
}
return true;
}
private boolean hasDisableCacheAnnotation(HandlerMethod handlerMethod) {
return handlerMethod.getMethodAnnotation(DisableCache.class) != null ||
AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getBeanType(), DisableCache.class) != null;
}
private void disableCacheForRequest() {
LOGGER.trace("@DisableCache found, disabling cache for this request");
cacheDisabler.getObject().disableCache();
}
}
The preHandle
method is called before the controller method is called. The handler
parameter should always be of type HandlerMethod
, but just to be sure I included an instanceof
check. Better ClassCastException-safe than sorry!
The hasDisableCacheAnnotation
uses the AnnotatedElementUtils
from Spring to check if the @DisableCache
is either on the controller class or on the method. If applied to the class, then the cache is disabled for all controller
methods.
Last step is to register the DisableCacheInterceptor
with Spring MVC:
@Configuration
@Import(DisableCacheInterceptor.class)
public class DisableCacheInterceptorConfiguration extends WebMvcConfigurerAdapter {
private final DisableCacheInterceptor disableCacheInterceptor;
@Autowired
public DisableCacheInterceptorConfiguration(DisableCacheInterceptor disableCacheInterceptor) {
this.disableCacheInterceptor = disableCacheInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(disableCacheInterceptor);
}
}
Inherit from WebMvcConfigurerAdapter
, override addInterceptors
and add the DisableCacheInterceptor
.
The whole setup can even be tested with an integration test:
@WebMvcTest({
// These are our two test controllers (inner classes)
DisableCacheTest.CacheDisabledController.class,
DisableCacheTest.CacheEnabledController.class,
})
@Import({
// Registers the @DisableCache annotation with Spring
DisableCacheInterceptorConfiguration.class,
CachingConfiguration.class
})
@RunWith(SpringRunner.class)
public class DisableCacheTest {
/**
* Logger.
*/
private static final Logger LOGGER = LoggerFactory.getLogger(DisableCacheTest.class);
@RestController
@DisableCache
// This controller has a cache, but it's disabled
public static class CacheDisabledController {
private final AtomicInteger counter = new AtomicInteger(0);
@GetMapping("/test/cache/disabled")
@Cacheable("name-of-the-cache")
public int test() {
LOGGER.info("Cache miss");
return counter.incrementAndGet();
}
}
@RestController
// This controller has a cache, and it's enabled
public static class CacheEnabledController {
private final AtomicInteger counter = new AtomicInteger(0);
@GetMapping("/test/cache/enabled")
@Cacheable("name-of-the-cache")
public int test() {
LOGGER.info("Cache miss");
return counter.incrementAndGet();
}
}
@Test
public void cacheDisabled() throws Exception {
// When we request the 1st time
LOGGER.info("1st request");
ResultActions result = mockMvc.perform(get("/test/cache/disabled"));
// Then we get 1
result.andExpect(content().string("1"));
// When we request the 2nd time
LOGGER.info("2nd request");
result = mockMvc.perform(get("/test/cache/disabled"));
// Then we get 2 (as nothing is cached)
result.andExpect(content().string("2"));
}
@Test
public void cacheEnabled() throws Exception {
// When we request the 1st time
LOGGER.info("1st request");
ResultActions result = mockMvc.perform(get("/test/cache/enabled"));
// Then we get 1
result.andExpect(content().string("1"));
// When we request the 2nd time
LOGGER.info("2nd request");
result = mockMvc.perform(get("/test/cache/enabled"));
// Then we get 1 (served from cache)
result.andExpect(content().string("1"));
}
}
Note the @DisableCache
annotation on the CacheDisabledController
- with this, all the black magic is called into action and the cache is disabled.
I think the whole setup is very elegant and demonstrates the power of the Spring framework. Happy caching (or not caching)!