ASP.NET MVCについてもチマチマと、とりあえず基本のあたり(2)

VCのCの拡張をやったので、次はVCのVの拡張について見てみます(・∀・)

Viewの基礎知識

ASP.NET MVCでは、コントローラが返すActionResult派生クラスによってクライアントへの出力処理が行われます。
標準では、10数個のActionResult派生が用意されていますが。
例としては、ContentResult(stringのResponse.Write)、EmptyResult(do nothing)、File*Result(byte[]やStreamのResponse.Write)、HttpUnauthorizedResult(401)、RedirectResult(Response.Redirect)なんかがありますが、その中でも最も使うのがaspxでレンダリングするViewResultになるかと思います。


ViewResultの処理は、ViewEngineCollection内のIViewEngine実装から該当するIView(を含むViewEngineResult)を検索し、IView.Render()によりビューのレンダリングを行うものです。
IViewEngineの標準実装として用意されているのはWebFormViewEngineで、これは~/Views仮想パス下から{controller}/{action}.aspxとかを探して、そのWebFormViewを作成します。*1
っで、IViewの標準実装として用意されているのがWebFormViewになります。
WebFormViewは、System.Web.Compilation.BuildManager.CreateInstanceFromVirtualPath()でViewPage(System.Web.Page派生)を作成し、Page.ProcessRequest()を呼び出してaspxのレンダリングを行います。



…っという所までが、ASP.NET MVCのVの基礎知識(・∀・)
これを踏まえて、ビュー周りの拡張ポイントを考えてみます(´ω`)

View Engineの拡張

まず、IViewEngineに関するビューの拡張というと、aspx以外の、何かしらのテンプレートエンジンを使ったレンダリングを行うIViewEngine/IViewを作るなんていうネタが思いつくわけですが。
でも、実用的に使うのはaspxだろうし、そこまでするのは今回はやめておいて(´Д`)
ここでは、標準のWebFormViewEngineでは対応していない、階層化されたのViewsフォルダを扱うための拡張を考えてみたいと思います(・∀・)


標準のWebFormViewEngineでは、aspxファイルの検索先は、~/Views下の{コントローラー名}フォルダ/{アクションメソッド名}.aspxになっています。
まあ、小さなアプリであれば、このパターンだけでも困らないでしょうけど(・ω・)
でも、ちょっと本格的なアプリを作ろうと思った場合、コントローラー&ビューの数が増えてくると、Views直下にフォルダがたくさん出来て、ちょっとゲンナリしてしまいます(´・ω・`)


例えば、サブシステムとしてBlogやForum機能なんかを含む、CMSのようなものを作るとを想定として。
URLは「http://.../{サブシステム}/{controller}/{action}/」の形にして、Viewsフォルダ下の構成もそれとあわせて{サブシステム}/{controller}/{action}.aspxにしたいというような要望はありがちだと思いますが(・ω・)


っで、そんなViews下の階層化に対応したIViewEngineの実装例が、S#arp Architectureの中にあるんだな〜、っということで、その中身を見てみたい思います(`・ω・´)


提供されているのはAreaViewEngineクラスというWebFormViewEngine派生クラスで、IViewEngine.FindView()、IViewEngine.FindPartialView()が階層(Area)に対応した作りになっています(・∀・)


っで、まずは使い方からということで、以下のような構成を例にやってみます。

コントローラは、以下の3つのを用意します。

  • MvcTest.Blogs.Controllers.HomeController (URLは/Blogs/に設定)
  • MvcTest.Forums.Controllers.HomeController (URLは/Forums/に設定)
  • MvcTest.Root.Controllers.HomeController (URLは/に設定)

また、ビューについては、右上の画像のようなフォルダ構成に配置します。

次はルートの設定になりますが、これはAreaViewEngineと一緒に提供されているAreaRouteHelper(RouteCollectionエクステンション)のCreateArea()メソッドを使用して、以下のような記述をします。

public class MvcApplication : System.Web.HttpApplication
{
    public static void RegisterRoutes(RouteCollection routes)
    {
...
        routes.CreateArea( "Blogs", "MvcTest.Blogs.Controllers",
            routes.MapRoute( null, "Blogs/{controller}/{action}",
                             new { controller = "Home", action = "Index" } )
        );

        routes.CreateArea( "Forums", "MvcTest.Forums.Controllers",
            routes.MapRoute( null, "Forums/{controller}/{action}",
                             new { controller = "Home", action = "Index" } )
        );

        routes.CreateArea( "Root", "MvcTest.Root.Controllers",
            routes.MapRoute( null, "{controller}/{action}",
                             new { controller = "Home", action = "Index" } )
        );
    }
...
}

