ラベル ASP.NET MVC の投稿を表示しています。 すべての投稿を表示
ラベル ASP.NET MVC の投稿を表示しています。 すべての投稿を表示

2010年2月13日土曜日

OptionalなRoutingパラメータ

ASP.NET MVC 2 Optional URL Parameters

Philさんのところで取り上げられてるUrlParameter.Optional。

RC2で既に実装されてて、標準のプロジェクトテンプレートが生成するGlobal.asaxのルーティング登録で使われてますね。どういう機能なのかはPhilさんが書かれてる通り、Routing登録時に初期値をセットせず、ルーティングデータにキー自体含めなくするためのモノですね。なるほど~。

      routes.MapRoute(
        "Default", 
        "{controller}/{action}/{id}",
        new { controller = "Home", action = "Index", id = "" }
      );

これまで通りのルーティング登録だと上記のように空文字列を指定したりするところをUrlParameter.Optionalを使って下記のように登録する。

      routes.MapRoute(
        "Default", 
        "{controller}/{action}/{id}", 
        new { controller = "Home", action = "Index", id = UrlParameter.Optional } 
      );

そうすると、デバッグでルートURLにアクセス(~/Home/Indexか何も無しか)すると以下のようなルーティングデータとなりました。

urlparam1

id=””とした場合は、ルーティングデータにidが含まれるのに対し、Optionalの場合はキーすら含まれず、controller名とaction名のみです。

      routes.MapRoute(
        "Default", 
        "{controller}/{action}/{id}", 
        new { controller = "Home", action = "Index" } 
      );

ちなみに↑これはダメっす。idがUrlに含まれない限り解決できないから、ちゃんとすべてのパラメータを指定しなくてもアクセスしたい場合には使えないデス。

なんとなくRoutingの機能が拡張されたのかな?と思えるところですが、MvcHandlerのProcessRequestInitで呼び出してるRemoveOptionalRoutingParametersがOptionalならRouteDataから削除してるんですね。賢い!

これをいじってる時に気がついたんだけど、HtmlHelperにValidation/ValidationForなんてのが追加されてるじゃないですか。RCからなのかな。いったい何に使うのかいまいちよくわからないな~。FieldValidationMetadataをどうしたいんだろう...。クライアントバリデーションに絡んでんのかな~。ViewContext.FormContext.FieldValidatorsに入るはずなのにViewで実行しても特に変化が見られない。使い方間違ってるのかの。FormContextってそもそも誰ぞな。DataAnnotationsModelValidatorのエラーメッセージが日本語なのはなんでっすかね。

2010年2月6日土曜日

動的なRouting登録の素敵な方法

MVC V2 RC2が出てるのに関係ない話です。

Editable Routes Using App_Code

少し前にエントリされてたけど、ずっと放置してたです。そもそもEditable Routesで最初の実装サンプルが出てた話。

何をしてるのかというと、動的なRoute登録をするのにいちいちビルドし直さずに、Routes.cs ファイルをBuildManager.GetCompiledAssemblyで実行時にコンパイル。Routes.cs自体にCacheDependencyでファイルの変更監視をさせておき、csを書き換えた時点で動的にコンパイルしなおす(上記BuildManagerで)。そこから取り出される IRouteRegistrar実装がルーティングを登録するようにしておくことで、Global.asaxにルーティング情報を保持しなくて良くなるし、アプリケーションの機能とルーティング情報を分離して、柔軟な環境にできる(Subtextがそうなってるのかね)んですね。

で、 CacheDependencyでファイルを監視させるときにFileSystemWatcherを中で使ってて、それがFullTrustじゃないと実行できないから不便だねっていうのがこのエントリの本題みたい。解決方法としてはシンプルに~/App_Code下にRoutes.csをおいて、自分では監視しないようにするところ。そうしとけば、MediumTrustでも動くんだよと。なるほど。

~/App_Code配下のファイルなら更新時にAppDomainがシャットダウン(CodeDirChangeOrDirectoryRename)して再起動されるから、その ASP.NETの仕組みに任せとかばいいじゃんと。素敵だす。その場合、コンパイルは勝手にされるから、 BuildManager.GetCompiledAssemblyじゃなくてBuildManager.GetTypeでAppDomainに読み込まれてるTypeを取得(もちろんIRouteRegistrarの実装)して実行すればよろしな流れ。

2010年1月24日日曜日

ControlBuilder.ProcessGeneratedCodeを活用

Angle Bracket Percent : Take your MVC User Controls to the next level

確かにNext Level。

普通、ascxをレンダリングするときにはRenderPartialヘルパーを使うところだけど、 T4MVCとか使ってないとパスがマジックストリングになりますね。T4MVC使ってれば、そんなことにはならないけど、そもそもascx単位の RenderPartial的なヘルパーを用意してしまうほうがいいんじゃないの?という話。

で、それを実現するためにいちいちコントロール毎にヘルパーを定義するのはバカらしいってことで、動的生成ですよ。これもまさに黒魔術。

App_Codeの動的コンパイルに介入(ControlBuilder.ProcessGeneratedCodeのoverride)して、実行時動的に拡張メソッドクラスを作り出してます。CodeSnippetTypeMemberとか初めて見た。

