CLOVER🍀

That was when it all began.

SpringのCache AbstractionでRedisを使ってみる

SpringのCache Abstractionで、Redisを試してみたくなったので、気になるところの確認を含めて
ちょっと遊んでみます。

Spring BootのAutoConfigure対象で、裏ではSpring Data Redisを使っているみたいです。

Caching / Redis

Spring Data Redis

ここでは、簡単なJavaBeansをRedisに突っ込むことと、@CacheableアノテーションでCacheを使うくらいの確認を
してみます。

準備

まずは、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>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>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring.boot.version}</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

とりあえずSpringのCache AbstractionとRedis、それからテストが動けばいいことにするので、
依存関係はこのくらいで。

対象のRedisは、3.2.6でprotection modeはoffとします。

アプリケーションの準備

簡単な確認用のアプリケーションを準備します。

まずは、キャッシュに格納する値とするクラスを。
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;
    }
}

キャッシュしていることがわかりやすいように、インスタンス作成時の現在時刻を持つように
しておきましょう。あと、Serializableにしています。

キャッシュを適用するクラス。
src/main/java/org/littlewings/spring/cache/MessageService.java

package org.littlewings.spring.cache;

import java.util.concurrent.TimeUnit;

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

@Service
public class MessageService {
    @Cacheable(cacheNames = "myCache")
    public Message build(String message) {
        try {
            TimeUnit.SECONDS.sleep(3L);
        } catch (InterruptedException e) {
            // ignore
        }

        return new Message(message);
    }
}

ちょっとスリープして、引数を先ほど作成したクラスに包んで返すだけです。

SpringのCache Abstractionの有効化。
src/main/java/org/littlewings/spring/cache/CacheConfig.java

package org.littlewings.spring.cache;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching
public class CacheConfig {
}

@EnableCachingがあるだけですね。

直接は使いませんが、Spring Bootのエントリポイントも作成しておきます。
src/main/java/org/littlewings/spring/cache/App.java

package org.littlewings.spring.cache;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }
}

一応、テストコードの起動では間接的に使うので。

確認

それでは、使ってみましょう。

先ほど用意したクラスを使う、こんな感じのテストコードを用意。
src/test/java/org/littlewings/spring/cache/SimpleRedisCacheTest.java

package org.littlewings.spring.cache;

import java.time.LocalDateTime;

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.test.context.junit4.SpringRunner;
import org.springframework.util.StopWatch;

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

@RunWith(SpringRunner.class)
@SpringBootTest
public class SimpleRedisCacheTest {
    @Autowired
    MessageService messageService;

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

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

        LocalDateTime now1 = m1.getNow();
        long elapsed1 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(m1.getValue())
                .isEqualTo("Hello World");
        assertThat(elapsed1)
                .isGreaterThanOrEqualTo(3L);

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

        LocalDateTime now2 = m2.getNow();
        long elapsed2 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(m2.getValue())
                .isEqualTo("Hello World");
        assertThat(elapsed2)
                .isEqualTo(0L);
        assertThat(now2)
                .isEqualTo(now1); // cached

        // slow
        stopWatch.start();
        Message m3 = messageService.build("Hello Redis!!");
        stopWatch.stop();

        LocalDateTime now3 = m3.getNow();
        long elapsed3 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(m3.getValue())
                .isEqualTo("Hello Redis!!");
        assertThat(elapsed3)
                .isEqualTo(3L);
        assertThat(now3)
                .isNotEqualTo(now1);
    }
}

キャッシュが効いているか測りつつ実行しますが、そこにはSpringのStopWatchを使いました。

        StopWatch stopWatch = new StopWatch();

1回目は時間がかかっていることを確認し、

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

        LocalDateTime now1 = m1.getNow();
        long elapsed1 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(m1.getValue())
                .isEqualTo("Hello World");
        assertThat(elapsed1)
                .isGreaterThanOrEqualTo(3L);

2回目はキャッシュされていて高速になり、現在時刻に変化もないことが確認できます。

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

        LocalDateTime now2 = m2.getNow();
        long elapsed2 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(m2.getValue())
                .isEqualTo("Hello World");
        assertThat(elapsed2)
                .isEqualTo(0L);
        assertThat(now2)
                .isEqualTo(now1); // cached

