2009年2月28日土曜日

Base64でエンコード

Url encoded slash in URL - Stack Overflow

この投稿から始まって、以下のエントリにたどり着く。

Allowing special characters (forward slash, hash, asterisk etc) in ASP.Net MVC URL parameters

まさに、同じ問題に直面した内容。

Double/incomplete Parameter Url Encoding - Stack Overflow

↑ここへのリンクもあったから覗いてみたら、Uri.EscapeDataString メソッド (System)を使ったエンコーディングがあるということを初めて知る。なにこれ~、と思って試したけど、Server.UrlEncodeと同じだった。ガッカリ。いや、違う方が驚きか。

結局、Philさんの投稿のように"/"か他の文字に置換して区切ってしまうか、Base64エンコードで渡すのがいいのかな。Base64といえばFuturesに入ってるLinqBinaryModelBinderが使えるんだもんね。

というわけで試してみた。

登録するルートは以下の通り。

routes.MapRoute(null,
"Proxy/{*base64url}", 
new { controller = "Images", action = "Proxy" } 
); 

Controllerはシンプルに以下。LinqBinaryModelBinderを使うので、参照設定にFuturesのアセンブリを含めるのを忘れずに(単純に文字列で渡しておいて、Binary型にしなくてもConvert.FromBase64Stringでも可)。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Ajax;
using System.Net;
using System.Text;
using System.Data.Linq;

namespace Mvc.RC.Controllers
{
 public class ImagesController : Controller
 {
   public ActionResult Proxy(Binary base64url)
   {
     var web = new WebClient();
     var url = Encoding.UTF8.GetString(base64url.ToArray());

     var bytes = web.DownloadData(url);
     return File(bytes, web.ResponseHeaders["Content-Type"]);
   }
 }
}

Viewでリンクを作る時にBase64エンコードしたパラメータを渡す。
  <% = Html.ActionLink("いぬ", "Proxy", "Images", new {
 base64url = Convert.ToBase64String(Encoding.UTF8.GetBytes(
         @"http://farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg"
         ))
 }, null)%>

これが出力されると以下のHTML。

<a href="/Proxy/aHR0cDovL2Zhcm0xLnN0YXRpYy5mbGlja3IuY29tLzEzMS8zNTM3NTMzMTBfMWVkMDRmNjk0Y19tLmpwZw==">いぬ</a>

リンクをクリックする。

base64

ちゃんと犬が表示されました。一応、ブレークポイントセットして変数の中身を確認。

base64_2

ちょと見にくいけど、引数のbase64UrlにはBase64エンコードされたBinary型の値が入ってて、変数urlには元のURLがデコードされてる。

問題はぱっと見どこのURLを参照してるのかを判断出来ないところ。フレンドリURLとは言い難し。

ところで、Windows Live Writerから投稿すると、毎回無駄な改行が入るのはなんでなんだろう。

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にレンダリングしてくれたりするので。

2009年2月25日水曜日

Bloggerをいじる

とりあえずは、コードをエントリーに書くことが多くなるとは思うので、綺麗に見せるためにGoogle Code Prettifyを導入してみる。

google-code-prettify - Google Code

ここからコードをダウンロードできるんだけど、はて、このJSファイルとCSSファイルはどこにアップロードするんでしょうね。困りますね。面倒なのでずるいとは思いつつ、SVN-Tranc(google-code-prettify - Revision 64: /trunk/src)から直接貼り付けちゃった。てへ。

テンプレートを編集する画面でheadタグ内の<title>の下にダイレクト挿入!

blogger1

<script src='http://google-code-prettify.googlecode.com/svn/trunk/src/prettify.js'
       type='text/javascript'/>
<link href='http://google-code-prettify.googlecode.com/svn/trunk/src/prettify.css'
     rel='stylesheet' type='text/css'/>

後は、コードを載せたい箇所を<pre class=”prettyprint”></pre>で囲むだけ! とは言ってもですね、毎回ソースを開いてそんなことやってられないじゃないか! 便利な物はないかな~、と思って行き着いたのが「Windows Live Writer用のプラグインを開発する:CodeZine」。

おっと、いきなりプラグイン開発ですか?そうですか。それもやむなしですね。どれどれ、記事を読んでみると大して難しいことしなくてもこの程度のことならサクッと作れそう。だってpreで囲むだけだし。SmartContentSourceじゃなくてContentSourceで作れるじゃんね。んで、作ったDLLをPluginフォルダに入れとけばイイみたいだし。良し作るか。と、思ってちなみに同じ事考えてる人いるんじゃないかと思って、改めて検索し直してみたらいた。Vistaのサイドバーガジェットの時の無駄作業を思い出して良かった。あのときも同じようなの作ってる人いたもんね。ちゃんと調べないで作っちゃって損した気分になったし。

Code Prettify for Windows Live Writer – Home

コード見たらビックリだね。こんなに潔いプラグインも珍しい。いや、だって、自分でもきっとこう書く。

