2009年2月26日木曜日

ModelBinderでLINQ to SQLのモデルをそのまま使う

ASP.NET MVC Tip #49 - Use the LinqBinaryModelBinder in your Edit Actions

ステファン君それは無理じゃない?更新以前にバインドした時点で例外でるじゃん。

と、思ってたのは今は昔。RCでは何の問題もなくバインド出来るみたい。試してみたら、普通に出来た。ずいぶん前に試したときはデータベースコンテキストがスタティックじゃないと例外でたり(ModelBinderに気をつけねば)、そもそも直接DBO使うのはうんぬんかんぬん。綺麗な設計どうのじゃなくて、純粋に少ないコード量でどこまで出来るか、っていうのを考えたら直接使う事もあったりするのかもね。

これまでも使ってたサンプルはDBを使ってなかったので、改めてDBを用意して、LINQ to SQLのモデルを作成。

db1

これを利用するためのコントローラも定義。

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 OnePeaceController : Controller
{
  OnePeaceDataContext _context = null;

  public OnePeaceController()
  {
    _context = new OnePeaceDataContext();
  }

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

  public ActionResult List()
  {
    var people = _context.Persons;

    return View("List", people);
  }

  public ActionResult Details(int id)
  {
    var people = _context.Persons;

    return View(people.First(p => p.Id == id));
  }

  [AcceptVerbs(HttpVerbs.Get)]
  public ActionResult Edit(int id)
  {
    var people = _context.Persons;

    return View(people.First(p => p.Id == id));
  }

  [AcceptVerbs(HttpVerbs.Post), ValidateInput(false)]
  public ActionResult Edit(int id, Person person)
  {
    try
    {
      _context.Persons.Attach(person, true);
      _context.SubmitChanges();

      return RedirectToAction("Index");
    }
    catch (Exception ex)
    {
      return View(person);
    }
  }
}
}

List/Detail/Editの各ViewはRCで追加されたAdd Viewコマンドでサラッと作成。こういう時にとても便利ですね。Attachのところでブレークポイントをセットして、まずはこのまま動かしてみたところ、DB使っててもホントに動く。信じてなかったわけじゃないけど、こうもあっさりと動くとちょっと感動する。

bind

↑Listページ。

bind2

↑Editページ。

bind4

↑普通にバインドされてる様子。

でも、このままステップ実行すると、確かに例外が発生。

bind3

エンティティは、バージョン メンバを宣言するか、または更新チェック ポリシーを 含まない場合にのみ、元の状態なしに変更したものをアタッチできます。

System.Exception {System.InvalidOperationException}

と、言うことで、簡単に更新チェックポリシーをオフにしてしまおうかとも思ったけど、それよりちゃんと競合チェックするようにtimestamp型の列をテーブルに追加して動かしてみます。

db2

↑ChangeStampという名前のtimestamp型の列を追加したモデル。

でも、timestamp型はSystem.Data.Linq.Binary型としてクラスが生成されるので、このままだとちゃんとモデルが復元されません。なによりViewにちゃんと出力してないし。まずはEditのViewにChangeStampをhiddenで展開するコードを追加。

            <p>
              <%= Html.Hidden("ChangeStamp",
                              Convert.ToBase64String(Model.ChangeStamp.ToArray())) %>
              <input type="submit" value="Save" />
          </p>
      </fieldset>

  <% } %>

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

</asp:Content>

submitの手前に入れてます。Base64エンコードしてHiddenフィールドに。このままだとDefaultModelBinderが復元してくれくれないので、Global.asaxのApplication_Start時にASP.NET MVC Futuresに入ってるLinqBinaryModelBinderを登録。

    protected void Application_Start()
  {
    RegisterRoutes(RouteTable.Routes);
    ModelBinders.Binders.Add(typeof(System.Data.Linq.Binary),
                              new LinqBinaryModelBinder());
  }

もう、何もかもステファンさんのいいなりです。

この状態で動かしてみて、Viewが出力するHTMLソースのChangeStamp部分を確認してみたのが↓これ。

<input id="ChangeStamp" name="ChangeStamp" type="hidden" value="AAAAAAAAB9Q=" />

ちゃんと、エンコードされて出力されてます。あたりまえだっちゅーの。

bind5

これを更新するためにsubmitして、ブレークポイントでモデルの中身を確認。ちゃんとLinqBinaryModelBinderでBinary型も復元されてます。そのまま実行を続けても例外は出なくて、テーブルも更新されてました。で、更新された後のViewのCangeStampを確認してみる。

<input id="ChangeStamp" name="ChangeStamp" type="hidden" value="AAAAAAAAB9U=" />

ちゃんと、最初とは違う値が入ってますね~。

と、いうことで、ステファンさんのやったことをそのまま試したみたわけですが、これが出来るって事はLINQ to SQL+スキャッフォールディングで凄く簡単にDBを使ったアプリケーションを作成出来る事になりますね。Repositoryは作るにしても、ViewModelを作らずシンプルなコードで開発することが出来るのでWebForms並の生産性(ViewStateとデータバインディング)を実現できてるんじゃないかと思う次第です。

※ViewStateはModelStateが各inputフィールドの値を保持しつつHTMLにレンダリングしてくれたりするので。