Caching of nested cacheable operation via SpringCa

2020-02-14 08:07发布

问题:

I was given the task to make use of SpringCache for one of our services to reduce the number of DB lookups. While testing the implementation I noticed that some of the cacheable operations are invoked multiple times via log-statements. Investigations revealed that if a cacheable operation is called within a cachable method, the nested operation is not cached at all. Therefore, a later invocation of the nested operation leads to a further lookup.

A simple unit-test describing the problem is enlisted below:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {SpringCacheTest.Config.class} )
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public class SpringCacheTest {

  private final static String CACHE_NAME = "testCache";
  private final static Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
  private final static AtomicInteger methodInvocations = new AtomicInteger(0);

  public interface ICacheableService {

    String methodA(int length);
    String methodB(String name);
  }

  @Resource
  private ICacheableService cache;

  @Test
  public void testNestedCaching() {

    String name = "test";
    cache.methodB(name);
    assertThat(methodInvocations.get(), is(equalTo(2)));

    cache.methodA(name.length());
    // should only be 2 as methodA for this length was already invoked before
    assertThat(methodInvocations.get(), is(equalTo(3)));
  }

  @Configuration
  public static class Config {

    @Bean
    public CacheManager getCacheManager() {
      SimpleCacheManager cacheManager = new SimpleCacheManager();
      cacheManager.setCaches(Arrays.asList(new ConcurrentMapCache(CACHE_NAME)));
      return cacheManager;
    }

    @Bean
    public ICacheableService getMockedEntityService() {
      return new ICacheableService() {
        private final Random random = new Random();

        @Cacheable(value = CACHE_NAME, key = "#root.methodName.concat('_').concat(#p0)")
        public String methodA(int length) {
          methodInvocations.incrementAndGet();
          LOG.debug("Invoking methodA");
          char[] chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toCharArray();
          StringBuilder sb = new StringBuilder();
          for (int i=0; i<length; i++) {
            sb.append(chars[random.nextInt(chars.length)]);
          }
          String result = sb.toString();
          LOG.debug("Returning {} for length: {}", result, length);
          return result;
        }

        @Cacheable(value = CACHE_NAME, key = "#root.methodName.concat('_').concat(#p0)")
        public String methodB(String name) {
          methodInvocations.incrementAndGet();
          LOG.debug("Invoking methodB");

          String rand = methodA(name.length());
          String result = name+"_"+rand;
          LOG.debug("Returning {} for name: {}", result, name);
          return result;
        }
      };
    }
  }
}

The actual work of both methods is unimportant for the test-case itself as just the caching should be tested.

I somehow understand the reason why the result of the nested operation is not cached, but I was wondering if there is a configuration available, which I haven't figured out yet, to enable caching for return values of nested cacheable operations.

I know that through refactoring and providing the return value from the nested operation as argument for the outer operation will work, but as this might involve to change a number of operations (as well as unit-test them) a configuration or other workaround (if available) would be preferable in our concrete case.

回答1:

The problem is that you are accessing methodA directly from methodB and therefore this prevents going through the Java proxies which handles the caching mechanims. Additionally, you did not add the @EnableCaching annotation, so there were actually no caching at all in your test.

The following test demonstrates that if you properly go through the proxies created by Spring, the nested cache pattern works as expected:

import static org.junit.Assert.*;

import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;

import javax.annotation.Resource;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { SpringCacheTest.Config.class })
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public class SpringCacheTest {

    private final static String CACHE_NAME = "testCache";
    private final static AtomicInteger methodInvocations = new AtomicInteger(0);

    public interface ICacheableService {

        String methodA(int length);

        String methodB(String name);
    }

    @Resource
    private ICacheableService cache;

    @Test
    public void testNestedCaching() {

        String name = "test";
        cache.methodB(name);
        assertEquals(methodInvocations.get(), 2);

        cache.methodA(name.length());
        // should only be 2 as methodA for this length was already invoked before
        assertEquals(methodInvocations.get(), 2);
    }

    @Configuration
    @EnableCaching
    public static class Config {

        @Bean
        public CacheManager getCacheManager() {
            SimpleCacheManager cacheManager = new SimpleCacheManager();
            cacheManager.setCaches(Arrays.asList(new ConcurrentMapCache(CACHE_NAME)));
            return cacheManager;
        }

        @Bean
        public ICacheableService getMockedEntityService() {
            return new ICacheableService() {
                private final Random random = new Random();

                @Autowired
                ApplicationContext context;

                @Override
                @Cacheable(value = CACHE_NAME, key = "#root.methodName.concat('_').concat(#p0)")
                public String methodA(int length) {
                    methodInvocations.incrementAndGet();
                    System.out.println("Invoking methodA");
                    char[] chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toCharArray();
                    StringBuilder sb = new StringBuilder();
                    for (int i = 0; i < length; i++) {
                        sb.append(chars[random.nextInt(chars.length)]);
                    }
                    String result = sb.toString();
                    System.out.println("Returning " + result + " for length: " + length);
                    return result;
                }

                @Override
                @Cacheable(value = CACHE_NAME, key = "#root.methodName.concat('_').concat(#p0)")
                public String methodB(String name) {
                    methodInvocations.incrementAndGet();
                    System.out.println("Invoking methodB");
                    ICacheableService cache = context.getBean(ICacheableService.class);
                    String rand = cache.methodA(name.length());
                    String result = name + "_" + rand;
                    System.out.println("Returning " + result + " for name: " + name);
                    return result;
                }
            };
        }
    }
}