まずは、FileLevelUserControlBuilderを派生させたUserControlHtmlHelperControlBuilderを作成。このクラスが動的生成をおこなうクラスで、ProcessGeneratedCodeをoverrideしてCodeTypeMemberCollectionにヘルパークラスのCodeSnippetTypeMemberを追加してます。動的クラスのコーディングはサンプルと言うことでシンプルな実装ですね。

            dummyClass.Members.Add(new CodeSnippetTypeMember(String.Format(@"
        }}
    }}
    public static class {2}Extensions {{
        public static void Render{2}(this System.Web.Mvc.HtmlHelper htmlHelper{3}) {{
            var uc = new {0}();
    {5}
            uc.RenderView(htmlHelper.ViewContext);
        }}

        public static string {2}(this System.Web.Mvc.HtmlHelper htmlHelper{3}) {{
            return {1}.RenderHelper(htmlHelper.ViewContext, () => Render{2}(htmlHelper{4}));
    ", codeGenUserControlFullTypeName, userControlHtmlHelperFullTypeName, helperMethodBaseName, paramBuilder, callParamBuilder, fieldAssignementBuilder)));

 

続いて、このControlBuilderを使ってくれるようにASP.NETに指示をださなきゃいけないんだけど、それってどこでやってるんでしょうね。ぱっと見、以下のクラスの属性指定?

    [FileLevelControlBuilder(typeof(UserControlHtmlHelperControlBuilder))]
    public class UserControlHtmlHelper : ViewUserControl {
        // Helper method which renders the code in a temporary writer and returns it as a string
        public static string RenderHelper(ViewContext viewContext, Action render) {
            TextWriter oldWriter = viewContext.Writer;
            var tmpWriter = new StringWriter(CultureInfo.CurrentCulture);
            viewContext.Writer = tmpWriter;
            try {
                render();
            }
            finally {
                viewContext.Writer = oldWriter;
            }

            return tmpWriter.ToString();
        }
    }

ここが実行時に勝手に解釈されるようになってるのかな~。動的ヘルパーを生成したいascxのInherits="MvcUserControlHtmlHelpers.UserControlHtmlHelper"という記述が通常と違う部分なのはわかるんだけど。

FileLevelControlBuilderAttribute コンストラクタ (System.Web.UI)

実行時に初めてコードが生成されるから、コーディング時には↓こんな感じでメソッドないぜと怒られる。

cb

実行したところで、VSはそんなコードしらないんだってさ。

cb2

そもそもMVCのViewUserControlクラスがこれと同じようにViewUserControlControlBuilderを使ってるんじゃんね。でも、BaseTypeをうわがいてるくらい(目的はよくわかってないデス)ですね。

追記:ascxなりaspxのベースクラス(ViewPage,ViewUserControl)の派生元(親)クラスを指定してるんでした。そりゃそうだ。

<%@ Control Language="C#" Inherits="MvcUserControlHtmlHelpers.UserControlHtmlHelper" ClassName="Gravatar" %>

<script runat="server">
    // Declare the paramaters that we need the caller the pass to us
    public string Email;
    public int Size;
</script>

<%
    // Build hash of the email address
    // Note: in spite of its name, this API is really just a general MD5 encoder
    string hash = FormsAuthentication.HashPasswordForStoringInConfigFile(Email.ToLower(), "MD5").ToLower();

    // Construct Gravatar URL
    string imageURL = String.Format("http://www.gravatar.com/avatar/{0}.jpg?s={1}&d=wavatar", hash, Size);
%>

<img src="<%= imageURL %>" alt="<%= Email %>" title="<%= Email %>" />

※人さまのコードをこんなにコピペしていいのだろうか...。

上記のApp_Codeに作成されてるGravator.ascxのscript blockから正規表現でpublicなプロパティ定義を取り出して、ヘルパーのパラメータ生成に利用してるところも見逃せない。

T4MVCでの動的生成、Emitによる黒魔術に次いで、新たな黒魔術として覚えておくのもいいんではないでしょうか。

※ASP.NET MVCに限らずASP.NETならすべてに適用できるのがミソですよ!

2010年1月2日土曜日

ASP.NET MVCに似合うSubmitの振り分け

How can I change the action a form submits to based on what button is clicked in ASP.NET MVC? - Stack Overflow

前にも、この方法について考えたことがあって、その時はストラテジパターンを使ってDelegateでのコマンド振り分けでの実装をしてたんですが、少し前に違う方法を実装してるのを見て悔い改めたんです。

MVCによく似合う方法は、属性ベースで対象となるアクションを振り分ける(判定する)方法ですよね。

AcceptVerbsでHTTP Method毎に処理を振り分ける事ができるのを上手く利用して、Submitの値毎に処理を振り分けるためにActionMethodSelectorAttributeを派生したSubmitCommandAttributeというのを定義していました。

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

namespace MvcApplication2.Controllers
{
  [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
  public class SubmitCommandAttribute : ActionMethodSelectorAttribute
  {
    private string _submitName;
    private string _submitValue;
    private static readonly AcceptVerbsAttribute _innerAttribute = 
new AcceptVerbsAttribute(HttpVerbs.Post); public SubmitCommandAttribute(string name) : this(name, string.Empty) { } public SubmitCommandAttribute(string name, string value) { _submitName = name; _submitValue = value; } public override bool IsValidForRequest(ControllerContext controllerContext,
MethodInfo methodInfo) { if (!_innerAttribute.IsValidForRequest(controllerContext, methodInfo)) return false; // Form Value var submitted = controllerContext.RequestContext
.HttpContext
.Request.Form[_submitName]; return string.IsNullOrEmpty(_submitValue) ? !string.IsNullOrEmpty(submitted) : string.Equals(submitted, _submitValue,
StringComparison.InvariantCultureIgnoreCase); } } }

これだけなんですけども...。HttpPostのコードを参考に内部にAcceptVerbsAttributeを保持してPOST時のみをIsValidにしてますが、DeleteとPutも必要なら要修正。

using System.Web.Mvc;

namespace MvcApplication2.Controllers
{
    public class HomeController : Controller
    {
        //
        // GET: /Home/

        public ActionResult Index()
        {
            return View();
        }

    [ActionName("Different")]
    [SubmitCommand("DoSave")]
    public ActionResult DifferentSave()
    {
      TempData["message"] = "saved! - defferent";
      return View("Index");
    }

    [ActionName("Different")]
    [SubmitCommand("DoDelete")]
    public ActionResult DifferentDelete()
    {
      TempData["message"] = "deleted! - defferent";
      return View("Index");
    }

    [ActionName("Same")]
    [SubmitCommand("DoSubmit","保存")]
    public ActionResult SameSave()
    {
      TempData["message"] = "saved! - same";
      return View("Index");
    }

    [ActionName("Same")]
    [SubmitCommand("DoSubmit","削除")]
    public ActionResult SameDelete()
    {
      TempData["message"] = "deleted! - same";
      return View("Index");
    }
  }
}

コントローラでこんな感じにアクションを定義しておいて、Viewを以下のようにしておきます。

<%@ Page Language="C#" Inherits="System.Web.Mvc.ViewPage" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" > <head runat="server"> <title>Index</title> </head> <body> <h1><%= TempData["message"] ?? "Click some button" %></h1> <h2>異なるnameのsubmit</h2> <% using (Html.BeginForm("Different", "Home")) { %> <input type="submit" name="DoSave" value="保存" /><br /> <input type="submit" name="DoDelete" value="削除" /> <% } %> <h2>同一nameでValueの異なるsubmit</h2> <% using (Html.BeginForm("Same","Home")) { %> <input type="submit" name="DoSubmit" value="保存" /><br /> <input type="submit" name="DoSubmit" value="削除" /> <% } %> </body> </html>

動かしてみると、最初に↓。ボタンが4種類出てきます。上2つはnameとvalueの異なるsubmitで、下2つがnameが同じでvalueが違うsubmit。

submit1

上から順にボタンを押したのが↓。

submit2 submit3 submit4 submit5

ちゃんとTempDataの値が違うのが確認できますね(ページ上部のH1)。

で、ココまで書いときながら、前にどこで見たのかを検索して探し出してみてショック。

ASP.NET MVC – Multiple buttons in the same form - David Findley's Blog

まんま、同じになるという大失態。やっぱり年始からこんなコードを書いてて先が思いやられる...。

2009年11月20日金曜日

Html.ActionとHtml.RenderAction

なんともエントリを書かなすぎでした。いろいろ遊んだりしてたんす。シバトラ読みふけったり。

Html.RenderAction and Html.Action

PDC09が盛り上がりまくって、Feedの確認が全然追いつかないなか、ASP.NET MVC 2もベータが公開。早速Philさんが面白い機能の紹介をしてくれてたので少し確認。確認してタンブラのほうで軽くコメント書いとこうと思ってたけど、内容が面白すぎたので、こっちに書いてみるっす。

まずはプロジェクトを作ろうとVS立ち上げたけど、なんかちゃんとできない。なんで~!と思ったらベータインストールしてなかった...。ソースだけダウンロードして見てるだけな楽しみ方もありですよね。無しだな。Preview2をアンインストールしてからベータ入れて見るものの、V1の時と同じように日本語環境にはちゃんと入ってくれなかった。いきなりギャフンですね。プロジェクトテンプレートを1041にコピーしてdevenv /installvatemplates。

準備も出来たところで、サブジェクトの機能について簡単に説明。

Html.Actionはアクションの実行結果をMvcHtmlStringにして返してくれるもので、Html.RenderActionは同じくアクションの実行をしてくれるけど、こっちは文字列としてではなく現在のレスポンスストリームに結果を書き出してくれるものです。

もともとFuturesに入ってた機能ですけど、出世してリリースアセンブリに含まれるようになりました。パッと見、Html.ActionLinkと勘違いしそうなヘルパーだけど、中身は全然違う物です。ソースではChildActionExtensionsにまとまってるので興味のある方はぜひ。

で、中では何をしてるのかというと、Server.Executeです(なのでPageの派生クラス使ってる)。IHttpHandlerとしてラッピングしてProcessRequestを実行してます。この辺の仕組みはFuturesの頃から変わってないです。コードはカッコ良くリファクタリングされてますが。ちなみに今回のリリースには非同期アクション実行も含まれてるので、IHttpAsyncHandlerにももちろん対応してます。ここら辺の実装がHttpHandlerUtil.WrapForServerExecuteですね。500エラー以外のHttpExceptionをServer.Executeが伝播してくれないらしく、がんばった感じが見て取れます。

    public ActionResult Partial(string id)
    {
      return Content("Partial result" + id);
    }

↑こんなアクションメソッドをHomeControllerに定義しておき、Home/Index.aspxで以下のように書いておく。

    <div><% = Html.Action("Partial",new{id=" cool!"}) %></div>
    <div><% Html.RenderAction("Partial", new {id = " so nice!"}); %></div>

そうすると出力されるのは↓こんな感じです。

action

でもって、このPartialアクションメソッドはそのまま"/home/partial"ってやっても呼び出せてしまいますね。Ajaxでの部分更新に使うならいいけど、そうじゃなくHtml.Action/RenderActionでしか使わないならChildActionOnly属性をアクションメソッドに指定しておきましょう。

そしたら↓こう。

action2

ブラウザからは直接アクセス出来なくなります。なんで~!と気になったらソースを確認。ChildActionExtensions.CreateRouteDataでServer.Execute対象のRouteDataを生成してるんですが、その時に現在のViewContextをRoute.DataTokensに入れてます。Areaでnamespaceを渡して違うnamespaceのControllerをRoute登録するのと同じやり方ですね。そのDataTokensが存在してるのかどうかをChildActionOnlyAttributeがチェックして存在してなければ、実行出来ないようにする仕組みです。素敵だね。

で、Viewのコードを見てみるとHtml.Actionは<%= … %>で実行してるのがわかるでしょうか?これはつまり文字列をそのままViewの一部に埋め込んで最後にまとめてレスポンスですよね。これに対してHtml.RenderActionは<% …; %>コード実行の書き方です。なので、レスポンスストリームに書き出すものです。なので、Philさんが書いてる通り、部分的なCacheを有効にしたいときはRenderActionを使う必要があります。Response.WriteSubstitutionを使った出力はFuturesに別途用意されてるね。Html.Substitute。

ね?面白いでしょ?

2009年10月17日土曜日

ASP.NET MVC 2 Preview2でのArea

Visual Web Developer Team Blog : Single Project Add View in ASP.Net MVC 2 Preview 2

まんまな感じのエントリで申し訳ない気持ちもするんですが、Preview1から全然使い方の変わってる部分でもあるんで、エントリしとこうと思った次第です(もう役にたたない情報ですがPreview1の時のエントリはこちら)。言い訳から書き出すのもすっかり定着してきた感が否めない...。

MVC 2に関してはMSDNにドキュメント整備も進んでるんで、そちらも参照すると更に理解が進んでいいですね。

Walkthrough: Creating an ASP.NET MVC Areas Application Using a Single Project

今回からRouteCollection.MapAreaRouteは廃止され、代わりにAreaRegistrationクラスが導入されてます。同じ目的でAreaRegistrationContextも追加されてます。メインはAreaRegistrationですが、このクラスを派生させたクラス(サンプルはRoutesになってるけど名前は何でもいいです)各エリアフォルダのルートに作成しておき、AreaName/RegisterAreaをそれぞれoverrideして、Route登録時にNamespacesが自動登録されるようになる仕組みです。

特に特徴的なのは、プロジェクトを分けなくてもエリア機能が使えるようになってるところです。これまでわざわざ別プロジェクトを作成しないとエリア機能を使えなかったんだけど(規模が大きくなっても開発効率が悪くならなくても済むようにプロジェクト分割は常套手段ですよね?)、あえて分割を強制しなくなりました。

と、文章だと全然意味わからないですね。

p2area

↑こんな感じです。

MvcApplication1という名前のプロジェクトの中にAreasフォルダ(これも名前は何でもいんですが、慣例としてあえてAreas)を作成。その中にエリア分割したいフォルダを更に作成。今回ならSub1とSub2。更にその中にControllersとViewsをそれぞれ作成。仕組みをわかりやすくするためにController名はHome、Action/Viewの名前はIndexとして作成しておきます。

これで、3つのHome/Indexの組が出来たことになります。各Indexアクションは以下の通り。

標準のHome/Index

    public ActionResult Index()
    {
      ViewData["Message"] = "RootのHome/Index";

      return View(new{});
    }

Sub1のHome/Index

    public ActionResult Index()
    {
      ViewData["Message"] = "Sub1エリアのHome/Index";
        return View(new ModelsLibrary.Class1());
    }

Sub2のHome/Index

    public ActionResult Index()
    {
      ViewData["Message"] = "Sub2エリアのHome/Index";
      return View();
    }

ちょいちょいViewに渡すモデルを変えてるのは実験のためです。Sub1とSub2にそれぞれAreaRegistrationクラスを作成しておいときます。

Sub1のAreaRegistration

  public class Routes : AreaRegistration
  {
    public override string AreaName
    {
      get { return "Sub1"; }
    }

    public override void RegisterArea(AreaRegistrationContext context)
    {
      context.MapRoute(
        "sub1_Default",
        "sub1/{controller}/{action}/{id}",
        new { controller = "Home", action = "Index", id = "" }
      );
    }
  }  

Sub2のAreaRegistration

  public class Routes : AreaRegistration
  {
    public override string AreaName
    {
      get { return "Sub2"; }
    }

    public override void RegisterArea(AreaRegistrationContext context)
    {
      context.MapRoute(
        "sub2_Default",
        "sub2/{controller}/{action}/{id}",
        new { controller = "Home", action = "Index", id = "" }
      );
    }
  }

最後にGlobal.asaxのルート登録部分に以下のコードを追加。

    public static void RegisterRoutes(RouteCollection routes)
    {
      routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
      
      AreaRegistration.RegisterAllAreas();
      routes.MapRoute(
        "Default",                                              // Route name
        "{controller}/{action}/{id}",                           // URL with parameters
        new { controller = "Home", action = "Index", id = "" }, // Parameter defaults
        null,
        new[] { "MvcApplication1.Controllers" }
      );

    }

前回のエントリにも書いたように、namespacesを指定しないと各エリアのHome/Indexと区別出来なくてエラーになってしまうので、標準のHome/Indexに対してきちんと指定するようにします。

p2area2

※ちゃんとnamespacesを指定しないと↑こんな感じでエラーになるっす。

えと、眠くなってきた。あと少し!

すべてのIndex.aspxは以下で統一。

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

    <h2><%= Html.Encode(ViewData["Message"]) %></h2>
  <h3>Area: <%= ViewContext.RouteData.DataTokens["area"] %></h3>
  <h3>Model Type:<%= Model != null ? Model.GetType().ToString() : "(null)"%></h3>
  
  <% = Html.ActionLink("Root Home", "Index", "Home", new { area = "" }, null)%><br />
  <% = Html.ActionLink("Sub1 Home", "Index", "Home", new { area = "sub1" }, null)%><br />
  <% = Html.ActionLink("Sub2 Home", "Index", "Home", new { area = "sub2" }, null)%>

</asp:Content>

で、Sub1の時だけViewPage<ModelsLibrary.Class1>を指定してModelの型を変えておきます。なんでこうするかというと、単に以下のエラーが起きるのを確認したかったから。

p2area3

Asp.NET MVC 2 Preview 2: Area's aspx namespace problem - Stack Overflow

この現象を起こしてみたかったんデス。これを回避するには~/Areas/Sub1/Viewsフォルダに~/Viewsフォルダにあるweb.configをコピーしておく事。これを忘れるとジェネリックで他のアセンブリ(じゃ無かったとしても?試してみてね!)に含まれるモデルクラスを指定すると上記エラーが発生。ビックリですね~。pageParserFilterTypeが処理してくれないって事です。

ここまでやって実行したのが↓この画面。標準/Sub1/Sub2それぞれのHome/Indexがちゃんと判別できて実行されてるのが確認出来ます。

p2area4 p2area5 p2area6

ちなみにAreaRegistrationクラスの派生クラスの名前と、~/Areasフォルダが何でもいい理由というのが、AreaRegistration.RegisterAllAreasのソースに書かれてる内容から判断出来ます。何をしてるかというとBuildManagerWrapper.GetReferencedAssembliesでアセンブリの参照出来るすべてのTypeの中からAreaRegistrationの派生クラスを抽出(IsAreaRegistrationTypeをPredicateとして)してるからですね。後はCreateContextAndRegisterでNamespacesを追加した上でoverrideされてるRegisterAreaを呼び出して、ルートを登録するだけ。なので、AreaRegistration派生(ちなみにAreaRegistration自体はabstractなので除外されます)をすべて抽出するので名前は自動でわかるようになってるというオシャレ実装。素敵だね!

DataAnnotationsのカスタム属性実装とセットになったソースは以下からどうぞ。

眠し!

2009年10月7日水曜日

V2のFuturesにViewStateが!?

Exploring the ASP.NET MVC 2 futures assemby

小野さんに振られたので↑こちらのエントリに書かれてるViewStateについての調べてみました。こういうきっかけが無いとソースを追いかけない自分に少し反省。

まさかホントにASP.NET MVCにViewStateを持ち込むのか?と、疑いたくなるようなエントリだけど何となくサンプルとして提示されてるコードが怪しい。そのまま転載させてもらうと↓こうですよ。

<% using (Html.BeginForm()) {%>
    <%Html.Serialize("person", Model); %>
    <fieldset>
        <legend>Edit person</legend>
        <p>
            <%=Html.DisplayFor(p => Model.FirstName)%>
        </p>
        <p>
            <%=Html.DisplayFor(p => Model.LastName)%>
        </p>
        <p>
            <label for="Email">Email:</label>
            <%= Html.TextBox("Email", Model.Email) %>
            <%= Html.ValidationMessage("Email", "*") %>
        </p>
        <p>
            <input type="submit" value="Save" />
        </p>
    </fieldset>
<% } %>

Html.Serialize(“person”,Model)ってなんか怪しいですよね。おまえ、ホントにViewStateを吐いてくれるのか?と。1つのViewに何個も書いたらどうなるんだよ、とか、ポストバックしたControllerでコントロールツリーを構築するのか、とかなんやかんやデス。

で、考えててもラチがあかないので、サンプル書いて試してみました。

  [Serializable]
  public class Drink
  {
    [Required]
    [StringLength(10)]
    public string Name { get; set; }

    [Compare("Name", ErrorMessage = "一致しないよ!")]
    public string CheckName { get; set; }

    [Range(10, 50)]
    public int Size { get; set; }
  }

まずは、前回も使ったDrinkクラスにSerializable属性を追加。これつけないとそもそもシリアライズ出来ないです。どこでシリアライズしてるのかFuturesのソースを追いかけると、MvcSerializerクラスで実装してます。ちなみにシリアライズの方式としてPlaintext、Encrypted、Signed、EncryptedAndSignedの4種類があり、初期値はPlaintext。これはSystem.Web.UI.ObjectStateFormatterを使ってシリアライズしてて。って、おや?マジViewStateなのか?まぁ、いいや。

なのでSerialize属性をつけるわけですが、上記クラスに値を入れてサンプル通りにView書いて実行しても何も出力されない...。

<% Html.Serialize("drink",Model); %>

サンプルだからなんかおかしいのかな~。気になるので更にソースを読み進めると、Html.SerializeはそもそもMvcHtmlStringクラスを返してきます。これは、あれですよね、ASP.NET 4で導入される<%: …%>出力に向けた実装ですよ。IHtmlStringってヤツですよ。でも、ASP.NET 3.5にはそんなもの無いので、Futuresの実装はインターフェースは無しバージョン。ってことは、単純に実行したらレンダリングされるわけじゃ無くて、ToString()かToHtmlString()で取得して、それをレンダリングするようにしないとちゃんと出力されないわけですね。

そうとわかれば、以下のように変更。

<% = Html.Serialize("drink",Model).ToHtmlString() %>

これでちゃんと出力されました。以下のようなModelを渡して結果を見てみます。

    public ActionResult Drinks()
    {
      var model = new Drink {Name = "Cola", CheckName = "Pepsi", Size = 30};
      return View(model);
    }

↑これが↓こうなります。

<input name="drink" type="hidden" value="/wEy7AEAAQAAAP////8BAAAAAAAAAAw
CAAAARk12Y0FwcGxpY2F0aW9uMSwgVmVyc2lvbj0xLjAuMC4wLCBDdWx0dXJlPW5ldXRyYWw
sIFB1YmxpY0tleVRva2VuPW51bGwFAQAAABxNdmNBcHBsaWNhdGlvbjEuTW9kZWxzLkRyaW5
rAwAAABU8TmFtZT5rX19CYWNraW5nRmllbGQaPENoZWNrTmFtZT5rX19CYWNraW5nRmllbGQ
VPFNpemU+a19fQmFja2luZ0ZpZWxkAQEACAIAAAAGAwAAAARDb2xhBgQAAAAFUGVwc2keAAA
ACw==" />

バリバリBase64エンコードされてViewStateっぽいです。でも、これを復元させるコードがどうなるかと言うと、↓こうなります。

    [HttpPost]
    public ActionResult Drinks([Deserialize]Drink drink)
    {
      if(ModelState.IsValid)
      {
        // ... success code
      }
      return View(drink);
    }

このDeserialize属性クラスが何をしてるか、ってことデスよね。またしてもソースを確認すると、そこには...。

        private sealed class DeserializingModelBinder : IModelBinder {

            private readonly SerializationMode _mode;

            public DeserializingModelBinder(SerializationMode mode) {
                _mode = mode;
            }

            public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) {
                if (bindingContext == null) {
                    throw new ArgumentNullException("bindingContext");
                }

                ValueProviderResult vpResult;
                bindingContext.ValueProvider.TryGetValue(bindingContext.ModelName, out vpResult);
                if (vpResult == null) {
                    // nothing found
                    return null;
                }

                MvcSerializer serializer = new MvcSerializer();
                string serializedValue = (string)vpResult.ConvertTo(typeof(string));
                return serializer.Deserialize(serializedValue, _mode);
            }

        }

単なるModelBinder...。Page.ViewStateなわけじゃないですね。単にシリアライズするためにViewStateと同じものを利用してるだけです。最初の方にも書いたけど、ViewStateと同じようにシリアライズ出来るようにするためにEncryptedやSignedが指定出来るようになってるって事です。ちなみにソースを見てみると、暗号化ViewStateを生成するためにprivate sealed class TokenPersister : PageStatePersisterっていうクラスを定義してて、その中でPageクラスのインスタンスを生成し処理させてます。なんか強引。でも、AntiForgeryDataSerializerでも同じ事してたりしてちょいビックリ。

そんなことはいいとして、これが完全に独自のModelBinderになってしまってるので、これを使う場合にはDataAnnotationsが効かない。ので、hidden書き換えを抑制したいときにはEncryptedやSigned、EncryptedAndSignedを指定しておくようにしないとね!

なぜASP.NET MVCにViewStateを持ち込むんだ~、と怒り心頭な方!ご心配なく。WebFormsでいうところのViewStateでは無かったです。ホッとした。

Futuresのソースみてて気がついたんだけど、AsyncControllerがFuturesに戻されてる。Expression系のユーティリティクラスがたんまり入ってて何やら面白そうな予感がしますが、それはまた今度ってことで。

2009年10月4日日曜日

ValidationAttributeがちょっと違う

ASP.NET MVC V2も既にPreview 2が出ましたね。入力検証がDataAnnotationsを前提にした設計(IDataErrorInfoじゃ物足りないしね)になってるのと、Templateベースのモデル描画がV2での重要ポイントですよね。

入力検証にxValと同じようにModelValidatorProviderとクライアントサイドバリデーションにjQuery.validateが導入されてのが興味深い。

そもそもV1にも追加コードとして公開されてるDataAnnotationsModelBinderがあって、System.ComponentModel.DataAnnotationsのValidationAttrbiuteを使えるようになてます。が、残念ながら標準アセンブリじゃなく別途配布されてるDLLを参照設定して使うようにしないと、全く機能しないというのがあります。何でかというとValidationAttributeクラスの実装が違うのと、その検証属性の実行方法が違うから。

.NET 3.5 SP1に含まれるVaridationAttributeクラスってIsValid(object value)なのに対し、配布されてるアセンブリではIsValid(object value)に加え、IsValid(object value, ValidationContext validationContext, out ValidationResult validationResult)があります。入力値を取得して検証するだけなら対象となる、プロパティ値があればそれで事足りるかも知れないけど、たとえばCompareValidatorは実装できないですよね。同じモデルインスタンス内の他のプロパティを見れないと比較できないじゃないですか。V1の場合は、特殊なアセンブリだからいいかもしれないけど、V2は標準アセンブリだからVaridationAttribute実装にValidationContextなんて無い(実はある?)。先日1.0がリリースされたxValが使用するアセンブリも標準アセンブリ参照だから当然インスタンス参照なんてない。

と、言うわけで、V1でxValを使うときの入力検証でモデルの他プロパティを参照する際のCompareAttributeクラスを書いて遊んでみました。何に使うのかは後で考える。

まずは、モデルインスタンスを渡せるIsValidを実装するためのインターフェイス定義。ValidationAttributeとそのインターフェースを実装するCompareAttributeクラスを定義。

  public interface IInstanceValidationAttribute
  {
    bool IsValid(object instance, object value);
  }

  public class CompareAttribute : ValidationAttribute, IInstanceValidationAttribute
  {
    public string PropertyName { get; set; }
    
    public CompareAttribute(string propertyName)
    {
      PropertyName = propertyName;
    }

    public override bool IsValid(object value)
    {
      throw new NotImplementedException();
    }

    public bool IsValid(object instance, object value)
    {
      var property = instance.GetType().GetProperty(PropertyName);
      if(property==null)
        throw new ArgumentException("パラメータ間違えてるよ");

      var targetValue = property.GetValue(instance, null);
      if (targetValue != null && targetValue.Equals(value))
        return true;

      return false;
    }
  }

CompareAttributeを使うモデルクラスの定義。他にも必須チェックやら付けてみる。

  public class Drink
  {
    [Required]
    [StringLength(10)]
    public string Name { get; set; }

    [Compare("Name",ErrorMessage = "一致しないよ!")]
    public string CheckName { get; set; }
    
    [Range(10, 50)]
    public int Size { get; set; }
  }

続いて、xValのサンプルを参考に、入力検証を実行するためのDataAnnotationsValidationRunnerクラスを実装。

  internal static class DataAnnotationsValidationRunner
  {
    public static IEnumerable<ErrorInfo> GetErrors(object instance)
    {
      var attrs = from prop in TypeDescriptor.GetProperties(instance)
.Cast<PropertyDescriptor>() from attribute in prop.Attributes.OfType<ValidationAttribute>() select new { Property = prop, Validator = attribute, IsInstanceValidator = attribute is IInstanceValidationAttribute }; foreach(var attr in attrs) { bool isvalid; if (!attr.IsInstanceValidator) isvalid = attr.Validator.IsValid(attr.Property.GetValue(instance)); else isvalid = (attr.Validator as IInstanceValidationAttribute)
.IsValid(instance, attr.Property.GetValue(instance)); if (!isvalid) yield return new ErrorInfo(attr.Property.Name,
attr.Validator.FormatErrorMessage(string.Empty),
instance); } } }

なんかちょっとダサいけど、まぁ、その辺はセンスが無いってことで勘弁してください。 最後にControllerにアクションを追加してコードは完成。

    public ActionResult Drinks()
    {
      var model = new Drink();
      return View(model);
    }

    [AcceptVerbs(HttpVerbs.Post)]
    public ActionResult Drinks(Drink model)
    {
      var errors = DataAnnotationsValidationRunner.GetErrors(model);
      if (errors.Any())
        new RulesException(errors).AddModelStateErrors(ModelState,"");
      
      return View(model);
    }

実行したのが↓これ。

xVal xVal2 

ここまでが、V1の話で、ここからV2 P2で同じことをしてみようと思います。こんなに違うのかと思えるほどDataAnnotationsの組み込みと、ModelValidatorProviderに感動です。CompareAttributeクラスとモデルクラス(Drinkクラス)は一切いじりません。追加で作成の必要なクラスは以下の1つのみ。

  public class CompaireValidator : DataAnnotationsModelValidator<CompareAttribute>
  {
    public CompaireValidator(ModelMetadata metadata, ControllerContext context, CompareAttribute attribute) : base(metadata, context, attribute) { }
    internal static ModelValidator Create(ModelMetadata metadata, ControllerContext context, ValidationAttribute attribute)
    {
      return new CompaireValidator(metadata, context, (CompareAttribute)attribute);
    }

    public override IEnumerable<ModelValidationResult> Validate(object container)
    {
      if (!(Attribute as IInstanceValidationAttribute).IsValid(container, Metadata.Model))
        yield return (new ModelValidationResult
                        {
                          MemberName = Metadata.PropertyName, 
                  Message = Attribute.FormatErrorMessage(Metadata.GetDisplayName())
                        });
    }
  }

すごいっすね~。たったこれだけ。で、このValidatorクラスを登録するためにGlobal.asax.csのApplication_Startに1行追加。

    protected void Application_Start()
    {
      RegisterRoutes(RouteTable.Routes);
      DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(CompareAttribute), typeof(CompaireValidator));
    }

プロパティーの取出しからAttributeの取り出しまで一切合財がMVCの処理の範疇になってます。DataAnnotations関連のProvider/Validatorを使って作ってるけど、この辺も自分でベースクラスから派生させたり、AssociatedValidatorProviderからの派生で作ることも可能。もちろん簡単なのはDataAnnotations関連のクラスを派生させて作ること。

ちなみにControllerでの使用は何も考える必要も無く以下のようにしとくだけ。

    public ActionResult Drinks()
    {
      return View(new Drink());
    }

    [HttpPost]
    public ActionResult Drinks(Drink model)
    {
      if(ModelState.IsValid)
      {
        // ... success code
      }
      return View(model);
    } 

ValidationAttributeクラスは標準クラスのままなので、モデルクラスのインスタンス参照を持ってないのは今後もそうだと思うので、今回のような方法でうまいことインスタンスを渡せるオーバーロードを定義することで、対応していくのがいいんじゃないかと思う次第です。

ASP.NET MVC Reference
Brad Wilson: Enterprise Library Validation example for ASP.NET MVC 2
Shaan's Official Blog : New features in ASP.NET MVC 2 Preview

2009年9月15日火曜日

こういうのはいいのかどうか

ASP.NET MVC Transaction Attribute (using NHibernate) - Scott's Blog

NHibernateじゃなくても、以下のようなTransactionAttributeで。

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

namespace MainSite.Controllers
{
  [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
  public class TransactionAttribute : ActionFilterAttribute
  {
    private TransactionScope _transactionScope;

    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
      _transactionScope = new TransactionScope();

      base.OnActionExecuting(filterContext);
    }

    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
      base.OnActionExecuted(filterContext);

      if (filterContext.Exception == null)
        _transactionScope.Complete();

      _transactionScope.Dispose();
    }
  }
}

適当なDataContextを用意してこれまた適当なAction(適当すぎてすいません)で。

    public ActionResult Index()
    {
      ViewData["Message"] = "Welcome to ASP.NET MVC!";

      return View();
    }

    [HttpPost,ActionName("Index")]
    [Transaction]
    public ActionResult IndexPost(string name)
    {
      var db = new DataClasses1DataContext();
      db.Table1s.InsertOnSubmit(new Table1 {name = name});
      db.SubmitChanges();

      //throw new HttpException(500,"Error!");
      return RedirectToAction("Index");
    }

Unit of Workって言うんですかね~。Exception起こせばロールバックするんだけど...。トランザクションってこのレイヤで意識するものなのかな~。う~ん。好みや規模に合わせて好きなやり方でどうぞ、ってことで。

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を用意してないです)時のものです。

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

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

2009年8月2日日曜日

MVC V2のチラ見

やっと、ため込んだFeedの処理に追いついて、ビデオの確認ができた。

Hanselminutes on 9 - ASP.NET MVC 2 Preview 1 with Phil Haack and Virtual Scott | Scott Hanselman | Channel 9

内容も15分と短いので、是非。

ってことで、このビデオ見つつ少しだけv2のソースも少しだけ確認。Editor/EditorFor(EditorTemplateフォルダ)、Display/DisplayFor(DisplayTemplateフォルダ)がそれぞれあって、なおかつLabelForがFuturesから標準に入ってきてる。ここでTextBoxForを作ろうとして、これは拡張性に問題がありそうだと気がついて、Dynamic Dataで使われてるTemplateベースのレンダリングを参考に拡張方法を変えたのかな。前のFuturesには入ってたモンね、 TextBoxFor。

なので、TemplateHelperがかなり重要なポジションをしめる気がする。さらにModelBinderの時と同じようにDefaultDisplayTemplate/DefaultEditorTemplateを用意して、ある程度は自動テンプレート出力をしてくれる模様。すばらしい。

v2 のリリースノートをチラ見した時には「えぇ~、そっちかよ~!」と思ったけど、中身を見ると素敵かも。ITemplateでのテンプレート処理じゃなくてあくまで、HTMLをベースにascxでのテンプレート処理にこだわることで、ViewStateを取り込まないし、クリーンHTMLをはき出せるしで、 MVCらしさをそのまま引き継いだ上での拡張っす!System.Web.UI.WebControls.DataBoundControlModeがInitializeDataCellDataControlRowStateを使わなくて良くしてくれてるんでしょう。

Scott Hanselman's Computer Zen - Hanselminutes on 9 - ASP.NET MVC 2 Preview 1 Released

↑ここにも書かれてる通り、Preview1ではそれほど大量の機能追加はなくて、Preview2が本気リリースっぽい。なので、V2に関しても順を追って追いかけて行くことで、一度に沢山の機能を調べなくて済む感じです。

DataAnnotationsの取り込みがどの程度のものなのか。その片鱗をこのビデオで垣間見れた気がします。Dynamic Dataと同じくらい取り込もうとしてますね。DataType、DisypayNameにUIHint、それらもしっかりとUIの出力(TemplateHelperとDefaultEditorTemplate)に反映されてる。前まではDataAnnotaionModelBinderを使って、システムに対して入力方向での連携で入力値検証でしか利用してなかったのが、V2からは出力方向にもしっかり活用。なので、Templateベースの出力になってるのもうなずける。

大枠はいつものごとく、ガスリーさんところで書かれてるから、そこで確認。

ASP.NET MVC V2 Preview 1 Released - ScottGu's Blog

まだちゃんと見てないけどね!

すでにフィルさんところでAreaに関する投稿(Single Project Areas With ASP.NET MVC 2 Preview 1)もあるし、Maartenさんとこ(ASP.NET MVC 2 Preview 1 released!)にも簡単な説明が出てるので、これから少しずつ見ていこうと思います。

つか、この週末でちゃんと調べる予定だったのに、全然観れなくてちょっと残念...。

T4MVC再び

世界中でMVC v2祭りな雰囲気の中、少し前にリリースのあったT4MVC 2.4.0.1について。早くMVC祭りに参加したいところだけど、そこは焦らずに少しずつ...。

Angle Bracket Percent : T4MVC 2.4 updates: settings file, sub view folders, ActionName support and more

前回のT4MVCの投稿でActionNameをサポートするようttファイルの書き換えを行ったけど、新しいバージョンではしっかりとその辺も含めた改良が施されてます。なので、もうActionNameで名前を変えたものを指定したいからと、たけはらエディションを使う必要は全く無くなりました。

どうやってActionName属性を取り出すのかなと確認したところ、メソッドのCodeAttribute2を取り出してActionNameAttributeが付いてるかを見てました。ActionMethodInfoが変更かかってるのも合わせて、やり方はだいたい同じですね。間違ってなくてホッとした。変更前のAction名が選択肢に出てくるあたりの仕様も同じだし。

HomeControllerに以下のIndexPostを追加してみる。

    public virtual ActionResult Index()
    {
      ViewData["Message"] = "Welcome to ASP.NET MVC!";

      return View();
    }

    [AcceptVerbs(HttpVerbs.Post)]
    [ActionName("Index")]
    public virtual ActionResult IndexPost(string value)
    {
      ViewData["Message"] = "Welcome to ASP.NET MVC!";

      return View();
    }

このIndexPostを参照するようにIndex.aspxにFORMを書いてみる。

    <% using (Html.BeginForm(MVC.Home.Actions.IndexPost, MVC.Home.Name))
       { %>
       
       <input type="submit" value="送信" />
       
    <% } %>

結果、出力されるHTMLは↓。

    <form action="/" method="post">
       
       <input type="submit" value="送信" />
       
    </form>

それから、既定ではMVCがクラス名だったり、ViewsフォルダやControllersが固定だったけど、今回からT4MVC.settings.t4というファイルで変更出来るようになってます。なので、MVC.Home.~じゃ無くてMy.Home.~とかに変更も可能。サブディレクトリを再帰(なんちゃらRecursive関数達)で潜っていくようになってるので、より実用的に。

Actionの引数をなくしたoverloadを定義してるみたで、上記BeginFormの書き方を以下のように変えてもOKらしい。

    <% using (Html.BeginForm(MVC.Home.IndexPost()))
       { %>
       
       <input type="submit" value="送信" />
       
    <% } %>

だけど、これで出力されるURLがなんかキモイ。

    <form action="/?RouteValues=System.Web.Routing.RouteValueDictionary" method="post">
       
       <input type="submit" value="送信" />
       
    </form>

変なの。と、思って出力されたコードを確認してみたけど、今回のような使い方の場合、上記のようにBegineFormを使うと、標準のHtmlHelper.BeginFormが呼ばれるから変なURLになる。正しくは↓こう。

    <% using (Html.BeginForm(MVC.Home.IndexPost(), FormMethod.Post))
       { %>
       
       <input type="submit" value="送信" />
       
    <% } %>

ちゃんと、FormMethodを指定しましょう。じゃないと、T4MVCで定義してるBeginFormのoverloadに入ってこないもん。こうすれば、ちゃんとURLが出てくるようになりました。にゃろめ。ちなみにルーティング登録時(MapRoute)でもマジックストリングを使わずに登録できるようになってるT4MVC。

最高です。

2009年7月4日土曜日

男らしさってなんですか?

前回のエントリーに引き続きT4MVCです。

前回判明したのが、ActionName属性を指定したアクションはオリジナルのアクション名でしかMVCクラスに展開されないというもの。で、改善リリースを待つか、自分でttファイルをいじるのか、どっちが男らしいかというところで、とりあえず逃げの一手を打ったんでしたね。

このままじゃべーさんにどやされる。週明けに「この腰抜けが!」なんて言われた日には、枕を涙でぬらす日々。涙じゃない、悲しみのシミだよ、なんて言ったところで「誠意ってなにかね? by 文太」とたたみかけられる。

まぁ、いいや。

早速、面白そうなものを見つけたんだから、T4追いかけてみるかと、VSでソースを開くと真っ白背景に白の文字。あ、コードハイライト効かないんだった。

そんなときには"Clarius Visual T4"。Professionalエディションを$99.99払ってまでは使い込まないだろうから、Communityエディションで。無料だし。Code Generatorエディション出たら買ってもいいかもね。いや、買わないか。で、インストールして早速T4MVC.ttファイルを開いてみると...。

t4mvc1

ぽかーんデス。なんじゃこれ。テーマ設定してるとグチャグチャっすね。

Clarius Forums • View topic - t4Editor and custom fonts and colors

フォーラム見てみたら、白バックのデフォルトテーマに戻せと書いてるけど、日本語版のVSだからか、「ツール>オプション>環境>フォントおよび色>テキストエディタ・表示項目」にそんな選択肢がそもそも出てこなかったり。ガッカリだな。無料だし、それもやむなし。そういえば、CommunityエディションだとIntelliSenceも使えないんだった。インストールする意味がまるでなかった。

で、結局↓こんな素っ気ない画面で。

t4mvc2

そろそろ、中身を確認せねば。T4の使い方は前にどこかで読んだことがあったから、細かい仕様的なのは無視。とにかく、<# #>で囲まれてる部分がテンプレート処理部。で、テンプレート内で参照出来る変数を下の方で宣言してて、その辺にいろいろコードがあるはずなので、そこら辺中心にチェックで。

一応ASP.NET MVCに関する投稿デス。

たぶんアセンブリ内をリフレクションでグルグル処理してるんだろーなと、たかをくくってたらこれが全然違うのね。ビックリした。CodeFunction2って誰ちゃん?CodeElementインターフェースってどこからはえてきたの?みたいな。どうやらVisual Studioオートメーションってことらしい。クラスの定義をpartialにしたり、関数定義をvirtualにしたり、ソースをいじるのにそうしとかないと面倒なことになるからなんだろうね。そんな物の存在を全然知らなくてたまげたけど、使う部分はちょびっとだけなんだから気にせず読み進める。

Controllers変数の中にControllerInfoのコレクションが入ってて、ControllerInfo.ActionMethodsの中にActionMethodInfoのコレクションが入ってるんだって。それらコレクション達を見ながらコード部を生成させる仕組み。今回はActionNameAttributeが宣言されてれば、アクション名をそっちに切り替えるってことをしたいので、ActionMethodInfoあたりを中心にチェック。ふと思ったけど、オリジナルのアクション名が消えるとちょっと分かりにくいかも?オリジナル+ActionName指定の両方を展開しといたほうが、優しさチラリな気がしたので、そういう事にします。

CodeFunctionのNameを書き換えるのは無理なんだろうから、ActionNameが指定されてるならそっちの名前を返すように内部のクラスを書き換える。

// Data structure to collect data about a method
class FunctionInfo: BaseFunctionInfo {
public string _actionName = null; public FunctionInfo(CodeFunction2 method) : base(method) { } public FunctionInfo(CodeFunction2 method, string actionName) : base(method) { _actionName = actionName; } public string Name { get { return _actionName ?? _method.Name; } } public string ReturnType { get { return _method.Type.CodeType.Name; } } public bool IsPublic { get { return _method.Access == vsCMAccess.vsCMAccessPublic; } } }

太字の部分が追加したコードです。さらに、このクラスを派生させてる部分も同じように変える。

// Data structure to collect data about an action method
class ActionMethodInfo: FunctionInfo {
    private bool _isOverride = true;
	
    public ActionMethodInfo(CodeFunction2 method, ControllerInfo controller): base(method) {
        Controller = controller;
    }

    public ActionMethodInfo(CodeFunction2 method, ControllerInfo controller, string actionName): base(method,actionName) {
        Controller = controller;
        _isOverride = false;
    }
    
    public bool IsOverride {get {return _isOverride;} }
    public ControllerInfo Controller { get; private set; }

    public string GeneratedName {
        get {
            // If the action name would cause a class/method conflict, append an underscore to it
            if (Controller.Name == Name)
                return Name + "_";
            return Name;
        }
    }
}

これも太字。IsOverrideって言うのが追加されてるのは、Controllerの書き換えをされる時に、アクション関数はすべてvirtualになって、そのControllerを内部で派生させて、すべてのアクションをoverrideしてるから。でも、今回追加するActionNameで指定したアクション関数はそもそもそんな名前でControllerには存在しないから、派生クラスでしか不要だし、overrideするものじゃ無いじゃないですか。なので、ここでそのフラグを保持しておいて、テンプレート処理部でこれを見て、overrideを付加するかどうか判定させるって感じです。

続いて、ActionMethodsコレクションを構築しているProcessControllerActionMethodsを書き換える。

void ProcessControllerActionMethods(ControllerInfo controllerInfo, CodeClass2 type) {
    foreach (CodeFunction2 method in GetMethods(type)) {
        ~ 長いのでココはカット ~
        // Collect misc info about the action method and add it to the collection
        controllerInfo.ActionMethods.Add(new ActionMethodInfo(method, controllerInfo));

        // ActionNameAttributeを見てそっちも追加する。
        // オリジナルが不要なら↑のAddを削除。
        foreach(CodeAttribute2 attr in method.Attributes)
        {
          if(attr.Name == "ActionName")
          {
            foreach(CodeAttributeArgument arg in attr.Arguments)
            {
              var actionName = arg.Value.Replace("\"","");
              controllerInfo.ActionMethods.Add(new ActionMethodInfo(method, controllerInfo, actionName));
            }
          }
        }
    }
}

太字部です。なんか、リフレクションじゃないから属性とか属性パラメータの取り方がちょっと特殊。MSDN最高。基本すべてソースコードを解析した結果構築されるオブジェクト達だから中身は単なる文字列。arg.Valueで取り出したActionNameに指定してるName値がソース上で定義されてるのと同じようにダブルクォーテーション付きになってるので、そこを削除。

最後にテンプレート部で処理してる部分を書き換え。

public static class MVC {
<#  foreach (var controller in Controllers) { #>
    public static <#= controller.DerivedClassName #> <#= controller.Name #> = new <#= controller.DerivedClassName #>();
<#  } #>
}

まずはMVCクラスの部分でオリジナルControllerクラスを入れ物にしてたから、テンプレートで生成した派生クラスになるように変更。こうしないとActionName名のアクション関数が無いから困るんです。

namespace <#= T4MVCNamespace #> {
<#  foreach (var controller in Controllers.Where(c => !c.SharedViewFolder)) { #>
    [CompilerGenerated]
    public class <#= controller.DerivedClassName #>: <#= controller.FullClassName #> {
        public <#= controller.DerivedClassName #>() : base(_Dummy.Instance) { }

<#      foreach (var method in controller.ActionMethods) { 
        var overrideKeyword = method.IsOverride ? "override " : "";
#>
        public <#= overrideKeyword #><#= method.ReturnType #> <#= method.Name #>(<# method.WriteFormalParameters(true); #>) {
            var callInfo = new T4MVC_<#= method.ReturnType #>("<#= controller.Name #>", "<#= method.Name #>");
<#          if (method.Parameters.Count > 0) { #>
<#              foreach (var p in method.Parameters) { #>
            callInfo.RouteValues.Add("<#= p.Name #>", <#= p.Name #>);
<#              } #>
<#          }#>
            return callInfo;
        }

<#      } #>
    }
