Spring Bootのキャッシュ抽象化(Redis)でJSONを利用したい場合のメモです。
■ 目次
Redisの中身を直接調べる場合に、ヒューマンリーダブルであることが大きな理由です。デフォルトであるJavaのオブジェクトシリアライズ文字列だと、ほとんど中身が分からないので。
Configuration
先に結論ですが、Configurationは以下のようになりました。
やっていることは、
- デフォルトとJSON用の2つの
CacheManager
を登録 *1
- デシリアライズでエラーがあった場合、無視して直接メソッドが呼ばれるように、カスタムのエラーハンドラを登録
の2つです。
@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {
デフォルトのCacheManager
@Bean
@Primary
CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
Map<String, Long> expires = new HashMap<>();
RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);
cacheManager.setExpires(expires);
return cacheManager;
}
JSON版のCacheManager
@Bean(name = "JsonCacheManager")
CacheManager jsonCacheManager(RedisConnectionFactory connectionFactory) {
ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.enableDefaultTypingAsProperty(ObjectMapper.DefaultTyping.JAVA_LANG_OBJECT, "@class");
StringRedisTemplate redisTemplate = new StringRedisTemplate();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(mapper));
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.afterPropertiesSet();
Map<String, Long> expires = new HashMap<>();
RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);
cacheManager.setExpires(expires);
return cacheManager;
}
@Override
@Bean
public CacheErrorHandler errorHandler() {
return new SimpleCacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
if (isSerializationError(exception)) {
return;
}
throw exception;
}
private boolean isSerializationError(RuntimeException exception) {
if (exception instanceof SerializationException) {
return true;
}
Throwable cause = exception.getCause();
return (cause != null && cause instanceof SerializationException);
}
}
}
}
Jackson2JsonRedisSerializerとGenericJackson2JsonRedisSerializerの違いについて
CacheConfig
の中では、 value serializer(Redisへの値の書き込み・読み込み手段)として GenericJackson2JsonRedisSerializer
を使いました。
そもそもRedisTemplate
に渡すJSON 用の value serializer には2種類あります。
- Jackson2JsonRedisSerializer
- GenericJackson2JsonRedisSerializer
両者の違いは、シリアライズ・デシリアライズの対象の型にあります。前者は特定の型に対してのものであり、後者はいろんな型( java.lang.Object
とそのサブクラス)で使えます。
インスタンス作成方法の違いを見ると分かりやすいかもしれません。
Jackson2JsonRedisSerializer<Entity> x = new Jackson2JsonRedisSerializer<>(Entity.class);
x.setObjectMapper(mapper);
GenericJackson2JsonRedisSerializer y = new GenericJackson2JsonRedisSerializer(mapper);
Jackson2JsonRedisSerializer
はコンストラクタ中でクラスを指定する必要があることから、結果的に、キャッシュが返す型ごとに CacheManager
を用意する必要があり、煩雑になります。JSON用の CacheManager
は1つにしたいので、 GenericJackson2JsonRedisSerializer
を使っています。
ちなみに、
Jackson2JsonRedisSerializer<Object> x = new Jackson2JsonRedisSerializer<>(Object.class);
とすればいいじゃん?と思うかもしれませんが、これは、結局 GenericJackson2JsonRedisSerializer
を使った場合と同じ挙動になります。
GenericJackson2JsonRedisSerializerを使うと、戻り値がLinkedHashMapになり、呼び出し時にキャストエラーになる
さてここで一つ問題があります。単純にGenericJackson2JsonRedisSerializerを使っただけだと、キャッシュから値が取得できた場合に戻り値が LinkedhashMap
になってしまいます。
そうすると、メソッドを呼び出して結果を代入する箇所で ClassCastException
(実行時エラー)が起きてしまいます。
具体的な例を示します。キャッシュを使ったメソッドの実装を以下のようにした場合 *2 に、
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class HogeService {
private final HogeRepository hogeRepository;
@Cacheable(value = "aaa", key = "'example:hoge:' + #key", cacheManager = "JsonCacheManager")
public Hoge findByKey(String key) {
return hogeRepository.findByKey(key);
}
}
呼び出し側は
Hoge hoge = hogeService.findByKey("xxx");
となりますが、ここで、 LinkedHashMap
から Hoge
へのキャストが発生し、 ClassCastException
が起きます。
キャッシュ内のJSON中に、型解決のためのヒントとなるプロパティを埋め込むようにする
上記の問題点の解決方法です。
まず、Spring BootというよりJacksonの話になりますが、デコード時の型として java.lang.Object
を指定した場合、戻り値の型は、JSON文字列に応じて以下のようになります。
- “1” とか “5.3” などの数値の場合 =>
Integer
や Double
- “[ 3, 4 ]” などのリストの場合 =>
ArrayList
- “{ "aaa” : “nnn” }“ のようなオブジェクトの場合 =>
LinkedHashMap
GenericJackson2JsonRedisSerializer
の実装では、java.lang.Object
を使ってデコードをしようとしていることから、キャッシュから値が取得できた時に LinkedHashMap
になってしまい、戻り値の型 Hoge
へのキャストが発生しているわけですね。
「java.lang.Object
ではなく、戻り値の型を使ってデコードしてくれればいいのに…」と思うわけですが、残念ながら RedisSerializer
のデシリアライズのメソッドシグネチャは、
T deserialize(byte[] bytes) throws SerializationException;
のようになっており、メソッドの引数から、戻り値の型を解決することができません。
じゃあどうすればよいか?と思うわけですが deserialize
メソッドの引数はデコード対象のバイト列(JSON文字列)だけです。
なので、これを唯一の手がかりとして、JSON文字列の中に、具体的な型を解決するためのヒントを埋め込んであげればよさそうです。
キャッシュから値を読み込む時に、型情報を参照にする
CacheConfig
中の以下のコードが該当します。
objectMapper.enableDefaultTypingAsProperty(ObjectMapper.DefaultTyping.JAVA_LANG_OBJECT, "@class");
これは「デコード時に指定された型が java.lang.Object
の場合、JSON文字列中の “@class” プロパティをヒントにして型を解決してね」という意味です。なお、 “@class” の値には、クラスのFQCN文字列がセットされます。
ちなみに、JSON文字列中に “@class” プロパティが存在しない場合はエラーとなります。キャッシュの中身が不正なだけで、呼び出すたびにエラーになってしまうのは不便なので、カスタムエラーハンドラを作り、 SerializationException
の時は無視してメソッドの呼び出しを行なうようにしています。
キャッシュに値が書き込む時に、型情報を付与する
クラスに @JsonType
アノテーションを付けてあげます。
package sample;
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
public class Hoge {
}
これにより、JSONエンコード時、すなわちキャッシュに値を書き込む時に、 @class
というプロパティに “sample.Hoge” が付与されて書き込まれます。
まとめ
Spring BootのRedisCacheManagerでJSONをいろんな型で利用する場合のまとめです。
CacheManager
を用意し、 value serializer に GenericJackson2JsonRedisSerializer
を与える
- キャッシュの戻り値として利用するクラスには
@JsonTypeInfo
を付けてあげる
GenericJackson2JsonRedisSerializer
にセットする ObjectMapper
には、型を解決するためのヒントを enableDefaultTypingAsProperty
や setDefaultTyping
メソッドで指定してあげる
- キャッシュのデシリアライズに失敗した場合に備えて、
`CachingConfigurerSupport#errorHandler
をオーバーライドしてカスタムのエラーハンドラを作り、デシリアライズエラー時はキャッシュを諦めてメソッドを呼ぶようにした方が、(おそらく)安全