Spring の Cache Abstraction で、複数の CacheManager を合わせる CompositeCacheManager を使う

2018/12/25 posted in  Spring
2017-02-04

原文地址 https://kazuhira-r.hatenablog.com/entry/20170204/1486194922

Spring の Cache Abstraction では、バックエンドのキャッシュに対する Cache Provider があり、それぞれ CacheManager の
実装を提供していますが、複数の CacheManager を組み合わせる CompositeCacheManager というものがあります。

Cache / Dealing with caches without a backing store

※CompositeCacheManager の説明というより、適切なキャッシュが存在しなかった時のフォールバックケースとして書いてありますが…

CompositeCacheManager (Spring Framework 4.3.6.RELEASE API)

こちらを使って、今回は Caffeine と Redis の 2 つのキャッシュを同時に使ってみたいと思います。ひとつ前のエントリで、
キャッシュのアノテーションの cacheNames に複数のキャッシュを指定しましたが、今回はキャッシュごとにバックエンドの
キャッシュを分けてみます。

Spring の Cache Abstraction で、 アノテーションに複数のキャッシュを指定した場合の動きを確認する - CLOVER

サンプルコードは、この時使ったものと近いものを使用しています。

では、試してみましょう。

準備

まずは Maven 依存関係から。

    
        UTF-8
        1.8
        1.8
        1.8
        1.5.1.RELEASE
    

    
        
            
                org.springframework.boot
                spring-boot-dependencies
                ${spring.boot.version}
                pom
                import
            
        
    

    
        
            org.springframework.boot
            spring-boot-starter-cache
        
        
            com.github.ben-manes.caffeine
            caffeine
        
        
            org.springframework.boot
            spring-boot-starter-data-redis
        

        
            org.springframework.boot
            spring-boot-starter-test
            test
        
    

Spring の Cache の依存関係と、Caffeine、Spring Data Redis を追加。

Redis は、3.2.7 をリモートホストで起動させています。

サンプルコードの準備

では、確認用のサンプルアプリケーションを作成します。

キャッシュに格納する用のクラス。

src/main/java/org/littlewings/spring/cache/Message.java 
package org.littlewings.spring.cache;

import java.io.Serializable;
import java.time.LocalDateTime;

public class Message implements Serializable {
    private static final long serialVersionUID = 1L;

    private String value;
    private LocalDateTime now;

    public Message() {
    }

    public Message(String value) {
        this.value = value;
        now = LocalDateTime.now();
    }

    public String getValue() {
        return value;
    }

    public LocalDateTime getNow() {
        return now;
    }
}

キャッシュ関連のアノテーションを付与したクラス。@Cacheable、@CachePut を使い、それぞれ「caffeineCache」と
「redisCache」を指定しています。@Cacheable を付与したメソッドは、キャッシュが存在しない場合は 3 秒間スリープします。
src/main/java/org/littlewings/spring/cache/MessageService.java

package org.littlewings.spring.cache;

import java.util.concurrent.TimeUnit;

import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class MessageService {
    @Cacheable(cacheNames = {"caffeineCache", "redisCache"}, key = "#value")
    public Message build(String value) {
        try {
            TimeUnit.SECONDS.sleep(3L);
        } catch (InterruptedException e) {
            // ignore
        }

        return new Message(value);
    }

    @CachePut(cacheNames = {"caffeineCache", "redisCache"}, key = "#message.value")
    public Message update(Message message) {
        return message;
    }
}

CacheManager の設定。@EnableCaching アノテーションを設定してキャッシュ機能を有効化するとともに、Caffeine と Redis
それぞれの CacheManager を作成し、CompositeCacheManager で組み合わせています。
src/main/java/org/littlewings/spring/cache/CacheConfig.java

package org.littlewings.spring.cache;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.cache.support.CompositeCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.core.RedisTemplate;

@Configuration
@EnableCaching
public class CacheConfig {
    CacheManager caffeineCacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();

        cacheManager.setCacheSpecification("expireAfterWrite=3s");
        cacheManager.setCacheNames(Arrays.asList("caffeineCache"));

        return cacheManager;
    }

    CacheManager redisCacheManager(RedisTemplate redisTemplate) {
        RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);

        cacheManager.setUsePrefix(true);

        Map expires = new HashMap<>();
        expires.put("redisCache", 6L);

        cacheManager.setExpires(expires);

        cacheManager.afterPropertiesSet();
        return cacheManager;
    }

    @Bean
    public CacheManager compositeCacheManager(RedisTemplate redisTemplate) {
        CompositeCacheManager cacheManager = new CompositeCacheManager(caffeineCacheManager(), redisCacheManager(redisTemplate));
        cacheManager.setFallbackToNoOpCache(false);  // キャッシュの定義が見つからない場合は、getCacheがnullを返すようにする
        cacheManager.afterPropertiesSet();
        return cacheManager;
    }
}

