2009年8月9日日曜日

ASP.NET MVC 2 Preview1でのArea

ふぅ~。出遅れ。かなり出遅れでしょんぼり。

ASP.NET MVC V2 Preview 1 Released - ScottGu's Blog
日本語はもちろんこちら→ASP.NET MVC V2プレビュー1がリリース - @IT

少しずつ順番に見ていこうと思います。順番なのでまずはArea機能。もともとPhilさんがこの機能をV1の時から紹介(Grouping Controllers with ASP.NET MVC)してて、V1のPreviewのいつだか忘れたんですがRouting登録時にControllerのnamespaceを指定出来るようになってるんです。それを使って違うnamespaceのControllerを指定することを"Area"と呼んでるという感じです。RouteControllerへのArea登録用拡張メソッドの作成でV1は実現して他のをV2では最初から用意しといたら便利かもね~、な流れ(特に開発規模が大きくなって分業始める事を考えると)でしょうか。

とりあえず、使ってみましょう。と、いっても上記記事を見てもやり方は書いてないのでサッパリ分からないですよね。でも今回からはMSDNにすでにドキュメントが用意されてます。

Walkthrough: Organizing an ASP.NET MVC Application by Logical Areas

MSDNマガジンを機械翻訳にするなら、その分のリソースをこっちにさいて日本語化しといてくれよ、なんてぼやきは無しでいきましょう。ぶつぶつ。

まず、通常通りにMVC 2のプロジェクトを作成。”MainSite”という名前で作ってみました。

area1

そうそう、V1とV2はそれぞれ参照するアセンブリが違うので、一緒にインストールしててもなんの問題もないので、ガンガン入れて試すといいと思います。

area2

プロジェクトテンプレートももちろん最初から日本語版のVSで動きますね。こなれてきてます。

ドキュメントに書かれてる手順でサブプロジェクトを作っていきましょう。ソリューションに新しく「ソリューションフォルダ」を追加して、その中に"Sub1"と"Sub2"という名前でMVC2のプロジェクトを作成。でもって、不要なファイル達をゴッソリ削除。とにかくControllersフォルダとルートのweb.configだけが残ってればよろしデス。

area3

↑こんなようになってればOK。

で、ルーティング登録用のファイルを各サブプロジェクトに作っておけと、ドキュメントに書かれてるのでそれに従います。ん?それだと面白味に欠ける?分かりました。んじゃ、MainSiteのルーティングにまとめて書くやり方でやってみましょう!(本来はきちんと分けた方が機能分離出来て分担しやすいはずです)

MainSiteのGlobal.asaxのルート登録を以下のように書き換えます。

    public static void RegisterRoutes(RouteCollection routes)
    {
      routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

      routes.MapAreaRoute(
        "AreasSub1",
        "Sub1Default",
        "s1/{controller}/{action}/{id}",
        new {controller = "Home", action = "Index", id = ""},
        new[] {"Sub1.Controllers"}
      );

      routes.MapAreaRoute(
        "AreasSub2",
        "Sub2Default",
        "s2/{controller}/{action}/{id}",
        new { controller = "Home", action = "Index", id = "" },
        new[] { "Sub2.Controllers" }
      );

      routes.MapAreaRoute(
        "MainSite",
        "Default",
        "{controller}/{action}/{id}",
        new { controller = "Home", action = "Index", id = "" },
        new[] { "MainSite.Controllers" }
      );

      //routes.MapRoute(
      //    "Default",                                              // Route name
      //    "{controller}/{action}/{id}",                           // URL with parameters
      //    new { controller = "Home", action = "Index", id = "" }  // Parameter defaults
      //);

    }

デフォルトで登録されてるMapRouteがコメントアウトになって、MapAreaRouteで登録し直してるのには理由がありまして、Areas内のHome/Indexを表示したりしてるときに、MainSiteへのリンクをActionLinkで出力する際にエリア名を指定しておく必要があるんですね。その時、デフォルトの登録だとエリア名がないので、ちゃんとリンクを出力できなくなります。気をつけて!

MainSiteのHome/IndexにSub1とSub2へのリンクを書きます。

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

<asp:Content ID="indexTitle" ContentPlaceHolderID="TitleContent" runat="server">
    Home Page
</asp:Content>

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

  <% = Html.ActionLink("MainSite Home","Index","Home") %><br />
  <% = Html.ActionLink("Sub1 Home", "Index", "Home", new { area = "AreasSub1" }, null)%><br />
  <% = Html.ActionLink("Sub2 Home", "Index", "Home", new { area = "AreasSub2" }, null)%><br />

</asp:Content>

これで一度動かしてみます。

area4