次にデフォルトのコメントシステムはいきなりスパムが来たりするくらいなので、速攻で消してとりあえずFriendConnectにしてみたんだけど、TumblrでTypePad Connectを使ってるの思い出した。

Bloggerもサポートしてるよ!と豪語してるだけあって、設置方法もスクリーンショットでテンプレート編集箇所を書いてあったりして、親切だ。

でも、動かなかった。 だいたい、書いてあるとおりにやってみたけどダメだった。Bloggerのテンプレートがどうなってる(構造が)のか調べるのも面倒だしな~。このブログに適用してるブログにはそもそもインストール手順に書いてるような項目もないし。インストール手順がデフォルトテンプレートを基準にするのは、そりゃあたりまえだしね。違うデザインのテンプレートを使ってるんだから致し方なし。

かといって、諦めるのはダンディじゃない。 無理矢理にでも動かしてやる。大まかな手順は3つ。

1.コメントフォームとコメント一覧を表示する部分のコードを貼り付け。 2.コメント件数を表示するアンカーを貼り付け。 3.コメント件数を表示するスクリプトを貼り付け。

1と3の作業は手順書通りに出来そう。ちゃんと該当箇所も見つかるし。 2の作業は...。今度は手順書の場所が見あたらない。なんか、ちょっとテンプレートが違う。

でも、まぁ、同じようなモンだろう、ってことで、↓こんな感じで貼り付けてみた。

    <b:if cond='data:post.allowComments'>
     <h4>
       <b:if cond='data:post.numComments == 1'>
         1 <data:commentLabel/>:
       <b:else/>
         <data:post.numComments/> <data:commentLabelPlural/>:
       </b:if>
     </h4>
    <b:else/>
     <a expr:href='data:post.url + "#comments"'>Comments</a>

で、動かしてもこれがちゃんと表示してくれないんだよね~。 上のコードのリンクは表示されてるんだけど、入力フォームが出てこない。こりゃどうしたものか。 出てこないってことは、貼り付ける場所を間違えてるんだろう、と。 絶対出てくるところに貼り付ければいいじゃん。 ってことで、上記Commentsリンクの直下に貼り付けてみた。

    <b:if cond='data:post.allowComments'>
     <h4>
       <b:if cond='data:post.numComments == 1'>
         1 <data:commentLabel/>:
       <b:else/>
         <data:post.numComments/> <data:commentLabelPlural/>:
       </b:if>
     </h4>
   <b:else/>
     <a expr:href='data:post.url + "#comments"'>Comments</a>
<!-- START TypePad Connect -->
<b:else/>
<div class="comments-content">
~ここに手順書のコード~
</div>
<!-- END TypePad Connect -->

これで、どうかな~。うぬ。ちゃんと出るね。

ということで、Friend Connectは削除して、コメントシステムはTypePad Connectで一本化です! スッキリした。

LINQで今日以降今年いっぱいの日曜の日付を表示

LINQ練習 #1 「LINQ to Object 基本」 - 悠希 - builder by ZDNet Japan

ここで書かれてるコードが↓これ。

List<DateTime> list;
list = new List<DateTime>();
for(int i=0;i<=365;i++) list.Add(DateTime.Today.AddDays(i));

int nowYear;
nowYear = DateTime.Today.Year;
var sunday = from M in list
            where M.DayOfWeek == DayOfWeek.Sunday &&
                  M.Year = nowYear select M;

foreach (var row in sunday) {
   Console.WriteLine(row.ToShortDateString());
}

※今年かどうか判定する条件の部分が代入になってるよ~。

せっかくLINQを使うっていうお題なんだから日付の生成部分もList<DateTime>に生成して入れておく、なんてことをしないで、そこもLINQに含めちゃった方がオシャレ感でると思うよ~。そう、例えば↓こんな感じでね。

var today = DateTime.Today;
var sunday = from date in
             from day in Enumerable.Range(0, 365)
             select today.AddDays(day)
            where date.DayOfWeek == DayOfWeek.Sunday &&
                  date.Year == today.Year
            select date; 

2009年2月24日火曜日

心機一転

ブログをBloggerに移行します。 FeedBurner経由でFeedを購読してる場合は、そのままのでOKです。 特に、現在がんばってるASP.NET MVCに関するエントリーは移行しました。 が、その他のエントリーは、移行せずそのままにしておきます。 今後、こちらのブログでの更新がメインになります。 あと、Tumblrはそのまま継続使用デス。

BloggerのRate Limit

まさか、自分がこの制限に引っかかるとは...。 調子に乗って、前ブログからエントリーを移行させるという行為が仇になった。 24時間で解除されるみたいだけど、それまでWindows Live Writer経由でのエントリーが出来ませぬ。 Windows Live Writerで書き込もうとすると...。
400 Bad Request Blog has exceeded rate limit or otherwise requires word verification for new posts
と、悲しいメッセージが出てくる。 50件あたりで出てきた気がする...。切ないね~。 ブラウザ経由でも、API経由でも出るみたいだから、一気にやるならImport機能のフォーマット似合わせたXMLファイルを作成して、それを取り込むのがいいんだろうね。 面倒くさいな~。特に、画像をPicasaに入れてimgタグのsrc書き換えとかゲンナリするし。 まぁ、ASP.NET MVC関係のエントリだけを移行させようと思ってるから手作業でやるさ。

