Spring Bootでキャッシュ(Redis)からのデータ取得エラー時のカスタマイズ

(この前書いた以下の記事に近い話ですが、今回はSpring Sessionに限らない話です)

Spring Bootでキャッシュ機構としてRedis( org.springframework.boot:spring-boot-starter-data-redis )を使っている場合、デフォルトでは Redis への書き込み、Redis からの読み込みは、JdkSerializationRedisSerializer によるJavaのオブジェクトシリアライズ・デシリアライズが行われ、キャッシュの取得時にデシリアライズに失敗すると、「 @Cacheable をつけたメソッドの呼び出しそのものがエラー 」になってしまいます。

この挙動を、デシリアライズに失敗したら、 「 リザルトキャッシュを利用せずに対象メソッドを実行し、その結果をキャッシュに書き込む 」といったものに変更したいと思います。キャッシュにhitしなかった時と同じように扱うということですね。

結論として、Springの場合、 CacheErrorHandler というインタフェースが用意されているので、カスタマイズしたエラーハンドラを使うようにしてあげればよいです。

やり方は、例えば以下の記事にて紹介されています。

qiita.com

ポイントだけをまとめたconfigurationは次のような感じです。

@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

    @Override
    public CacheErrorHandler errorHandler() {

        return new SimpleCacheErrorHandler() {
            @Override
            public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
                // キャッシュ取得時のエラーハンドラの内容を書く
            }
        };
    }
}

CacheErrorHandlerは get, put, evict, clear のそれぞれにおけるエラー時のハンドラメソッドを実装しなければならないインタフェースですが、あらかじめSimpleCacheErrorHandlerという何もしない(デフォルトと同じ挙動をさせる)実装が用意されているので、必要なメソッドだけoverrideすればよいです。

今回は次のように実装しました。

@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
    Throwable cause = exception.getCause();
    if (cause != null && cause instanceof SerializationFailedException) {
        return;
    }
    throw exception;
}

ハンドラメソッドの中で例外をスローせずに return すれば、直接対象メソッドが実行されます。
また、結果はキャッシュに書き込まれ、その際、デシリアライズに失敗する古いキャッシュは上書きされる形になります(Spring Boot 1.4.2.RELEASEで検証)。そのため、ハンドラの中で cache#evict を呼んだりしなくてもよいようです。

小ネタ) SpockのData Driven Testingでテスト観点をUnrollのメソッド名に記述するとよいかもしれないと思った話

Spockのデータ駆動テスト(Data Driven Testing)では、メソッド名としてデータ変数を含んだ文字列を使うことで、テスト結果を見た時にどこで失敗したか、わかりやすくすることができます。

データ駆動テスト — Spock 1.0-SNAPSHOT

さて、この時に利用するデータ変数は、テストで利用するパラメータと期待値を使うことが基本だと思います(実際ほとんどのケースではそれで十分だと思いますが)。

しかし、イテレーション(テストのパターン数)が増えてきたり、複雑なロジックのテストの場合、パラメータと期待値だけでは、そのパターンのテスト理由・意図が一見してわからないこともあります。その時、テスト観点も含めると便利だと思ったので、紹介します。

デフォルト

そもそもデータ変数をメソッド名として使わない場合、例えば、こんな感じのテストがあった時、

    @Unroll
    def "maximum of two numbers"() {
        expect:
        max(a, b) == ans

        where:
        a  | b   || ans
        3  | 4   || 4
        -3 | -4  || -3
        10 | 10  || 10
        10 | -20 || 10
        0  | 5   || 5
        -5 | 0   || 0
    }

個々のメソッド名はイテレーションのインデックスだけで区別されます。

f:id:jappy:20170114073900p:plain

パラメータと期待値