<#  } #>

    [CompilerGenerated]
    public class _Dummy {
        private _Dummy() { }
        public static _Dummy Instance = new _Dummy();
    }
}

上記太字の部分で最後です。無駄にソースを引用してるんで、長いですけど、実質10行ほどじゃないっすかね。思ったより、変更箇所も少なくて助かりました。Visual Studioオートメーション&T4の組み合わせ恐るべし。

そうすると、前回の投稿ではIntelliSenceに出てこなかったActionName指定の部分も出てくるようになりました。

t4mvc4

やったね!

これまた興味ある方はダウンロードどうぞ(T4MVC-T.ttに名前変えてます)。

T4MVC

べーさんも気になってるように、ASP.NET MVCでは文字列(マジックストリング)を指定して、リンクやURLを生成するのが普通ですね。

    <% using(Html.BeginForm("Index","Home")) { %>
      <% = Html.TextBox("Name") %>
      <input type="submit" value="ポスト" />
    <% } %>

例えば、↑こんな感じで書くと↓こんな感じの出力に。

    <form action="/" method="post">
<input id="Name" name="Name" type="text" value="" /> <input type="submit" value="ポスト" /> </form>

リンクの場合もそう。

    <% = Html.ActionLink("ホーム", "Index") %>

↑こんな感じでしょう。URL生成はコントローラとアクションを指定したり、ルーティング名を指定したりするのが、今までのオーソドックスな書き方。

