2009年9月6日日曜日

HandleErrorの使い方リベンジ

前回の投稿の時には全然気にしてなかった問題を最近突っ込まれたので、改めて試して見ました。

そもそもASP.NET MVCで例外発生時のハンドリングをどうするのか、というのは前回の投稿に書いてるので読んでもらえれば分かると思います。と、言うと読んでもらえない気もするので簡単に説明すると、web.configでsystem.web/customErrorsをOnにしておき、ControllerやActionにHandleError属性を指定する。そうするとException毎に出力する内容を自分で制御出来るのさ!、というものです。

ここでポイントになるのはsystem.web/customErrorsでエラーハンドリングをする部分です。と、いうのも発生したエラーを捕捉し、表示したい内容を切り替える際に、この方法だとエラーページから302 Redirectで表示ページに遷移します。でも、人ではない何か(クローラやらAjaxなんかでのRESTクライアント)に正しくエラーを伝えるにはHTTP StatusCodeに正しい値をセットしたレスポンスを返さないとダメじゃないですか。ダメだとしましょう。

前回の実装では、302 Redirectの後に返されるレスポンスで正しいHTTP StatusCodeを返してる。けど、ちょっと待てと。そもそもRedirectいらないだろと。おっしゃる通りです。まったくその通りで、リクエストに対してRedirectなんかせず、スパッとステータスを返すのが正しい実装ですよね。

どうすればそういう実装が簡単にできましょうか、が今回の主題です。で、この主題を実装して正しく動くのはIIS7以降に限定なのでそうじゃない環境の人は「ふ~ん、そっすか」くらいにしか得るものがないかも。ガンバです!

まずsystem.web/customErrorsだけだとリダイレクトされてしまうので、これを何とかしたい。IHttpModuleとかで実装するのも手としてはありかもしれないですが、そんなことしなくてもweb.configにsystem.webServer/httpErrorsというのがありまして。このセクションにエラー発生時の挙動を書きます。それだけだとcutomErrorsと何が違うんだと思われても致し方なし。決定的に違うものがありまして、それがresponseMode="ExecuteURL"という属性。これを指定するとただのHTMLレスポンスでもなく、リダイレクトでもなく、指定したURLの実行結果を返してくれるんです。まばゆいくらい素敵です。

httpErrors Element [IIS 7 Settings Schema]

詳しくはリファレンスをチェケラッなんだけど、残念なことに日本語になって無くてですね...。ここは一つ「早く日本語にしてください!」とこのエントリを読んだ人はチャックに言いましょう!IISというかインフラは担当が違うと弱音を吐くようなら「担当に伝えてください!」とチャックに言いましょう!だって担当知らないし。

MSDNのドキュメントの苦情もすべてチャックに!そんなチャックはTHE TRUTH IS OUT THEREこちら(ゴメンねチャック)。

話を進める前に前回の状況のスクリーンショットを確認しておきます。

error1 error2

左が立ち上げた時の状態。右がBad Requestのリンクをクリックした状態。クリックすると拡大するのでステータスを確認してみてください。302 Redirectの後に400 Bad Requestになってますね。ちなみに開発環境のwebDev.webServerはwebServerセクションを見てくれないので、ローカルのIISにデプロイして確認する必要があります。今回はWindows 7上のIIS7.5にデプロイしてます。

まずはsystem.webServer.httpErrorsを設定します。

    <httpErrors errorMode="Custom">
      <clear />
      <error statusCode="400" path="/ErrorHandle/Errors/400" responseMode="ExecuteURL" />
      <error statusCode="403" path="/ErrorHandle/Errors/403" responseMode="ExecuteURL" />
      <error statusCode="404" path="/ErrorHandle/Errors/404" responseMode="ExecuteURL" />
    </httpErrors>

適当な感じで申し訳ないっす。 で、web.server/customErrorsを変更します。

    <customErrors mode="On" defaultRedirect="Errors" />

前回の設定内容をガッツリ消して、上記のみにしてしまいましょう。これすら消すとHandleError属性が効かなくなってエラーが捕捉出来なくなるので残しておきます。

続いて、HandleErrorAttributeクラスを派生させたHttpHandleErrorAttributeクラスを作成します。この派生クラスでOnExceptionをoverrideして処理してしまいましょう!

using System.IO;
using System.Web;
using System.Web.Mvc;

namespace ErrorHandling.Controllers
{
  public class HttpHandleErrorAttribute : HandleErrorAttribute
  {
    public override void OnException(ExceptionContext filterContext)
    {
      var exception = filterContext.Exception as HttpException;
      if (exception == null)
      {
        base.OnException(filterContext);
        return;
      }

      var statusCode = exception.GetHttpCode();
      var errorViewName = string.Format("~/Views/Errors/Error{0}.aspx", statusCode);
      if (!File.Exists( filterContext.HttpContext.Server.MapPath(errorViewName)))
        errorViewName = "~/Views/Shared/Error.aspx";

      filterContext.Result = new StatusViewResult(new ViewResult { ViewName = errorViewName }) { StatusCode = statusCode };
      filterContext.ExceptionHandled = true;
    }
  }
}

普通のExceptionは無視して、HttpExceptionだけ捕捉します。後は、ControllerにHttpHanderErrorを追加するだけ。

using System;
using System.Web;
using System.Web.Mvc;

namespace ErrorHandling.Controllers
{
  [HandleError(Order = 1)]
  [HttpHandleError]
  public class HomeController : Controller
  {
    public ActionResult Index()
    {
      ViewData["Message"] = "Welcome to ASP.NET MVC!";

      return View();
    }
// 以下省略

太字のところです。 ちなみに前回のサンプルソースに付け足してます。

実行すると↓こうなります。

error3

ちゃんと1度目のレスポンスで400がが返ってますね!

error4 error5

上の2つは左がThrow Exceptionリンクをクリックしたもので、これはcustomErrorsが効いてる結果です。右がMethod not arrowedをクリックした結果で、HttpHandleErrorでViewの定義が無かった(405を用意してないです)時のものです。

エラー処理もこれでスッキリ!