ASP.NET MVCとGearsの連携

Maarten Balliauw {blog} - Creating an ASP.NET MVC application with Google Gears

面白いエントリー発見。 Gearsは最初のリリースの時に少し触ってみた程度で、名前からGoogleが消えたあたりから全く状況を追いかけてないんだけど、このサンプルがかなり面白い。

エントリーには2つソリューションがあって、1つはMVCオンリーでもう一つがGears対応版。両方見比べると分かりやすいんだけど、なにせDBファイル込みになってるから少しサイズが大きいのが難点。今時気にしないか。

どっちにも共通して存在してる(デフォルトで作成されるファイルを除く)のが、NoteControllerとそのView達(List/Detail/Edit/Create)。

で、Gears対応版では、Routeにmanifest.jsonへのリクエストに応えるためのルート登録をして(GearsController)、関連jsファイルが含まれててそれをインクルードするためにSite.Masterが少し違うだけ。

MVC部に注目してみると、GearsControllerのIndexアクションが~/Contentと~/Scriptsフォルダの全ファイルのURL、それとMVCがViewを返すURL(/Homeや/NoteList)、データ詳細のURLをJsonで返すだけに見える。

一体全体どこでデータとViewのHTMLとを分離するコードがあるんだろう。テンプレート的な物が定義されてるんだとばかり思ってたけど、そんなコードが見あたらない。

demo_offline.jsはGearsを有効にしたり、無効にしたときのローカルストアの操作と、完全オフラインになったときに編集用リンクを無効にしたり、オフラインかどうかを監視するコードだけっぽい。 gears_init.jsはいじらないコードだよね。

formやinputのname属性とかを見て自動で判断してくれてるのかな。それともシンプルに各URLに対するアクションをすべて記録してて、オンラインになったときに再生? ちゃんとGearsの仕様を確認すれば分かることなんだろうけど、まぁ、ねぇ。今はそんなに興味ないって言うか。

それにしても、Gearsはずいぶんスゴイ事になってる気がするし、MVCとの連携がこれほどシンプルに出来るのもViewStateとPostBackがないおかげだよね。

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のエントリは全部移行しました。手作業で...。

なにを~!タイムリー過ぎるぜ!

You Still Can’t Create a jQuery Plugin? – NETTUTS

まだ、作ったことないっていうかjQuery自体まだ使ったことないってばよ。fnに入れればいいんでしょ?

そのくらいしか、知識がないので、ビデオでお勉強。
シンプルなプラグインだけど、まずはシンプルな物を理解しないとね!

(function($){

})(jQuery);

っていうのは基本みたい。jQuery本体を引数に渡して$でショートカット。

ちょっと、不思議に思ったのがthisの扱い。
Enumerable(っていうのかな)なリストに対して、プラグインを実行すると、プラグイン内でのthisがこのリストを指す。プラグイン内でそのリストをeachで回したときのthisはDOMエレメント(だよね?)。だからeach内では$(this)でjQueryでかぶせて色々便利になるようにしないといけない。

hoverに渡すfunctionがmouseenterとmouseoutの両方なのが素敵な感じがする。
ビデオ中ではmouseoutの時に$(‘tooltip’).hide()って書いてるけど、これじゃ、無限に同じIDのtooltipが出来ちゃうからダメじゃん!と思ってたけど、デモで使ってるコード(ブログエントリの最後のコードも)ではちゃんとremove()になってる。前もそうだったけど、ここで取り上げるビデオな何かしら引っかけがあるよね。

最後に、チェインするためにreturn thisでエレメントのリストをそのまま返すあたりも、jQuery流なのかな。これしないと、"."で区切って続けていろいろ出来ないもんね。

この例だと、fnに単一のfunction(内部では無名関数使ってるけど)を入れてるだけの、シンプルな物だけどもっと大きなプラグインを作る時にはどうするか気になる。けど、そこはすでに公開されてる他のプラグインをいくらでも参照すればいいから、さぁ、作ってみよう!って言うときにはそれほど問題にならないよね、きっと。

2009年2月19日木曜日

AreasサンプルがRCに対応

Philさんすげーよ。あんたやっぱり最高だよ。
RCに対応させたAreasのコードがアップロードされてる。

Grouping Controllers with ASP.NET MVC

RouteCollectionの簡単登録ヘルパーと、Viewの位置を解決させるためだけのカスタムViewEngine。

何でこれだけで出来るのか不思議だ。コントローラのパスだってデフォルトの名前空間だって違うのに、ControllerFactoryを作ることなく出来るんだよ。

で、いつだったか忘れた(ベータだっけな~?もっと前?)けど、ルーティングのDataTokensにNamespacesを入れとけば、コントローラのNamespaceに自動で入れてくれるっていう仕様になったような。