そんな状況は誰も嬉しくないよ!だってアクション名変えたら、指定してる箇所全部検索して変更しなきゃ行けないし、1文字でも間違ってたらちゃんと出力してくれない。あぁ、誰か助けて。

そもそもの始まりは↓ここからでした。

Angle Bracket Percent : A BuildProvider to simplify your ASP.NET MVC Action Links

BuildProviderを使ってASP.NET実行時に、CodeDom使ったコンパイラを走らせてがんばる方法(自アセンブリをリフレクションで探索)。なるほど~、と。ただ、やっぱり応用しにくいっていうか、コードが書きにくい感じで、その時はそれほど便利でもないかな~、なんて油断してました。それでも、この時点でHtmlHelperを拡張して、文字列指定じゃなくて関数指定でリンク生成出来てました。この辺でPhilさんもエントリ上げてて、ActionNameで別名使ったときにこれだと対応出来ないから、その辺上手いこと処理出来るのを次のバージョンに向けて考えてます的なことを言ってた気がする。

その後、いろいろ試行錯誤があって、出てきたのが↓これですよ。

Angle Bracket Percent : A new and improved ASP.NET MVC T4 template

CodeDomじゃなくてT4でいいんじゃね?と気がついたんでしょうか。コード生成するならこういうテンプレートエンジン使った方が断然生産性が高いしね。このときはまだMvc-CodeGenっていう名前だったんですが、これを更にブラッシュアップして出てきたのが↓。