メソッド名を次のように変えると、

    def "maximum of two numbers (#a, #b) -> #ans"() {

どのケースで失敗したかが一目瞭然になります。

f:id:jappy:20170114074230p:plain

(このキャプチャではすべてpassしていますが)

テスト観点を加える

今度は、以下のようにしました。

    @Unroll
    def "maximum of two numbers (#a, #b) -> #ans (#description)"() {
        expect:
        max(a, b) == ans

        where:
        description << [
                'positive & positive',
                'negative & negative',
                'same',
                'positive & negative',
                'zero & positive',
                'zero & negative'
        ]

        a  | b   || ans
        3  | 4   || 4
        -3 | -4  || -3
        10 | 10  || 10
        10 | -20 || 10
        0  | 5   || 5
        -5 | 0   || 0
    }

ポイントは2つで、

  • データ変数として、テスト内で直接使うわけではないが、テスト観点・意図を表す description を追加する
  • テスト観点は長くなりがちなので、データテーブルの可読性を下げないように、データテーブルの列ではなく << オペレータで列挙する

実行結果は想像がつくと思いますが、以下のようになります。

f:id:jappy:20170114075047p:plain

おわりに

上の例だと単純過ぎてあまりメリットが見えにくいですが、実際に業務でテストコードをレビューしていてこんなことがありました。

  • テスト対象のロジックが複雑(パラメータの組み合わせ)なため、データテーブル中にコメントを書いて、テスト目的を分かるようにしていた。
    (コメントに書くくらいなら、テストメソッド名になっていた方が、CIでのレポート結果などでもすぐに分かるので、有用だと思います)
  • パラメータや期待値が複数の項目から成るリストで、データ変数としてそのまま展開するとものすごい長いメソッド名になってしまい、テスト結果の一覧性を悪くしていた。

なので、その辺りをちょっとだけ改善するために、テストの意図を表す文字列をメソッド名に含めるとよいのでは?と思ったのでした。

マルチデータソースにおけるGradleでのFlywayタスク実行

マルチデータソース(2つのRDB)を使っている場合に、FlywayのGradle PluginでflywayMigrateなどのタスクを実行し、両方のDBにマイグレーションを実行したい場合。

flyway {
    user = 'user1'
    password = 'pass1'
    url = 'jdbc:xxx://host1/db1'
}

def flyway2 = new org.flywaydb.core.Flyway()
flyway2.setDataSource('jdbc:xxx://host2/db2', 'user2', 'pass2')
flyway2.setLocations('filesystem:./src/main/resources/db_migration2')

tasks['flywayMigrate'].doLast {
    flyway2.migrate()
}

// 他タスク(flywayClean, flywayInfo, ...)も同様

プラグインの機能としては提供されていないので、Gradle(Groovy)側のカスタマイズで対応してみました。

Spring Sessionのバージョンアップとデシリアライズエラー

Spring Security + Spring Sessionを使っている場合、通常、セッションの中身はシリアライズされてセッションストアに保存されます(実際に確認したのはRedisのみ)。

シリアライズされる org.springframework.security.core.context.SecurityContextImpl には serialVersionUID が定義されているのですが、その値は SpringSecurityCoreVersion の中にあります。
https://github.com/spring-projects/spring-security/blob/4.1.x/core/src/main/java/org/springframework/security/core/SpringSecurityCoreVersion.java#L35-L41

コードの通り、Spring Securityのバージョンごとに決まった値になります。

したがって、Spring Securityをバージョンアップした場合、それまで使っていたセッションがリクエストされると、デシリアライズに失敗します。
issueがGitHubやstarckoverflowに挙がってます。

シリアライズに失敗した場合、サーバ側エラーとなって500エラーを返してしまうので、本来はセッションの有効期限切れと見なして403エラー辺りを返したいところです。

対象方法についてのメモです。

対処1: いったんセッションの中身をクリアする

乱暴ではありますが、ソースコメントにも「Classes are not intended to be serializable between different versions.」とある通り、シンプルに、バージョンアップの際にセッションストア側をクリアしてしまうのが確実だと思います。

Redisの場合は、キーが spring:session:* にマッチするものを削除すればOKです。

対処2: 独自の SessionRepository を作ってデフォルトの挙動をカスタマイズする

以下に紹介されていました。

sdqali.in

シリアライズエラーをcatchして、該当のセッションをRedisから削除しています。
また、getSessionnull を返すことで、有効なセッションが見つからなかった時と同じ振る舞いにさせているっぽいです。

方法1と比較すると、追加のコードが必要ではあるものの、バージョンアップの度にRedisの中身をメンテする必要はなくなるので、保守性では優れている気がします。

対処3: セッションの中身をマイグレートする

方法1と方法2も、バージョンアップ前のセッションを破棄するアプローチでした。
Spring Sessionのバージョンアップの頻度はそう高くはないとは思いますが、依存ライブラリのバージョンアップというサーバ側の都合で、セッションが破棄されてしまうのが許容できないケースもあるかもしれません。

そういうったケースの対応方法を考えてみました。一つのプログラム内で新旧の SecurityContextImpl が共存できないので、2つのステップ(プログラム)に分けています。

  1. 既存のセッションの中身をエクスポートする。エクスポートする際は旧バージョンのSpring Securityを利用してデシリアライズし、 Javaシリアライズに依存しない形 (例えばJSONなど)で出力する。
  2. エクスポートした内容を読み取り、新バージョンのSpring Securityを利用してセッションストアに戻す。

うーん、無理やりな感じが否めないので、オススメはできそうにないですね。

正解が見えない課題を圧倒的に解決する超仮説思考 #書籍

正解が見えない課題を圧倒的に解決する 超仮説思考

正解が見えない課題を圧倒的に解決する 超仮説思考

印象に残ったところを自分なりにまとめるとこんな感じでした。

  • 5F, 3C, SWOT等のフレームワークは有用ではあるものの、枠にあてはめてしまうが故に、多様的・立体的である現実の事象に対して見落としや乖離があり、的外れな解を導き得る。
  • すぐに思いつく最初の10個のアイディアは捨て、ひねり出した11個目以降のアイディアを採用する。
  • 自分の見えないところに答えがある。視点を移し、想像力を磨いていく。
  • 前提条件はいつか変わる、変えられる。
  • 実践(仮説検証)を繰り返していく。うまくいかなかった時は、仮説が間違っていたことよりも、仮説はあっていたがどこかにボトルネックがあって見落としているという可能性を疑う。

Spring BootでJettyのログを少しカスタマイズする

Spring Bootでリクエストログを出力するための方法については、以下の記事で紹介されています。

qiita.com

以下の内容はSpring Boot 1.4.3で確認してます。また、Jetty以外のアプリケーションサーバの場合は当然ながら全く違った設定方法になります。

X-Forwardedヘッダを解釈させる

ELB(AWS)やHerokuなど、前段でSSLが終端されてから(SSL Termination)後続のSpring Bootアプリがリクエストを処理するようなケースでは、本来の送信元IPをアプリが知るためにX-Forwarded-For他、X-Forwarded系のヘッダを解釈させるようにする必要があります。

JettyではForwardedRequestCustomizerというクラスが提供されているので、それを追加してあげればよいです。

    // 途中略

    for (Connector connector : server.getConnectors()) {
        if (connector instanceof ServerConnector) {
            HttpConnectionFactory connectionFactory = connector.getConnectionFactory(HttpConnectionFactory.class);
            connectionFactory.getHttpConfiguration().addCustomizer(new ForwardedRequestCustomizer());
        }
    }
}

