環境

  • Java 17
  • Spring Boot 2.6.0
  • Jackson 2.13.0
  • Redis 6

関連

Spring SecurityでREST API + JSONによる認証を行う(Session/Cookie + Redis編) ※SessionにRedisを用いる場合も同様にGenericJackson2JsonRedisSerializerを使う。こちらにSession用の設定を記載している。

Spring BootからRedisを使う

JdkSerializationRedisSerializer

Spring BootからRedisを使うときは、標準ではJdkSerializationRedisSerializerがシリアライズ・デシリアライズで利用される。implements Serializableをクラスに設定する必要があり、当然シリアライズできないフィールドを持つことはできないが、特に問題なく使うことができる。

しかしJavaの標準のシリアライズ・デシリアライズには以下の問題がある。

  • redis-cligetしてもバイナリのため人間には読みづらい
  • サイズが大きく、Redisのメモリを圧迫する
  • シリアライズ・デシリアライズの性能が悪い
  • セキュリティに問題がある

redis-cligetしてもバイナリのため人間には読みづらいに関しては、例えば以下のような表示になる。

$ redis-cli get 5min::api
"\xac\xed\x00\x05sr\x00)com.example.jacksonredis.Application$Json\xbc\xe7\x8c\x97\xb7Q+\x7f\x02\x00\bL\x00\x01it\x00\x13Ljava/lang/Integer;L\x00\x05innert\x001Lcom/example/jacksonredis/Application$Json$Inner;L\x00\tinnerListt\x00\x10Ljava/util/List;L\x00\tinnerNullq\x00~\x00\x02L\x00\x05listSq\x00~\x00\x03L\x00\x05nullSt\x00\x12Ljava/lang/String;L\x00\x01sq\x00~\x00\x04L\x00\x04timet\x00\x19Ljava/time/LocalDateTime;xpsr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x00dsr\x00/com.example.jacksonredis.Application$Json$InnerR\xdf\xe5O@T\xea\x8d\x02\x00\x01L\x00\x06innerSq\x00~\x00\x04xpt\x00\tinnerTestsr\x00\x11java.util.CollSerW\x8e\xab\xb6:\x1b\xa8\x11\x03\x00\x01I\x00\x03tagxp\x00\x00\x00\x01w\x04\x00\x00\x00\x02sq\x00~\x00\nt\x00\ninnerTest1sq\x00~\x00\nt\x00\ninnerTest2xpsq\x00~\x00\r\x00\x00\x00\x01w\x04\x00\x00\x00\x03t\x00\x02t1t\x00\x02t2t\x00\x02t3xpt\x00\x04testsr\x00\rjava.time.Ser\x95]\x84\xba\x1b\"H\xb2\x0c\x00\x00xpw\x0e\x05\x00\x00\a\xe5\x0c\x14\x0c\x0b\x14.HL\x10x"

RedisInsight等のツールを使えば読めはするものの、Integer i = 100;だけでこれだけの量を占める。

以下の2点の問題もこの情報量のせいである。

  • サイズが大きく、Redisのメモリを圧迫する
  • シリアライズ・デシリアライズの性能が悪い

セキュリティに問題がある点は、docs.spring.ioに記載がある。

By default, RedisCache and RedisTemplate are configured to use Java native serialization. Java native serialization is known for allowing the running of remote code caused by payloads that exploit vulnerable libraries and classes injecting unverified bytecode. Manipulated input could lead to unwanted code being run in the application during the deserialization step. As a consequence, do not use serialization in untrusted environments. In general, we strongly recommend any other message format (such as JSON) instead.

GenericJackson2JsonRedisSerializer

Java標準のシリアライザーの代わりにGenericJackson2JsonRedisSerializerを使うと良い。

Spring Bootでは、JdkSerializationRedisSerializerを使う場合にはRedis周りの設定コードを何も書かなくても@Cacheableをメソッドに付ければRedisにキャッシュを保存・利用してくれたのに対し、GenericJackson2JsonRedisSerializerを使う場合は設定コードを書かなくてはいけない。しかし先程の問題点が解消するとともにimplements Serializableが不要になるメリットもある。

StringRedisSerializer

Javaのクラスを自分で作成してRedisに保存する場合はGenericJackson2JsonRedisSerializerを使いたいが、文字列を単純に保存する場合はStringRedisSerializerの方がさらに効率が良いため、StringRedisSerializerも使えるように設定する。

実装

サンプルアプリケーション

まずはサンプルのアプリケーションを記載する。

