xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

例外発生時にリトライする

アプリケーションの要件によっては「ココは失敗してもらっちゃ困る!」という処理が出てきます。こんな場合に例外が発生したりすると、もう目も当てられない状態になります。失敗しないように制御することは大事ですが、失敗しないことを過度に期待するのは良くないです。

失敗したところでやめてしまうから失敗になる。 成功するところまで続ければ、それは成功になる。

とりあえず、再試行

ということで、失敗したらリトライしましょう。ここでは簡単のために "失敗 = 例外発生" として扱います。簡単に再試行をしようと思ったら、例えば以下のようになるでしょう。

void DoSomething()
{
    while(true)
    try
    {
        //--- 失敗したら困る処理
        return;
    }
    catch
    {}
}

しかし、要件によってはどこかで見限らなければならないケースもあります。このような場合は再試行する回数を決めるのですが、例えば5回まで再試行しようと思ったら次のようになるでしょう。

void DoSomething()
{
    for (int retryCount = 0; retryCount <= 5; retryCount++)
    try
    {
        //--- 失敗したら困る処理
        return;
    }
    catch
    {}
}

また、発生する例外の中でも再試行する条件を判別しようとしたら、例えば次のようになるでしょう。

void DoSomething()
{
    for (int count = 0; count <= 5; count++)
    try
    {
        //--- 失敗したら困る処理
        return;
    }
    catch (InvalidOperationException)
    {
        //--- 特定の例外だったら再試行
    }
    catch
    {
        return;
    }
}

でも、このような記述を再試行が必要な箇所にちりばめるのはすこぶるメンドクサイですし、何かこうやりたい処理に集中できない感じがあります。ということで、少し汎用化して隠蔽しましょう。

汎用化

再試行するためのループは記述が煩わしいので、必要な処理だけをデリゲートとして外出しして隠蔽してしまいましょう。

public static class RetryHelper
{
    //--- 失敗したら無条件に再試行
    public static void RetryIfError(Action onAction)
    {
        if (onAction == null)
            throw new ArgumentNullException("onAction");
        RetryHelper.RetryIfErrorCore(onAction, null, null, null);
    }

    //--- 指定回数以内で再試行し、最大試行回数を超えたらエラー処理
    public static void RetryIfError(Action onAction, Action<Exception> onError, uint retryCount)
    {
        if (onAction == null) throw new ArgumentNullException("onAction");
        if (onError == null)  throw new ArgumentNullException("onError");
        RetryHelper.RetryIfErrorCore(onAction, onError, null, retryCount);
    }

    //--- 条件に合う場合は再試行し、それ以外の場合はエラー処理
    public static void RetryIfError(Action onAction, Action<Exception> onError, Func<Exception, bool> retryCondition)
    {
        if (onAction == null)       throw new ArgumentNullException("onAction");
        if (onError == null)        throw new ArgumentNullException("onError");
        if (retryCondition == null) throw new ArgumentNullException("retryCondition");
        RetryHelper.RetryIfErrorCore(onAction, onError, retryCondition, null);
    }

    //--- 条件に合う場合は指定回数以内で再試行し、それ以外の場合はエラー処理
    public static void RetryIfError(Action onAction, Action<Exception> onError, Func<Exception, bool> retryCondition, uint maxRetryCount)
    {
        if (onAction == null)       throw new ArgumentNullException("onAction");
        if (onError == null)        throw new ArgumentNullException("onError");
        if (retryCondition == null) throw new ArgumentNullException("retryCondition");
        RetryHelper.RetryIfErrorCore(onAction, onError, retryCondition, maxRetryCount);
    }

    //--- コアロジック
    private static void RetryIfErrorCore(Action onAction, Action<Exception> onError, Func<Exception, bool> retryCondition, uint? retryCount)
    {
        if (onError == null)        onError        = ex => {};
        if (retryCondition == null) retryCondition = ex => true;
        uint count = 0;

    Retry:
        try
        {
            onAction();
        }
        catch (Exception ex)
        {
            if (retryCondition(ex))
            {
                if (!retryCount.HasValue)       goto Retry;
                if (count++ < retryCount.Value) goto Retry;
            }
            onError(ex);
        }
    }
}

ここでは "失敗したら困る処理" からの戻り値がない場合のみ挙げていますが、戻り値を受けられるオーバーロードも作ると良いでしょう。上記のような準備をするだけで、利用側としては次のように記述できるようになります。

RetryHelper.RetryIfError(() =>
{
    //--- 失敗したら困る処理
},
ex =>
{
    //--- エラー処理
},
ex => ex is InvalidOperationException,  //--- 条件
5);                                     //--- 再試行回数

基本的にはこれで困らないのですが、最大の問題は例外を再スローするときに "throw ex;" のような記述をする必要があるということです。何か使い勝手を悪くせずに解決する良い方法はないかと考えているのですが、どれもピンと来ていません。ちなみにですが、僕はgoto文がNGだなんていう神話は信じていません。過度に使うのはどうかと思いますが、コードが意味的になって見やすくなるなら使ってイイと思っています。

追記 : 2013/9/8 22:03

@neueccさんに、スタックトレースを維持しつつ例外を再スローする方法を教えていただきました。.NET Framework 4.5から搭載されたExceptionDispatchInfoクラスを利用すると良いようです。感謝!

RetryHelper.RetryIfError(() =>
{
    //--- 失敗したら困る処理
},
ex =>
{
//  throw ex;                                  //--- スタックトレース破棄
    ExceptionDispatchInfo.Capture(ex).Throw(); //--- スタックトレース維持
}, 5);

データベースのデッドロック検出とリトライ

別のアプリケーションやスレッドから同じテーブルへの更新を行う場合など、データベース処理をしているとどうしてもデッドロックに出会ってしまう場合があります。手を尽くしてデッドロックが起こらないようにしたいものですが、避けられない場合はリトライして逃げるしかありません。データベースアクセスをする箇所はアチラコチラに出てくるので、先のようなヘルパーを使えば比較的簡単に回避できるでしょう。

void DoSomething()
{
    SqlHelper.RetryIfDeadlock(() =>
    {
        //--- データベースの更新
    },
    ex =>
    {
        //--- デッドロック以外の例外が出たときのエラー処理
    });
}

//--- SQL用としてラップしたり
public static class SqlHelper
{
    public static void RetryIfDeadlock(Action onAction, Action<Exception> onError)
    {
        if (onAction == null) throw new ArgumentNullException("onAction");
        if (onError == null)  throw new ArgumentNullException("onError");
        RetryHelper.RetryIfError(onAction, onError, ex =>
        {
            var ex2 = ex as SqlException;
            return  ex2 == null
                ?   false
                :   ex2.Number == 1205;  //--- デッドロックのエラーコード
        });
    }
}