検索キーワード「ModelBinder」に一致する投稿を日付順に表示しています。 関連性の高い順 すべての投稿を表示
検索キーワード「ModelBinder」に一致する投稿を日付順に表示しています。 関連性の高い順 すべての投稿を表示

2009年2月23日月曜日

DataAnnotationsだけでの入力検証の盲点

以前の投稿(ASP.NET MVC RCの入力検証)で、如何にASP.NET MVCのDefaultModelBinder(IModelBinder)が汎用的になったかを取り上げましたね。

で、以前の投稿で見事に見逃してたのが「必須入力ではないけど、入力形式の不正メッセージを表示したいな」というところ。分かりにくいですね。例えば日付フィールドがあってモデルのプロパティはDateTimeなら当然日付形式の文字列じゃないとキャスト出来ないから、入力エラー。数値型ならintで当然アルファベットとか勘弁してくれよ、と。

必須フィールドならRequire属性でいいですよね。キャストに失敗した場合、何もセットされず型初期値(default(T))が入ったままだから、エラーメッセージに「ちゃんと入力してね(ハート)」って表示すれば。それでも、初期値が数値で0だと困る!って時にはNullable<Int32>とかでnullにしとけば、初期値のまま処理が進んじゃうって事も防げますから。

だけど、必須じゃない場合に「キャスト出来ませんでした」なんていうシステム固定のメッセージを出すのは、どうなのよ、なんて時があるもん。社内システムとかならそういうモンだから、で済むかもしれないけど、ネットに公開するならそういうメッセージはダサイ。いや、社内システムでもダサイけど、対象ユーザー層を考えれば、それでもまぁいいじゃん、っていうかね。

ちなみに、DataAnnotationsを使って、入力検証を実装した以前の実装だと、キャストエラー表示してくれないもんね。ModelStateDictionaryには(モデルを復元したタイミングで)ちゃんとエラーとして入ってるんだけど、ModelErrorクラスのErrorMessageにはメッセージが入って無くて、Exceptionプロパティに例外情報として入ってるから展開されないんです。

以前のテストプロジェクトにここで登場してもらいましょう。で、1箇所変更点として、PersonViewModelクラスのAgeプロパティについてるRequire属性を削除して、Range属性だけにしてみます。クラス定義は以下の通り。

public class PersonViewModel : BaseViewModel
{
 public int Id { get; set; }

 [Required(ErrorMessage="名前は?")]
 public string FirstName { get; set; }

 [Required(ErrorMessage="名字は?")]
 public string LastName { get; set; }

    [Range(0,150,ErrorMessage="0歳から150歳で")]
    public int? Age { get; set; }

 public List Weapons { get; set; }

 public PersonViewModel()
 {
   Weapons = new List();
 }

 public override string Error
 {
   get
   {
     if (Weapons == null || Weapons.Count == 0)
       return "武器、っていうか必殺技は?";

     return null;
   }
 }
}

太字のところですね。 これで、入力値に整数以外を入れて、ポストしたときのスクリーンショットが↓これです。

modelbind

ViewがRenderされるときに、ModelStateDictionaryがどうなってるかをブレークポイントをセットして確認してみましょう。

modelbind2

クリックすると大きく見れます。 Render時には、ModelState内のModelErrorは存在してるけど、ErrorMessageは""空文字で、ExceptionにInvalidOperationExceptionが入ってるのが分かります。

で、このExceptionをエラーメッセージとして表示するなら、そのまま取り出して、ErrorMessageに入れてしまうようなコードを書いてしまえばいいですね。ただ、エラーメッセージが今回の場合だと「17a は Int32 の有効な値ではありません。」と、出ちゃうんですよね。Int32って...。そんなこと表示されても普通の人は理解できないし。そもそもどのタイミングでメッセージの取り出し処理をすればいいでしょうね、って展開になります。

そこで、登場するのがIModelBinderのオーバーライド出来るメソッド群。これまたオレルールの方のエントリーの一番最後に書いてるIModelBinderのイベント発生順がキーになります。

結論から言うと、OnModelUpdatedのタイミング(モデルの復元完了時)に、ModelStateDictionary内のModelErrorに上記例外が含まれてるかチェックしてしまえばいい、という事になります。 ドンドン意味の分かりにくいエントリーになってきてますね~。

で、コードとしてはシンプルに↓こんな感じで動きます。LINQ部がかなり適当...。

public class ValidateModelBinder : DefaultModelBinder
{
 protected override void OnModelUpdated(ControllerContext controllerContext, ModelBindingContext bindingContext)
 {
   base.OnModelUpdated(controllerContext, bindingContext);

   var modelStates = bindingContext.ModelState;


   // ここでModelStateDirctionaryにInvalidCastExceptionを含んだエラーが
   // ないかチェックして、あれば入力エラーメッセージをここでいれる。
   // ※表示するときに空メッセージ(InvalidCastException)は除外するから、
   //   コレクションには入れたままにしておく。
   // ModelErrorを消さずにそのまま残しておくことで、ViewPageでCSSクラス名
   // が追加されてどのフォームエレメントがエラーなのかは視覚的に
   // 判断できる。
   var key = "__InvalidOperationException__";
   if (!modelStates.Keys.Contains(key))
   {
     var isInvalidCast = (
                           from ms in modelStates
                           from err in ms.Value.Errors
                           where err.Exception is InvalidOperationException
                           select err
                         ).Count() > 0;
     if (isInvalidCast)
       modelStates.AddModelError(key, "入力形式の間違った項目があります。");
   }
 }
} 

こんな感じで、DefaultModelBinderを派生させたクラスを作成し、このModelBinderをDefaultBinderにしちゃいます。もう、これだけでいいっす。 なので、Global.asaxのApprication_Startの所に以下のコードを追加。

protected void Application_Start() { RegisterRoutes(RouteTable.Routes); ModelBinders.Binders.DefaultBinder = new ValidateModelBinder(); }

特定のViewModelでしか使わないよ!っていうならViewModelクラスの宣言時に

[ModelBinder(typeof(ValidateModelBinder))] public class モデルクラス {…}

って、書きましょう。

この状態で、もう一度動かしてみます。

modelbind3

今度はちゃんと、ValidationSummary()で表示されるようになりました。 今回のコードは無理矢理エラー項目のキーに"__InvalidOperationException__"と入れて、複数のエラーメッセージが出ないようにしてますが、もちろん項目毎に表示してもいいですよね。

若干気になるのは、なんでInvalidOperationExceptionを発生させるようにしてるのかな~、というところ。なんとなくだけど、InvalidCastExceptionのほうがシックリ来ないですかね~。そういうもんなんですか? ちなみにどこでInvalidOperationExceptionを発生させてるかというと、ASP.NET MVCに含まれるValueProviderResultクラスのConvertSimpleTypeメソッド。この中でTypeDescriptor.GetConverter()で取得したコンバーターのConvertToを呼び出すところらへん。

ココまで書いて思ったんだけど、ASP.NET MVCに関するエントリーだけでも全部こっちに持ってくればいいのかな。今はそれ以外の事を書く事もほとんどないし。そうしちゃおっかな。

ASP.NET MVCのエントリは全部移行しました。手作業で...。

2009年2月11日水曜日

ASP.NET MVC RCの入力検証

とにかく、簡単に検証したい。

RCになってからIDataErrorInfoをモデルクラスに実装することで、DefaultModelBinderがプロパティ毎にValidationを呼び出すようになったりました。なのでコレを上手く生かしたい。 なおかつIDataErrorInfoの実装時にプロパティ名毎の検証ロジックを自分でコーディングしないでDynamic Dataで導入された、DataAnnotationsを使うようにしたい。

結果的にかなりシンプルに実装出来ることが判明。自分でDefaultModelBinderを派生させたクラスを作る必要もなく! DataAnnotationsっていうのはナオキさんの書いてるASP.NET Dynamic Dataの記事「簡単なデータ編集はお任せ! ASP.NET Dynamic Dataアプリケーション:CodeZine」に分かりやすい説明があるのでそちらを見てね。

簡単に言うとクラスのプロパティに属性ベースで検証ルールとエラーメッセージを指定するもの。 データベースのモデルクラス(DBO)に、検証ルールを入れたいときなんかにはMetadataType属性をクラスに指定しとけば、別のクラスで検証ルール属性を定義できたり、そりゃ~もう、便利そうでたまらない機能ですよ。

ScreenCast - How to work with DataAnnotations (VS2008 SP1 Beta) - Noam King's Blog ↑ここでビデオでもどんなものか確認できます。

ちょっと古いしUIに関することにも触れてるけど、DataAnnotationsの簡単な使い方が分かると思います(動的検証はまた別の話なので今回は触れてません)。

DataAnnotationsそのものは検証を実行してくれないので、検証の実行は自分で書く。書くと行っても属性指定しているValidationAttributeクラス(各検証属性の基底クラス)のIsValid()を呼ぶだけなんだけどね。

この呼び出し部分をIDataErrorInfoのItemプロパティ(インデクサ)に書く。 入力検証はDBOじゃなくViewModelに属性を定義して、そこでコードを書くようにしたい。なので、イメージ的には↓こんな感じ(図にしたほうが分かりやすいんだけど、面倒くさい)。

ViewModel : IDataErrorInfo
{
// モデルのプロパティを定義
 [検証属性A]
プロパティA
 [検証属性B]

プロパティB

// IDataErrorInfoの実装
Error {
 モデルに対する検証
}
this[columnName] {
 プロパティに対する検証
  → 検証属性クラスのIsValidを呼ぶ
}
} 
簡単そうでしょ?

ということで、前に書いたコード(ASP.NET MVC RCでIDataErrorInfoの使い方)をベースに進めることにします。 ここでちょっと訂正です。↑このエントリーでList<WeaponViewModel>の復元がDefaultModelBinderでは出来ないと書いちゃってて、個別にUpdateModelを呼び出さないきゃいけないよね~、なんて思いっきり間違いを書いてました。

