ASP.NET Web API 2 のグローバル エラー処理

投稿者: David MatsonRick Anderson

このトピックでは、ASP.NET 4.x 向けの ASP.NET Web API 2 でのグローバル エラー処理の概要について説明します。 現在、Web API では、エラーをグローバルにログに記録または処理する簡単な方法はありません。 一部のハンドルされない例外は例外フィルターを使用して処理できますが、例外フィルターでは処理できないケースが多数あります。 次に例を示します。

  1. コントローラー コンストラクターからスローされる例外。
  2. メッセージ ハンドラーからスローされる例外。
  3. ルーティング中にスローされる例外。
  4. 応答コンテンツのシリアル化中にスローされる例外。

これらの例外をログに記録して処理する簡単で一貫した方法 (可能な場合) を提供したいと考えています。

例外を処理する主なケースは 2 つあります。エラー応答を送信できるケースと、できることが例外をログに記録することのみであるケースです。 後者のケースの例は、ストリーミング応答コンテンツの途中で例外がスローされた場合です。その場合、ステータス コード、ヘッダー、および部分コンテンツが既にネットワークを経由しているため、新しい応答メッセージを送信するのは遅すぎます。そのため、単に接続を中止します。 例外を処理して新しい応答メッセージを生成することはできませんが、例外のログ記録は引き続きサポートされています。 エラーを検出できる場合は、次に示すように適切なエラー応答を返すことができます。

public IHttpActionResult GetProduct(int id)
{
    var product = products.FirstOrDefault((p) => p.Id == id);
    if (product == null)
    {
        return NotFound();
    }
    return Ok(product);
}

既存のオプション

現在、例外フィルターに加えて、メッセージ ハンドラーを使用して 500 レベルのすべての応答を観察できますが、元のエラーに関するコンテキストがないため、それらの応答に対する処理は困難です。 メッセージ ハンドラーには、処理できるケースに関する例外フィルターと同じ制限事項もあります。 Web API には、エラー状態をキャプチャするトレース インフラストラクチャが用意されていますが、トレース インフラストラクチャは診断目的であり、運用環境での実行向けには設計されておらず、適してもいません。 グローバル例外処理とログ記録は、運用環境で実行でき、既存の監視ソリューション (ELMAH など) に接続できるサービスである必要があります。

ソリューションの概要

IExceptionLogger と IExceptionHandler という 2 つの新しいユーザー置き換え可能サービスを提供して、ハンドルされない例外をログに記録して処理します。 このサービスは非常に似ていますが、主に次の 2 つの違いがあります。

  1. 複数の例外ロガーの登録をサポートしていますが、例外ハンドラーは 1 つだけです。
  2. 接続を中止しようとしている場合でも、例外ロガーは常に呼び出されます。 例外ハンドラーは、送信する応答メッセージを選択できる場合にのみ呼び出されます。

どちらのサービスも、例外が検出された時点からの関連情報を含む例外コンテキスト (特に HttpRequestMessageHttpRequestContext、スローされた例外、例外ソース) へのアクセスを提供します (詳細については、以下を参照)。

設計原則

  1. 破壊的変更なし この機能はマイナー リリースで追加されているため、ソリューションに影響を与える重要な制約の 1 つは、コントラクトの入力または動作に対する破壊的変更が存在しないということです。 この制約により、例外を 500 応答に変換する既存の catch ブロックの観点から、いくつかのクリーンアップが除外されました。 この追加のクリーンアップは、今後のメジャー リリースで検討する可能性があります。
  2. Web API コンストラクトとの一貫性を維持する Web API のフィルター パイプラインは、アクション固有、コントローラー固有、またはグローバル スコープでロジックを柔軟に適用することで、横断的な問題を処理する優れた方法です。 例外フィルターを含むフィルターは、グローバル スコープで登録されている場合でも、常にアクション コンテキストとコントローラー コンテキストを持ちます。 このコントラクトはフィルターには適していますが、グローバル スコープの例外フィルターであっても、アクションやコントローラー コンテキストが存在しないメッセージ ハンドラーからの例外など、一部の例外処理ケースには適していないことを意味します。 フィルターによって提供される柔軟なスコープを例外処理に対して使用する場合は、例外フィルターが引き続き必要です。 ただし、コントローラー コンテキストの外部で例外を処理する必要がある場合は、完全なグローバル エラー処理 (コントローラー コンテキストとアクション コンテキストの制約がないもの) 用に別のコンストラクトも必要です。

使用すべきとき

  • 例外ロガーは、Web API によってキャッチされたすべてのハンドルされない例外を表示するためのソリューションです。
  • 例外ハンドラーは、Web API によってキャッチされたハンドルされない例外に対して可能なすべての応答をカスタマイズするためのソリューションです。
  • 例外フィルターは、特定のアクションまたはコントローラーに関連するハンドルされない例外のサブセットを処理するための最も簡単なソリューションです。

サービスの詳細

