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>これを実行したときの画面が↓これら。
前回同様、一覧。Editでルフィーを選択。
名前とタイプのところが、List<WeaponsViewModel>の入力フォーム。 この状態のまま、Saveボタンを押してポストする。
バッチリ復元されました。 まぁ、これだとDataAnnotations関係ないので、あえて入力エラーになるように、フォームの内容を変更。
すると、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行追加するだけ。 この状態でさっきと同じ入力エラーを発生させる。
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> <% } %>
こんな感じの単純な物なんだけど。 例えばコレで、テキストエリアに"<script />"なんて入れて送信すると...。
見慣れたエラーが出るね。 だけど、アクションに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するので、その違いがこんな所に出てきてます。まぁ、デフォルト安全動作っていうのはいいことだね。ベータ以前から移行の場合は修正箇所は増えるけど。
肝心のファイルアップロードといえば、以下の通り。普通に入ってるね(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は書き換えてないです。
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"を入れる。 これだけなんだけど、なかなか気がつかないよね。
今度は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の出力ウィンドウに出てるじゃん。
小さすぎて見えないね。
'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
みたいな。 でね、これがね、ダメなの。
ゴルァ~!っと怒られるわけっす。 ところで、このエラーいつだれが出してるんですかね? ってことで、調べてたんだけど、たぶん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を使うのがいいみたい。 って、ことで実行結果は。
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の使い方を公開するってことなんでそれまで待つのがいいかもね。
dotnetConf2015 Japan
https://github.com/takepara/MvcVpl ↑こちらにいろいろ置いときました。 参加してくださった方々の温かい対応に感謝感謝です。
-
Working with SSL at Development Time is easier with IISExpress - Scott Hanselman Hanselmanさんのエントリに書かれてる通りデス! IIS ExpressでSSLを有効にしたデバッグだと無効...
-
How to iterate through objects in ViewData via javascript on the page/view? - Stack Overflow この質問の回答としては、ページにscriptタグを書き、その中でJavaScriptを書き...
-
すでにコロケーションサービスを利用したりして、データセンターには自前のサーバーがいて。でも、今のデータセンターの利用を拡張していったり、するのもどうかなー、資産化するのもなー。という時にはパブリッククラウドにVPN接続させて、オンプレミスとのハイブリッド。 ハイブリッドクラウド...