kagamihogeの日記

kagamihogeの日記です。

Spring RetryのREADME読んだ

https://github.com/spring-projects/spring-retry 初めて使ったんで記念にREADME読んだ。

spring-retry - README.md

本プロジェクトはSpringアプリケーションで宣言的なリトライ機能を提供します。Spring Batch, Spring Integration, Spring for Apache Hadoopや、それ以外でも、使用できます。

Quick Start

例:

@Configuration
@EnableRetry
public class Application {

    @Bean
    public Service service() {
        return new Service();
    }

}

@Service
class Service {
    @Retryable(RemoteAccessException.class)
    public void service() {
        // ... do something
    }
    @Recover
    public void recover(RemoteAccessException e) {
       // ... panic
    }
}

"service"メソッドを呼ぶ時にRemoteAccessExceptionで失敗する場合はリトライし(デフォルトは三回まで)、すべてのリトライが不成功に終わる場合は"recover"を呼びます。

@Retryableアノテーションの属性には様々なオプションがあり、リトライする例外のinclude, exclude指定、リトライ回数制限、バックオフポリシー、などがあります。

Building

Requires Java 1.7 and Maven 3.0.5 (or greater)

$ mvn install

Features and API

RetryTemplate

処理をロバストにして障害に繋がりにくくするには、もし試行回数を増やせば成功する可能性があるなら、失敗した操作を自動リトライすると良い場合があります。そういう類のエラーは本質的には一時的です。たとえば、webサービスRMIのリモート呼び出しの失敗は、ネットワーク障害やDBアップデートでのDeadLockLoserExceptionが原因になることがありますが、これらは短時間経過後に解消する場合があります。そうした操作のリトライを自動化するため、Spring RetryにはRetryOperationsがあります。RetryOperationsインタフェースは以下の通りです。

public interface RetryOperations {

    <T> T execute(RetryCallback<T> retryCallback) throws Exception;

    <T> T execute(RetryCallback<T> retryCallback, RecoveryCallback<T> recoveryCallback)
        throws Exception;

    <T> T execute(RetryCallback<T> retryCallback, RetryState retryState)
        throws Exception, ExhaustedRetryException;

    <T> T execute(RetryCallback<T> retryCallback, RecoveryCallback<T> recoveryCallback,
        RetryState retryState) throws Exception;

}

この基礎となるコールバックはシンプルなインターフェースで、ここにリトライするビジネスロジックを指定します。

public interface RetryCallback<T> {

    T doWithRetry(RetryContext context) throws Throwable;

}

コールバックを実行して失敗(Exceptionのスロー)すると、成功するか実装でアボートするまで、リトライします。

RetryOperationsインタフェースには各種のオーバーロードメソッドがあり、引数に、すべてのリトライが失敗する場合の様々なリカバリ、クライアントと実装で呼び出し中の情報を保存するリトライステータス(詳細後述)、を指定できます。

RetryOperationsの最もシンプルな汎用実装がRetryTemplateです。以下のように使います。

RetryTemplate template = new RetryTemplate();

TimeoutRetryPolicy policy = new TimeoutRetryPolicy();
policy.setTimeout(30000L);

template.setRetryPolicy(policy);

Foo result = template.execute(new RetryCallback<Foo>() {

    public Foo doWithRetry(RetryContext context) {
        // webサービスなど失敗する可能性のある何らかの処理
        return result;
    }

});

上の例ではwebサービスを実行してユーザに結果を返しています。もし呼び出しが失敗する場合はタイムアウトに達するまでリトライします。

RetryContext

RetryCallbackのメソッド引数はRetryContextです。基本的にはコールバックでこのコンテキストを使うことは少ないですが、場合によってはリトライの繰り返し中にデータを保存する領域として使用します。

RetryContextは、同一スレッド内で実行中のネストしたリトライがある場合、親コンテキストを持ちます。親コンテキストは呼び出し中に共有したいデータを格納するのに使用します。

RecoveryCallback

リトライがすべて失敗する場合にRetryOperationsは別のコールバックRecoveryCallbackに制御を渡せます。この機能を使うにはメソッドに一緒に2つのコールバックを渡します。