/にアクセスするとオブジェクトがGenericJackson2JsonRedisSerializerでシリアライズされて型情報がついたJSONになり、Redisに保存される。

/?performanceにアクセスするとStringStringRedisSerializerで文字列としてRedisに保存される。

@EnableCaching
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    public static final String CACHE_5_MIN = "5min";
    public static final String CACHE_5_MIN_STRING = "5minStr";
    public static final String CACHE_30_MIN = "30min";
    public static final String CACHE_30_MIN_STRING = "30minStr";

    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
        var serializer = new GenericJackson2JsonRedisSerializer(redisCacheObjectMapper());
        var redisConfigWithJackson = RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(SerializationPair.fromSerializer(serializer));
        var redisConfigString = RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(SerializationPair.fromSerializer(new StringRedisSerializer()));
        return (builder) -> builder
                .withCacheConfiguration(CACHE_5_MIN, redisConfigWithJackson.entryTtl(Duration.ofMinutes(5)))
                .withCacheConfiguration(CACHE_5_MIN_STRING, redisConfigString.entryTtl(Duration.ofMinutes(5)))
                .withCacheConfiguration(CACHE_30_MIN, redisConfigWithJackson.entryTtl(Duration.ofMinutes(30)))
                .withCacheConfiguration(CACHE_30_MIN_STRING, redisConfigString.entryTtl(Duration.ofMinutes(30)))
                ;
    }

    private ObjectMapper redisCacheObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper
                .registerModule(new JavaTimeModule())
                .registerModule(new ParameterNamesModule(JsonCreator.Mode.PROPERTIES))
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
                .activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(),
                                       DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY)
        ;
        GenericJackson2JsonRedisSerializer.registerNullValueSerializer(objectMapper, null);
        return objectMapper;
    }

    @RequiredArgsConstructor
    @RestController
    public static class Controller {
        private final ObjectMapper objectMapper;

        @Cacheable(cacheNames = CACHE_5_MIN, key = "'api'")
        @GetMapping("/")
        public Json api() {
            return createJson();
        }

        // Controllerは文字列をレスポンスを返すだけで戻り値をオブジェクトとしてプログラム中で利用しない。
        // あらかじめobjectMapperでStringにしておくことで、
        // JSONのsizeが下がったり、デシリアライズがシンプルになるなどのメリットがある。
        @Cacheable(cacheNames = CACHE_5_MIN_STRING, key = "'apiS'")
        @GetMapping(path = "/", params = "performance", produces = MediaType.APPLICATION_JSON_VALUE)
        public String apiS() throws JsonProcessingException {
            return objectMapper.writeValueAsString(createJson());
        }

        private Json createJson() {
            return new Json(LocalDateTime.now(),
                            100,
                            "test",
                            null,
                            List.of("t1", "t2", "t3"),
                            new Inner("innerTest"),
                            null,
                            List.of(new Inner("innerTest1"), new Inner("innerTest2"))
            );
        }
    }

    @AllArgsConstructor(onConstructor_ = @JsonCreator)
    @Data
    public static class Json {
        LocalDateTime time;
        Integer i;
        String s;
        String nullS;
        List<String> listS;
        Inner inner;
        Inner innerNull;
        List<Inner> innerList;

        @AllArgsConstructor(onConstructor_ = @JsonCreator)
        @Data
        public static class Inner {
            String innerS;
        }
    }
}

RedisCacheManagerBuilderCustomizer

redisCacheManagerBuilderCustomizer()でSpring BootでRedisのカスタマイズができる。

今回実施しているカスタマイズは以下の通り。

  • Expireの設定
  • StringRedisSerializerの設定
  • GenericJackson2JsonRedisSerializerの設定

GenericJackson2JsonRedisSerializerの設定以外はシンプルなコードである。GenericJackson2JsonRedisSerializerの設定で行なっていることは、オブジェクトをJSONに変換するためのObjectMapperの設定だけだが、以下の点について説明する。

  1. .activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY)
  2. GenericJackson2JsonRedisSerializer.registerNullValueSerializer(objectMapper, null);

1のactivateDefaultTypingは、シンプルにいうとfinalがついていないフィールドをJSON化の対象としている。

2のregisterNullValueSerializerは、@Cacheableがついたメソッドの戻り値がnullだった場合の対処。Redisでnullを保存しようとするとエラーが発生するため、代わりにNullValueというオブジェクトをJSONにして保存してくれる。NullValueはJava上ではnullに変換してくれる。