気になったらとめられない。とりあえずRCのソースをチェック。
たぶんControllerFactoryあたりだろう。

   

protected internal virtual Type GetControllerType(string controllerName) {
    if (String.IsNullOrEmpty(controllerName)) {
        throw new ArgumentException(MvcResources.Common_NullOrEmpty, "controllerName");
    }

    // first search in the current route's namespace collection
    object routeNamespacesObj;
    Type match;
    if (RequestContext != null && RequestContext.RouteData.DataTokens.TryGetValue("Namespaces", out routeNamespacesObj)) {
        IEnumerable<string> routeNamespaces = routeNamespacesObj as IEnumerable<string>;
        if (routeNamespaces != null) {
            HashSet<string> nsHash = new HashSet<string>(routeNamespaces, StringComparer.OrdinalIgnoreCase);
            match = GetControllerTypeWithinNamespaces(controllerName, nsHash);
            if (match != null) {
                return match;
            }
        }
    }

正解だね。
太字のところ。コレでRouteData.DataTokensからNamespacesを取得して、Controllerの型を判定してる。

   

public static class AreaRouteHelper {
    public static void MapAreas(this RouteCollection routes, string url, string rootNamespace, string[] areas) {
        Array.ForEach(areas, area => {
            Route route = new Route("{area}/" + url, new MvcRouteHandler());
            route.Constraints = new RouteValueDictionary(new { area });
            string areaNamespace = rootNamespace + ".Areas." + area + ".Controllers";
            route.DataTokens = new RouteValueDictionary(new { namespaces = new string[] { areaNamespace } });
            route.Defaults = new RouteValueDictionary(new { action = "Index", controller = "Home", id = "" });
            routes.Add(route);
        });
    }

    public static void MapRootArea(this RouteCollection routes, string url, string rootNamespace, object defaults) {
        Route route = new Route(url, new MvcRouteHandler());
        route.DataTokens = new RouteValueDictionary(new { namespaces = new string[] { rootNamespace + ".Controllers" } });
        route.Defaults = new RouteValueDictionary(new { area="root", action = "Index", controller = "Home", id = "" });
        routes.Add(route);
    }
}

なので、上記ヘルパー(Philさんのコードです)の太字の部分さえ書いておけば、ちゃんとサブフォルダーのControllerとしてインスタンスを作ってくれる。凄いな~。
Namespacesがうんたらかんたらってアナウンスしてたときは、はぁ、そうですか、それ何に使うんですか?ってなもんだったけど、こうやって実装を見せられると納得。

ところで、Windows Live Writerのアドインでコードを色つきに出来るみたいだけど、BloggerにポストするとCSSがエンコードされちゃってちゃんと出来ない。使い方としてはCSSを外部に出してlinkタグを書くのが正んでしょうか?その場合、外部CSSはどこに置いておくのが正しいんでしょうか?いまいち使い方が分からずだけど、徐々に慣れていこうと思うところです。

2009年2月18日水曜日

DataAnnotationsのValidationAttributeを作る

何となくね、n以上の値のみ許可、っていうValidationAttributeが欲しいな、と思ったわけですよ。 でも、実際はRangeで最小値とdouble.MaxValue(intでもいいけど)とかで、代用出来るじゃないですか。 悩ましいけど、コードを見てコレってようは最小値を制限したいってことなんでしょ?っていうのをすぐ分かるようにしとくなら専用のValidationAttributeかなって思ってね。

で、思ったわけ。そもそもなんて言う名前のクラスにすれば適切なんですか、って。 辞書で調べたらor moreとか、aboveとか出てくるじゃない。でもなんかね~、クラス名にするにはしっくり来ない。

みんなどうしてるんだろ。気になる。結局はさ古典(昔、なんかで勉強したときに確かGTEとかって書いた気がするし)に則って、GreaterThanOrEqualAttributeって名前にしたんだけどさ。長いよね。ちなみにLINQのExpression Treeではどうしてるのかな、とMSDN見てみたら(ExpressionType 列挙体 (System.Linq.Expressions))、ExpressionType.GreaterThanOrEqualだね。じゃ、そういうことで。

   

public class GreaterThanOrEqualAttribute : ValidationAttribute { private Type targetType { get; set; } private object minValue { get; set; }

public GreaterThanOrEqualAttribute(Type type, object min) { targetType = type; minValue = min; }

public override bool IsValid(object value) { if (value == null) return false;

var val = Convert.ChangeType(value, targetType) as IComparable; var min = Convert.ChangeType(minValue, targetType) as IComparable; // 同値以上ならOK return val.CompareTo(min) > 0; } }

って、書いて動くんだけど、まぁ、ちょっと、ね。

最初から用意されてないってことはRange使えよっていう意図があるような気がするから、Range使いましょう。

2009年2月17日火曜日

残念なことが続いてさ

こないだノートパソコンのメモリを2GBから4GBに変えたのよ。Vista32bitだから全部は使えないけど、そこはそれ、みんながやってるようにRAM DISK(Gavotte Ramdisk)にしてさ。
1GBくらいできるじゃないですか。

IMG_0445

んで、思ったわけですよ。
このディスクの使い道をIEとFirefoxのキャッシュじゃなくて、USBメモリ扱い(Removable Media)のドライブにしてReady boostにすると、結局メモリとしてすべて使えて早くなるんじゃないかって。

しばらくそれで動かしてたんだけど、あれだね、関係ないね。Ready Boostとして使える領域が減る分HDDが回転しちゃうからか、バッテリの持ちも悪くなったし、パフォーマンスも上がらないし。

結局Fixed Mediaに設定を戻してReady BoostはSDカードに戻したよ。いいアイデアだと思ったんだけどな~。

これもかなり残念なことだったんだけど、それ以上に残念だったのがこれ。

IMG_0446

最近ホッケーよりも水泳の方が楽しくてね、週3で泳いでるんだけどね、いつも行ってるプールがね、休みだったの...。泣ける。メモリ上手く使えなかったことなんかよりよっぽど泣ける。ちゃんと定休日調べてなかったのが悪いんだけどさ。この休館が実は10日間も続くなんて思ってもなくてさ。だけど、泳ぎたいじゃない?だから違うプールに行ったわけですよ。そしたらね、完全アウェーな空気に心が折れそうになっちゃってさ。プールってコースの泳ぎ方とか場所によってローカルルールがあるわけじゃないですか。でも、なんか、いつもと違うってだけで、心折れる。ちゃんと泳ぎはしたけど、なんかね。スッキリしないっていうか。

今はもうホームプールが復活してるから、我が物顔で泳ぎまくってるんだけど、アウェー怖しだね。

URLは自動生成になっちゃうの?

今気がついたというか、確認してみたら"blog-post_n"みたいなURL(Slug)が生成されてるけど、コレを変えるにはどうするのかな~?

WLWだと出来なかったりするの?

slug

あ、スラッグが"いいえ"になってる...。

自分で指定は出来ないけど、アルファベットで始まるタイトルなら、それを利用するようには出来てるんだね。このエントリのアドレス中途半端に"url"とかってなってるし。

まぁ、いいか。特に使いたいシステムが他にあるわけでもないし、自分でサーバー用意するのも面倒だから、これからは徐々にこっちに移行してしまおう。

最初からFriend Connectでしかコメントを受け付けないようにしとけば、へんなスパムコメントもないだろうし(昨日の今日ですでにスパムコメントがあったのに驚いたけど即削除)。

今度は

Windows Live Writerで書いてみる。 フォントとかどうなっちゃうのかちょっと不安なんだよね。

ちゃんと見れるかな?

2009年2月16日月曜日

と、言うわけで

どういう形にすれば、引っ越しできるのかを試す。 とりあえず画像をどうすればいいのかを確認。

ブログの引っ越しをようようかと

いろいろ思うところがあって。 趣味や技術的なこともすべてこっちに引っ越そうかな。 でも、データの移行をどうするのかが、大問題だ!

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年2月4日水曜日

ASP.NET MVCでLambda使ったRepeaterヘルパー

Bug squash: Repeater with separator for ASP.NET MVC

やってることは、Actionデリゲートを受け取るRepeaterヘルパーを書いて、その中でイテレータでデータを取り出して、Actionデリゲート(アイテム描画と区切り描画)を実行するものだけど、初回実行と2回目以降の描画を判定するイテレータの書き方がオシャレ。

1.itemsをSelectでアイテム描画FuncのIEnumerableに変換。 2.Intersperseで最初のイテレータなら、アイテム描画のFuncをyield return、2回目以降は区切り描画Funcをyield return後アイテム描画Funcをyield returnで新たなIEnumerableに変換。 3.最後に、2のFuncのIEnumerableをSelectして実行させる。

元はPhilさんの↓このエントリー。

Code Based Repeater for ASP.NET MVC

ちょっと、古いじゃないですか!と、思うところですが、気になったんですよね。 そういえば、このLambda(Actionデリゲート)には何が渡されてるんだ?と。 WebFormViewEngineなんだから、aspxをc#(VB.NETかもしれないけど)に変換してるのは分かるけど、そういえば最近変換内容を確認してないな~、なんて。

  <p>
 <% Html.Repeat(new []{"ルフィー","ゾロ","ウソップ"}, val => {%>
   <% = Html.Encode(val)%>
 <% }, ()=>{ %>
   <br />
 <% }); %>
 </p>

↑こんな感じで書いたときに、どういうcsが出力されてるのか。 WebDev.WebServerで動かしてる時って、どこにいるんだべか。 悩む必要なんか無いことに気がついた。VSの出力ウィンドウに出てるじゃん。

img.aspx

小さすぎて見えないね。

'WebDev.WebServer.EXE' (マネージ): 'C:\Windows\Microsoft.NET\Framework\v2.0.50727\Temporary ASP.NET Files\root\c83bad6c\726bdcee\App_Web_index.aspx.a8d08dba.52iuaodz.dll' が読み込まれました。シンボルが読み込まれました。

↑こう。 ってことで、上記フォルダを見てみる。それっぽい拡張子csのファイルを開く。 コメントが多いから消したのが↓これ。

            @__w.Write("\r\n  <p>\r\n  ");
    Html.Repeat(new []{"ルフィー","ゾロ","ウソップ"}, val => {
           @__w.Write( Html.Encode(val));
           @__w.Write("\r\n  ");
    }, ()=>{
           @__w.Write("\r\n    <br />\r\n  ");
    });
           @__w.Write("\r\n  </p>\r\n\r\n");

※@__writeはHtmlTextWriter。

そっか。普通に<%= ~ %>はHtmlTextWriter.Writeになるだけで、<% ~ %>はそのままのコードだもんね。 スッキリ。

2009年2月2日月曜日

RoutingのパラメータにURLを指定したかったり

前回のエントリでFlickrから画像を取得する部分のコードに違和感を持った人がいますかね? ルートの登録でもアクションの定義でも"url"って書いてるのに、使ってないじゃないか!と。 そうなんですよ。

非同期サンプル書くのにURLを渡して、そのURLに対してWebRequest(WebClientでも)で取得するのを書こうとしてたんだけど、エラーになるのでとりあえずは固定で対応という逃げの一手。 で、どういうことかというと、例えばProxy(string url)という感じでアクションを定義(前回のサンプルだとSync2とAsync3)する感じ。

ルートの定義は

      routes.MapRoute(null,
       "Proxy/{*url}",
       new { controller = "WebRequest", action = "Proxy" }
     ); 

みたいな。 ※WebRequestControllerっていうのがいたとして。

リクエストするときのURLが昨日の例だと

http://localhost/Proxy/http://farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg

みたいな。 でも、そこはURLエンコードしとかないとさ、っていうんで

http://localhost/Proxy/http%3A%2F%2Ffarm1.static.flickr.com%2F131%2F353753310_1ed04f694c_m.jpg

みたいな。 でね、これがね、ダメなの。

img.aspx

ゴルァ~!っと怒られるわけっす。 ところで、このエラーいつだれが出してるんですかね? ってことで、調べてたんだけど、たぶんRouting(コレだ!っていう資料を見つけられなかった)。 WebServerじゃないと思うんだけどどうでしょう。 だってね。ASP.NET WebFormで以下のように書くとこれはちゃんととれるんだもん。

  public partial class _Default : System.Web.UI.Page
 {
   protected void Page_Load(object sender, EventArgs e)
   {
     if (!IsPostBack)
       var url = Request.QueryString["url"];
   }
 } 

何がまずいのかな~、と。

URL Encodingに書かれてる、使っちゃいけない文字も含まれて無いし。

だけど、MVCのRoutingってcontrollerとかactionとかっていうデータを構築してくれて、GetVirtualPathで勝手にURLエンコードした文字列返してくれるじゃないっすか。

VirtualPathData.VirtualPath プロパティ (System.Web.Routing)

その辺については特に書かれてないんだけど。 でも、Routeを登録するときにワイルドカード指定できるでしょ。 ワイルドカード内のスラッシュ'/'はエンコードしないじゃないですか。 もしやと、思って、UrlEncodeしないで試してみたんですよね。 もちろん':'は使えないんで除外します。

そしたらちゃんとリクエスト受け付けてくれるんですよ('/'を2個連続はダメだけど)。 http://localhost/Proxy/http/farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg このままだとアックションで取得出来るurlが"http/"で始まるんでちょっと感じ悪い。 ので、ルートの定義をちょっと変更。

      routes.MapRoute(null,
       "Proxy/{scheme}/{*url}",
       new { controller = "WebRequest", action = "Proxy" },
       new { scheme = @"(http|https)" }
     ); 

みたいな。 アクションの定義も。

    public ActionResult Proxy(string scheme, string url){...} 

みたいな。 このルートを使うようにHtml.ActionLinkを書いて出力させると、以下のように。

  <a href="/Proxy/http/farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg/">Flickr</a>

ふむ。

UrlEncodeしないとまずい文字を含めた、場合にどうなるかを試してみるのに、このUrlの最後に"/\<'09 in オレ>"をくっつけてみる。出力結果は↓。

  <a href="/Proxy/http/farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg/%5C%3C'09%20in%20%E3%82%AA%E3%83%AC%3E">Flickr</a>

ちゃんとUrlEncodeしてくれてる。 けど、これだとまた"Bad Request"。 まぁ、'\'とか'<','>'をエンコードしてるとはいえ、使っちゃってるのがいけないんだな、と。 そこはUrlEncodeしてるんだから通して欲しいけどね。ってことで、使用不可の文字を使ってる場合は、UrlEncodeしててもBad Requestになるので気をつけよう。

  <% = Html.ActionLink("Flickr", "Proxy", "WebRequest", new {
   scheme = "http",
   url = @"farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg/'09 in オレ"
   }, null)%>

↑こんな感じで、ActionLinkやUrl.Actionを使うのがいいみたい。 って、ことで実行結果は。 img.aspx2 img.aspx3

ASP.NET MVCは関係無いね。

ASP.NET MVCで非同期リクエスト

Improve scalability in ASP.NET MVC using Asynchronous requests « Steve Sanderson’s blog まずは↑。

非同期のIHttpAsyncHandlerをMVCでも使おうよ、という話。 ソースは部分的。

で、このたびリリースされたASP.NET MVC RC(Refreshはお早めに)。 の、FuturesにAsyncControllerが含まれてる。

そりゃ~、もう気になって仕方ないよね。 とりあえずは、ソースを確認して、どういう構成で非同期実装してるのかを見ることにしたんだけど、どうにも要領を得ないな。

AsyncManager.RegisterTast

上記エントリだとアクション内でRegisterAsyncTaskを読んで、Begin/Endそれぞれのdelegateを登録するという流れなので、 RC Futuresのソースを眺めてて、パッと目につくのがAsyncManager.RegisterTast()。 名前からしてタスクを登録するメソッド。

public IAsyncResult RegisterTask(Func beginDelegate, AsyncCallback endDelegate) 

こんな宣言なのを見ると、上記エントリと同じ使い方でいいんじゃないかと思えるんだけど。 試しに書いたコードが↓。

    public void Image(string fileName)
    {
      AsyncManager.RegisterTask(cb => {
        Debug.WriteLine(string.Format("request thread:{0}={1}({2})",
          new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, fileName }));

        Thread.Sleep(3000);
        cb(null);
      }, delegate(IAsyncResult result) {
        Debug.WriteLine(string.Format("response thread:{0}={1}({2})",
          new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, fileName }));

        var dir = Server.MapPath("~/App_Data/Images");
        var path = string.Format("{0}\\{1}", dir, fileName);

        if (System.IO.File.Exists(path))
        {
          File(path, "image/png").ExecuteResult(ControllerContext);
        }
        else
          Response.StatusCode = 404;
      });
    }

Add_Data/ImagesにPNG画像ファイルを入れて、そのファイル名を指定するとファイルを返すという簡単なもの。 動くのは動く。普通に。 でも、スレッドIDはbegin/endどっちも同じものしか使われてない様子。 開発環境だからかな~。なんでかな~。 レスポンスを返す部分で、ActionResult.ExecuteResult()を呼んで、その場でResponseしちゃうっていうのはちょっと違う気がしなくもない。

Action/ActionCompleted

次にソースを追っかけて気がついた。「// Is this the Foo() / FooCompleted() pattern?」なんてコメント発見。普通にアクションを書いて、同じ名前のアクション名+Completedっていうアクションを定義する方法。わかりにく!

    public void Image2(string fileName)
    {
      Thread.Sleep(3000);

      HttpContext.Items["params"] = fileName;
      Debug.WriteLine(string.Format("request thread:{0}={1}({2})",
        new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, fileName}));
    }

    public ActionResult Image2Completed()
    {
      Debug.WriteLine(string.Format("response thread:{0}={1}({2})",
      
new object[] { Thread.CurrentThread.ManagedThreadId,
Thread.CurrentThread.IsThreadPoolThread, HttpContext.Items["params"]
}));

      var dir = Server.MapPath("~/App_Data/Images");
      var path = string.Format("{0}\\{1}", dir, HttpContext.Items["params"]);

      if (System.IO.File.Exists(path))
        return File(path, "image/png");
     
      Response.StatusCode = 404;
      return new EmptyResult();
    }