今回用意したコードの場合、メソッドの引数がキーになるので、引数を変えるとまた時間がかかることが
確認できます。

        // slow
        stopWatch.start();
        Message m3 = messageService.build("Hello Redis!!");
        stopWatch.stop();

        LocalDateTime now3 = m3.getNow();
        long elapsed3 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(m3.getValue())
                .isEqualTo("Hello Redis!!");
        assertThat(elapsed3)
                .isEqualTo(3L);
        assertThat(now3)
                .isNotEqualTo(now1);

OKそうですね。

有効期限を設定する

先ほどの例では、特にキャッシュの有効期限は設定していませんでした。

設定できないとちょっと困りそうですが、Spring Data Redisだと@TimeToLiveというアノテーションを使いそうな
雰囲気です。

Redis Repositories / Time To Live

では、Spring CacheでのRedisではどうするかですが、RedisCacheManagerで設定すればよさそうです。

先ほど作成した、@EnableCachingを付与したJavaConfigクラスを次のように変更。
src/main/java/org/littlewings/spring/cache/CacheConfig.java

package org.littlewings.spring.cache;

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

import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
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 {
    @Bean
    public CacheManager cacheManager(RedisTemplate<Object, Object> redisTemplate) {
        RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);

        cacheManager.setUsePrefix(true);  // true => key prefix cache-name

        Map<String, Long> expires = new HashMap<>();
        expires.put("myCache", 5L);  // key is cache-name

        cacheManager.setExpires(expires);  // expire per cache

        cacheManager.setDefaultExpiration(10L);  // default expire

        cacheManager.afterPropertiesSet();
        return cacheManager;
    }
}

RedisCacheManager#setExpiresにMapを与えることで、キャッシュごとに有効期限を設定できます。

        Map<String, Long> expires = new HashMap<>();
        expires.put("myCache", 5L);  // key is cache-name

        cacheManager.setExpires(expires);  // expire per cache

今回は、@Cacheableに設定していた「myCache」というキャッシュに対して有効期限を設定しています。

また、デフォルトの有効期限も設定できて、RedisCacheManager#setDefaultExpirationで指定すれば
OKです。今回は10秒にしてみました。

        cacheManager.setDefaultExpiration(10L);  // default expire

なにも指定しないと、有効期限が0になっていてexpireしません。

なお、RedisCacheManager#setUsePrefixというのはキーの接頭辞としてキャッシュの名前を入れるかどうかで、
デフォルト「true」でキャッシュの名前が入ります。

        cacheManager.setUsePrefix(true);  // true => key prefix cache-name

https://github.com/spring-projects/spring-boot/blob/v1.5.1.RELEASE/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/cache/RedisCacheConfiguration.java#L56

では、確認してみます。

デフォルトの有効期限が効いているかどうかも確認するために、Serviceにメソッドを追加します。
src/main/java/org/littlewings/spring/cache/MessageService.java

package org.littlewings.spring.cache;

import java.util.concurrent.TimeUnit;

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

@Service
public class MessageService {
    @Cacheable(cacheNames = "myCache")
    public Message build(String message) {
        try {
            TimeUnit.SECONDS.sleep(3L);
        } catch (InterruptedException e) {
            // ignore
        }

        return new Message(message);
    }

    @Cacheable(cacheNames = "anotherCache")
    public Message buildAnother(String message) {
        try {
            TimeUnit.SECONDS.sleep(3L);
        } catch (InterruptedException e) {
            // ignore
        }

        return new Message(message);
    }
}

超適当…。

テストコード。
src/test/java/org/littlewings/spring/cache/RedisCacheExpireTest.java

package org.littlewings.spring.cache;

import java.time.LocalDateTime;
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.test.context.junit4.SpringRunner;
import org.springframework.util.StopWatch;

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

@RunWith(SpringRunner.class)
@SpringBootTest
public class RedisCacheExpireTest {
    @Autowired
    MessageService messageService;

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

「myCache」を使う方は、こんな感じに。5秒で有効期限が切れることが、確認できます。

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

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

