github twitter linkedin
Disable Spring Caching per request
2018-09-18

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:

  1. We can’t get the current CacheDisabler, because no request is going on. This is the case if you access a @Cachable method in a CommandlineRunner, etc. In this case we use a default value.
  2. The getObject from ObjectFactory has returned null. 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.
  3. 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)!


Back to posts