環境
- 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-cli
でget
してもバイナリのため人間には読みづらい- サイズが大きく、Redisのメモリを圧迫する
- シリアライズ・デシリアライズの性能が悪い
- セキュリティに問題がある
redis-cli
でget
してもバイナリのため人間には読みづらいに関しては、例えば以下のような表示になる。
$ 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
にアクセスするとString
がStringRedisSerializer
で文字列として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
の設定だけだが、以下の点について説明する。
.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY)
GenericJackson2JsonRedisSerializer.registerNullValueSerializer(objectMapper, null);
1のactivateDefaultTyping
は、シンプルにいうとfinal
がついていないフィールドをJSON化の対象としている。
2のregisterNullValueSerializer
は、@Cacheable
がついたメソッドの戻り値がnull
だった場合の対処。Redisでnull
を保存しようとするとエラーが発生するため、代わりにNullValue
というオブジェクトをJSONにして保存してくれる。NullValue
はJava上ではnull
に変換してくれる。