2009年3月12日木曜日

IDataErrorInfoの危険ゾーン

ASP.NET MVCで入力検証を行う場合、以前の投稿でDataAnnotationsとIDataErrorInfoの組み合わせ最強!なんて言ってましたが、ASP.NET MVC eBook Tutorialを読んでいて、おやや?と思ったわけです。ナゼ、彼らはコレを使ってないんだろうかと。

このチュートリアルの中で実装されている入力検証ではIDataErrorInfoを使わず、ASP.NET MVC Preview 5 and Form Posting Scenarios - ScottGu's Blogで紹介されてるRuleViolationクラスを使う方法なんですよね。どういう検証ロジックになってるのかを公開されてるソースとPDFに書かれてる内容から整理してみます。

LINQ to SQLを使ってモデルを作成してるので、モデルクラスのOnValidateパーシャルメソッドを使って、データ更新時に検証ロジックが実行されるようにしてます。

LINQ to SQL (Part 5 - Binding UI using the ASP:LinqDataSource Control) - ScottGu's Blog

実行した検証結果はRuleViolationクラスのインスタンスとして、プロパティ名とエラーメッセージを保持。GetRuleViolations()で、プロパティ毎の検証を実施。この関数はIEnumerable<RuleViolation>でyield return new RuleViolation(~)の形式で各プロパティの検証結果を返すようにすることでLINQのAny()でエラーの有無をすべてチェックしなくても判定できるってことですね(実際はCount()==0でエラー無しっていうコードになってるけど)。OnValidate()内ではIsValid()を呼んで、エラーがあるなら例外(ApplicationException)を発生させてるんですね。

更新処理自体はRepositoryの中で実装してますが、実質InsertOnSubmit()してるだけ。それをAction内で実行するわけですが、入力エラーがある場合Repository.Save()を呼び出した段階で例外が発生するので、catch内でGetRuleViolations()を呼び出して、エラー詳細を取得。ヘルパーを用意しておき、RuleViolationをModelState.AddModelError経由でModelErrorとして展開。これでViewの処理時に自動でエラークラスもセットされるし、ValidationSummaryの表示なんかもされる。

ここまでで、検証ロジックについて触れてなかったですけど、そこはもうベタな感じで作ってる。String.IsNullOrEmpty()で必須チェックや、正規表現で形式チェック。どこにもDataAnnotationsなんて使ってないです。しかも検証処理の実行をDefaultModelBinderに任せないのでIDataErrorInfoの実装もしていない。

ちょっと長くなったけど、こんな感じです。

昨日のエントリでViewData.Eval()が便利だよ、って話をしました。ここでやっと今回の本題。MVCに用意されてるHtmlHelperのInputExtensions(Html.TextBoxとかの入力用ヘルパー)は、内部でこのViewData.Evalを実行してるんですね。で、そのEvalの中で呼び出されてるのが以下の関数。

  private static object EvalComplexExpression(object indexableObject, string expression) {
     foreach (ExpressionPair expressionPair in GetRightToLeftExpressions(expression)) {
         string subExpression = expressionPair.Left;
         string postExpression = expressionPair.Right;

         object subtarget = GetPropertyValue(indexableObject, subExpression);
         if (subtarget != null) {
             if (String.IsNullOrEmpty(postExpression))
                 return subtarget;

             object potential = EvalComplexExpression(subtarget, postExpression);
             if (potential != null) {
                 return potential;
             }
         }
     }
     return null;
 }

ViewDataDictionary.csの200行から抜粋。プロパティが別のクラスのインスタンスになってても取り出せるように再帰ですね(例えばModel.Address.ZipCodeとか)。ここでGetRightToLeftExpressions()で上手いこと取得したいプロパティをすべて取り出せるんですが、問題はそのプロパティを取り出す関数であるGetPropertyValue()。またしてもソースを引用しておきます。

  private static object GetPropertyValue(object container, string propertyName) {
     // This method handles one "segment" of a complex property expression

     // First, we try to evaluate the property based on its indexer
     object value = GetIndexedPropertyValue(container, propertyName);
     if (value != null) {
         return value;
     }

     // If the indexer didn't return anything useful, continue...

     // If the container is a ViewDataDictionary then treat its Model property
     // as the container instead of the ViewDataDictionary itself.
     ViewDataDictionary vdd = container as ViewDataDictionary;
     if (vdd != null) {
         container = vdd.Model;
     }

     // Second, we try to use PropertyDescriptors and treat the expression as a property name
     PropertyDescriptor descriptor = TypeDescriptor.GetProperties(container).Find(propertyName, true);
     if (descriptor == null) {
         return null;
     }

     return descriptor.GetValue(container);
 }