Foo foo = template.execute(new RetryCallback<Foo>() {
    public Foo doWithRetry(RetryContext context) {
        // ビジネスロジック
    },
  new RecoveryCallback<Foo>() {
    Foo recover(RetryContext context) throws Exception {
          // リカバリロジック
    }
});

テンプレートがアボートをする前にビジネスロジックが失敗する場合、リカバリコールバック経由で何らかの代替処理を行えます。

Stateless Retry

シンプルなケースではリトライは単にwhileループです。RetryTemplateは成功か失敗するまで試行し続けます。RetryContextはリトライかアボートするかを決定するための状態を持ちますが、この状態はスタック上にありグローバルに置く必要が無いので、これはステートレスリトライ(Stateless Retry)と呼びます。ステートレスとステートフルリトライの違いはRetryPolicyの実装にあります(RetryTemplateは両方とも出来ます)。ステートレスリトライでは、コールバックは失敗時のリトライと同一スレッドで実行します。

Stateful Retry

ある失敗がトランザクションリソースを無効にするケースの場合、何らかの考慮が必要です。これはシンプルなリモートコールでは無関係で、その理由は(基本的には)トランザクションリソースが無いためですが、Hibernateを使用しているなど、DB更新の場合には関係してきます。この場合には、失敗となった例外を即座に再スローすることだけが理にかなっており、これによって、トランザクションロールバックして新しいトランザクションを開始します。

こうした場合ではステートレスリトライでは不十分で、再スローとロールバックRetryOperations.execute()を終わる必要がありスタックにあったコンテキストを潜在的にロストするためです。このロストを回避するにはスタックではなくヒープ(少なくとも)を使うストレージ機能が必要です。Spring RetryにはRetryContextCacheがあり、これをRetryTemplateに追加します。RetryContextCacheのデフォルト実装はインメモリでMapを使います。メモリリーク回避のため、厳密に強制される最大容量は持ちますが、time to liveなど高度なキャッシュ機能はありません。必要に応じてそうした機能を持つMapの使用を考慮してください。また、クラスタでの複数プロセッサによる高度な環境においても何らかのクラスタキャッシュ機能を持つRetryContextCacheの実装を検討してください(とはいえ、クラスタ環境だとしても、これはやり過ぎかもしれませんが)。 RetryOperationsの責務の一つは、新規実行(基本的には新規トランザクションでラップ)に復帰する際、失敗した操作を認識することです。これを扱うには、Spring RetryではRetryStateを使います。これはRetryOperationsの特別なexecuteメソッドと組み合わせて動作します。

失敗した操作を認識する方法とは、複数のリトライに横断する状態を識別することです。状態の識別には、アイテムを識別するユニークキーを返す責務を持つRetryStateオブジェクトをユーザが返します。識別子はRetryContextCacheでキーとして使われます。

Warning: RetryStateが返すキーのObject.equals()Object.hashCode()は注意深く実装してください。アイテムの識別にはビジネスキーを使用するのが良いです。例としてJMSメッセージではメッセージIDを使用できます。

リトライが失敗する場合、(失敗する可能性が高い)RetryCallbackを呼ぶのではなく、失敗を処理する別の方法もあります。ステートレスの場合同様にRecoveryCallbackを使用する方法で、これはRetryOperationsのexecuteメソッドに渡します。

リトライするかどうかの決定は通常はRetryPolicyにデリゲートするので、リミットとタイムアウトに関する関心事はここに入れます(後述)。

Retry Policies

RetryTemplateの内部では、executeメソッドのリトライか失敗かの決定はRetryPolicyが判断し、また、RetryContextのファクトリでもあります。RetryTemplateの責務には、RetryContextを生成するポリシーを使うことと、毎回のリトライ時にRetryCallbackRetryContextを渡すこと、があります。コールバックが失敗すると、RetryTemplateRetryPolicyに(RetryContext内の)状態の更新を行うための呼び出しをする責務があり、それから、次回のリトライが実行可能かどうかをポリシーに問い合わせます。次回のリトライが実行不可能(例:リミットに達した、タイムアウトを超えた)な場合、ポリシーは失敗の状態を識別する責務はありますが、例外ハンドリングの背sキムはありません。RetryTemplateはオリジナルの例外をスローし、ステートフルの場合以外でリカバリが利用可能では無い場合はRetryExhaustedExceptionをスローします。また、コールバック(ユーザのコードのこと)から無条件にオリジナルの例外をスローするようなフラグをRetryTemplateに設定することも出来ます。

Tip: 失敗は本質的にはリトライ可能か不可能かのどちらかです。もしビジネスロジックがどんな場合でも同一の例外をスローする場合、リトライでその例外は使えません。かといってすべての例外型でリトライするのは止めて下さい。プログラマがリトライ可能と判断した例外でだけリトライしてください。ビジネスロジックを積極的にリトライするのは基本的には邪悪ではないですが、もし失敗が決定論的な場合、fatalが自明なリトライは時間を浪費するだけなので無駄です。

Spring Retryはシンプルで汎用のステートレスRetryPolicyの実装をいくつか提供しており、例えば上述の例で、SimpleRetryPolicyTimeoutRetryPolicyを使っています。

SimpleRetryPolicyは、指定の回数、例外型のリストに対してリトライします。

// 初回リトライを含む最大回数および
// すべての例外(デフォルト)に対するリトライを設定
SimpleRetryPolicy policy = new SimpleRetryPolicy(5, Collections.singletonMap(Exception.class, true));

// 上記のポリシーを適用
RetryTemplate template = new RetryTemplate();
template.setRetryPolicy(policy);
template.execute(new RetryCallback<Foo>() {
    public Foo doWithRetry(RetryContext context) {
        // ここにビジネスロジックを記述
    }
});

また、ExceptionClassifierRetryPolicyというより柔軟な実装があり、これはExceptionClassifierを使用して複数の例外型にそれぞれのリトライを設定できます。このポリシーはclassifierを呼び出して例外をRetryPolicyへのデリゲートに変換するので、たとえば、ある例外型を異なるポリシーにマッピングすることで、その例外型は他よりも多くリトライするようなことが可能です。

必要であれば自前のカスタムリトライポリシーを実装して下さい。たとえば、ソリューション固有で頻出なものがある場合、例外のclassificationをリトライ可能・不可能に割り当てます。

Backoff Policies

一時的な問題の発生後に再度リトライする場合、すこし待つのが良い場合が多く、基本的にはそうした問題の解決には待機だけが有効だからです。RetryCallbackが失敗するときに、RetryTemplateは設定されたBackoffPolicyに従って例外を待機させられます。

public interface BackoffPolicy {

    BackOffContext start(RetryContext context);

    void backOff(BackOffContext backOffContext)
        throws BackOffInterruptedException;

}

BackoffPolicyではbackOffを任意の方法で実装します。Spring Retryが提供するすべてのポリシーのデフォルト動作はObject.wait()を使います。backoffのよくある使い方は待機間隔を指数関数的に増加させるもので、これは二つのリトライがロックして両方とも失敗するのを避けるためです。これはイーサネットから得られた教訓です。Spring RetryはExponentialBackoffPolicyを用意しています。また、ランダム版のディレイポリシーもあり、これは複雑なシステムで関連性のある失敗が共鳴しあうのを避けたい場合に有用です。

Listeners

場合によっては、複数の異なるリトライに対する横断的関心事のためのコールバックを追加すると便利なケースがあります。Spring RetryにはRetryListenerがあります。RetryTemplateでRetryListenersを登録可能で、そのリトライ中で利用可能なRetryContextThrowableとコールバックがリスナに渡されます。

インタフェースは以下の通りです。

public interface RetryListener {

    void open(RetryContext context, RetryCallback<T> callback);

    void onError(RetryContext context, RetryCallback<T> callback, Throwable e);

    void close(RetryContext context, RetryCallback<T> callback, Throwable e);
}

最もシンプルなケースのリトライではopenとcloseコールバックはリトライ全体の前後に呼ばれてonErrorは毎回のRetryCallbackで呼ばれます。closeもThrowableを受け取ります。何らかのエラーがある場合はRetryCallbackが最後に投げたものになります。

注意点として、複数のリスナはリストに格納されるので順序を持ちます。この場合、openはその順序で呼ばれますが、onErrorとcloseは逆順で呼ばれます。

Declarative Retry

ある種のビジネスロジックが呼ばれるときはすべて毎回リトライさせたい場合があります。これの良くある例はリモートサービス呼び出しです。Spring RetryはAOPのインターセプタを提供しており、これはメソッド呼び出しをRetryOperationsでラップします。RetryOperationsInterceptorインターセプトされたメソッドを実行し、設定されているRepeatTemplateRetryPolicyに従って失敗時にリトライします。

Java Configuration for Retry Proxies

@Configuration@EnableRetryを追加して、リトライしたいメソッド(全メソッドに対しては型レベル)に@Retryableを付与します。また、複数のリトライリスナを指定できます。

@Configuration
@EnableRetry
public class Application {

    @Bean
    public Service service() {
        return new Service();
    }
    
    @Bean public RetryListener retryListener1() {
        return new RetryListener() {...}
    }
    
    @Bean public RetryListener retryListener2() {
        return new RetryListener() {...}
    }

}

@Service
class Service {
    @Retryable(RemoteAccessException.class)
    public service() {
        // ... do something
    }
}

@Retryableの属性でRetryPolicyBackoffPolicyが制御できます。

@Service
class Service {
    @Retryable(maxAttempts=12, backoff=@Backoff(delay=100, maxDelay=500))
    public service() {
        // ... do something
    }
}

100から500ミリ秒のランダムバックオフで12回まで試行します。また、stateful属性(デフォルトfalse)でリトライをステートフルにするかどうかを制御します。ステートフルにするにはインターセプトされるメソッドに引数を持たせる必要があり、それが状態のキャッシュキー構築に使われます。

また、@EnableRetryは、Sleeper型のbean・RetryTemplate内で使われるその他のストラテジーオブジェクト・実行時にリトライの振る舞いを制御するためのインターセプター、を参照します。

@EnableRetry@Retryable beansのプロキシを生成し、そのプロキシ(アプリケーションのbeanインスタンス)にはRetryableインタフェースを追加します。@EnableRetryは単にマーカーインタフェースですが、ある種のツールがリトライアドバイスを適用したい場合に役に立ちます(こうしたツールはbeanがRetryableを実装しているかどうかを基本的に考慮しない)。

リトライがすべて失敗する場合のエラー処理を設けたい場合はリカバリメソッドを作ります。@Retryableとそれに対する@Recoverのメソッドは同一クラスに宣言します。リカバリの戻り値型は@Retryableと一致させる必要があります。引数は任意でスローされる例外を含めることが可能で、また、同じく任意で大本のretryableのメソッドに渡される引数も含めることが可能です(引数の一部だけや引数無しも可能)。

@Service
class Service {
    @Retryable(RemoteAccessException.class)
    public void service(String str1, String str2) {
        // 何かしらの処理
    }
    @Recover
    public void recover(RemoteAccessException e, String str1, String str2) {
       // 必要に応じて大本の引数を使用してエラーハンドリング
    }
}

Version 1.2で特定プロパティの式を使用する機能を導入しました。

@Retryable(exceptionExpression="#{message.contains('this can be retried')}")
public void service1() {
  ...
}

@Retryable(exceptionExpression="#{message.contains('this can be retried')}")
public void service2() {
  ...
}

@Retryable(exceptionExpression="#{@exceptionChecker.shouldRetry(#root)}",
    maxAttemptsExpression = "#{@integerFiveBean}",
  backoff = @Backoff(delayExpression = "#{1}", maxDelayExpression = "#{5}", multiplierExpression = "#{1.1}"))
public void service3() {
  ...
}

いわゆるSpring SpEL式(#{...})を使用します。

式は#{${max.delay}}もしくは#{@exceptionChecker.${retry.method}(#root)}などのプロパティ・プレースホルダを持ちます。

  • exceptionExpressionはスローされる例外である#rootオブジェクトに対して評価が行われます。
  • maxAttemptsExpression@BackOffの式の属性は初期化中に一度だけ評価が行われます。こちらは評価用のルートオブジェクトはありませんが、コンテキスト内の他のbeanを参照できます。

XML Configuration

以下はSpring AOPを使用してremoteCallというサービスメソッドをリトライする設定の例です(AOPインターセプターの設定の詳細についてはSpring User Guideを参照してください)。

<aop:config>
    <aop:pointcut id="transactional"
        expression="execution(* com..*Service.remoteCall(..))" />
    <aop:advisor pointcut-ref="transactional"
        advice-ref="retryAdvice" order="-1"/>
</aop:config>

<bean id="retryAdvice"
    class="org.springframework.retry.interceptor.RetryOperationsInterceptor"/>

上記例はインターセプター付属のデフォルトのRetryTemplateを使用しています。ポリシーやリスナーを変更するには、インターセプターにRetryTemplateインスタンスをインジェクションする必要があります。

Contributing

Spring Retryはnon-restrictive Apache 2.0 licenseでリリースしており、一般的なGithub開発プロセスに従い、issueにはGithub trackerを使用してmasterにpull requestをマージしています。何か小さなことでもcontributeを望む場合遠慮は無用ですが、以下のガイドラインには従ってください。

non-trivialのパッチもしくはpull requestをアクセプトする前に、我々は https://cla.pivotal.io/*1へのサインを必要とします。contributor's agreementへのサインはメインリポジトリへのコミット権を意味しませんが、contributionsの受け入れ可能なことを意味し、無事通ればauthor creditを得られます。アクティブな開発者はコアチームへの参加を求められる場合があり、このときpull requestのマージ権が与えられます。

Code of Conduct

本プロジェクトにはContributor Covenantがあります。参加する場合、この規範を守ることを期待します。よからぬ行動に対しては spring-code-of-conduct@pivotal.io へ報告してください。

*1:リンクがあるが壊れている……