1.21 jigowatts

Great Scott!

ASP.NET MVCはじめました~エラー処理

概要

ASP.NET MVCのエラー処理を実装してみます。

f:id:sh_yoshida:20141216233354p:plain

環境

Visual Studio 2010
ASP.NET MVC2

今回の要件は

  1. 例外を発生させる
  2. 例外を補足しログに出力する
  3. エラー画面を表示する

として、サンプルコードを書いてみました。

実装

グローバルなエラー処理

補足しきれない例外をすべて補足して、エラー画面を表示します。

エラーコントローラの用意

引数ごとにステータスコードと表示メッセージを設定しました。
■Mvc2App/Controllers/ErrorController.cs

public class ErrorController : Controller
{
    public ActionResult Show(string id)
    {
        var model = new ErrorViewModel();
        var message = string.Empty;
        HttpContext.Response.TrySkipIisCustomErrors = true;
        
        if (id == "Unauthorized")
        {
            HttpContext.Response.StatusCode = 401;
            message = Resource.GetValue("APP_ERR_002");
        }
        else if (id == "LoginFail")
        {
            HttpContext.Response.StatusCode = 403;
            message = Resource.GetValue("APP_ERR_003");
        }
        else if (id == "NotFound")
        {
            HttpContext.Response.StatusCode = 404;
            message = Resource.GetValue("APP_ERR_004");
        }
        else 
        {
            HttpContext.Response.StatusCode = 500;
            message = Resource.GetValue("APP_ERR_001");
        }

        model.SysMessage = message;
        return View(model);
    }
}
エラービューモデルの用意

ViewModelBaseクラスのシステムメッセージプロパティを使用します。

public class ViewModelBase
{
    public string SysMessage { get; set; }
}

■Mvc2App/ViewModels/Error/ErrorViewModel.cs

public class ErrorViewModel : ViewModelBase
{
}
ビューの用意

■Mvc2App/Views/Error/Show.aspx

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master"
 Inherits="System.Web.Mvc.ViewPage<Mvc2App.ViewModels.Error.ErrorViewModel>" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
	Error
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

    <h2><%: Model.SysMessage %></h2>

</asp:Content>
ロギングクラスの用意

ログの出力はlog4netを使ったりすることが多いと思いますが、勉強がてら簡単なロギングクラスを手作りしました。
ログレベルを3種類(デバッグ、インフォメーション、エラー)用意して、Web.configファイルの設定とメソッドで出力を制御しています。
■Mvc2App/Common/Logger.cs

public enum LogLevel : int
{
    Debug,
    Info,
    Error
}

public static class Logger
{
    public static void Debug(string log)
    {
        if (!CheckLogLevel(LogLevel.Debug))
        {
            return;
        }
        else
        {
            Write(log, LogLevel.Debug);
        }
    }

    public static void Info(string log)
    {
        if (!CheckLogLevel(LogLevel.Info))
        {
            return;
        }
        else
        {
            Write(log, LogLevel.Info);
        }
    }

    public static void Error(string log)
    {
        if (!CheckLogLevel(LogLevel.Error))
        {
            return;
        }
        else
        {
            Write(log, LogLevel.Error);
        }
    }

    private static void Write(string log, LogLevel level)
    {
        var path = HttpContext.Current.Server.MapPath("~/Logs");
        var logFile = "System.log";
        var absolutePath = Path.Combine(path, logFile);

        if (!Directory.Exists(path))
        {
            Directory.CreateDirectory(path);
        }

        using (StreamWriter sw = new StreamWriter(absolutePath, true, Encoding.GetEncoding("Shift-JIS")))
        using (TextWriter syncWriter = TextWriter.Synchronized(sw))
        {
            syncWriter.Write(DateTime.Now.ToString());
            syncWriter.Write("\t");
            switch (level)
            {
                case LogLevel.Debug:
                    syncWriter.Write("[Debug]\t");
                    break;
                case LogLevel.Info:
                    syncWriter.Write("[Info]\t");
                    break;
                case LogLevel.Error:
                    syncWriter.Write("[Error]\t");
                    break;
                default:
                    throw new InvalidOperationException();
            }

            var session = HttpContext.Current.Session;
            if (session != null)
            {
                if (session["LogOnUser"] != null)
                {
                    syncWriter.Write("UserName:" + session["LogOnUser"] + "\t");
                }
            }

            syncWriter.WriteLine(log);
        }
    }

    private static Boolean CheckLogLevel(LogLevel logLevel)
    {
        var lL = System.Configuration.ConfigurationManager.AppSettings["logLevel"];
        if (lL == null) { return false; }

        int level;
        int.TryParse(lL, out level);

        if ((LogLevel)level > logLevel) { return false; }

        return true;
    }
}

log4netのように設定したログレベル以上の内容が出力されます。*1
■Mvc2App/Web.config

<configuration>
  <appSettings>
    <!-- 0:Debug / 1:Info / 2:Error -->
    <add key="logLevel" value="0"/>
  </appSettings>