コード見て分かるとおりPersonViewModelクラスのWeaponsがプロパティじゃなくてパブリックメンバ変数になってるから復元出来ないだけでした。 プロパティにすればちゃんと復元されます。

何でかというと、DefaultModelBinderで値を復元するときに、どのプロパティを対象にするか抽出するのに TypeDescriptor.GetProperties()を使ってるから(DefaultModelBinderクラスの316行目 GetModelPropertiesのコード参照)。名前の通りメンバ変数じゃなくてプロパティを抽出する関数なんだよね。そりゃ復元されないわ。 ってことで、DefaultModelBinder最強! ごにょごにょ言うよりコード見た方がたぶん早いと思うんでコードを載せときます。

1.IDataErrorInfoの実装

  public class BaseViewModel : IDataErrorInfo
 {
   public virtual string Error
   {
     get { return null; }
   }

   public string this[string columnName]
   {
     get
     {
       // ここで検証実行させる
       return this.Validate(columnName);
     }
   }

   // ↑ここまでがIDataErrorInfoの実装

   private PropertyInfo GetProperty(string name)
   {
     return this.GetType()
                .GetProperties()
                .Where(p => p.Name == name)
                .FirstOrDefault();
   }

   private string Validate(string columnName)
   {
     var property = this.GetProperty(columnName);
     if (property == null)
       return "致命的!";

     return Validate(property);      // 検証
   }

   private string Validate(PropertyInfo property)
   {
     // 検証ルール取得
     var validators = property.GetCustomAttributes(typeof(ValidationAttribute), true);
     foreach (ValidationAttribute validator in validators)
     {
       var value = property.GetValue(this, null);
       // 検証!
        if (!validator.IsValid(value))
         return validator.ErrorMessage;
      }

     return null;
   }
 } 
BaseViewModelクラスがIDataErrorInfoを実装。ViewModelは基本的にこのクラスを派生させる。

何でかというとItemインデクサの処理はどのクラスでも全く一緒だからっていうのと、Errorはシンプルなモデルクラスなら空(null)でイイから。 太字の部分がValidationAttributeの呼び出し。1つのプロパティには複数の属性をセットしてもいいので、全部の検証がOKかどうかチェック。

2.ViewModelクラスの定義

  public class WeaponViewModel : BaseViewModel
 {
    [Required(ErrorMessage="タイプは絶対!")]
   [StringLength(10, ErrorMessage = "タイプは10文字以内でね")]
    public string Type { get; set; }

    [Required(ErrorMessage = "名前は絶対!")]
   [RegularExpression("[^a-zA-Z0-9]*", ErrorMessage = "名前に半角英数含んだらダメ")]
    public string Name { get; set; }
 }

 public class PersonViewModel : BaseViewModel
 {
   public int Id { get; set; }

   [Required(ErrorMessage="名前は?")]
   public string FirstName { get; set; }

    [Required(ErrorMessage="名字は?")]
    public string LastName { get; set; }

    [Required(ErrorMessage="整数で入れてね")]
   [Range(0,150,ErrorMessage="0歳から150歳で")]
    public int? Age { get; set; }

   // ちゃんとプロパティにしときます。
   public List Weapons { get; set; }

   public PersonViewModel()
   {
     Weapons = new List();
   }

   public override string Error
   {
     get
     {
       if (Weapons == null || Weapons.Count == 0)
         return "武器、っていうか必殺技は?";

       return null;
     }
   }
 } 

※太字がDataAnnotationsのValiudationAttribute。 PersonViewModelではモデルのエラーチェックをしたい(Weaponsプロパティのアイテム数チェック)から、Errorをオーバーライド。 ホントはRequire属性で判定出来るんじゃないかと思ってたんだけど、Listに1件もポストされない時に呼び出されないってことが判明(復元対象の値が存在しないから)。 試しにValidationAttributeクラスを派生させて、ListRequireAttributeクラスなんてものを書いてみたけど、そもそも呼び出してくれないから意味なかった。

  public class ListRequireAttribute : ValidationAttribute
 {
   public override bool IsValid(object value)
   {
     var list = value as IList;

     if (list == null)
       return false;

     if (list.Count == 0)
       return false;

     return true;
   }
 } 

※全く無意味なクラス。 ↑ こんなクラスを書いても、List<T>の検証が呼び出されない(Listに1つでもアイテムが追加されるなら呼び出されるけど、何も追加されない場合はスルー)ので、IDataErrorInfo.Errorをオーバーライドしてモデル検証としてチェックするようにしました。 AgeプロパティがintじゃなくてNullable<Int32>なのは、何も入力しなかった場合にintだと0になっちゃうのと、整数じゃなくて実数を入れたときにも0になっちゃう(intの初期値)のが都合悪いからです。

3.DefaultModelBinderで値を復元

    [AcceptVerbs(HttpVerbs.Post), ValidateAntiForgeryToken]
   public ActionResult Edit(int id, PersonViewModel person)
   {
     if(ModelState.IsValid)
       return RedirectToAction("Index");

     return View(person);
   } 

ココまででコントローラでは普通に復元されるようになります。 入力エラーが分かりやすくなるように、Viewも少しいじったのでEdit.aspxのソースも貼り付けときます(と、言ってもValidationMessageを入れただけ)。

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

<asp:Content ID="Content1" ContentPlaceHolderID="head" runat="server">
   <title>Edit</title>
</asp:Content>

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

   <h2>Edit</h2>

   <%= Html.ValidationSummary() %>

   <% using (Html.BeginForm()) {%>

       <fieldset>
           <legend>Fields</legend>
           <p>
               <label for="Id">Id:</label>
               <%= Html.Encode(Model.Id) %>
           </p>
           <p>
               <label for="FirstName">FirstName:</label>
               <%= Html.TextBoxFor(p=>p.FirstName) %>
               <%= Html.ValidationMessage("FirstName", "*") %>
           </p>
           <p>
               <label for="LastName">LastName:</label>
               <%= Html.TextBoxFor(p=>p.LastName) %>
               <%= Html.ValidationMessage("LastName", "*") %>
           </p>
           <p>
               <label for="Age">Age:</label>
               <%= Html.TextBoxFor(p=>p.Age) %>
               <%= Html.ValidationMessage("Age", "*") %>
           </p>
         
           <p>
             <% foreach(var weapon in Model.Weapons){
                  var index = Model.Weapons.IndexOf(weapon);
                  %>
               タイプ
               <%= Html.TextBox(string.Format("Weapons[{0}].Type", index), weapon.Type)%>
               <%= Html.ValidationMessage(string.Format("Weapons[{0}].Type", index), "*")%>
               名前
               <%= Html.TextBox(string.Format("Weapons[{0}].Name", index), weapon.Name)%>
               <%= Html.ValidationMessage(string.Format("Weapons[{0}].Name", index), "*")%>
               <br />
             <% } %>
           </p>
           <% = Html.AntiForgeryToken() %>
           <p>
               <input type="submit" value="Save" />
           </p>
       </fieldset>

   <% } %>

   <div>
       <%=Html.ActionLink("Back to List", "Index") %>
   </div>

</asp:Content>

これを実行したときの画面が↓これら。

img.aspx10

前回同様、一覧。Editでルフィーを選択。

img.aspx11 名前とタイプのところが、List<WeaponsViewModel>の入力フォーム。 この状態のまま、Saveボタンを押してポストする。

img.aspx12

バッチリ復元されました。 まぁ、これだとDataAnnotations関係ないので、あえて入力エラーになるように、フォームの内容を変更。

img.aspx13 今度はこれをポスト。

img.aspx14 すると、DataAnnotationsがちゃんと動作してるのが確認できます。 すばらしい。

ただ、コレだとちょっとエラーメッセージが分かりにくいと思う。 メッセージそのものの文章が変とかっていう話じゃなくて、List<WeaponsViewModel>のどのアイテムがエラーなのかが分かりにくいんじゃないかと。

なので、エラーメッセージにアイテムのインデックスを含めたい。 でも、ValidationAttributeクラスのErrorMessageって固定文字列。動的に生成出来ないですよね。 これは参ったな~。DataAnnotationsの限界か!? と、思ったけど、ちょっと待て。 エラー情報は、どのフォームフィールドなのかと入力値(ValueProviderResult)、それとエラーメッセージ(ModelError)は自動でModelStateDictionaryに入るので、これを強制的に書き換えればいいんじゃないかななんて思ったわけです。 なので、エラーメッセージにインデックス番号を含めるように置き換える拡張メソッドをModelStateDictionaryに追加することにしました。

4.ModelStateDicrionaryの拡張メソッド

  public static class ModelStateExtensions
 {
   public static void ReplaceSequencialErrorMessage(this ModelStateDictionary modelState, string prefix, string format)
   {
     foreach (var ms in modelState)
     {
       var replaceErrors = new Dictionary();
       if (ms.Key.StartsWith(prefix + "["))
       {
         // 置き換え対象のエラー検索
         foreach (var error in ms.Value.Errors)
         {
           if (!string.IsNullOrEmpty(error.ErrorMessage))
           {
             var start = prefix.Length + 1;
             var end = ms.Key.IndexOf("]", start);
             if (end > start)
             {
               var indexVal = ms.Key.Substring(start, end - start);
               int index;
               if (int.TryParse(indexVal, out index))
               {
                 replaceErrors.Add(error, new ModelError(
                   format.NamedFormat(new { index = index + 1, message = error.ErrorMessage })
                 ));
               }
             }
           }
         }
       
         // 消して追加
         foreach (var e in replaceErrors)
         {
           ms.Value.Errors.Remove(e.Key);
           ms.Value.Errors.Add(e.Value);
         }
       }
     }
   }
 } 