Angle Bracket Percent : T4MVC 2.2 update: Routing, Forms, DI container, fixes

ちょっとバージョンアップしたんだけど、T4MVCに名前変わってからはMVCのソースが公開されてるCodePlexに組み込まれました(アドインだからこういう言い方はおかしいかも?)。

ASP.NET - Release: ASP.NET MVC v1.0 Source

ドキュメントなんてかけらもないので、どういう物かはソースを見て判断しましょう。まぁ、TTファイルの最初に書かれてる内容がそのままなんですけどね。

とにかくまず、自分のMVCプロジェクトのルートにT4MVC.ttをコピー。準備はこれだけ。後はT4なんで勝手にコード生成してコンパイルしてくれるので、すぐに使い始められます(最初にApp_Codeに放り込んでみたら全然動かなくてビックリした)。

t4mvc1

大枠でMVCという静的クラスが生成されて、そこからたどっていく感じになります。ASP.NETでも自動生成されるグローバルクラスにASPっていうのがあるけどそれと同じような感覚ですね。とりあえず、最初に書いたサンプルをT4MVCで書くとどうなるか、ですが、↓こうです。

    <% using(Html.BeginForm(MVC.Home.Actions.Index, MVC.Home.Name)) { %>
      <% = Html.TextBox("Name") %>
      <input type="submit" value="ポスト" />
    <% } %>

    <% = Html.ActionLink("ホーム", MVC.Home.Index()) %>

