CLOVER🍀

That was when it all began.

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

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依存関係から。

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <spring.boot.version>1.5.1.RELEASE</spring.boot.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring.boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

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<Object, Object> redisTemplate) {
        RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);

        cacheManager.setUsePrefix(true);

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

        cacheManager.setExpires(expires);

        cacheManager.afterPropertiesSet();
        return cacheManager;
    }

    @Bean
    public CacheManager compositeCacheManager(RedisTemplate<Object, Object> 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;
	}

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