ModelError のErrorMessageが直接書き換えできるならもっと簡単に書けるけど、残念ながらこのプロパティはリードオンリー(getterしかない)。なので、ModelErrorを削除してErrorMessageを書き換えたModelErrorを追加することで、この機能を実現。なんか遠回りだね。 エラーメッセージを置き換えたいプロパティ名と、どういう書式でエラーメッセージを書き換えるのかのフォーマット文字列を引数に指定する。ここで先日作成したNamedFormat(いまだ最速?)を使ってみました(ソース中太字のところ)。 これらを利用するようにアクションを少し変更。

    [AcceptVerbs(HttpVerbs.Post), ValidateAntiForgeryToken]
   public ActionResult Edit(int id, PersonViewModel person)
   {
     if(ModelState.IsValid)
       return RedirectToAction("Index");

      ModelState.ReplaceSequencialErrorMessage("Weapons", "{index}番目の{message}");
      return View(person);
   } 

※これまた1行追加するだけ。 この状態でさっきと同じ入力エラーを発生させる。

img.aspx15

5.DefaultModelBinderのイベント

ちょっと、長くなってきたけど最後にRCになって変更されたModelBinderのイベントについて少し確認してみました。と、言っても、DefaultModelBinderを派生させて各タイミングでログを出すだけなんですけどね。

リリースノート17ページの「ModelBinder API Changes」にまるっと全部書いてることですけど、以下のメソッドをオーバーライドすることで、各イベントに合わせて処理を実行出来るようになってます。 1. CreateModel 2. OnModelUpdating 3. GetModelProperties 4. BindProperty a. OnPropertyValidating b. SetProperty c. OnPropertyValidated 5. OnModelUpdated まんまですが、以下のような単純なクラスを書いて、PersonViewModelとWeaponsViewModelのModelBinderとして宣言してみました。

  public class DebugModelBinder : DefaultModelBinder
 {
   public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
   {
     Debug.WriteLine(
       string.Format("BindModel - {0}", bindingContext.ModelName)
     );
     return base.BindModel(controllerContext, bindingContext);
   }


protected override void BindProperty(ControllerContext
controllerContext, ModelBindingContext bindingContext,
System.ComponentModel.PropertyDescriptor propertyDescriptor)
   {
     Debug.WriteLine(
       string.Format("BindProperty - {0}", propertyDescriptor.Name)
     );
     base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
   }


protected override object CreateModel(ControllerContext
controllerContext, ModelBindingContext bindingContext, Type modelType)
   {
     Debug.WriteLine(
       string.Format("CreateModel - {0}", modelType.Name)
     );
     return base.CreateModel(controllerContext, bindingContext, modelType);
   }


protected override System.ComponentModel.PropertyDescriptorCollection
GetModelProperties(ControllerContext controllerContext,
ModelBindingContext bindingContext)
   {
     Debug.WriteLine("GetModelProperties");
     return base.GetModelProperties(controllerContext, bindingContext);
   }

   protected override void OnModelUpdated(ControllerContext controllerContext, ModelBindingContext bindingContext)
   {
     Debug.WriteLine(
       string.Format("OnModelUpdated")
     );
     base.OnModelUpdated(controllerContext, bindingContext);
   }

   protected override bool OnModelUpdating(ControllerContext controllerContext, ModelBindingContext bindingContext)
   {
     Debug.WriteLine(
       string.Format("OnModelUpdating")
     );
     return base.OnModelUpdating(controllerContext, bindingContext);
   }


protected override void OnPropertyValidated(ControllerContext
controllerContext, ModelBindingContext bindingContext,
System.ComponentModel.PropertyDescriptor propertyDescriptor, object
value)
   {
     Debug.WriteLine(
       string.Format("OnPropertyValidated - {0} = {1}",
                     propertyDescriptor.Name,
                     value )
     );
     base.OnPropertyValidated(controllerContext, bindingContext, propertyDescriptor, value);
   }


protected override bool OnPropertyValidating(ControllerContext
controllerContext, ModelBindingContext bindingContext,
System.ComponentModel.PropertyDescriptor propertyDescriptor, object
value)
   {
     Debug.WriteLine(
       string.Format("OnPropertyValidating - {0} = {1}",
                     propertyDescriptor.Name,
                     value)
     );

     return base.OnPropertyValidating(controllerContext, bindingContext, propertyDescriptor, value);
   }


protected override void SetProperty(ControllerContext
controllerContext, ModelBindingContext bindingContext,
System.ComponentModel.PropertyDescriptor propertyDescriptor, object
value)
   {
     Debug.WriteLine(
       string.Format("SetProperty - {0} = {1}",
                     propertyDescriptor.Name,
                     value)
     );
     base.SetProperty(controllerContext, bindingContext, propertyDescriptor, value);
   }
 } 

※BindingContextに復元に必要なすべての情報が入ってます。 ViewModelの先頭で以下の属性を追加。

[ModelBinder(typeof(DebugModelBinder))] public class PersonViewModel : BaseViewModel {...}

これを試すために、ルフィーのデータをそのままポストしてみました。 出力された内容が↓こちら(インデントは自分で入れてます)。