キャッシュの有効期限は、Caffeine は 3 秒、Redis は 6 秒にしています。

CompositeCacheManager を作成する時に、CompositeCacheManager#setFallbackToNoOpCache を true に設定すると
CompositeCacheManager が内部的に保持する CacheManager に NoOpCacheManager というものが追加されます。
NoOpCacheManager が追加されると、CacheManager#getCache でキャッシュ定義が見つからない時に
なにもしないキャッシュが返されるようになり、宣言的キャッシュを使ってキャッシュが見つからない時にエラーに
ならなくなります。

デフォルトは false(キャッシュが見つからないと null を返す)です。

@SpringBootApplication を付与したクラスを作成。テスト用です。
src/main/java/org/littlewings/spring/cache/App.java

package org.littlewings.spring.cache;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
}

最後に、Redis への接続設定。
src/main/resources/application.properties

spring.redis.host=172.17.0.2
spring.redis.password=redispass

ここまでで、準備は完了です。

動作確認

それでは、テストコードを書いて動作確認をしてみます。テストコードの雛形は、こちら。
src/test/java/org/littlewings/spring/cache/CompositeCacheTest.java

package org.littlewings.spring.cache;

import java.util.concurrent.TimeUnit;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.util.StopWatch;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest
public class CompositeCacheTest {
    @Autowired
    CacheManager cacheManager;

    @Autowired
    MessageService messageService;

    // ここに、テストを書く!
}

まずは @Cacheable での確認から。テストコードは、こちら。

    @Test
    public void cacheable() throws InterruptedException {
        StopWatch stopWatch = new StopWatch();

        // 1回目
        stopWatch.start();
        Message m1 = messageService.build("Hello World");
        stopWatch.stop();

        // 低速
        assertThat((long)stopWatch.getLastTaskInfo().getTimeSeconds())
                .isGreaterThanOrEqualTo(3L);

        // キャッシュに登録
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("caffeineCache").get("Hello World").get()).getNow())
                .isEqualTo(m1.getNow());
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m1.getNow());

        // 2回目
        stopWatch.start();
        Message m2 = messageService.build("Hello World");
        stopWatch.stop();

        // 高速
        assertThat((long)stopWatch.getLastTaskInfo().getTimeSeconds())
                .isEqualTo(0L);

        // キャッシュにはまだ存在
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("caffeineCache").get("Hello World").get()).getNow())
                .isEqualTo(m2.getNow())
                .isEqualTo(m1.getNow());
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m2.getNow())
                .isEqualTo(m1.getNow());

        // 3秒スリープ
        TimeUnit.SECONDS.sleep(3L);

        // caffeineCacheのみ有効期限切れ
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNull();
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m2.getNow())
                .isEqualTo(m1.getNow());

        // 3回目
        stopWatch.start();
        Message m3 = messageService.build("Hello World");
        stopWatch.stop();

        // 高速
        assertThat((long)stopWatch.getLastTaskInfo().getTimeSeconds())
                .isEqualTo(0L);

        // caffeineCacheのみ有効期限切れ
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNull();
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m3.getNow())
                .isEqualTo(m1.getNow());

        // 3秒スリープ
        TimeUnit.SECONDS.sleep(3L);

        // どちらのキャッシュも有効期限切れ
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNull();
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNull();

        // 再度アクセス
        messageService.build("Hello World");

        // キャッシュに再登録
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNotNull();
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();

        // 後始末
        cacheManager.getCache("caffeineCache").evict("Hello World");
        cacheManager.getCache("redisCache").evict("Hello World");
    }

@Cacheable なメソッドにアクセスすると、キャッシュにエントリが保存されます。この時、Caffeine と Redis の両方に保存されます。

        // 1回目
        stopWatch.start();
        Message m1 = messageService.build("Hello World");
        stopWatch.stop();

        // 低速
        assertThat((long)stopWatch.getLastTaskInfo().getTimeSeconds())
                .isGreaterThanOrEqualTo(3L);

        // キャッシュに登録
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("caffeineCache").get("Hello World").get()).getNow())
                .isEqualTo(m1.getNow());
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m1.getNow());

2 回目のアクセスでは、高速になります。

        // 2回目
        stopWatch.start();
        Message m2 = messageService.build("Hello World");
        stopWatch.stop();

        // 高速
        assertThat((long)stopWatch.getLastTaskInfo().getTimeSeconds())
                .isEqualTo(0L);

        // キャッシュにはまだ存在
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("caffeineCache").get("Hello World").get()).getNow())
                .isEqualTo(m2.getNow())
                .isEqualTo(m1.getNow());
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m2.getNow())
                .isEqualTo(m1.getNow());