ようするに↑こうなんだけど。 んで、これだと、スレッドIDが変わることがあるから、なんか上手く行ってる気がしなくもない。 なんせ、リクエストを受け付けたときのパラメータがCompletedの方には渡されないから、HttpContext.Itemに入れてるのが、かなり自信ない。 でも、この書き方だと、Completedの戻り値がActionResultだから分かりやすいんじゃないかと思える。 スレッドIDも違うし。

BeginAction/EndAction

さらにコードを追いかけてると、今度は「// Is this the BeginFoo() / EndFoo() pattern?」と書かれてる。 ってことは、アクション名にBeginなんちゃら/Endなんちゃらってかいておくと、それを呼び出してくれるのかなと。ソースもAsyncActionMethodSelectorだし。 で、試した。 今度はBegin/Endのパラメータ指定に制限があって、Beginは戻り値IAsyncResultで引数AsyncCallback callback, object stateが必須。EndにはIAsyncResult resultが必須。

    public IAsyncResult BeginImage3(AsyncCallback callback, object state)
    {
      Debug.WriteLine(string.Format("request thread:{0}={1}({2})",
        new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, "test" }));

      var web = WebRequest.Create("http://farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg");
      HttpContext.Items["web"] = web;
      return web.BeginGetResponse(callback, state);
    }

    public void EndImage3(IAsyncResult result)
    {
      Debug.WriteLine(string.Format("response thread:{0}={1}({2})",
        new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, "test" }));

      var web = HttpContext.Items["web"] as WebRequest;
      WebResponse res = web.EndGetResponse(result);

      Response.ContentType = res.ContentType;

      var reader = new BinaryReader(res.GetResponseStream());
      Response.BinaryWrite(reader.ReadBytes((int)res.ContentLength));
    } 

