以前の投稿(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;
}
}
}
太字のところですね。
これで、入力値に整数以外を入れて、ポストしたときのスクリーンショットが↓これです。
ViewがRenderされるときに、ModelStateDictionaryがどうなってるかをブレークポイントをセットして確認してみましょう。
クリックすると大きく見れます。
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 モデルクラス {…}
って、書きましょう。
この状態で、もう一度動かしてみます。
今度はちゃんと、ValidationSummary()で表示されるようになりました。
今回のコードは無理矢理エラー項目のキーに"__InvalidOperationException__"と入れて、複数のエラーメッセージが出ないようにしてますが、もちろん項目毎に表示してもいいですよね。
若干気になるのは、なんでInvalidOperationExceptionを発生させるようにしてるのかな~、というところ。なんとなくだけど、InvalidCastExceptionのほうがシックリ来ないですかね~。そういうもんなんですか?
ちなみにどこでInvalidOperationExceptionを発生させてるかというと、ASP.NET MVCに含まれるValueProviderResultクラスのConvertSimpleTypeメソッド。この中でTypeDescriptor.GetConverter()で取得したコンバーターのConvertToを呼び出すところらへん。
ココまで書いて思ったんだけど、ASP.NET MVCに関するエントリーだけでも全部こっちに持ってくればいいのかな。今はそれ以外の事を書く事もほとんどないし。そうしちゃおっかな。
ASP.NET MVCのエントリは全部移行しました。手作業で...。