Spring BootのRedisCacheManagerでJSONをいろんな型で利用する

Spring Bootのキャッシュ抽象化(Redis)でJSONを利用したい場合のメモです。

■ 目次

そもそもなんで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()) // LocalDateTime などの Date and Time API 関連のフィールドを扱う 
                .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)); // 値はJSONエンコード・デコードを利用する
        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 // Lombok
@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” などの数値の場合 => IntegerDouble
  • “[ 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 には、型を解決するためのヒントを enableDefaultTypingAsPropertysetDefaultTyping メソッドで指定してあげる
  • キャッシュのデシリアライズに失敗した場合に備えて、 `CachingConfigurerSupport#errorHandler をオーバーライドしてカスタムのエラーハンドラを作り、デシリアライズエラー時はキャッシュを諦めてメソッドを呼ぶようにした方が、(おそらく)安全

*1:複数の CacheManager を統合する CompositeCacheManager というのもありますが、今回は簡略化のため複数利用しました。

*2:キャッシュの使い方としてちょっと古いバージョンの実装かもしれません。。