        LocalDateTime now1 = m1.getNow();
        long elapsed1 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(elapsed1)
                .isGreaterThanOrEqualTo(3L);

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

        LocalDateTime now2 = m2.getNow();
        long elapsed2 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(elapsed2)
                .isEqualTo(0L);
        assertThat(now2)
                .isEqualTo(now1); // cached

        TimeUnit.SECONDS.sleep(5L);  // sleep

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

        LocalDateTime now3 = m3.getNow();
        long elapsed3 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(elapsed3)
                .isGreaterThanOrEqualTo(3L);
        assertThat(now3)
                .isNotEqualTo(now1);
    }

もうひとつ用意した方では、5秒ではキャッシュが有効期限切れせず、10秒経過後に有効期限切れすることが
確認できます。

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

        // slow
        stopWatch.start();
        Message m1 = messageService.buildAnother("Hello World Another");
        stopWatch.stop();

        LocalDateTime now1 = m1.getNow();
        long elapsed1 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(elapsed1)
                .isGreaterThanOrEqualTo(3L);

        // quick
        stopWatch.start();
        Message m2 = messageService.buildAnother("Hello World Another");
        stopWatch.stop();

        LocalDateTime now2 = m2.getNow();
        long elapsed2 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(elapsed2)
                .isEqualTo(0L);
        assertThat(now2)
                .isEqualTo(now1); // cached

        TimeUnit.SECONDS.sleep(5L);  // sleep

        // quick
        stopWatch.start();
        Message m3 = messageService.buildAnother("Hello World Another");
        stopWatch.stop();

        LocalDateTime now3 = m3.getNow();
        long elapsed3 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(elapsed3)
                .isGreaterThanOrEqualTo(0L);
        assertThat(now3)
                .isEqualTo(now1);  // cached

        TimeUnit.SECONDS.sleep(5L);  // sleep

        // slow
        stopWatch.start();
        Message m4 = messageService.build("Hello World Another");
        stopWatch.stop();

        LocalDateTime now4 = m4.getNow();
        long elapsed4 = (long) stopWatch.getLastTaskInfo().getTimeSeconds();

        assertThat(elapsed4)
                .isEqualTo(3L);
        assertThat(now4)
                .isNotEqualTo(now1);
    }

Redisに保存する時のシリアライズ形式を変更する

ここまでのコードでRedisにデータを保存した場合ですが、デフォルトはJDKシリアライズの仕組みを使うため、
CLIなどで見るとこんな感じになります。

127.0.0.1:6379> keys *
1) "myCache:\xac\xed\x00\x05t\x00\x0bHello World"
2) "anotherCache:\xac\xed\x00\x05t\x00\x13Hello World Another"

127.0.0.1:6379> get "myCache:\xac\xed\x00\x05t\x00\x0bHello World"
"\xac\xed\x00\x05sr\x00$org.littlewings.spring.cache.Message\x00\x00\x00\x00\x00\x00\x00\x01\x02\x00\x02L\x00\x03nowt\x00\x19Ljava/time/LocalDateTime;L\x00\x05valuet\x00\x12Ljava/lang/String;xpsr\x00\rjava.time.Ser\x95]\x84\xba\x1b\"H\xb2\x0c\x00\x00xpw\x0e\x05\x00\x00\a\xe1\x02\x01\x00\x117-\xb7}\xc0xt\x00\x0bHello World"

127.0.0.1:6379> get "anotherCache:\xac\xed\x00\x05t\x00\x13Hello World Another"
"\xac\xed\x00\x05sr\x00$org.littlewings.spring.cache.Message\x00\x00\x00\x00\x00\x00\x00\x01\x02\x00\x02L\x00\x03nowt\x00\x19Ljava/time/LocalDateTime;L\x00\x05valuet\x00\x12Ljava/lang/String;xpsr\x00\rjava.time.Ser\x95]\x84\xba\x1b\"H\xb2\x0c\x00\x00xpw\x0e\x05\x00\x00\a\xe1\x02\x01\x00\x12(\nO\xc5@xt\x00\x13Hello World Another"