追記 ここはHttpContext.Item使わなくてもBeginGetResponseにstateの代わりにweb渡せばEndAsync3でresult.AsyncStateでとれますね。

今度はFlickrの画像をWebRequestで取得するように変更。 戻り値が必要だし。 んで、デバッグ出力のスレッドIDはBeginとEndでちゃんと違う。 そんなこんなで、どうやって書くのが正しいのかいまいちよく分からないAsyncController。 ちなみにRouteは↓こんな感じで書いてます。

      routes.MapAsyncRoute(null,
        "Async3/{*url}",
        new { controller = "AsyncImages", action = "Image3" }
      );

      routes.MapAsyncRoute(null,
        "Async2/{fileName}",
        new { controller = "AsyncImages", action = "Image2" }
      );

      routes.MapAsyncRoute(null,
        "Async/{fileName}",
        new { controller = "AsyncImages", action = "Image" }
      ); 

悩ましいな~。なんて思ってた所に新たな非同期エントリ登場。

Extend ASP.NET MVC for Asynchronous Action - Happy Coding

これまた全然違う作り方してるんだよね。

Controllerはそのままに、AsyncMvcHandler実装(他にもいろいろあるけど)で乗り切る方法。 これだとControllerFactoryもいじる必要無いし、アクション単位でAsyncAction属性指定で非同期判別。 戻り値もActionResultだし。