リンクに表示する部分以外のマジックストリングが無くなりました。しかもこのMVCクラスは動的なので、アクションやコントローラの名前を変更したら、コンパイルエラーが起きるので、変更し忘れともおさらばですよ!素敵です。

これだけじゃなくてデスね、Linksというクラスも同時に生成されるんですが、そこにはContentフォルダとScriptsフォルダに含まれてるファイル達のリンクが生成されます。

    <% = Links.Content.Site_css %>
    <% = Links.Scripts.jquery_1_3_2_js %>

↑こんな感じで。

さて、ここでActionNameAttributeをつけたアクションはちゃんと指定出来るのか気になるところなので、簡単に実験してみましょう。

    [ActionName("Index2")]
    public ActionResult KoukaiShitakunaiNamaeNoAction()
    {
      return View("Index");
    }

こんなのを作成してみる。保存したりすると、勝手にT4MVCが動く。で、ViewでIntelliSenceをきかせてみると~?

t4mvc2

残念!ActionNameまでは見てくれませんでした。Index2が出てくるのを期待したんだけど。実行結果も残念ながら。元の名前のままでした。がんばって自分でtt書き換えてActionName属性を見るようにするか、おとなしく新しいバージョンを待つか。どっちが男らしい?

ところで、このT4MVCを実行するとすべてのコントローラはpartialクラスになって、すべてのアクションはvirtualが自動でくっつきます。どうしてかというと、ActionResultを引数に受け取るHtmlHelper拡張に渡して、URLを生成しやすくするためですよね。内部で独自クラスに派生させたコントローラを生成し、アクションをオーバーライドしてるんですね。なんとも強引な方法。コントローラ書き換えられるのが気持ち悪い!って人にはお勧めしないですけど...。