CreateArea()の引数は順に、Area名、コントローラの名前空間、Route配列になります。
複数のRouteをまとめて、1つのAreaとして登録するということですね。*2


っで、以下のようにして、ViewEngineを標準のものから切り替えます。

public class MvcApplication : System.Web.HttpApplication
{
...
    protected void Application_Start()
    {
        RegisterRoutes( RouteTable.Routes );

        // カスタムViewEngineの設定
        ViewEngines.Engines.Clear();
        ViewEngines.Engines.Add( new AreaViewEngine() );
    }
}

やることは以上で終わりです。
これだけで、階層化したViewsフォルダのaspxファイルが使用されることを確認できますポン(・∀・)


っで、動作確認ができたら、次はその仕組みを見ていきたいと思います(`・ω・´)
AreaViewEngineのソースからポイントを抜粋すると、下記のような所でしょうかね(・ω・)

// コンストラクタ
public AreaViewEngine() : base()
{
    ViewLocationFormats = new[] { 
        "~/{0}.aspx",
        "~/{0}.ascx",
        "~/Views/{1}/{0}.aspx",
        "~/Views/{1}/{0}.ascx",
        "~/Views/Shared/{0}.aspx",
        "~/Views/Shared/{0}.ascx",
    };
...
}

// IViewEngine.FindView()
public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
{
    ViewEngineResult areaResult = null;

    if( controllerContext.RouteData.Values.ContainsKey( "area" ) )
    {
        string areaViewName = FormatViewName( controllerContext, viewName );
        areaResult = base.FindView( controllerContext, areaViewName, masterName, useCache );

        if( areaResult != null && areaResult.View != null )
        {
            return areaResult;
        }
...
    }

    return base.FindView( controllerContext, viewName, masterName, useCache );
}

// ヘルパ
private static string FormatViewName(ControllerContext controllerContext, string viewName)
{
    string controllerName = controllerContext.RouteData.GetRequiredString( "controller" );
    string area = controllerContext.RouteData.Values[ "area" ].ToString();
    return "Views/" + area + "/" + controllerName + "/" + viewName;
}

まず、コンストラクタでViewLocationFormatsを設定しているところですが、ViewLocationFormatsプロパティは、AreaViewEngineの基底のWebFormViewEngineの更に基底のVirtualPathProviderViewEngineクラスで定義されているもので、パス検索時のフォーマットになります。
標準のWebFormViewEngineの定義では"~/Views/{1}/{0}.aspx"のパターンは定義されておらず、階層化には対応していません。


次にFindView()メソッド及びFormatViewName()メソッドについてですが、AreaViewEngineの実装ではRouteDataの"area"を見て、Area名を含むビュー名のViewResultを返すような作りとなっており、これにより階層化に対応しています。


っで、誰がRouteDataの"area"を設定しているのかという話になりますが、この仕掛けがAreaRouteHelper.CreateArea()内にあります。

public static class AreaRouteHelper
{
    public static void CreateArea(this RouteCollection routes, string areaName, string controllersNamespace, params Route[] routeEntries)
    {
        foreach( var route in routeEntries )
        {
...
            route.Constraints.Add( "area", areaName );
            route.Defaults.Add( "area", areaName );
            route.DataTokens.Add( "namespaces", new string[] { controllersNamespace } );

            if( !routes.Contains( route ) )
                routes.Add( route );
        }
    }
}

CreateArea()メソッドの実装は上記のようなカンジで、Area内の各Routeに必要な情報を付加した上でRouteCollectionに登録する処理となっています。


なお、DataTokensに"namespace"を設定していますが、この仕掛けによりControllerFactoryが名前空間の異なる同じ名称のクラスを扱えるようになっています。
以下はDefaultControllerFactory.GetControllerType()からの処理抜粋ですが、"namespace"が定義されている場合はその名前空間からコントローラクラスを探すような作りになっています。

protected internal virtual Type GetControllerType(string controllerName)
{
...
    if( RequestContext != null && RequestContext.RouteData.DataTokens.TryGetValue( "Namespaces", out routeNamespacesObj ) )
    {
        // routeNamespacesObjの名前空間からコントローラを探す
...
    }
...
}

…っということで、View周りの拡張はこんなカンジでできました(・∀・)
これでCとVの拡張は大体わかったので、後はその他のところの話をチョットかな。


ちなみに、MVC Contribの中には、SpringやUnityと連携するIControllerFactoryや、NVelocityを使ったIViewEngineの実装なんかが入っているので、そういう事をしたい人は中を見ておくと良いと思います(・∀・)

*1:Sharedフォルダとか、master、ascxの話は省略

*2:ここでは各Areaとも1つのRouteしか登録していないですけど(´д`;)