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かどうかの検証とかココに入れるのはどうなんだ、って思えなくもないし。