例外ロガーとハンドラー サービス インターフェイスは、それぞれのコンテキストを受け取る単純な非同期メソッドです。

public interface IExceptionLogger
{
   Task LogAsync(ExceptionLoggerContext context, 
                 CancellationToken cancellationToken);
}

public interface IExceptionHandler
{
   Task HandleAsync(ExceptionHandlerContext context, 
                    CancellationToken cancellationToken);
}

また、これらの両方のインターフェイスの基底クラスも提供します。 推奨されるタイミングでログに記録または処理するために必要なのは、コア (同期または非同期) メソッドのオーバーライドだけです。 ログ記録の場合、ExceptionLogger 基底クラスは、コア ログ メソッドが例外ごとに 1 回だけ呼び出されるようにします (後で呼び出し履歴がさらに伝達され、再びキャッチされた場合でも)。 ExceptionHandler 基底クラスは、呼び出し履歴の先頭にある例外に対してのみコア処理メソッドを呼び出し、レガシの入れ子になった catch ブロックを無視します。 (これらの基底クラスの簡略化されたバージョンは、下記の付録にあります。)IExceptionLoggerIExceptionHandler の両方が、ExceptionContext を介して例外に関する情報を受け取ります。

public class ExceptionContext
{
   public Exception Exception { get; set; }

   public HttpRequestMessage Request { get; set; }

   public HttpRequestContext RequestContext { get; set; }

   public HttpControllerContext ControllerContext { get; set; }

   public HttpActionContext ActionContext { get; set; }

   public HttpResponseMessage Response { get; set; }

   public string CatchBlock { get; set; }

   public bool IsTopLevelCatchBlock { get; set; }
}

フレームワークが例外ロガーまたは例外ハンドラーを呼び出すときに、常に、ExceptionRequest が提供されます。 単体テストを除き、常に RequestContext も提供されます。 まれに、ControllerContext および ActionContext が提供されます (例外フィルターの catch ブロックから呼び出す場合にのみ)。 Response はほとんど提供されません (応答を書き込もうとしている最中の特定の IIS のケースのみ)。 これらのプロパティの一部が null である可能性があるため、コンシューマーは、例外クラスのメンバーにアクセスする前に null をチェックする必要があります。CatchBlock は、例外が発生した catch ブロックを示す文字列です。 catch ブロック文字列は次のとおりです。

  • HttpServer (SendAsync メソッド)

  • HttpControllerDispatcher (SendAsync メソッド)

  • HttpBatchHandler (SendAsync メソッド)

  • IExceptionFilter (ExecuteAsync での ApiController の例外フィルター パイプラインの処理)

  • OWIN ホスト:

    • HttpMessageHandlerAdapter.BufferResponseContentAsync (出力のバッファリング用)
    • HttpMessageHandlerAdapter.CopyResponseContentAsync (ストリーミング出力用)
  • Web ホスト:

    • HttpControllerHandler.WriteBufferedResponseContentAsync (出力のバッファリング用)
    • HttpControllerHandler.WriteStreamedResponseContentAsync (ストリーミング出力用)
    • HttpControllerHandler.WriteErrorResponseContentAsync (バッファー出力モードでのエラー回復でエラーが発生した場合)

catch ブロック文字列の一覧は、静的な読み取り専用プロパティでも使用できます。 (コア catch ブロック文字列は静的 ExceptionCatchBlocks にあります。残りは、OWIN と Web ホストごとに 1 つの静的クラスに表示されます)。IsTopLevelCatchBlock は、呼び出し履歴の先頭でのみ例外を処理する推奨パターンに従う場合に役立ちます。 入れ子になった catch ブロックが発生した場所で例外を 500 応答に変換するのではなく、例外ハンドラーは、ホストによって確認されるまで例外を伝達できます。

ExceptionContext に加えて、ロガーは完全な ExceptionLoggerContext を通してさらに情報を取得します。

public class ExceptionLoggerContext
{
   public ExceptionContext ExceptionContext { get; set; }
   public bool CanBeHandled { get; set; }
}

2 番目のプロパティ CanBeHandled は、処理できない例外をロガーが識別できるようにします。 接続が中止されようとしていて、新しい応答メッセージを送信できない場合、ロガーは呼び出されますが、ハンドラーは "呼び出されず"、ロガーはこのプロパティからこのシナリオを識別できます。

ExceptionContext に加えて、ハンドラーは、例外を処理するために完全な ExceptionHandlerContext に設定できるプロパティをもう 1 つ取得します。

public class ExceptionHandlerContext
{
   public ExceptionContext ExceptionContext { get; set; }
   public IHttpActionResult Result { get; set; }
}

例外ハンドラーは、Result プロパティをアクションの結果 (ExceptionResultInternalServerErrorResultStatusCodeResult、カスタム結果など) に設定して例外を処理したことを示します。 Result プロパティが null の場合、例外はハンドルされず、元の例外が再スローされます。

