Spring Bootにおける、おすすめRedis設定方法

こんにちは。 エキサイト株式会社の三浦です。

以前こちらの記事で、Spring BootのRedisキャッシュで List.of などを使う方法について説明しました。

tech.excite.co.jp

今回はそれも含んだ、Redisキャッシュのおすすめ設定方法を紹介します。

なお実は、弊社の中尾さんが以下のブログで同様におすすめ設定方法を紹介してくれています。

tech.excite.co.jp

今回は、これに更に詳しい説明を加えたり、一部設定を変えたものとなります。

Redisキャッシュのおすすめ設定方法

結論から言うと、以下のような設定になります。

キャッシュキー一覧

package sample;

import lombok.Getter;

import java.util.concurrent.TimeUnit;

public enum CacheKeyType {
    SAMPLE_1(SAMPLE_1_KEY, TimeUnit.MINUTES.toSeconds(1)),
    SAMPLE_2(SAMPLE_2_KEY, TimeUnit.MINUTES.toSeconds(1)),
    SAMPLE_3(SAMPLE_3_KEY, TimeUnit.MINUTES.toSeconds(1)),

    @Getter
    private final String key;

    @Getter
    private final Long ttl;

    CacheKeyType(String cacheName, Long ttl) {
        this.key = cacheName;
        this.ttl = ttl;
    }

    public static final String SAMPLE_1_KEY = "sample1Key";
    public static final String SAMPLE_2_KEY = "sample2Key";
    public static final String SAMPLE_3_KEY = "sample3Key";
}

使いやすいよう、キー名とTTLをまとめてEnumで管理しています。

Redis設定

package sample;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.lettuce.core.ReadFrom;
import sample.CacheKeyType;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.connection.RedisStaticMasterReplicaConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;
import java.util.Arrays;
import java.util.stream.Collectors;

@Configuration
@EnableCaching
@RequiredArgsConstructor
public class RedisConfig {
    private final RedisProperties redisProperties;

    /**
     * Redis用のプロパティ
     * ここでは、application.ymlからデータをとってくるようにしています
     * @param primary プライマリエンドポイントのプロパティ
     * @param reader リーダーエンドポイントのプロパティ
     * @param database データベース番号
     */
    @ConfigurationProperties(prefix = "spring.redis")
    public record RedisProperties(Endpoint primary, Endpoint reader, Integer database) {

        /**
         * Redisエンドポイント用のプロパティ
         * @param host ホスト名
         * @param port ポート番号
         */
        public record Endpoint(String host, Integer port) { }
    }

    /**
     * カスタマイズした GenericJackson2JsonRedisSerializer を取得する
     * - List.of 等の Unmodifiable collection をキャッシュで使用可能にする
     * - LocalDateTime 等を、各プロパティでの Serializer/Deserializer の指定なしにキャッシュで使用可能にする
     *
     * <p>
     * NOTE: GenericJackson2JsonRedisSerializer.registerNullValueSerializer(objectMapper, null) について
     * disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) の設定に必要だが、
     * DefaultTyping.EVERYTHING では一部処理を変更する必要があり、現状上記設定はしないのでここでは設定しない
     * spring-data-redis が ver.2.7 以降であれば設定されているようなので、アップデートすれば設定しても問題ない
     * </p>
     *
     * @see <a href="https://github.com/spring-projects/spring-data-redis/pull/2237">Switch Jackson default mapping default from NON_FINAL to EVERYTHING</a>
     *
     * @return カスタマイズした GenericJackson2JsonRedisSerializer
     */
    private GenericJackson2JsonRedisSerializer serializer() {
        ObjectMapper objectMapper = new ObjectMapper()
                .registerModule(new JavaTimeModule()) // これを設定することで、これ以外の設定なしに日付系データをキャッシュできるようになります
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // キャッシュ時の日付データのフォーマットを見やすくします。少なくとも LocalDateTime においては、なくても機能としては問題ありません
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) // キャッシュ側に、Javaモデルでは存在しないプロパティがある(Javaモデル側のプロパティを減らしてデプロイしたなど)場合でもエラーが起きないようにします
                .activateDefaultTyping(
                        LaissezFaireSubTypeValidator.instance,
                        ObjectMapper.DefaultTyping.EVERYTHING, // finalなモデルでもキャッシュ時に型情報がRedisに保存されるようにします。これにより、 `List.of` 等もキャッシュに使用できるようになります
                        JsonTypeInfo.As.PROPERTY
                );

        // GenericJackson2JsonRedisSerializer.registerNullValueSerializer(objectMapper, null);

        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }

    /**
     * Redis接続用設定のFactory
     * Primary / Secondary の接続設定をします
     * @return Lettuce接続用設定のFactory
     */
    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
                .readFrom(ReadFrom.REPLICA_PREFERRED)
                .build();

        RedisStaticMasterReplicaConfiguration serverConfig = new RedisStaticMasterReplicaConfiguration(
                redisProperties.primary().host(),
                redisProperties.primary().port()
        );
        serverConfig.addNode(redisProperties.reader().host(), redisProperties.reader().port());
        serverConfig.setDatabase(redisProperties.database());

        return new LettuceConnectionFactory(serverConfig, clientConfig);
    }

    /**
     * キャッシュ用のReactiveRedisTemplate設定を追加
     * キャッシュのキー・バリューのシリアライズ方法を指定します
     * @param factory ConnectionFactory
     * @return キャッシュに使用するReactiveRedisTemplate
     */
    @Bean
    public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(LettuceConnectionFactory factory) {
        StringRedisSerializer keySerializer = new StringRedisSerializer();
        RedisSerializationContext.RedisSerializationContextBuilder<String, Object> builder = RedisSerializationContext
                .newSerializationContext(keySerializer);
        RedisSerializationContext<String, Object> context = builder.value(this.serializer()).build(); // バリュー側で、カスタムしたシリアライザを使用するようにします

        return new ReactiveRedisTemplate<>(factory, context);
    }

    /**
     * Redisでのキャッシュ時、カスタムタイプを使って保存できるようにBuilder設定を追加
     * 先に設定したキャッシュのキー・TTL一覧を使ってキャッシュするようにします
     */
    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
        return builder -> {
            var map = Arrays.stream(CacheKeyType.values())
                    .collect(
                            Collectors.toMap(
                                    CacheKeyType::getKey,
                                    e -> RedisCacheConfiguration
                                            .defaultCacheConfig()
                                            .prefixCacheNameWith("prefix:") // profile名などを持ってくると、環境ごとにprefixを付けられます
                                            .entryTtl(Duration.ofSeconds(e.getTtl()))
                                            .disableCachingNullValues()
                                            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.string()))
                                            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(this.serializer())) // バリュー側で、カスタムしたシリアライザを使用するようにします
                            )
                    );
            builder.withInitialCacheConfigurations(map);
        };
    }
}

設定です。 細かくはコメントで書いてあります。

使用方法

public class SampleImpl implements Sample {

    @Override
    @Cacheable(cacheNames = CacheKeyType.SAMPLE_1_KEY)
    public String getSample() {
        // ....
    }

使用したいメソッドに @Cacheable を付け、キャッシュ名として先に作成していたキー名を指定します。 これにより、そのキー名と抱き合わせられているTTLでキャッシュしてくれます。

終わりに

キャッシュ周りはよく使うものなので、良い感じの設定にしておきたいものです。

今の所この設定では、(キーが増えると管理が大変になること以外は)特に不便は無いので、よければ参考にしてもらえれば幸いです。