</configuration>
ルートの定義とグローバルエラーハンドラーの用意

"Error"ルートの定義は/Error/Show/○○/Error/○○で処理できるように定義しました。
"CatchAll"ルートの定義は補足しきれなかったルーティングをエラーコントローラに引き渡します。
補足しきれない例外をグローバルな例外ハンドラーでキャッチするにはGlobal.asaxのApplication_Errorハンドラーに記述します。ここで補足した例外のメッセージをログに出力するようにしました。
■Mvc2App/Global.asax

public class MvcApplication : System.Web.HttpApplication
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute(
            "Error", // ルート名
            "Error/{id}", // パラメーター付きの URL
            new { controller = "Error", action = "Show", id = UrlParameter.Optional } // パラメーターの既定値
        );

        routes.MapRoute(
            "Default", // ルート名
            "{controller}/{action}/{id}", // パラメーター付きの URL
            new { controller = "Home", action = "Index", id = UrlParameter.Optional } // パラメーターの既定値
        );

        routes.MapRoute(
            "CatchAll",
            "{*anything}",
            new { controller = "Error", action = "Show", id = "NotFound" }
        );

    }

    ...

    protected void Application_Error(Object sender, EventArgs e)
    {
        var exception = Server.GetLastError();
        if (exception == null)
        {
            return;
        }
        System.Diagnostics.Debug.WriteLine(exception.Message);
        Logger.Error(exception.Message);
        Logger.Error(exception.StackTrace);

        if (exception.InnerException != null)
        {
            System.Diagnostics.Debug.WriteLine(exception.InnerException.Message);
            Logger.Error(exception.InnerException.Message);
            Logger.Error(exception.InnerException.StackTrace);
        }

        Server.ClearError();

        Response.Redirect("~/Error");
    }
}
コントローラで例外を発生させる

■Mvc2App/Controllers/HomeController.cs

public ActionResult About()
{
    throw new ArgumentException("グローバルエラー処理テスト");
}

実行結果

/Home/Aboutへ遷移し、ArgumentExceptionを発生させます。
f:id:sh_yoshida:20141216233354p:plain
■Mvc2App/Logs/System.log

2014/12/16 23:28:53	[Info]	Application Start
2014/12/16 23:28:53	[Debug]	Session Start
2014/12/16 23:28:59	[Error]	UserName:YOSHIDA	グローバルエラー処理テスト

/Home/About/test/123へアクセスするとルーティングの例外でエラーコントローラに制御が移り、エラー画面が表示されます。これは"CatchAll"ルートの定義により/Error/Show/NotFoundが実行されているためです。
f:id:sh_yoshida:20141217004216p:plain

コントローラレベルでのエラー処理

コントローラ内での例外であれば、HandleError属性を使用する方法もあります。

カスタムエラーを有効にする

HandleErrorを使うにはcustomErrorsのmodeをOnに設定します。
■Mvc2App/Web.config

<configuration>
  <system.web>
    <customErrors mode="On">
    </customErrors>
  </system.web>
</configuration>
コントローラのアクションにHandleError属性を指定する

HandleErrorで例外が処理された場合は、Application_Errorハンドラーに到達せずログが出力できないので、例外を投げるときにログ出力するように変更しました。
■Mvc2App/Controllers/HomeController.cs

[HandleError(ExceptionType=typeof(ArgumentException))]
public ActionResult About()
{
    try
    {
        Hoge();//ArgumentException("グローバルエラー処理テスト");
    }
    catch (ArgumentException e)
    {
        Logger.Error(e.Message);
        Logger.Error(e.StackTrace);
        throw;              
    }
}
ビューの用意

HandleErrorは規定でSharedのErrorビューを表示します。
これはテンプレートで用意されているので、例外メッセージが表示されるように変更しました。
■Mvc2App/Views/Shared/Error.aspx

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master"
 Inherits="System.Web.Mvc.ViewPage<System.Web.Mvc.HandleErrorInfo>" %>

<asp:Content ID="errorTitle" ContentPlaceHolderID="TitleContent" runat="server">
    エラー
</asp:Content>

<asp:Content ID="errorContent" ContentPlaceHolderID="MainContent" runat="server">
    <h2>
        要求の処理中にエラーが発生しました。
    </h2>
    <div>
        <%: Model.Exception.Message %>
    </div>
</asp:Content>

実行結果

/Home/Aboutへ遷移し、ArgumentExceptionを発生させます。
f:id:sh_yoshida:20141217000627p:plain



コントローラ内はHandleError属性を使用し、コントローラの外の例外はApplication_Errorハンドラーで補足できるようにしてみました。

正直なところ、結構悩みまして試行錯誤しているうちに本の内容が少し理解できた程度です。
ASP.NET MVC5ではHandleError属性をグローバルフィルターとして登録できるようですし、コントローラのOnExceptionメソッドをオーバーライドを実装することでもエラー処理ができます。
実践投入してもう少しこねくりまわしてみる必要がありますね。

*1:たとえば設定を1(Info)にした場合は、InfoとErrorの内容が出力される