GetIndexedPropertyValue()ですよ。これが最初に値を取得するために呼び出されるんだけど、これが内部でGetProperty("Item",~)を呼び出すんですよね。

  private static object GetIndexedPropertyValue(object indexableObject, string key) {
     Type indexableType = indexableObject.GetType();

     ViewDataDictionary vdd = indexableObject as ViewDataDictionary;
     if (vdd != null) {
         return vdd[key];
     }

     MethodInfo containsKeyMethod = indexableType.GetMethod("ContainsKey", BindingFlags.Public | BindingFlags.Instance, null, new Type[] { typeof(string) }, null);
     if (containsKeyMethod != null) {
         if (!(bool)containsKeyMethod.Invoke(indexableObject, new object[] { key })) {
             return null;
         }
     }

     PropertyInfo info = indexableType.GetProperty("Item", BindingFlags.Public | BindingFlags.Instance, null, null, new Type[] { typeof(string) }, null);
     if (info != null) {
         return info.GetValue(indexableObject, new object[] { key });
     }

     PropertyInfo objectInfo = indexableType.GetProperty("Item", BindingFlags.Public | BindingFlags.Instance, null, null, new Type[] { typeof(object) }, null);
     if (objectInfo != null) {
         return objectInfo.GetValue(indexableObject, new object[] { key });
     }
     return null;
 }

しつこくソースを引用しますが、ViewDataDicstionaryそのものであれば、直接ディクショナリの値を返そうとしてます。でも、内部ではTryGetValueで一致するものがないからとnullを返します。そうなると、Modelを参照して、プロパティを取得するので、ViewData.Modelにセットされてるインスタンスのプロパティを取得するときはそのままインスタンスのプロパティを返してくることになるので、問題にはなりません。が、しかしですよ、このModelにセットしてるインスタンスが別のクラスのインスタンスを保持してて、それをプロパティとして公開してる場合にはですね、GetIndexedPropertyValue()が"Item"プロパティを参照するようになるんです。この”Item"っていうプロパティはデフォルトインデクサのプロパティ名みたいで、IDataErrorInfoを実装するっていうことはコレを実装することになるので、なんとまぁエラーメッセージが返ってきてInput要素に出力されてしまうんですね。最高に分かりにくい文章ですいません。サンプル書くと長くなりそうなんです...。なので、適当なコードで表現してみます。

public class Person : IDataErrorInfo
{
 [Required(ErrorMessage="名前は?")]
 public string FirstName { get; set; }
 [Required(ErrorMessage="名字は?")]
 public string LastName { get; set; }

 public Person Father { get; set; }
 public Person Mother { get; set; }

 public string Error {
   get { // model validation }
 }

 public string this[string key] {
   get { // property validation }
 }
}

こんな感じのクラスがあったとします(中途半端ですいません) 。これをViewData.ModelにセットしてViewを表示するとしましょう。

public ActionResult Details(int? id)
{
 var person = new Person() { Father = new Person(), Mother = new Person() };

 return View(person);
}

こんな感じでしょうかね。これを表示するとしましょう。そうするとPersonクラスのFatherとMotherプロパティは空のPersonインスタンスが入ってます。でも、PersonクラスはIDataErrorInfoを実装しててプロパティの検証コードが入ってます(DataAnnotationsの検証実行が入ってるとしてください)。そうするとですね、Viewで<%= Html.TextBox(“Father.FirstName”) %>なんて書いてると、ViewData.Eval経由で値を取得しようとするけど、上記理由のためIDataErrorInfoの実装のつもりで書いてるデフォルトインデクサにアクセスされて、エラーメッセージとしての"名前は?"がテキストボックスのvalueに設定されてしまうという罠。しかもコレはテキストボックスのinput要素を作成してるInputHelperの引数useViewDataにtrueをセットして呼び出した時にEvalが実行されるんだけど、プロパティの値がnullならtrueとなる実装。そもそもstringの初期値はnullだから、今回のような場合必ずDataAnnotationsの検証が実行されて、エラーメッセージが表示されてしまいます。はてさて、どうしたものか。初期値として空文字("")をセットするとかしておけばnullじゃないから、デフォルトインデクサにアクセスされないんだけど、なんか気持ち悪し。IDataErrorInfo使わない方がいいんですかね~。う~ん。う~~~~ん。どうすればいいのか。教えて偉い人!

dotnetConf2015 Japan

https://github.com/takepara/MvcVpl ↑こちらにいろいろ置いときました。 参加してくださった方々の温かい対応に感謝感謝です。