ぬほ!ちゃんと動く。リンクをクリックしてもエラーでない。でも、まだ、Sub1/Sub2用のIndex Viewを作成してないのですべてのリンクで全く同じViewが出てきます。とりあえず、この状態でレンダリングされるHTMLは↓こんな感じで予想通り。

  <a href="/">MainSite Home</a><br />
  <a href="/s1">Sub1 Home</a><br />
  <a href="/s2">Sub2 Home</a><br />

そりゃそうっすね。特になんの説明もなくここまで進みましたが、少し捕捉。MapAreaRouteの台1引数のエリア名とActionLinkなんかで指定するRouteValueDictionaryのarea指定が一致しないとちゃんと解決出来ないので気をつけましょう。そこだけです。上記の例で言えばAreasSub1/AreasSub2がそれぞれエリア名に当たるんで、同じものをActionLinkのareaに指定しなきゃダメです。あと、まだPreviewだからだと思いますが、今のところHtmlAttributeの指定がなくてもActionLinkの引数にはnull指定で全部していするようにしないと、これまたリンクが出力されません。overload実装が部分的。

続いて、Sub1/Sub2用のViewを定義します。これがデスね、勘でやってると気がつかない(Sub1/Sub2のプロジェクトにおきたくなるよね?)んですが、MainSiteに置いておく必要があります。いや、必須じゃないけどWebFormViewEngineを書き換えれば他のプロジェクトに置いておいてもいいけど、デフォルトはメインのプロジェクトのViewsフォルダを探すようになってるので、その辺気をつけましょう。

なので、MainSite/Views/Areas/AreasSub1/Home、MainSite/Views/Areas/AreasSub2/HomeにそれぞれのIndex.aspxをおきます。ここでもAreasSub1/AreasSub2とルーティングで指定したエリア名を使う事になってます。

area5

↑こんな感じで。それぞれのIndexが区別出来るように、AreasSub1の内容は

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

    <h2>AreasSub1のHome/Index</h2>

</asp:Content>

AreasSub2の内容は

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

    <h2>AreasSub2のHome/Index</h2>

</asp:Content>

と、しておきます。ドキュメントには以下のようなサンプルが載ってるので、これで試して見るとルーティングのデータが見れるので分かりやすいかも。

<div id="main">
    <asp:ContentPlaceHolder ID="MainContent" runat="server" />
        <p>
            Controller: <%= ViewContext.RouteData.Values["controller"] %><br />
            Action: <%= ViewContext.RouteData.Values["action"] %><br />
            Area: <%= ViewContext.RouteData.DataTokens["area"] %>
        </p>
    <div id="footer">
    </div>
</div>

areaの情報(どこのnamespaceを参照するのか)が、DataTokensに入ってControllerの解決時に参照されるのが分かりますね。ソースをテケテケ追いかけてみるとですね、MapAreaRouteがMapRouteを呼び出した後に、Defaults/Constraints/DataTokensそれぞれにareaを設定してる。内部ではこれまで通り"Namespaces"で参照するようになってるので、DefaultControllerFactoryでControllerTypeを取得する部分のコードには変化無しです。

area7 area6

実際には、これだけだと、Home/Aboutそれぞれの参照先がs1/s2のArea内に入ってしまって、MainSiteを参照しなくなってしまうので、そこら辺のActionLinkにもすべてareaを指定するように変更する必要があります(デフォルトのMapRouteを消した理由はココに絡んできます)。

ちなみに、Philさんのエントリで「Single Project Areas With ASP.NET MVC 2 Preview 1」というのがありまして、同じプロジェクト内でのAreaの作り方を説明してくれてます。こっちのほうがViewsも分離(WebFormViewEngineのパスを変更)出来るし、最初からこの方がいいんじゃないかと思わずにはいられないところです。

とにかくMVCはV2になっても目が離せないですね。

最後に気になる問題がASP.NET Forumsにあがってたので紹介。

MVC2 InvalidOperationException - ASP.NET Forums

ViewへViewDataを型付きで指定して渡すとき、インターフェースを指定して渡す事も出来るんですが、その時に渡されたインスタンスを使ってEditorForで出力しようとすると型が分からないといってInvalidOperationExceptionが発生するという話。解決策としてはダサイけどEditorForに"Object"と固定でテンプレート名を渡すことで、デフォルトテンプレートを参照してくれるようになるんだそうです。リリースノートのKnown Issuesにちゃんと書かれてるけど、解決方法は書かれてないよね。

追記です。今回みたいに複数のMVCプロジェクトをソリューションに追加して実行した場合、アプリケーションのインスタンスがプロジェクト毎に起動して(今回なら3つ)無駄使い感が漂います。なので、以下のようにデバッグ時に起動しないようするとエコなんじゃないかなと思うところです。

area8