ここで、3 秒間スリープすると有効期限の短い Caffeine のキャッシュのみ有効期限切れします。

        // 3秒スリープ
        TimeUnit.SECONDS.sleep(3L);

        // caffeineCacheのみ有効期限切れ
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNull();
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m2.getNow())
                .isEqualTo(m1.getNow());

3 回目のアクセス。Caffeine からはキャッシュエントリが消えましたが、Redis には残ったままなので高速です。

        // 3回目
        stopWatch.start();
        Message m3 = messageService.build("Hello World");
        stopWatch.stop();

        // 高速
        assertThat((long)stopWatch.getLastTaskInfo().getTimeSeconds())
                .isEqualTo(0L);

        // caffeineCacheのみ有効期限切れ
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNull();
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m3.getNow())
                .isEqualTo(m1.getNow());

さらに 3 秒間スリープさせると、Redis からもエントリがなくなります。

        // 3秒スリープ
        TimeUnit.SECONDS.sleep(3L);

        // どちらのキャッシュも有効期限切れ
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNull();
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNull();

もちろん、この状態で再度 @Cachable なメソッドにアクセスすると両方のキャッシュにエントリが入ります。

        // 再度アクセス
        messageService.build("Hello World");

        // キャッシュに再登録
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNotNull();
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();

両方のそれぞれのキャッシュを使えてそうですね。

あと、@CachePut も確認しておきましょう。テストコードは、こちら。

    @Test
    public void cachePut() throws InterruptedException {
        StopWatch stopWatch = new StopWatch();

        // 1回目
        stopWatch.start();
        Message m1 = messageService.build("Hello World");
        stopWatch.stop();

        // 低速
        assertThat((long)stopWatch.getLastTaskInfo().getTimeSeconds())
                .isGreaterThanOrEqualTo(3L);

        // キャッシュに登録されている
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("caffeineCache").get("Hello World").get()).getNow())
                .isEqualTo(m1.getNow());
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m1.getNow());

        // 3秒スリープ
        TimeUnit.SECONDS.sleep(3L);

        // caffeineCacheのみ有効期限切れ
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNull();
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m1.getNow());

        // キャッシュを更新
        Message newMessage = new Message("Hello World");
        messageService.update(newMessage);

        // キャッシュが更新されたことを確認
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("caffeineCache").get("Hello World").get()).getNow())
                .isEqualTo(newMessage.getNow())
                .isNotEqualTo(m1.getNow());
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(newMessage.getNow())
                .isNotEqualTo(m1.getNow());
    }

とりあえず、キャッシュにエントリを入れます。

        // 1回目
        stopWatch.start();
        Message m1 = messageService.build("Hello World");
        stopWatch.stop();

        // 低速
        assertThat((long)stopWatch.getLastTaskInfo().getTimeSeconds())
                .isGreaterThanOrEqualTo(3L);

        // キャッシュに登録されている
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("caffeineCache").get("Hello World").get()).getNow())
                .isEqualTo(m1.getNow());
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m1.getNow());

3 秒間スリープさせ、Caffeine のみ有効期限が切れたところで

        // 3秒スリープ
        TimeUnit.SECONDS.sleep(3L);

        // caffeineCacheのみ有効期限切れ
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNull();
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(m1.getNow());

@CachePut なメソッドを呼び出しキャッシュを更新して、両方のキャッシュに反映されたことを確認。

        // キャッシュを更新
        Message newMessage = new Message("Hello World");
        messageService.update(newMessage);

        // キャッシュが更新されたことを確認
        assertThat(cacheManager.getCache("caffeineCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("caffeineCache").get("Hello World").get()).getNow())
                .isEqualTo(newMessage.getNow())
                .isNotEqualTo(m1.getNow());
        assertThat(cacheManager.getCache("redisCache").get("Hello World"))
                .isNotNull();
        assertThat(((Message)cacheManager.getCache("redisCache").get("Hello World").get()).getNow())
                .isEqualTo(newMessage.getNow())
                .isNotEqualTo(m1.getNow());

こちらも OK そうです。

まとめ

CompositeCacheManager を使って、複数の CacheManager を組み合わせて使ってみました。

CompositeCacheManager のトリックですが、単に CacheManager#getCache した時に内部で持っている CacheManager から順次 Cache を
持っているか確認していき、最初に見つかったものを返すという単純な実装です。
https://github.com/spring-projects/spring-framework/blob/v4.3.6.RELEASE/spring-context/src/main/java/org/springframework/cache/support/CompositeCacheManager.java#L101

   @Override
    public Cache getCache(String name) {
        for (CacheManager cacheManager : this.cacheManagers) {
            Cache cache = cacheManager.getCache(name);
            if (cache != null) {
                return cache;
            }
        }
        return null;
    }

とはいえ、こういうのがあることを知っていると便利さアップですね、と。