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
キャッシュの有効期限は、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; }
とはいえ、こういうのがあることを知っていると便利さアップですね、と。