お試しあれ!

2009年6月13日土曜日

全然意識したこと無かったけど

ASP.NET MVCだからどうのこうのっていう話ではないんだけど、ちょっと衝撃。

ASP.NET MVC Routing vs. Reserved Filenames in Windows - Stack Overflow

↑ここで初めて目にした時は"へぇ~"くらいだった。

bitquabit - Zombie Operating Systems and ASP.NET MVC

↑ここで2回目に目にした時は"マジやべ~"に変わった。デフォルトルート設定のままCom1Controllerを作ってアクセスしてみた。

ng1

ぬふ。404ですね。普通に開発してたら、Com1Controllerなんて作ったりしないから平気だろう、なんて甘いこと考えてたらダメですよ!

続いて、HomeControllerのIndexアクションを以下のようにしてみましょう。普通です。

    public ActionResult Index(string id)
    {
      ViewData["id"] = id;

      return View();
    }

で、Home/Index.aspxにこれまた以下のようなコードを書いたとしましょう。

  <p>
  id="<%= Html.Encode(ViewData["id"]) %>"
  </p>
  
  <div></div>
  <dl>
    <dt>ダメなパターン</dt>
    <dd>
    <%
      foreach (var id in new[]{"COM","LPT"}.SelectMany(
              l=>Enumerable.Range(1, 9).Select(r=>l+r))
                           .Union(new[]{"CON","AUX","PRN","NUL"}))
        Response.Write(Html.ActionLink(id, "Index", new { id }) + " ");
    %>
    </dd>
    <dt>いいパターン</dt>
    <dd>
    <%
      foreach (var id in new[] { "COM0", "COM1029", "id", "PRINTER", "NULL" })
        Response.Write(Html.ActionLink(id, "Index", new { id }) + " ");
    %>
    </dd>
  </dl>

ワクワクしますな。ドキドキが止まりませんな。

ng2

こんなのが表示されますね。もうあからさまに怪しいのが見てとれます。早速リンクをクリックしてみようじゃないですか。

まずは、下段の"COM0"。普通上段からだよ...。まぁ、いいでしょう。

ng3

ちゃんと出ます。続いて"COM1209"、"NULL"も試してみたけどちゃんと出ます。

今度は上段の"COM1"。

ng4

ぎゃふーん!!

でも、変な値が出るわけじゃないから、まぁいいか。そんなURL入れるのが悪い!なんて思ってたら変な障害出たりしてあたふたすることになりかねないですよ!だって、IDに文字列入力許すような設計って普通じゃないですか。んで、ルーティングのパラメータにID含むようなURL設計って当たり前すぎるじゃないですか。でも、ID入力時に"COM1"やら"NUL"やら"AUX"やらはじくような処理を入れるのが当たり前なわけじゃないじゃないですか。当たり前なんですか?そーだったらすいません。

何となくRouteCollection.RouteExistingFiles プロパティ (System.Web.Routing)でtrueにしてみたけど、ダメだったから、そういうもんだとして入力値のチェックをちゃんとやろうと思うところです。

2009年5月26日火曜日

UnityとEntityのAttach

第2回 スキャフォールディング機能で軽々DB連携アプリケーション - @IT

まだ2回目だけど、楽しみにしてる連載です。今回の記事で少し気になる部分があったので、実際に動くコードを書いてみました。暇人...。いやいや、お勉強デス!

その気になる部分っていうのが、ページ5の「既存のレコードを変更したい今回のようなケースでは、UpdateModelメソッドを使用する必要がある。」という部分。いや、もちろん、ModelBinderのみでDBに保存なんてしないし、これが間違ってるという分けじゃないけど、単純に出来る出来ないという話なら出来るっていう話です。

EntityKey and ApplyPropertyChanges() - Stack Overflow

なんだかんだと、実際にコードを書いてみたわけじゃなく、たんなる耳年増なだけだと、ちょっとカッコ悪し。

efunity2

Home/Indexが一覧。Editを用意。

efunity3

"Chai"を"Chai XXX"に編集。

efunity4

ちゃんと保存されました。

↓これがObjectContextにアタッチされていないエンティティを使って、DB更新してみるコード。

   public void Save<TEntity>(TEntity entity, Func<TEntity, TEntity> setIDFunc) 