サンプルコードが↓こんなの。

        [AsyncAction]
        public ActionResult AsyncAction(AsyncCallback asyncCallback, [AsyncState]object asyncState)
        {
          
SqlConnection conn = new SqlConnection("Data
Source=.\\sqlexpress;Initial Catalog=master;Integrated
Security=True;Asynchronous Processing=true");
            SqlCommand cmd = new SqlCommand("WAITFOR DELAY '00:00:03';", conn);
            conn.Open();
           
            return this.Async(
                cmd.BeginExecuteNonQuery(asyncCallback, asyncState),
                (ar) =>
                {
                    int value = cmd.EndExecuteNonQuery(ar);
                    conn.Close();
                    return this.View();
                });
        }

this.AsyncっていうのがControllerの拡張メソッド。これでEndProcessRequestの時に呼び出すdelegateを指定。スゴイよね。 コード量も少ないし。delegateの戻りがそのままアクションの戻りとして使われるし。 なんかコードみてるとasyncCallbackとasyncStateをCallContextに入れるようになってる。初めて見た。 コレだとスレッド単位のコンテキストオブジェクト管理が出来るっぽい。

ActionResultなんかはHttpContext.Itemに入れて、リクエストコンテキストで管理。 ちなみにコレのプロジェクトにベンチマーク用のコードが入っててこれいいじゃん!みたいな。 PowerShellとIIS6リソースキットに含まれるTinyGetを使って時間を計測。 シンプルで簡単なテストだから試してみた。

試すに当たって使った同期版のコードは↓。

      public ActionResult Sync(string fileName)
      {
        Thread.Sleep(3000);

        var dir = Server.MapPath("~/App_Data/Images");
        var path = string.Format("{0}\\{1}", dir, fileName);

        if (System.IO.File.Exists(path))
          return File(path, "image/png");

        return new EmptyResult();
      }

      public ActionResult Sync2(string url)
      {
        var web = new WebClient();

        var bytes = web.DownloadData("http://farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg");
        return File(bytes, web.ResponseHeaders["Content-Type"]);
      } 

Sync vs Async(Imageアクション)

Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Sync/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Async/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Sync 21.33秒、Async 14.68秒。

Sync vs Async2(Image2アクション)

Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Sync/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Async2/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Sync 21.40秒、Async 12.70秒。

ここまでの2個のテストではそれぞれ3秒のウェイトを入れてこの時間だから、非同期でリクエストスレッドを早く開放したほうが次のリクエストを続々と受け入れることが出来るようになるんだから、最終的にこのくらいの差が出てくるのも納得(だよね?)。

Sync2vs Async3(Image3アクション):Flickrから取得

Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Sync2/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Async3/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Sync 3.84秒、Async 2.16秒。

コレに関してはあんまり意味ないね。Flickrからの取得にかかる時間が毎回一定なわけじゃないし。 Any good ideas to build an async action for scalability improvement? - ASP.NET Forums ここで、それぞれの設計について話をしてる。

OnAction~フィルターの動作も非同期にしないとっていう話? ふむ~。 じきにAsyncControllerの使い方を公開するってことなんでそれまで待つのがいいかもね。