呼び出し履歴の先頭にある例外については、応答が API 呼び出し元に適していることを確認するための追加の手順を実行しました。 例外がホストに伝達された場合、呼び出し元には、"黄色い死の画面" または他のホストが提供する応答が表示されます。これは通常は HTML であり、通常は適切な API エラー応答ではありません。 このような場合、結果は null 以外から開始され、カスタム例外ハンドラーによって明示的に null に戻された (ハンドルされない) 場合にのみ、例外がホストに伝達されます。 このような場合に Resultnull に設定すると、次の 2 つのシナリオで役立ちます。

  1. OWIN でホストされる Web API と、Web API の前後に登録されたカスタム例外処理ミドルウェア。
  2. ブラウザーを使用したローカル デバッグ。実際には、黄色い死の画面が、ハンドルされない例外に対して役立つ応答です。

ロガーまたはハンドラー自体が例外をスローした場合、例外ロガーと例外ハンドラーの両方に対して回復操作は何も行われません。 (例外を反映させる以外に、より適切なアプローチがある場合は、このページの下部でフィードバックを送信してください)。例外ロガーとハンドラーのコントラクトは、例外が呼び出し元に伝達されないようにする必要があるというものです。それ以外の場合、例外は単に伝達されます。多くの場合、ホストに到達する途中で HTML エラー (ASP.NET の黄色い画面など) がクライアントに送り返されます (通常、JSON または XML を必要とする API 呼び出し元には推奨されません)。

例外ロガーのトレース

次の例外ロガーは、構成されたトレース ソース (Visual Studio の [デバッグ出力] ウィンドウを含む) に例外データを送信します。

class TraceExceptionLogger : ExceptionLogger
{
    public override void LogCore(ExceptionLoggerContext context)
    {
        Trace.TraceError(context.ExceptionContext.Exception.ToString());
    }
}

カスタム エラー メッセージの例外ハンドラー

次の例外ハンドラーは、サポートに連絡するための電子メール アドレスを含む、クライアントへのカスタム エラー応答を生成します。

class OopsExceptionHandler : ExceptionHandler
{
    public override void HandleCore(ExceptionHandlerContext context)
    {
        context.Result = new TextPlainErrorResult
        {
            Request = context.ExceptionContext.Request,
            Content = "Oops! Sorry! Something went wrong." +
                      "Please contact support@contoso.com so we can try to fix it."
        };
    }

    private class TextPlainErrorResult : IHttpActionResult
    {
        public HttpRequestMessage Request { get; set; }

        public string Content { get; set; }

        public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
        {
            HttpResponseMessage response = 
                             new HttpResponseMessage(HttpStatusCode.InternalServerError);
            response.Content = new StringContent(Content);
            response.RequestMessage = Request;
            return Task.FromResult(response);
        }
    }
}

例外フィルターの登録

"ASP.NET MVC 4 Web アプリケーション" プロジェクト テンプレートを使用してプロジェクトを作成する場合は、WebApiConfig クラス内の App_Start フォルダーに Web API 構成コードを配置します。

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        config.Filters.Add(new ProductStore.NotImplExceptionFilterAttribute());

        // Other configuration code...
    }
}

付録: 基底クラスの詳細

public class ExceptionLogger : IExceptionLogger
{
    public virtual Task LogAsync(ExceptionLoggerContext context, 
                                 CancellationToken cancellationToken)
    {
        if (!ShouldLog(context))
        {
            return Task.FromResult(0);
        }

        return LogAsyncCore(context, cancellationToken);
    }

    public virtual Task LogAsyncCore(ExceptionLoggerContext context, 
                                     CancellationToken cancellationToken)
    {
        LogCore(context);
        return Task.FromResult(0);
    }

    public virtual void LogCore(ExceptionLoggerContext context)
    {
    }

    public virtual bool ShouldLog(ExceptionLoggerContext context)
    {
        IDictionary exceptionData = context.ExceptionContext.Exception.Data;

        if (!exceptionData.Contains("MS_LoggedBy"))
        {
            exceptionData.Add("MS_LoggedBy", new List<object>());
        }

        ICollection<object> loggedBy = ((ICollection<object>)exceptionData[LoggedByKey]);

        if (!loggedBy.Contains(this))
        {
            loggedBy.Add(this);
            return true;
        }
        else
        {
            return false;
        }
    }
}

public class ExceptionHandler : IExceptionHandler
{
    public virtual Task HandleAsync(ExceptionHandlerContext context, 
                                    CancellationToken cancellationToken)
    {
        if (!ShouldHandle(context))
        {
            return Task.FromResult(0);
        }

        return HandleAsyncCore(context, cancellationToken);
    }

    public virtual Task HandleAsyncCore(ExceptionHandlerContext context, 
                                       CancellationToken cancellationToken)
    {
        HandleCore(context);
        return Task.FromResult(0);
    }

    public virtual void HandleCore(ExceptionHandlerContext context)
    {
    }

    public virtual bool ShouldHandle(ExceptionHandlerContext context)
    {
        return context.ExceptionContext.IsOutermostCatchBlock;
    }
}