where TEntity : new() { var entitySetName = _dataContext.DefaultContainerName + "." + EntitySetName(typeof(TEntity)); setIDFunc(entity); // 空のエンティティをアタッチしておいて、更新情報はクリア _dataContext.AttachTo(entitySetName, setIDFunc(new TEntity())); _dataContext.AcceptAllChanges(); _dataContext.ApplyPropertyChanges(entitySetName, entity); }

オブジェクトのアタッチ (Entity Framework)

AttachToでObjectContextに空のエンティティをアタッチしておいて、ModelBinderで復元したエンティティの値をApplyPropertyChangesで反映させる流れです。ObjectStateManagerを使うともっと綺麗にできるのかも?

↑このコードはRepositoryクラスに。コレを呼び出すControllerコードは↓。

    [AcceptVerbs(HttpVerbs.Post)]
    public ActionResult Edit(int id, Product entity)
    {
      if (ModelState.IsValid)
      {
        _repository.Save<Product>(entity, e =>
        {
          e.ProductID = id;
          return e;
        });
        _repository.SaveChanges();

        return RedirectToAction("Index");
      }

      return View(entity);
    }

認証、認可、入力検証ははしょってるんですが、何となく雰囲気は伝わるかな~、と思います。データベースはNorthwindで、Entity Frameworkを使ってProducts/Categoriesだけのエンティティクラスを作成してます。

efunity

HomeControllerのIndexが一覧ページになるようにしておいて、Editを追加しただけのプロジェクトで試してます。今回はコレに加えてUnityを使ってDIでObjectContextをRepositoryにコンストラクタインジェクションと、RepositoryをControllerにコンストラクタインジェクションさせるようにしてみました。

Unityを使ったControllerFactoryなんかはMvcContribにもコードがあったりするので、そちらを参考にするのが近道です。

でもMvcContribのUnityControllerFactoryはLifetimeManagerを指定しないので、Resolveの度に新しいインスタンスを作ります(TransientLifetimeManager)。HttpContextにObjectContextを入れておいて、同じコンテキストなら無駄使いしないようにするためのHttpContextLifetimeManagerクラスを用意。「ASP.NET MVC Tip: Dependency Injection with Unity Application Block - Shiju Varghese's Blog」に書かれてる、HttpContextLifetimeManagerを使わせてもらいました。Get/Set/RemoveのoverrideでHttpContextを使うようにしてるだけですね。

ObjectContextのインスタンスがどうなってるのかを確認するコードをHomeControllerに書いて確認。

    INorthwindRepository _repository;

    public HomeController(INorthwindRepository repository)
    {
      _repository = repository;
      _repository.Debug("Constructor");
    }

    protected override void OnActionExecuting(ActionExecutingContext filterContext)
    {
      _repository.Debug("OnActionExecuting");
      base.OnActionExecuting(filterContext);
    }

    protected override void OnActionExecuted(ActionExecutedContext filterContext)
    {
      _repository.Debug("OnActionExecuted");
      base.OnActionExecuted(filterContext);
    }

    public ActionResult Index()
    {
      var httpApp = HttpContext.ApplicationInstance as IUnityContainerAccessor;
      new NorthwindRepository(
        (NorthwindEntities)httpApp.Container.Resolve(typeof(NorthwindEntities))
      ).Debug("Other-1");

      var list = _repository.All<Product>();

      new NorthwindRepository(
        (NorthwindEntities)httpApp.Container.Resolve(typeof(NorthwindEntities))
      ).Debug("Other-2");

      return View(list);
    }

Debug内ではObjectContext.GetHashCode()を出力するようにしてます。コンストラクターで渡されるRepositoryはUnityに任せたもので、OnActionExecutingとOnActionExecutedではコンストラクタで渡されたものを出力し、Index内でそれぞれ(Other-1/2)新しくリポジトリのインスタンスを作成(Resolve)して出力させてます。

TransientLifetimeManagerを使った出力。

Constructor:6658142
OnActionExecuting:6658142
Other-1:5603269
Other-2:50559794
OnActionExecuted:6658142

Otherはそれぞれ違うインスタンスが生成されてますね。

HttpContextLifetimeManagerを使った出力。Other-1/2は全然違うのが出力されますね。

Constructor:60183783
OnActionExecuting:60183783
Other-1:60183783
Other-2:60183783
OnActionExecuted:60183783

Otherも同じインスタンスです。お利口さんです。Global.asaxに以下のコードを追加してUnityに登録してます。

    void InitializeContainer()
    {
      if (_container == null)
        _container = new UnityContainer();

      IControllerFactory controllerFactory = new UnityControllerFactory(_container);
      ControllerBuilder.Current.SetControllerFactory(controllerFactory);

      // Register
      _container.RegisterType<NorthwindEntities>(
        /*
         * ContainerControlledLifetimeManagerを使うとSingleton。
         * デフォルトはTransientLifetimeManager
         */
          new HttpContextLifetimeManager<NorthwindEntities>()
                )
                .Configure<InjectedMembers>()
                .ConfigureInjectionFor<NorthwindEntities>(
                  new InjectionConstructor(
                    ConfigurationManager.ConnectionStrings["NorthwindEntities"].ConnectionString
                    )
                );
      _container.RegisterType<INorthwindRepository, NorthwindRepository>();
    }

とりあえず、今回はProductデータだけを利用したけど、他にもいろいろなテーブルを同じコードで簡単に処理できるように「An Irishman Down Under - Polymorphic Repository for ADO.Net Entity Framework - Keith Patton's blog」に書かれてるようなジェネリックなリポジトリを書いてみようかな~と試してみたんですが、どうですかね。あんまり便利な気がしない。簡単な機能実装ならいいかもしれないけど、この辺は機能ドメイン毎にちゃんと書いた方がいい気がする。ところで、EntitySet名ってエンティティクラスから簡単に取得する方法ってないんですかね。EntityKeyには入ってるみたいだけど、アタッチして無いとnullで取り出せないじゃないですか。今回はずるっこして、エンティティクラス名+"s"として生成してます。

んん~。今回もプロジェクト添付しておくので、動かしてみたい方はどーぞー。あ、データベースファイルは添付してないです。

2009年5月13日水曜日

HandleErrorの使い方

そういえば、今月に入って全然エントリを書いてなかった事に気がついた。人に言われて気がついた。ずっと本を読んでて、頭がプシューってなっててさ~。

そんなこんなで、ASP.NET MVCでHandleErrorを使って、HTTPステータスコードに合わせたエラーページを用意する方法が日本語情報として無いかもとふと思ったので書いておきます。

使い方はアクションにHandleError属性を指定して、web.configのcustomErrorsのモードをOn/RemoteOnlyに設定しておけば、defaultRedirectが表示されるというものです。

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

      return View();
    }

コントローラに指定しても、アクションに指定してもいいですよね。それぞれに指定した場合、コントローラの普通はHandleErrorが優先されます。 例えば、上記コントローラに以下のアクションを追加してみると確認できますね。

    [HandleError(ExceptionType=typeof(InvalidOperationException),View="Exception")]
    public ActionResult ThrowException()
    {
      throw new InvalidOperationException("ぶぶ~");
    }

ViewにThrowExceptionへのリンクを作成して表示させてみたのが↓こちら。

handleerror2

普通に標準のエラーページが表示されます。ちゃんとアクションのHandleErrorにはViewを指定しているにもかかわらず。これをアクションに指定してるほうを優先させるにはコントローラに指定してるHandleErrorの優先順位を下げればOKです。

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

      return View();
    }

↑こんな感じで。Orderは0スタートなので1以上なら何でもいいです。そうすると今度はどういう表示になるか確認。

handleerror1 

グレート!

ちなみにリリース版のVSテンプレートが出力する~/Views/Shared/Error.aspxでは例外情報の表示コードが無くなってるので分かりにくいですが、このページは少し特殊でInheritsが単なるViewPageじゃなく、例外情報が渡されるように型付きViewPageになってます。

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

なので、Viewページ内でModelを参照するとアクション内で発生したExceptionが参照出来るようになってます。あら便利。自分でエラーページを定義する場合のInheritsを上記のように変更しておけば、自動で参照できるという寸法です。

ちなみに上記スクリーンショットのViewは~/Views/Shared/Exception.aspxというのを作成して表示してます。

で、ですね、例外を捕捉して専用エラーページを表示するのももちろん必要だと思うんだけど、そこはやっぱりHTTPステータスコードに合わせたエラーページを定義して、レスポンスのステータスコードも合わせた方がサーチエンジンにもエラーが認識できるし、RESTfulな感じがして素敵だと思いませんか。例えば、上記のページの場合レスポンスされるステータスコードはなんになってるか確認してみる。

handleerror3

500ですね。例外だからそれで良し。でも、例えばidを指定してDB検索して一致するデータがない、なんて時は404で返したいな~、なんて思いませんか。

試しに、web.configのcustomErrorsを以下のように変更。

    <customErrors mode="On" defaultRedirect="Errors">
      <error statusCode="400" redirect="/Errors/400" />
      <error statusCode="403" redirect="/Errors/403" />
      <error statusCode="404" redirect="/Errors/404" />
    </customErrors>

で、HomeControllerに以下のコードを追加。

    public ActionResult BadRequest()
    {
      throw new HttpException(400, "Bad Request");
    }

    public ActionResult Forbidden()
    {
      throw new HttpException(403, "Forbidden");
    }

    public ActionResult NotFound()
    {
      throw new HttpException(404, "Not Found");
    }

ErrorsControllerを追加して、HTTPステータスコードに合わせてViewを返すようにします。

  public class ErrorsController : Controller
  {
    public ActionResult Index(int? statusCode, string aspxerrorpath)
    {
      var viewName = string.Format("Error{0}", statusCode);

      return View(viewName);
    }
  }

handleerror4 

で、Home/BadRequestにアクセスしたのが↑。拡大すると見えるけど、200が返ってます。400が返って欲しい。そんなときはViewResultを書き換えてしまいましょう。

  public class StatusViewResult : ViewResult
  {
    public int StatusCode { get; set; }

    public StatusViewResult(ViewResult viewResult)
    {
      ViewName = viewResult.ViewName;
      MasterName = viewResult.MasterName;
      ViewData = viewResult.ViewData;
      TempData = viewResult.TempData;
    }

    public override void ExecuteResult(ControllerContext context)
    {
      context.HttpContext.Response.StatusCode = StatusCode;
      
      base.ExecuteResult(context);
    }
  }

こんな感じのクラスを作成しておいて、Errorsコントローラでこっちを利用するように変更。

    public ActionResult Index(int? statusCode, string aspxerrorpath)
    {
      var viewName = string.Format("Error{0}", statusCode);
      var resStatusCode = statusCode ?? 500;

      return new StatusViewResult(View(viewName)) {
        StatusCode = resStatusCode
      };
    }

で、同じアクションを実行したのが↓こちら。

handleerror5 

うへへ。ちゃんと400が返ってます。そりゃそーだ。

エラー用のViewとして400/403/404の3種類だけ用意してみました。

handleerror6

それぞれの実行結果は省略しときますが、例えばこの状態で405とかエラー発生させたらどうでしょうね。HomeControllerに以下を追加。

    public ActionResult MethodNotArrowed()
    {
      throw new HttpException(405, "Method Not Arrowed");
    }

これにアクセスしてみたのが↓こちら。

handleerror7

ErrorsControllerのIndexアクションでError405をViewとして返そうとするけど、そんなViewは定義してないのでInternal Server Errorが発生して、テンプレートで用意されてる~/Views/Shared/Error.aspxがレンダリングされてます。すばらしいじゃないですか。

と、いうわけで、今回のプロジェクト一式もダウンロード出来るようにしておきます。

最近こんな話題ばっかりで、周りからはブログつまらないという声もちらほら。しょうがないので前に書いてた今は無き「オレがルールだ!」からサルベージしてごまかそうかと計画中。

dotnetConf2015 Japan

https://github.com/takepara/MvcVpl ↑こちらにいろいろ置いときました。 参加してくださった方々の温かい対応に感謝感謝です。