Spring Data Redis で Null サポートキャッシュ


Spring なプロジェクトで Spring Date Redis を使うと、Redis へのキャッシュが簡単にできるようになります。

ただ、キャッシュの難しい点の1つとして、null なものをキャッシュするかどうかという話があります。
時間を掛けて取得を試みたものが null だった場合(無かった場合)、null であったことを記憶しておきたいか、という話です。

今までは Spring Date Redis を使っていると、null はキャッシュ対象外でした。
(もちろん、シリアライザー/デシリアライザーをカスタマイズすることで対応はできます)
これは今まで結構困っていたのですが、バージョン 1.8 から公式にサポートされるようになっていました。

Support caching null values via RedisCache
https://jira.spring.io/browse/DATAREDIS-553

使い方としては、RedisCacheManager のコンストラクタ引数に null をサポートするかのフラグをセットするだけです。
以下、サンプルです。

Application.java
@EnableCaching
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Autowired
    private JedisConnectionFactory jedisConnectionFactory;

    @Bean
    public RedisTemplate<Object, Object> redisTemplate() {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(jedisConnectionFactory);
        redisTemplate.setKeySerializer(new GenericJackson2JsonRedisSerializer(""));
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(""));
        return redisTemplate;
    }

    @Bean
    public CacheManager cacheManager() {
        List<String> cacheNames = Arrays.asList("person");
        RedisCacheManager redisCacheManager = new RedisCacheManager(redisTemplate(), cacheNames, true); // 最後に引数で Null をキャッシュするようにする。

        // キャッシュキーのプレフィックスの設定
        redisCacheManager.setUsePrefix(true);
        redisCacheManager.setCachePrefix(new RedisCachePrefix() {
            @Override
            public byte[] prefix(String cacheName) {
                return ("SAMPLE:" + cacheName + ":").getBytes(StandardCharsets.UTF_8);
            }
        });

        return redisCacheManager;
    }
}
Person.java
@Data
@AllArgsConstructor
public class Person {
    private int id;
    private String name;
}
PersonService.java
@Slf4j
@Service
public class PersonService {

    @Cacheable("person")
    public Person findById(int id) {
        log.info("called findById. id = {}", id);

        try {
            TimeUnit.SECONDS.sleep(id);
        } catch (InterruptedException e) {
            // ignore
        }

        return id >= 10 ? null : new Person(id, "ほげ太郎" + id);
    }
}
PersonServiceTest.java
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class PersonServiceTest {

    @Autowired
    PersonService target;
    @Autowired
    CacheManager cacheManager;

    @Before
    public void setup() {
        Cache cache = cacheManager.getCache("person");
        cache.clear();
    }

    @Test
    public void testFindByIdNull() {
        StopWatch stopWatch = new StopWatch();

        stopWatch.start();
        Person p1 = target.findById(10); // 10s かかる
        stopWatch.stop();
        log.info("result = {}, elapsed = {}", p1, stopWatch.getLastTaskTimeMillis());
        assertNull(p1);
        assertTrue(stopWatch.getLastTaskTimeMillis() > 10000);

        stopWatch.start();
        Person p2 = target.findById(10); // 2回目はキャッシュが効いて速い
        stopWatch.stop();
        log.info("result = {}, elapsed = {}", p2, stopWatch.getLastTaskTimeMillis());
        assertNull(p2);
        assertTrue(stopWatch.getLastTaskTimeMillis() < 1000);
    }
}
TestResults
2017-06-23 12:01:34.434  INFO 3246 --- [           main] n.s.cachesample.service.PersonService    : called findById. id = 10
2017-06-23 12:01:44.449  INFO 3246 --- [           main] n.s.c.service.PersonServiceTest          : result = null, elapsed = 10058
2017-06-23 12:01:44.498  INFO 3246 --- [           main] n.s.c.service.PersonServiceTest          : result = null, elapsed = 49

ちなみに Redis にどう保存されているかというと以下のようになっています。

$ redis-cli -h 192.168.99.100 get 'SAMPLE:hello:10'                                                                                                                                                                                12:06:23
"{\"@class\":\"org.springframework.cache.support.NullValue\"}"

これは、GenericJackson2JsonRedisSerializer で定義されてる以下のクラスによるものです。

NullValueSerializer
/**
 * {@link StdSerializer} adding class information required by default typing. This allows de-/serialization of
 * {@link NullValue}.
 *
 * @author Christoph Strobl
 * @since 1.8
 */
private class NullValueSerializer extends StdSerializer<NullValue> {

    private static final long serialVersionUID = 1999052150548658808L;
    private final String classIdentifier;

    /**
     * @param classIdentifier can be {@literal null} and will be defaulted to {@code @class}.
     */
    NullValueSerializer(String classIdentifier) {

        super(NullValue.class);
        this.classIdentifier = StringUtils.hasText(classIdentifier) ? classIdentifier : "@class";
    }

    /*
     * (non-Javadoc)
     * @see com.fasterxml.jackson.databind.ser.std.StdSerializer#serialize(java.lang.Object, com.fasterxml.jackson.core.JsonGenerator, com.fasterxml.jackson.databind.SerializerProvider)
     */
    @Override
    public void serialize(NullValue value, JsonGenerator jgen, SerializerProvider provider)
            throws IOException {

        jgen.writeStartObject();
        jgen.writeStringField(classIdentifier, NullValue.class.getName());
        jgen.writeEndObject();
    }
}