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を使った検証はかなり有効な手段だと思います。お試しアレ。

dotnetConf2015 Japan

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