出力されるログに独自項目を追加する

元々用意されている NCSARequestLog を継承し、 logExtended メソッドを上書きした拡張ログクラスを作ることで、元の出力内容に加えて独自の項目を出力することができます。
例えばAuthorizationヘッダの内容を最後の項目に出力させたい場合は、以下のような感じです。

public class NCSARequestLogExt extends NCSARequestLog {

    @Override
    protected void logExtended(StringBuilder b, Request request, Response response) throws IOException {
        super.logExtended(b, request, response);

        String authToken = request.getHeader("Authorization");

        if (authToken == null) {
            b.append(" \"-\" ");
        } else {
            b.append(" \"");
            b.append(authToken);
            b.append("\" ");
        }
    }
}

Cloud First Architecture 設計ガイド #書籍

気が付いたら2017年も1週間が過ぎてしまいました。まさか7月のイベントが2016年の最後のエントリになるとは思いませんでした。

年々顕著にエントリが減ってきているので、今年は週1くらいのペースで書く習慣をつけていきないなと思います。
ネタが少ない時なんかは、読んだ本の簡単な感想で、エントリ数だけは積んでいきたいと思います(読書ログでいいじゃん、というツッコミはあるんですが…)

すぐに読めちゃうボリュームの割には広く浅く書かれていて、復習になりました。
AWSにおける具体的なアーキテクチャ例、みたいなものは書かれておらず、アーキテクチャ設計の考え方を知るための本、という印象です。 Salesforceの名も所々出てきます。 第5章「アーキテクチャー設計ガイド」はまた読み返したいと思います。