BindModel - person CreateModel - PersonViewModel OnModelUpdating GetModelProperties BindProperty - Id OnPropertyValidating - Id = 1 SetProperty - Id = 1 OnPropertyValidated - Id = 1 BindProperty - FirstName OnPropertyValidating - FirstName = ルフィー SetProperty - FirstName = ルフィー OnPropertyValidated - FirstName = ルフィー BindProperty - LastName OnPropertyValidating - LastName = モンキー SetProperty - LastName = モンキー OnPropertyValidated - LastName = モンキー BindProperty - Age OnPropertyValidating - Age = 17 SetProperty - Age = 17 OnPropertyValidated - Age = 17 BindProperty - Weapons BindModel - Weapons[0] CreateModel - WeaponViewModel OnModelUpdating GetModelProperties BindProperty - Type OnPropertyValidating - Type = ゴムゴム SetProperty - Type = ゴムゴム OnPropertyValidated - Type = ゴムゴム BindProperty - Name OnPropertyValidating - Name = ガトリング SetProperty - Name = ガトリング OnPropertyValidated - Name = ガトリング OnModelUpdated BindModel - Weapons[1] CreateModel - WeaponViewModel OnModelUpdating GetModelProperties BindProperty - Type OnPropertyValidating - Type = ゴムゴム SetProperty - Type = ゴムゴム OnPropertyValidated - Type = ゴムゴム BindProperty - Name OnPropertyValidating - Name = 鞭 SetProperty - Name = 鞭 OnPropertyValidated - Name = 鞭 OnModelUpdated OnPropertyValidating - Weapons = System.Collections.Generic.List`1[Mvc.RC.Models.WeaponViewModel] SetProperty - Weapons = System.Collections.Generic.List`1[Mvc.RC.Models.WeaponViewModel] OnPropertyValidated - Weapons = System.Collections.Generic.List`1[Mvc.RC.Models.WeaponViewModel] OnModelUpdated

無駄に長い貼り付けになっちゃったけど、BindModelでインスタンス化する変数名(またはプロパティ名)を決定し、CreateModelでインスタンス作成、OnModelUpdating~OnModelUpdatedの中でプロパティ毎の処理。プロパティはモデルと同じように BindPropertyで対象を決めて、SetPropertyでセット。セットの前後でOnPropertyUpdatingと OnPropertyUpdatedが呼ばれる。

なので、ModelBinderの中で直接検証したりするときには、OnModelUpdatedでモデル検証、OnPropertyUpdated(セット前に検証するならOnPropertyValidating)でプロパティの検証でいいのかな。

思いの外、挙動の確認に手間取ったけど、リリース版でもModelBinderはこの流れだろうから、DataAnnotationsを使った検証はかなり有効な手段だと思います。お試しアレ。

2009年2月7日土曜日

強力になったDefaultModelBinder

配列を保持するときに、コレまでHiddenにプレフィックス+”.Index”の名前でインデックス番号を保持しておかないと、きちんと復元してくれなかったのが、Index無しでもちゃんと復元出来るようになってる!

DefaultModelBinderクラスのUpdateCollectionのコードのリファクタリングを進めて、Index値を内部でループで回すように変更した結果だね。

なので0から始まる連番じゃないのは困っちゃう(-1から始めるとか1,3,5とか)けど、基本的に連番にするだろうから問題ないと思われる。 そもそも連番じゃないなら、違うフィールド(Hidden)に持つなりするはずだし。

コレまで、このHiddenのIndexが曲者で、一度Postされたあとに消して(ModelStateDictionaryの値が自動で復元されるルールが適用されて) おかないときちんと復元出来なかったのが、Indexそのものを使用しなくなったおかげで、Indexの出力も削除も不要に。

RC ModelBinder breaking changes for collections - ASP.NET Forums

コレクションをInput要素に展開する場合に、若い番号の値群を削除してもModelStateから若い番号の値が復元されてしまうってことなんで、結局 Indexを持つFormを作る時には、自分でModelStateの値を消して再構築するなり、Input生成時に値を渡すようにするか、 ViewDataに入れとくかという事はやらないとね。

ベータになって

↑こういう問題があったのを↓解決させてた。

あぁ~、そうか、こうすればいいんだ

※AttemptedValueに直接null入れてるけど。

ベータの時のコードをRCに移植する際にエラーになってしまった物として、ModelStatesに入ってる値を消す方法がコレまで ModelState.Value.SetAttempedValueだったのが、RCから綺麗さっぱりそのメソッドは無くなって (ValueProvider経由のValueProviderResultで取得)、代わりにModelStates.SetModelValueでキー名とValueProviderResultを渡すようになったので、この問題に気がついた次第です。

Custom ModelBinder and Release Candidate - ASP.NET Forums

ベータの消し方 foreach(var ms in modelStates.Where(ms=>ms.Key == "消したいキー")) { ms.SetAttemptedValue(null); // これでModelStateの値が消える }

RCの消し方 foreach(var ms in modelStates.Where(ms=>ms.Key == "消したいキー")) { modelStates.SetModelValue( ms.Key, new ValueProviderResult(null,null,null) ); // これでModelStateの値が消える }

DefaultModelBinderがらみでもう一つ。 フォームポストされるデータを、アクションの引数にクラスを使って復元させるとき、クラスにHttpPostedFileBase(ファイルアップロード)を含んでいると、そのままじゃ復元してくれない罠。 何でだろね。あと、デフォルト動作としてValidateRequestが有効になるようになってる。

例えば、以下のようなアクションをデフォルトで作成されるHomeControllerに定義。

    [ActionName("Index"), AcceptVerbs(HttpVerbs.Post)]
   public ActionResult IndexPost(string textArea, HttpPostedFileBase uploadFile)
   {
     return View();
   } 
んで、Indexページに以下のコードを書く。
  <% using (Html.BeginForm("Index", "Home", FormMethod.Post, new { enctype = "multipart/form-data" })) { %>

 <fieldset>
 <legend>フォームテスト</legend>
 <% = Html.TextArea("textArea")%><br />
 <input type="file" name="uploadFile" /><br />
 <% = Html.SubmitButton("send", "送信")%>
 </fieldset>

 <% } %>

img.aspx4 こんな感じの単純な物なんだけど。 例えばコレで、テキストエリアに"<script />"なんて入れて送信すると...。

img.aspx5 見慣れたエラーが出るね。 だけど、アクションにRCで導入されたValidateInputAttributeを指定して以下のような定義に書き換えると、ASP.NETの入力チェックがスルーされてコレまでと同じ動きをしてくれます。

    [ValidateInput(false), ActionName("Index"), AcceptVerbs(HttpVerbs.Post)]
   public ActionResult IndexPost(string textArea, HttpPostedFileBase uploadFile)
   {
     return View();
   } 
Viewsフォルダのweb.configやaspxのPageディレクティブ指定のValidateRequestはページに対しての指定で、アクションに対する指定じゃないので、気をつけましょう。

WebFormsの時はPageにPostBackされてたけどMVCだとControllerにPostするので、その違いがこんな所に出てきてます。まぁ、デフォルト安全動作っていうのはいいことだね。ベータ以前から移行の場合は修正箇所は増えるけど。

肝心のファイルアップロードといえば、以下の通り。

img.aspx6

普通に入ってるね(Vistaにサンプルで入ってる写真をポスト)。 今度はアクションの引数に自作クラス(ViewModel)を用意して、DefaultModelBinderに復元してもらうようにする場合。

以下のようなクラスを用意。

  public class FormPost
 {
   public string textArea { get; set; }
   public HttpPostedFileBase uploadFile { get; set; }
 } 

んで、アクションを以下のように書き換え。

    [ValidateInput(false), ActionName("Index"), AcceptVerbs(HttpVerbs.Post)]
   public ActionResult IndexPost(FormPost post)
   {
     return View();
   } 

そうすると今度はどうなるかと言うと...。 ※Viewは書き換えてないです。

img.aspx7

post.uploadFileはnullになってますね。見にくいですけど。 これは、以下のようにViewを書き換えることでちゃんととれるようになります。

  <% using (Html.BeginForm("Index", "Home", FormMethod.Post, new { enctype = "multipart/form-data" })) { %>

 <fieldset>
 <legend>フォームテスト</legend>
 <% = Html.TextArea("textArea")%><br />
 <input type="file" name="uploadFile" /><br />
 <% = Html.Hidden("uploadFile.exists", true) %>
 <% = Html.SubmitButton("send", "送信")%>
 </fieldset>

 <% } %>

input=fileのフォーム要素と同じ名前+".exists"のhiddenを作成して、valueに"true"を入れる。 これだけなんだけど、なかなか気がつかないよね。

img.aspx8

今度はuploadFileがnullじゃな~い。 HttpPostedFileBase bug when binding - ASP.NET Forums

2009年1月29日木曜日

ASP.NET MVC RCでIDataErrorInfoの使い方

ソース出てきたので早速IDataErrorInfoのチェック。 普通にソース見れるって幸せだね。

まずはIDataErrorInfoをどこで使ってるのか検索。 そしたらDefaultModelBinderでしか使ってないのが判明。 しかもDefaultModelBinderでは、OnModelUpdatedとOnPropertyValidatedの2箇所だけ。 なるほど。モデル全体の更新完了タイミングと、個々のプロパティ検証時に実行されるわけね。 こういう設計にしたいからModelBinderが大きく変わったんだね。 WPF的な?

IDataErrorInfoだとErrorプロパティとItem(this[string columnName])を実装するんだけど、その中に検証コードを書いてしまうと。んで、入力検証処理はココに集約しましょうと。 ってことは、アレだね、結局DataAnnotationsを使うのも同じだね。DataAnnotationsだとエラー情報を集約して ModelState.AddModelErrorを呼び出すコードは自分で書かなきゃいけないけど検証処理自体は属性ベースで簡単にできる。 IDataErrorInfoだと検証コードは自分で書かなきゃいけないけど、エラーはModelStateに自動で入れてくれる。

どっちもどっちだね。 試しにコードを書いてみた。 Scaffoldingとかも試してみたかったしね!

ビューモデルの定義

ModelsフォルダにPersonViewModelクラスを作成。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.ComponentModel;

namespace Mvc.RC.Models
{
 public class WeaponViewModel
 {
   public string Type { get; set; }
   public string Name { get; set; }
 }

 public class PersonViewModel : IDataErrorInfo
 {
   public int Id { get; set; }
   public string FirstName { get; set; }
   public string LastName { get; set; }
   public int Age { get; set; }
   public List Weapons;

   public string Error
   {
     get {
       if (Id == 0 ||
           string.IsNullOrEmpty(FirstName) ||
           string.IsNullOrEmpty(LastName) ||
           Age == 0)
         return "ちゃんと全部の項目入れてね";

       return null;
     }
   }

   public string this[string columnName]
   {
     get {
       string error = null;
       switch (columnName)
       {
         case "FirstName":
           if (FirstName == "チョッパー")
             error = "禁句";
           break;
         case "LastName":
           if (LastName == "トニートニー")
             error = "禁句";
           break;
         case "Age":
           if (Age < 0)
             error = "0歳以上で";
           break;
       }

       return error;
     }
   }
 }
} 
※全体入力チェックでは未入力許すまじなもの。 ※項目入力チェックではチョッパーを入れたらエラーになるようなもの。

コントローラの追加

ControllersフォルダにPeopleControllerを作成。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Ajax;
using Mvc.RC.Models;

namespace Mvc.RC.Controllers
{
 public class PeopleController : Controller
 {
   //
   // GET: /People/
   private List _people;

   public PeopleController()
   {
     _people = new List() {
    
new PersonViewModel{Id = 1, FirstName = "ルフィー", LastName = "モンキー", Age
= 17, Weapons = new List(){
         new WeaponViewModel{Type="ゴムゴム", Name="ガトリング"},
         new WeaponViewModel{Type="ゴムゴム", Name="鞭"}
       }},
       new PersonViewModel{Id = 2, FirstName = "ゾロ", LastName = "ロロノア", Age = 19, Weapons = new List(){
         new WeaponViewModel{Type="三刀流", Name="鬼斬り"},
         new WeaponViewModel{Type="一刀流居合", Name="獅子歌歌"}
       }},
       new PersonViewModel{Id = 3, FirstName = "ロビン", LastName = "ニコ", Age = 28, Weapons = new List(){
         new WeaponViewModel{Type="ハナハナ", Name="トレスフルール"},
         new WeaponViewModel{Type="ハナハナ", Name="シンコフルール"}
       }}
     };
   }

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

   public ActionResult List()
   {
     return View("List", _people);
   }

   public ActionResult Details(int id)
   {
     return View(_people.First(p => p.Id == id));
   }

   [AcceptVerbs(HttpVerbs.Get)]
   public ActionResult Edit(int id)
   {
     return View(_people.First(p => p.Id == id));
   }

   [AcceptVerbs(HttpVerbs.Post), ValidateAntiForgeryToken]
   public ActionResult Edit(PersonViewModel person)
   {
     if (ModelState.IsValid)
       return RedirectToAction("Index");

     return View(person);
   }
 }
}

※最初にデータをゴッソリ作ってるけど、毎回作成されるから更新しても意味なし! ※最後のEditアクションだけがポイント。

Viewの追加

Controllerの各アクションでAdd Viewを実行してみました。 List/Details/Editとそのままやってみた。 img.aspx

まずはList。 Add Viewから型を指定して作成するんだけど、型って単一モデルじゃないっすか。

img.aspx2

自分でListとかにするのかなとも思ったけどそのままモデルを選んで実行すると、賢 く 「System.Web.Mvc.ViewPage>」っ ていうinherits指定に。

img.aspx3

続いてDetail。 これは特に。そのまんま。

img.aspx4

最後にEdit。ここではWeaponsがどうなるんだろう?と思いつつ実行。 案の定、Weaponsに関しては生成されませんでした。 何でかというとAddView\List.ttですよ。T4ですよ! ウキウキしながらファイルを見てみると、FilterProperties(tt内で定義してる)でモデルからプロパティ一覧を取得。その処理はどうなってるかっていうとコレは単純で、IsBindableType(これまたtt内で定義)でプロパティを展開するかどうかチェック。

そのチェック方法が

if (type.IsPrimitive || type.Equals(typeof(string)) || type.Equals(typeof(DateTime)) || type.Equals(typeof(decimal)) || type.Equals(typeof(Guid)) || type.Equals(typeof(DateTimeOffset)) || type.Equals(typeof(TimeSpan)))

というわけでListやArray、Collectionやらは展開されないってことデス。 なので、自分でWeapons部分は書きます。

            <p>
             <% foreach(var weapon in Model.Weapons){
                  var index = Model.Weapons.IndexOf(weapon);
                  %>
               <%= Html.TextBox(string.Format("Weapons[{0}].Type", index), weapon.Type)%>
               <%= Html.TextBox(string.Format("Weapons[{0}].Name", index), weapon.Name)%>
               <%= Html.Hidden("Weapons.Index", index) %>
               <br />
             <% } %>
           </p>

書くって言っても、ココだけだし。 ※プレフィックス+Indexの名前でhidden作成するっていうのは前と変わらず。 ちなみにFuturesに入ってるHtmlHelperを使うと、TextBoxを以下のように書けます。

            <p>
               <label for="FirstName">FirstName:</label>
               <%= Html.TextBoxFor(p=>p.FirstName) %>
               <%= Html.ValidationMessage("FirstName", "*") %>
           </p>
           <p>
               <label for="LastName">LastName:</label>
               <%= Html.TextBoxFor(p=>p.LastName) %>
               <%= Html.ValidationMessage("LastName", "*") %>
           </p>
           <p>
               <label for="Age">Age:</label>
               <%= Html.TextBoxFor(p=>p.Age) %>
               <%= Html.ValidationMessage("Age", "*") %>
           </p>

Html.TextBoxForの部分ね。ValidationMessageが文字列じゃねーか、っていう突っ込みがもちろんあるよね。でも、ValidationMessageについては特にそういう拡張用意されてません。ちょっと中途半端。 コントローラのEditアクション(Post)のところで、ValidateAntiForgeryTokenを指定してるからHtml.Submit付近に<% = Html.AntiForgeryToken() %>を書いておきましょう。表示とPOST送信したのが同じユーザーエージェントかをCookieと hidden(__RequestVerificationToken)で勝手に確認してくれます。エラーならもちろんストップTHE処理。 POST 版Editの定義で引数がPersonViewMode personになってるけど、コレで動かすと残念ながらWeaponsは復元されませんでした(他の項目はOKね)。なので、上記のコントローラコードのままではダメですたい。コレについてはベータと変わらずだね。

なのでEditを変更。

    public ActionResult Edit(int id, PersonViewModel person)
   {
      person.Weapons = new List();
     if (ModelState.IsValid)
       UpdateModel(person.Weapons, "Weapons");
      if (ModelState.IsValid)
       return RedirectToAction("Index");

     return View(person);
   } 

先にnewしておいてUpdateModelでWeaponsを復元。その他の部分についてはそのまま。 コレで復元出来るんだけど、UpdateModelでブレークポイント書けるとちょっと面白い。 img.aspx5

まだUpdateModelを実行してないからWeaponsが復元されてないのはいいとして。 Id に値が入ってるよね!ね!引数idにももちろん入ってる(BeginFormのAction先のURIに含まれてるからルーティングでちゃんと入れてくれる)んだけどpersonクラスのIdも復元されてるっていうのが、今回改善された[Bind(Prefix=""])無しでも復元してくれるっていうヤツ。

肝心のIDataErrorInfoはどうなってるんだってとこですね。 Errorとthisの最初の部分にブレークポイント入れてみる。

img.aspx6

いつ止まるのか気になりますね! なりませんか?そうですか。

最初に書いたとおり、DefaultModelBinderのOnModelUpdatedとOnPropertyValidatedでIDataErrorInfoを使ってると書いたとおり、Editの最初(personを復元するタイミング)で止まります。 最初にOnPropertyValidatedがプロパティの数だけ呼び出されて、最後にOnModelUpdatedが呼び出される。順当。

他にも今回の改訂でCreateModel/OnModelUpdating/OnPropertyValidating/SetPropertyなんかが IModelBinderで定義されてるから、自分でModelBinderを作成するときにはそのタイミングで処理を入れたりも出来たりするみたいよ。 WPFみたいなノリで!

img.aspx7

で、Bindでエラーが発生すると↑こんな感じで。 Model.IsValidがエラーになる(ModelStateにエラー情報が入ってる)から、Weaponsも復元しないしリダイレクトもしない。ちょっと手抜きだけどエラーになるのが分かればよし! ※「ちゃんと全部の項目入れてね」のエラーはIDataErrorInfo.Errorプロパティが出力してる。

こんな感じなんで、DataAnnotationsでもまぁいいかな、なんて気がしなくもなく。データベースに問い合わせてビジネスロジック的にOKかどうかの検証とかココに入れるのはどうなんだ、って思えなくもないし。

2009年1月28日水曜日

待ちに待ったASP.NET MVC RCリリース

なんかもうお祭り状態でフィードの嵐。海外ばっかりだけど 国内だとナオキさん小野さんくらい?

ASP.NET MVC 1.0 Release Candidate Now Available - ScottGu's Blog

とにもかくにもスコガルブログ。

ASP.NET MVC Release Candidate Controls Collection Cannot Be Modified Issue with ASP.NET MVC RC1

Philさんとこ2つ。

Download details: ASP.NET MVC RC 1

何はともあれダウンロードしてインストール! リリースノートも忘れずに。

細かい話はリリースノートに全部書いてる。 スクリプトが用意されててIIS6/7Classicで動かすときの設定が自動化されてるとか。 で、とりあえずスコガルブログの面白ポイントピックアップ。

便利なAdd Controller/Add Viewコマンド

それぞれT4テンプレートエンジンで生成。 マシンレベルならC:\Program Files\Microsoft Visual Studio 9.0\Common7\IDE\ItemTemplates\CSharp\Web\MVC\CodeTemplatesフォルダを書き換え。 プロジェクトレベルならプロジェクト直下にCodeTemplatesフォルダを作ってそのなかに入れる。ControllerテンプレートはAddController、ViewテンプレートはAddView。 ・Add Controllerの内容 単純にIndexアクションを定義するのみ。 ・AddViewの内容 こっちは登録テンプレートリストから生成(Scaffolding)するViewの選択が出来る。 型付けViewPageを選択しないとモデルが分からないから生成する項目が不明になるから、ちゃんと型を指定するんだけど、POCO(Plain Old CLR Object:単純なモデルクラス)を推奨。もちろんいろんな(LINQ to SQLもLINQ to Entityも)のが使えるけど、基本的にはView専用のモデル(ViewData.Modelに入れるモデル)を定義してそっちを使いましょう。

デフォルトで生成出来るタイプは以下の5通り。 ・Create(新規登録) ・Details(詳細表示) ・Edit(編集) ・List(一覧):普通にモデルを指定するとIEnumerableとして生成。賢い。 ・Empty(なんも無し)

便利なGo to Controller/Go to View

アクションのViewResultを返すのところでコンテキストメニューから対象ビューを開いたり、ViewのASPXからコントローラを開いたり。

自動コンパイル

コンパイル時にデフォルトではコードしかコンパイルしないから、View(ASPX)内のインラインコードのエラーは実行時にしか分からなかった (MSBuildの設定変えればBetaでもViewのコンパイルは可能)けど、RCでは最初からそれが出来るコンパイルようなオプションあり。 もちろん毎回そんなことしたらコンパイルに時間がかかり過ぎちゃうから普段使いではViewのコンパイルはしない方がいいかもね。 設定はcsprojの以下の箇所をtrueすればよろし。テキストエディタでよろしく!

<PropertyGroup>
 <MvcBuildViews>true</MvcBuildViews>
</PropertyGroup>

地味にリファクタリング機能追加

Controllerのクラス名を変えるとViewのフォルダ名が変わって、ControllerのActionメソッド名を変えるとViewのファイル名が変わるようにリファクタリング機能も追加。けっこう便利なはず。

これは強烈、Viewのaspxにコードビハインドファイルを全く無しに出来る。

事前アナウンスがあった待望の機能。 型付けViewPageのタメだけのコードビハインドなんていらない! そんな思いを実現するために、Viewフォルダ内のweb.configに不思議な設定が追加されて、Pageディレクティブのinheritsに直接ジェネリックViewPageを定義可能に。 これはもう最高だよね!

ページでのModel参照

ViewPage内でViewData.Modelを参照するときはViewPage.ViewDataプロパティを参照するためにViewDataから書く必要があったけど、これからはViewPage.Modelを参照するからModelから書けばいいさ。 ViewData.Model.HogeをModel.Hogeって書ける。少しだけどコード量が減るね。 もちろん今まで通りViewDataから参照することも可能。

ページタイトルの変更も簡単に

Site.Masterも変更になって、ページタイトル(head内のtitleタグ)がページから簡単 に変更できるようにContentPlaceHolderを2つ(headとMainContent)用意。これで、 ViewData["PageTitle"]とかに入れて、Site.Masterでtitleにセットする必要が無くなってちょっと綺麗に書けそう。 なにげにページタイトルって面倒だもんね。 共通処理にしようと思うと、ページタイトルを取得するためにController/Actionの全組み合わせのデータを内部で保持とかしなきゃだし。

ただPhilさんも指摘の通り、Head自体は相変わらずrunat="server"がついてるからSite.Masterのhead内に直接<%= ~ %>を書くとエラーになりんす。 回避するために<%= ~ %>はContentPlaceHolderをもう一つ作ってその中に入れましょう。 これまでは速攻でrunat="server"を消してたんだけど、ContentPlaceHolderを使いたい場合はでもなくていいんだから、パスの解決にUrl.Contentヘルパーとか使えば、サーバーコントロールにする必要全く無いし。

Futuresの機能だけどテキストフォーム拡張

HtmlHelperを拡張してフォームタグ出力の時に、テキストで項目名を指定(この名前をさらに内部でViewData.ModelやModelStateのリフレクションに利用)してたのを、ラムダ渡しで型付プロパティを直接渡せるようになりました。 ただ、コレクションや配列でプレフィックス使いたいときの指定はどうするのか気になるところ。プレフィックスを指定できるオーバーロードはなさげ。

Bind[Prefix=""]でもインスタンス化

フォームポスト時にBind属性を指定しなくても、ちゃんとデシリアライズしてオブジェクトが復元出来るようになりました。ポストする項目をちゃんと Domain Objectだけにしておけばわざわざプレフィックスをつける必要無いと。ModelBinder関係は凄く変わってるみたい。リリースノートにもなんか書いてある。

IDataErrorInfo

Validationがらみで追加されたのがIDataErrorInfo(もともとSystem.ComponentModelに持ってる)。いまいちよくわかんない。自動でリフレクションでチェックしていってくれるのかな~?だとしたらどのタイミングで?? Item実装時に項目毎のチェック処理を書くのかな~。 それだけだとDataAnnotationsとなにが違うんだろうってことになるから、やっぱりソース見ていつ実行されるのかが分かれば、見えてくる気がする。

Controller.ControllerContextがRequestContextから派生しなくなった

これまで、コントローラのテストが結構面倒だった。 それもこれもControllerContextが今までRequestContextから派生してたからなんだけど、今回から派生しなくなった(プロパティで保持)。 Moqは使ったことない(RhinoMock)んだけど、サンプルの通りこれだけのコードで済むなら乗り換えしようと思えるね。AccountControllerのテストが実際に見れるけど、Moq(じゃなくてもいいけど)使えばもっと簡単にできるような内容だね。

AntiForgeryの標準取り込み

CSRF(Cross Site Request Forgery)を防ぐための、AntiForgeryToken()とValidateAntiForgeryTokenフィルターがとうとう組み込まれた。これまでFuturesで別アセンブリだったのがこれからは標準装備だね。

FileResultクラスとController.File()メソッドでファイルの出力とダウンロードが簡単に

これまたFuturesだったBinaryResultとBinaryStreamResultを一個にまとめて、簡単に処理出来るようになってる。ファイル名を渡さなければ、バイナリストリームを直接レスポンスするし、渡せばattachmentで保存できるように。 と、簡単に書かれてるけど、中身を見てみるとController.File()は用途に合わせて FileContentResult/FileStreamResult/FilePathResult(いずれもFileResultの派生クラス)と3つのActionResultを返すようになってる。FileContentResultがbyte配列、FileStreamResultが Stream、FilePathResultがファイルのパスを指定。 これらはController.File()のオーバーロードだからあんまり意識しなくてもいいのかな。FilePathResultがResponse.TransmitFileで、他2つはResponse.OutputStream.Write。 グッジョブ!

アップロードも簡単に

ファイル出力だけじゃなくてアップロードも簡単にできるようにHttpPostedFileBaseに直接バインドされるようになってる。Request.Filesから取らなくても良くなりました。 ちなみにFuturesには複数ファイルアップロード時のBindも定義されてたよ。 非同期アップロード(iframe/Flash/Silverlight)なら個別アップロードでもいいから使わないかもしれないね。

Ajaxも改善。jQueryのインテリセンスがデフォで

1.3系じゃなくて1.2.6のまま。 なにより、これまでのIsMvcAjaxRequestプロパティはX-Requested-Withヘッダを見るだけだったけど、 IsAjaxRequestプロパティに名前が変わって各種ライブラリ(Prototype.jsも!!あとjQueryとDojo)で使ってるヘッダの解析も実装したので、自分で解析を実装しなくて良くなりました。超嬉しい!! JavaScriptResultの追加も。JavaScriptをそのままレスポンスするものみたいね。 サーバーサイドにJavaScriptコード書くのはちょっと...。

Futuresの中身を見てみた。 ソースがまだ公開されてないけど、そこはそれReflectorで。 なんと待望の非同期コントローラが!! 非同期で処理するには、IHttpHandlerじゃなくIAsyncHttpHandlerが必要になるんで、MvcAsyncHandlerももちろん含まれる。一式ちゃんとある感じ。 もちろんルートに登録するためのMapAsyncRouteもあるよ! Improve scalability in ASP.NET MVC using Asynchronous requests « Steve Sanderson’s blog ↑こんな感じなのかな? 使い方は...。ちょっと試す。 LinqBinaryModelBinderなんてものも。Base64のデータをSystem.Data.Linq.Binaryクラスにバインド。 キャッシュされてても強制書き換えが出来るHtml.Substitute(httpContext => ~)が入ってる。前からあった??

もちろんリリースノートもチェケラッチョ

・これまでformタグを出力するときにHtml.BegineForm/BeginAjaxFormだったからルート名を指定したアクションにポストするときなんかは直接formタグを書かなきゃいけなかったけど、BeginRouteFromが追加されて、そんな苦労ともおさらばだ! ・DropDownList/ListBoxが変更になったみたい。 今まではSelectList/MultiSelectListをViewDataに入れてたけど、これからはIEnumerable<ListItem>でいいんだって。

・沢山のバグフィックス。 "Html.BeginForm and Ajax.BeginForm have been fixed to not render a fully qualified URL." ここが凄く気になる。 Adding HTTPS/SSL support to ASP.NET MVC routing « Steve Sanderson’s blog ↑関係ないかもしれないけど、ここの問題ってコレ系だったりするんじゃないのかと。違うかもしれないけど要チェックや。 ・ベータのコードをRCに移行する方法 1.まずはアセンブリ参照の変更。 2.コンパイルが通るまでがんばってコード直す。 3.Viewsフォルダのweb.configを書き換え。

        <pages
           validateRequest="false"
           pageParserFilterType="System.Web.Mvc.ViewTypeParserFilter, System.Web.Mvc, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"
           pageBaseType="System.Web.Mvc.ViewPage, System.Web.Mvc, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"
           userControlBaseType="System.Web.Mvc.ViewUserControl, System.Web.Mvc, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35">
         <controls>
           <add assembly="System.Web.Mvc, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" namespace="System.Web.Mvc" tagPrefix="mvc" />
         </controls>
       </pages>

※ViewTypeParserFilter入れとくと、型付ViewPageをinherits指定だけで出来るようになる。 That's it! って、Viewのinherits書き換えたいから、やっぱゴッソリ書き直したくなるね。 最終リリースが来月と思いのほか早い展開になってきた楽しみのつきないASP.NET MVC。

2008年10月21日火曜日

ベータになって

ソースも公開されましたね~。

ASP.NET - Release: ASP.NET MVC Beta Source Code Release

これで少し救われた...。

今回の変更でかなりリファクタリングが進んで、いい感じですね。 だいたいの変更点はコンパイルエラーで判断できるので、まぁいいでしょう。 ちょっとビックリするのはMvcFuturesが一緒に配布されなくなったところ。 AntiForgeryToken/ValidateAntiForgeryTokenとFileResultを使ってたのでどうしようと悩む。 HtmlHelperが凄く整理(使う分には関係ないかもしんないけど)されてて、SubmitButtonが無くなったね。これまた使いたければ直に<input type="submit" />を書く。

と、思いきやMvcFuturesのbetaアセンブリがちゃんとダウンロード出来るようになってましたね。 ASP.NET - Release: ASP.NET MVC Beta Futures ※ダウンロード数が異様に少ない気がするのは気のせいか。あんまり誰も使わない?

がMvcFuturesにもFileResultが無く...(Html.SubmitButtonはこっちに入ってました)。 代わりにBinaryStreamResultっていうのがあって、これを使うことで対応出来る事が分かりました。 Streamをコンストラクタに渡さなきゃいけない(ファイル実体じゃなくてファイルのパスでもテスト出来ると思うけど、それじゃイカン!ヤカン!って事なんでしょうか)のが、変わったところ。

var fileStream = new System.IO.FileStream(filePath, System.IO.FileMode.Open);
var result = new BinaryStreamResult(fileStream) {
 ContentType = mimeType,       // MIMEタイプ
 FileDownloadName = fileName // Content-Dispositionのファイル名
}; 

Form の値を取得する部分が見た目それほど変化が無い(namespaceの変更とオーバーロードの変更)にも関わらず、内部では大幅な変更が入っている模様。それぞれのInputExtentions(CheckBox,Hidden,Password,RadioButton,TextBox)では privateのInputHelperを呼び出してinputタグを生成してるんだけど、その中でGetModelAttemptedValueを呼び出す。必ず呼び出す。これが曲者(ってわけじゃないけど)。 ModelState.AddModelErrorなんかが、今回の変更で入力値をあえて渡さなくていいようになってますよね。結局表示の段階でHtmlHelperのInputExtentionsを呼び出すから、それで前回入力値を取得して表示出来るっていう寸法で便利っちゃ便利なんです。が、しかしですよ、ということはInputExtentionsを使う限りは、 ViewData.ModelStateに入っている値が最優先で表示されるってことデスよ。いいじゃん、それで、と思う事なかれ。ModelState の値はPost時に確かに取得するけど、だからといってそれを表示したいわけじゃないってときもあるじゃないですか!ないですか!そうですか! UpdateModelを使って、Formの値を取得した場合、ModelStateにも同じ値が入るんで↓こうなる感じ。

public class Val
{
 public string MyValue {get;set;}
}

var  val = new Val();
UpdateMode(val); 

↑このとき、val.MyValueに"123"って入ってたとします。

Viewで

<%= Html.TextBox("MyValue", "456") %>

って書いててもデスよ、展開されるinputタグのvalueには"123"デスよ! 回避するにはどうするか。いまいち答えが見つけられず、とりあえずinputタグを直接書く(それだとエラーの時に自動でCssClassが追加されないし値も表示されない)...、か、ModelStateを消す? 泣けるっす...。 これだけじゃなく、配列(List<T>でも)の取得が出来るようになってたりするのに、いまいち書き方が分からずなところで、ソース公開だったので助かりました。以下にサンプルを。 まずはモデルクラスを定義。

using System; using System.Collections.Generic; using System.Linq; using System.Web; namespace MvcApplication.B1.Models { public class Person { public string Name { get; set; } public int? Age { get; set; } } public class MyViewData { public List People { get; set; } public MyViewData() { People = new List(); } } }

で、コントローラにアクション実装。

    public ActionResult People()
   {
     var viewData = new MyViewData();
     viewData.People = new List() {
       new Person() { Name = "たけはら", Age = 33 },
       new Person() { Name = "まうり", Age = 26 },
       new Person() { Name = "しんたろ", Age = 21 }
     };
     return View(viewData);
   }

   [ActionName("People"), AcceptVerbs(HttpVerbs.Post)]
   public ActionResult PeoplePost()
   {
     var formData = new MyViewData();

     UpdateModel(formData.People, "People.Person");

     formData.People.RemoveAt(1);

     return View(formData);
   } 

※これをHomeControllerに追加。 最後にViewを。Viewはコントローラと同じフォルダなので、今回はHomeに。

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="People.aspx.cs" Inherits="MvcApplication.B1.Views.Home.People" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">

<% using (Html.BeginForm()) { %>

<table>
<% var row = 0; %>
<% foreach(var person in ViewData.Model.People) { %>
<tr>
 <th>なまえ</th>
 <td>
 <% = Html.TextBox("People.Person[" + row + "].Name", person.Name)%>
 <input type="hidden" name="People.Person.index" value="<%= row %>" />
 </td>
</tr>
<tr>
 <th>年齢</th>
 <td><% = Html.TextBox("People.Person[" + row + "].Age", person.Age)%></td>
</tr>
<%
 row++;
 }
%>
</table>

<input type="submit" value="送信" />

<% } %>
</asp:Content>

で、一発目動かして、/Home/Peopleにアクセス。 img.aspx

わかりやすいよね~。3人分の名前と年齢が出てきました。 で、今度はそのまま送信を押す。と、Postなんで違うアクション(PeoplePost)が実行されます。 コードを見て分かるとおり、UpdateModelで復元したあと、2番目のデータを削除して再表示。 このとき配列を復元するためにFormに"People.Person.index"っていうhiddenの値を埋め込むのがコツみたいですよ。なんでHtml.Hiddenを使わないのかっていうのは、以下の続きを。 で、この場合、「まうり」が消えた状態の2件が表示されるはずよね。 動かしてみましょうか?

img.aspx2

なんとまぁ「しんたろ」が消えてるじゃないっすか。 でも、ちょっと待てと。データは確かに「まうり」が消えてる。

img.aspx3

ね。これがGetModelAttemptedValueの値が優先されるときに起きる現象。 InputExtensionsのInputHelper関数の以下の行。

tagBuilder.MergeAttribute("value", attemptedValue ?? ((useViewData) ? htmlHelper.EvalString(name) : Convert.ToString(value, CultureInfo.CurrentUICulture)))

attemptedValueにはModelStateの値が入ってる(UpdateModelした時とか)から、Viewで引数に渡した値を全然無視。なもんで、Html.Hiddenでindexを入れてしまうと、カンマ区切りのpost値が勝手に埋め込まれてNG。 こういう使い方を実際にしちゃってるもんだから、設計を変えるのにどうしようかと悩み中デス。 今回の大きな変更部はやっぱりModelBinder周り。

ちゃんとアクションのパラメータに対するBindも実装(ガスブロ参照)されてるみたいだし。UpdateModel/TryUpdateModelなんかも併せて大幅改修な感じ?keyを渡すのがホワイトリスト(取得対象)/ブラックリスト(拒否対象)だったり、指定すら必要無くなってたり(これが嬉しいんだけど、値の保持し過ぎ問題を含む)、インターフェースで取得項目を指定する感じだったり。 ViewDataのクラスを作る場合、表示のタメだけに使いたいフィールドの定義なんかもするけど、それを UpdateModelで取得する必要が無いって時に、いろんな方法で対応出来るようになったのはいいことですね。今までのコードも今まで通り動く(ホワイトリスト方式)し。 今回のリリースで手痛い思いをするような作りをしてる人は少ないのかなと思いますが、個人的には設計を見直す必要があるので、ダサイ作り方をしてしまった(自分のプロジェクトがって意味です)と割り切って、考え直します...。

2008年9月3日水曜日

ModelBinderに気をつけねば

前のエントリでModelBinderを使って、ActionのパラメータにDBから取ってきたエンティティモデルをそのまま渡す方法を書いちゃったけど、あれはダメでした。

DataContextをstaticにもつサンプルだったけど、そうだとしてもいつDisposeされるのかなんて分かんないから、試しに実装してみたらケチョンケチョン...。

で、正しい使い方はこちら↓。 How to use the ASP.NET MVC ModelBinder - Melvyn Harbour というのを、昨日ガスリー君のブログでも書かれててホッとした。 ASP.NET MVC Preview 5 and Form Posting Scenarios - ScottGu's Blog

サンプルをチェックしてると、ProductをModelBinderでとってるじゃないかと思えるかもしれないけど、あくまで新規登録時の空エンティティの時にしか使ってないですよね。 で、更新処理の時にはDataContextから取得したProductに対して、ModelUpdateを使って値の書き込み。 この方法だとですね、更新時にはProductBinderのGetValue走らない。サンプルだから両方乗せてるんだろうけど(ModelUpdateとModelBinder)、最初はどんな意味が込められてるのか混乱しちゃった。 ModelUpdateでセットされるデフォルトのエラーメッセージが気に入らなかったらどこで書き換えればいいのかはちょっと分かんなかった。リソースファイルに持ってるのをどうすればいいんだろか。

で、 ProductクラスはLINQ to SQLのクラスなんだけど、これに対してModelStateDictionaryへメッセージを突っ込むコードを書くと、クラスが密結合しすぎちゃうがために、あえてRuleViolationクラスを作って(後でModelStateDisctionaryに入れやすくするために)、 IRuleEntityを実装。

ProductクラスのOnValidateはSubmitOnChangeの時に自動で呼び出してくれるっていうのがミソですね! でも、実際の開発は、たぶんだけどLINQ to SQLのエンティティクラスに対して直接入力しないよね。もう一つ間にViewDataクラスをはさんで、ViewDataにDBから読み込んだ値を入れてFormに表示。更新の時にViewDataに読み込んだ後、エンティティクラスに値をマッピングしていって更新。そんな流れになると思うので、入力検証のサンプルが↓これ。

Maarten Balliauw {blog} - Form validation with ASP.NET MVC preview 5

このサンプルではViewDataに直接値を入れてるけど、ViewDataのクラスを用意してViewPage<UserViewData>でレンダリングをView(model)にするんだと、↓こんな感じになるんじゃないかと思いますがどうですかね。

public class UserViewData { public name {get;set;} public email {get;set;} public message {get;set;} }

Contactアクション(POST)の入力値の取得で

var viewData = new UserViewData(); ModelUpdate(viewData, new[]{"name","email","message"});

って、すれば個々に入力値を取得しなくてもviewDataに埋め込みますわね。 でも、それだと入力検証できませんわね。全部stringだし。 なので、UserViewDataクラスに検証用のメソッドを追加して、それを呼び出すときにModelStateDictionaryを渡すのが簡単でいいんじゃないかと思います。 例えば、↓こんな。

public bool Validate(ModelStateDictionary modelStates)
{
 if (string.IsNullOrEmpty(name))
  modelStates.AddModelError("name",name,"名前入れてね!");
 else if (name.length < 4)
  modelStates.AddModelError("name",name,"4文字以上で名前入れてね!");

return modelStates.IsValid;
}

で、アクションでは↓。

 var viewData = new UserViewData();
 if (TryModelUpdate(viewData, new[]{"name","email","message"})) {
  viewData.Validate(ViewData.ModelState);
 } 

なんかヘンテコなコード...。 ※ModelUpdateも中でModelStateDictionaryにエラー値を入れてくれます。キャストできないとか。 ※Prefixをつけた場合、今までは最後に'.'(ドット)を自分でつけなきゃいけなかったのに、自動でつくようになってちょっと涙目...。気がつくのに時間かかった。

ちなみにUpdateModelのキー名はmodelに持ってる項目だけにすべし。Modelの項目を指定して、Formにない場合はエラーにならないけど、その逆はエラー(FormにあってModelにない)になるので気をつけよう。 ModelState情報はうまく利用すれば、エラーフィールドを強調できる(class属性にinput-validation-errorが自動でつく)ので超便利です。

ModelState はRenderPartial時にViewDataDictionaryを渡さないと(ViewData.Modelだけだとダメ)ユーザーコントロールで取得できないから、入力項目を持つユーザーコントロールのRenderPartial時には問答無用でViewDataも渡すようにするのが吉!

その他気になったところ。

Default option label for DropDownList in ASP.NET MVC Preview 5 - Shiju Varghese's Blog

便利にはなるよね。でも、必須にしなくてもいいじゃないかと、思ってしまうんですよ。 LINQで取り出した、ソースの最初の行に空(ここでいうoptionLabel)のレコードを連結させるコードを書いてたから、それがなくなるのはいいんだけど。ちなみにotionLabelに空文字""を指定すると何も起きないからレンダリング結果は今まで通り。

Maarten Balliauw {blog} - ASP.NET MVC preview 5's AntiForgeryToken helper method and attribute Steve Sanderson’s blog » Blog Archive » Prevent Cross-Site Request Forgery (CSRF) using ASP.NET MVC’s AntiForgeryToken() helper 何に使うのかサッパリわからなかったけど、こうやって使うんだね。CookieとForm(HIDDEN)POSTの値を比較して有効なリクエストか判定。

file download as attachment in latest preview (like this blog post) - ASP.NET Forums

FileResultの簡単な使い方。FileResultはResponse.TransmitFileとかで結果を直接返さずにActionResultとして返すことで、テストしやすくなるよね。 AntiForgeryToken/FileResultともにMicrosoft.Web.Mvcに入ってるデス。

2008年8月30日土曜日

ModelBinderが素敵過ぎる

ASP.NET MVC Preview5の続き。

SingingEels : Model Binders in ASP.NET MVC

↑ここでサンプルダウンロードできるけど、DefaultModelBinderを派生させて独自Binderを定義しておくことで、Actionのパラメータをクラスの実体に置き換えることができるというもの。

とにかくダウンロードすればすぐわかるんだけど、少し解説。

まずはBinderのクラスを作成。これはDefaultModelBinderクラスを派生させましょう。 で、ConvertTypeメソッドをオーバーライドして、valueに入ってる値をdestinationTypeに変換してあげる。 例えば、ここのサンプルだとCustomersテーブルのID値をBase64にエンコードしたものをtargetCustomerという名前の QueryStringにしてActionLinkでリンクを生成して、そのリンクをクリックしたときのActionでCustomerクラスの実体が渡されるっていうものになってる。

this.Writer.Write(this.Html.ActionLink<HomeController>(c => c.Details(customer), customer.FullName));

↑これをIndex.aspx内で書いてて、リンク(aタグ)を出力してるんだけど、これの出力結果がたとえばID=1なら1をBase64エンコード('='は'_'に置換)して↓こうなる。

<a href="/Home/Details?targetCustomer=AQAAAA__" >Ivan Buckley</a>

これのアクション定義は↓。

public ActionResult Details(Customer targetCustomer){...}

んだけど、これはつまりデフォルトのルーティングをそのまま使うように変更してidという名前で渡すようにしてみるとですね、↓こうなるわけです。

アクション:public ActionResult Details(Customer id){...} リンク:<a href="/Home/Details/AQAAAA__" >Ivan Buckley</a>

これは、分かりやすくていいですよね。 型付きActionLinkで生成してるからIndex.aspxのほうは変更の必要なし。 わざわざアクションの中でIDからデータを取得するコードを書かなくても、MVCのハンドラがモデルに変換する処理をはさんでくれるというすぐれもの。 ただ、勝手に変換はしてくれないので、Global.asaxでModelBindersにどの型の変換をどのクラスで実行するかを登録しておく。

ModelBinders.Binders.Add(typeof(Customer), new MyCustomerBinder());

もう、楽しくてしょうがないね!

まさかまさかのPreview5

早いタイミングで出てきたのはいいけどPreview5とは。 実際、Controller変ったりViewEngine変ったりしてて大きな変更だからPreviewのままなんだろうね。 ※ViewEngineを作ったりはしんどそうだから、特に興味ないぜ!

img.aspx

ばびゅ~んとインストール。 後先考えずにインストール。 今までのプロジェクト動かないのはRelease Noteみたら書いてるから覚悟はしてたけど、ここまでダメだとは...。しかも、Sourceはまだ公開されてなくて、どうやって追っかけるんですか...。またしても見切り発車で先走り過ぎた。まぁ、いいや。

HtmlHelperが大きく変わって全く動かなくなりますね。 追加されたものとして↓。 HtmlHelper.RenderAction HtmlHelper.RenderRoute HtmlHelper.RenderPartial

今までのRenderUserControlが無くなって、これらが追加されてます。 これはViewDataの継承とかが全然変わるんじゃ...? と、思って簡単なテストプログラムを書いてみました。

まずはPreview 5で新規プロジェクト作成。

1.ModelsにUserViewData.cs(クラス)を追加。

namespace MvcApplication1.Models
{
public class UserViewData
{
  public string UserName { get; set; }
  public string ViewName { get; set; }
  public string Message { get; set; }
}
} 

2.Views/HomeにUsers.aspx(MVC View Content Page)を追加。

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Users.aspx.cs" Inherits="MvcApplication1.Views.Home.Users" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<h2>ユーザー</h2>

<h3>ページで表示するメッセージ:<% = Html.Encode(ViewData["ViewMessage"]) %></h3>

<%
 foreach(var user in ViewData.Model)
   Html.RenderPartial("~/Views/UserControls/User.ascx", user);
%>

</asp:Content>

コードビハインドで型指定

namespace MvcApplication1.Views.Home
{
 public partial class Users : ViewPage<List<Models.UserViewData>>
 {
 }
}

3.Viewsに"UserControls"フォルダ作成。 4.Views/UserControlsにUser.ascx(MVC View User Control)を追加。

<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="User.ascx.cs" Inherits="MvcApplication1.Views.UserControls.User" %>

<h5>コントロールで表示するメッセージ:<% = Html.Encode(ViewData["ViewMessage"]) %></h5>

<dl>
 <dt>ユーザー名</dt><dd><%= Html.Encode(ViewData.Model.UserName) %></dd>
 <dt>表示名</dt><dd><%= Html.Encode(ViewData.Model.ViewName) %></dd>
 <dt>メッセージ</dt><dd><%= Html.Encode(ViewData.Model.Message) %></dd>
</dl>

コードビハインドで型指定

namespace MvcApplication1.Views.UserControls
{
 public partial class User : System.Web.Mvc.ViewUserControl<Models.UserViewData>
 {
 }
}

5.HomeControllerにUsersアクション追加。

    public ActionResult Users()
  {
    ViewData["ViewMessage"] = "今日も雨が降ったり止んだりだね。";
    var users = new List()
    {
      new Models.UserViewData(){UserName = "takehara", ViewName="たけはら", Message="運動不足"},
      new Models.UserViewData(){UserName = "mauri", ViewName="マウリ", Message="ホッケーばっかり"},
      new Models.UserViewData(){UserName = "suzuki", ViewName="すずき", Message="ホッケーのみ"}
    };
 
    return View(users);
  }

6.Home/Index.aspxにリンク作成。

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Index.aspx.cs" Inherits="MvcApplication1.Views.Home.Index" %>

<asp:Content ID="indexContent" ContentPlaceHolderID="MainContent" runat="server">
   <h2><%= Html.Encode(ViewData["Message"]) %></h2>
   <p>
       To learn more about ASP.NET MVC visit <a href="http://asp.net/mvc" title="ASP.NET MVC Website">http://asp.net/mvc</a>.
   </p>

   <%= Html.ActionLink("ユーザーページ", "Users") %>  
</asp:Content>

で、これを実行した結果の画面が↓これ。 img.aspx2

相変わらず、ViewDataが参照できてないですね。 が!!

RenderPartialには違うオーバーロードがあってですね、User.aspxのコードを以下のように変更。

<%
 foreach(var user in ViewData.Model)
   Html.RenderPartial("~/Views/UserControls/User.ascx", user, ViewData);
%>

すると...。 img.aspx3

あびりーばぼー!!

ViewDataDictionaryの中身をUserControlの中から参照できるようになりました。 今までのRenderUserControlだと型指定してViewDataを渡すとディクショナリの中身は見れなくなってたんだけど、これでそのページで利用したい情報はすべてViewDataDictionaryに入れておくことで、全部のUserControlから参照できるようになりますね。 最高っす!!

ここで、RenderPartialが直接結果を返さないのがミソ。実行はギリギリまで遅らせてTestしやすくってことですな。モック使ってね。

次に変わってんのがHtmlHelper.ActionLink/RouteLinkのオーバーロード。object型の引数の解釈が先にHtmlAttributes。ちゃんとUrlが生成されなくてビビるので気をつけましょう。

今までのコードをなるべくそのまま使うなら↓こんな感じでnull渡しましょう。

Html.RouteLink("リンク", "RouteName", new {val1="1", val2="2"},null)

HtmlHelperはいろいろ変更が多そうだけど、ここら辺押さえておけば以降は大丈夫そう。 次はControllerでの変更点。 ビビるのはBindingHelperExtensionsが無くなってるとこ。 BindingHelperExtensions.UpdateFromが...。これがないとRequest.Formの値をまとめて取り出せないじゃないか。と、みんな同じようなことを思ったみたいでForum確認してみたらちゃんと答えが。 Controller.UpdateModelを使えと。なるほど。確かに取得できる。Request.Formを指定しなくても良くなってたり、TryUpdateModelを使えば例外も起きないっぽい。

さらに今まで、Executeの前後の処理をController.Executeをoverrideしてそこに実装してたけど、これがなんとinternalになって、overrideできなくなりました。 が、しかし、これはちゃんとRelease Noteに書いてるから、すんなりとExecuteCoreのoverrideへ切り替え。でも、Initializeのoverrideでいいような処理だから、こっちにしよ。 ActionFilterAttributeもなんか結構変わってて。

ActionMethod.Name何処にいったんですかね。リフレクションでどうのこうのって書いてるけど、RouteDataから引っ張ってくればいいんですかね? とりあえずはfilterContext.RouteData.Values["action"]でAction名はとれる。 Cancelもなくなってるし、アクションが実行されなかったかどうかはどこでセットすればいいんですかね? どうしよ的な変更箇所が多かったりする中で、RenderPartialヘルパーが実装されてたり、AcceptVerbでRESTfulっぽくアクションを書けたり(PUT/DELETEはどうすんの?_methodで書き換えれるのがいいんだけど.NET Reflectorで確認した限りではHttpMethodをそのまま判定に使ってるっぽいから、自分で書いたRESTfulAttributeクラスからの乗り換えはないかな)、AjaxHelperがSystem.Web.Mvc.Ajaxに移動してたり、よさげなこともあるから(他にも FileResultクラスがあったり)しっかりチェックしていこうと思うところです。 How to use the ASP.NET MVC ModelBinder - Melvyn Harbour ↑ModelBinderの使い方サンプル。

Maarten Balliauw {blog} - Form validation with ASP.NET MVC preview 5 ↑ModelStateを使った入力検証のサンプル。

これViewDataに入れたりして、エラー項目を保持するのを自分で実装しなきゃいけなかったりしてたけど、超いい感じ!

dotnetConf2015 Japan

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