https://github.com/spring-projects/spring-data-redis/blob/1.8.0.RELEASE/src/main/java/org/springframework/data/redis/core/RedisTemplate.java#L121

ちょっとこれだと読みづらかったりするので、保存形式を変えてみましょう。

今回は、キーをString、値をJSON(Jackson2使用)に変更してみます。

@EnableCachingを付与したJavaConfigクラスを、次のように変更。
src/main/java/org/littlewings/spring/cache/CacheConfig.java

package org.littlewings.spring.cache;

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

import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public RedisTemplate<Object, Object> redisTemplate(JedisConnectionFactory jedisConnectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();

        redisTemplate.setConnectionFactory(jedisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Message.class));
        redisTemplate.setHashKeySerializer(redisTemplate.getKeySerializer());
        redisTemplate.setHashValueSerializer(redisTemplate.getValueSerializer());

        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

    @Bean
    public CacheManager cacheManager(RedisTemplate<Object, Object> redisTemplate) {
        RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);

        cacheManager.setUsePrefix(true);  // true => key prefix cache-name

        Map<String, Long> expires = new HashMap<>();
        expires.put("myCache", 5L);  // key is cache-name

        cacheManager.setExpires(expires);  // expire per cache

        cacheManager.setDefaultExpiration(10L);  // default expire

        cacheManager.afterPropertiesSet();
        return cacheManager;
    }
}

RedisTemplateについての設定を追加します。

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(JedisConnectionFactory jedisConnectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();

        redisTemplate.setConnectionFactory(jedisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Message.class));
        redisTemplate.setHashKeySerializer(redisTemplate.getKeySerializer());
        redisTemplate.setHashValueSerializer(redisTemplate.getValueSerializer());

        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

Jackson2JsonRedisSerializerを使う場合は、対象のクラスを指定する必要があるので、今回作成したクラスの
Classクラスを指定しています。

保存対象のクラスに、LocalDateTimeを使っていたのでJacksonのモジュールを追加します。

        <dependency>
          <groupId>com.fasterxml.jackson.datatype</groupId>
          <artifactId>jackson-datatype-jsr310</artifactId>
        </dependency>

コードの方も、ちょっと変更。
src/main/java/org/littlewings/spring/cache/Message.java

package org.littlewings.spring.cache;

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

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;

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

    private String value;

    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    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;
    }
}

で、先ほどのテストコード(有効期限を指定した時に使ったもの)を実行した時に、CLIで見るとこのように。

127.0.0.1:6379> keys *
1) "myCache:Hello World"
2) "anotherCache:Hello World Another"

127.0.0.1:6379> get "myCache:Hello World"
"{\"value\":\"Hello World\",\"now\":[2017,2,1,0,22,26,982000000]}"

127.0.0.1:6379> get "anotherCache:Hello World Another"
"{\"value\":\"Hello World Another\",\"now\":[2017,2,1,0,22,38,118000000]}"

ちゃんと保存形式が変わりましたね?

今回、キーをStringにしましたが、こうすると今回のServiceクラスの定義だと引数を複数にすると

    @Cacheable(cacheNames = "decorateCache")
    public Message decorate(String message, String prefix, String suffix) {
        try {
            TimeUnit.SECONDS.sleep(3L);
        } catch (InterruptedException e) {
            // ignore
        }

        return new Message(prefix + message + suffix);
    }

エラーになったりします。

java.lang.ClassCastException: org.springframework.cache.interceptor.SimpleKey cannot be cast to java.lang.String

まあ、その場合はStringにまとめられるように、なんとかしましょうということで。

    @Cacheable(cacheNames = "decorateCache", key = "#prefix + #message + #suffix")
    public Message decorate(String message, String prefix, String suffix) {
        try {
            TimeUnit.SECONDS.sleep(3L);
        } catch (InterruptedException e) {
            // ignore
        }

        return new Message(prefix + message + suffix);
    }

まとめ

SpringのCache Abstractionで、Redisをキャッシュの保存先として使ってみました。

使うにあたっていくつか気になるところがあったので試しつつな感じでしたが、ちょっとずつ使い方がわかってきたので
まあ良しとしましょう。

参考)
https://ishiis.net/2016/09/02/spring-redis-cache/