2009年10月4日日曜日

ValidationAttributeがちょっと違う

ASP.NET MVC V2も既にPreview 2が出ましたね。入力検証がDataAnnotationsを前提にした設計(IDataErrorInfoじゃ物足りないしね)になってるのと、Templateベースのモデル描画がV2での重要ポイントですよね。

入力検証にxValと同じようにModelValidatorProviderとクライアントサイドバリデーションにjQuery.validateが導入されてのが興味深い。

そもそもV1にも追加コードとして公開されてるDataAnnotationsModelBinderがあって、System.ComponentModel.DataAnnotationsのValidationAttrbiuteを使えるようになてます。が、残念ながら標準アセンブリじゃなく別途配布されてるDLLを参照設定して使うようにしないと、全く機能しないというのがあります。何でかというとValidationAttributeクラスの実装が違うのと、その検証属性の実行方法が違うから。

.NET 3.5 SP1に含まれるVaridationAttributeクラスってIsValid(object value)なのに対し、配布されてるアセンブリではIsValid(object value)に加え、IsValid(object value, ValidationContext validationContext, out ValidationResult validationResult)があります。入力値を取得して検証するだけなら対象となる、プロパティ値があればそれで事足りるかも知れないけど、たとえばCompareValidatorは実装できないですよね。同じモデルインスタンス内の他のプロパティを見れないと比較できないじゃないですか。V1の場合は、特殊なアセンブリだからいいかもしれないけど、V2は標準アセンブリだからVaridationAttribute実装にValidationContextなんて無い(実はある?)。先日1.0がリリースされたxValが使用するアセンブリも標準アセンブリ参照だから当然インスタンス参照なんてない。

と、言うわけで、V1でxValを使うときの入力検証でモデルの他プロパティを参照する際のCompareAttributeクラスを書いて遊んでみました。何に使うのかは後で考える。

まずは、モデルインスタンスを渡せるIsValidを実装するためのインターフェイス定義。ValidationAttributeとそのインターフェースを実装するCompareAttributeクラスを定義。

  public interface IInstanceValidationAttribute
  {
    bool IsValid(object instance, object value);
  }

  public class CompareAttribute : ValidationAttribute, IInstanceValidationAttribute
  {
    public string PropertyName { get; set; }
    
    public CompareAttribute(string propertyName)
    {
      PropertyName = propertyName;
    }

    public override bool IsValid(object value)
    {
      throw new NotImplementedException();
    }

    public bool IsValid(object instance, object value)
    {
      var property = instance.GetType().GetProperty(PropertyName);
      if(property==null)
        throw new ArgumentException("パラメータ間違えてるよ");

      var targetValue = property.GetValue(instance, null);
      if (targetValue != null && targetValue.Equals(value))
        return true;

      return false;
    }
  }

CompareAttributeを使うモデルクラスの定義。他にも必須チェックやら付けてみる。

  public class Drink
  {
    [Required]
    [StringLength(10)]
    public string Name { get; set; }

    [Compare("Name",ErrorMessage = "一致しないよ!")]
    public string CheckName { get; set; }
    
    [Range(10, 50)]
    public int Size { get; set; }
  }

続いて、xValのサンプルを参考に、入力検証を実行するためのDataAnnotationsValidationRunnerクラスを実装。

  internal static class DataAnnotationsValidationRunner
  {
    public static IEnumerable<ErrorInfo> GetErrors(object instance)
    {
      var attrs = from prop in TypeDescriptor.GetProperties(instance)
.Cast<PropertyDescriptor>() from attribute in prop.Attributes.OfType<ValidationAttribute>() select new { Property = prop, Validator = attribute, IsInstanceValidator = attribute is IInstanceValidationAttribute }; foreach(var attr in attrs) { bool isvalid; if (!attr.IsInstanceValidator) isvalid = attr.Validator.IsValid(attr.Property.GetValue(instance)); else isvalid = (attr.Validator as IInstanceValidationAttribute)
.IsValid(instance, attr.Property.GetValue(instance)); if (!isvalid) yield return new ErrorInfo(attr.Property.Name,
attr.Validator.FormatErrorMessage(string.Empty),
instance); } } }

なんかちょっとダサいけど、まぁ、その辺はセンスが無いってことで勘弁してください。 最後にControllerにアクションを追加してコードは完成。

    public ActionResult Drinks()
    {
      var model = new Drink();
      return View(model);
    }

    [AcceptVerbs(HttpVerbs.Post)]
    public ActionResult Drinks(Drink model)
    {
      var errors = DataAnnotationsValidationRunner.GetErrors(model);
      if (errors.Any())
        new RulesException(errors).AddModelStateErrors(ModelState,"");
      
      return View(model);
    }

実行したのが↓これ。

xVal xVal2 

ここまでが、V1の話で、ここからV2 P2で同じことをしてみようと思います。こんなに違うのかと思えるほどDataAnnotationsの組み込みと、ModelValidatorProviderに感動です。CompareAttributeクラスとモデルクラス(Drinkクラス)は一切いじりません。追加で作成の必要なクラスは以下の1つのみ。

  public class CompaireValidator : DataAnnotationsModelValidator<CompareAttribute>
  {
    public CompaireValidator(ModelMetadata metadata, ControllerContext context, CompareAttribute attribute) : base(metadata, context, attribute) { }
    internal static ModelValidator Create(ModelMetadata metadata, ControllerContext context, ValidationAttribute attribute)
    {
      return new CompaireValidator(metadata, context, (CompareAttribute)attribute);
    }

    public override IEnumerable<ModelValidationResult> Validate(object container)
    {
      if (!(Attribute as IInstanceValidationAttribute).IsValid(container, Metadata.Model))
        yield return (new ModelValidationResult
                        {
                          MemberName = Metadata.PropertyName, 
                  Message = Attribute.FormatErrorMessage(Metadata.GetDisplayName())
                        });
    }
  }

すごいっすね~。たったこれだけ。で、このValidatorクラスを登録するためにGlobal.asax.csのApplication_Startに1行追加。

    protected void Application_Start()
    {
      RegisterRoutes(RouteTable.Routes);
      DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(CompareAttribute), typeof(CompaireValidator));
    }

プロパティーの取出しからAttributeの取り出しまで一切合財がMVCの処理の範疇になってます。DataAnnotations関連のProvider/Validatorを使って作ってるけど、この辺も自分でベースクラスから派生させたり、AssociatedValidatorProviderからの派生で作ることも可能。もちろん簡単なのはDataAnnotations関連のクラスを派生させて作ること。

ちなみにControllerでの使用は何も考える必要も無く以下のようにしとくだけ。

    public ActionResult Drinks()
    {
      return View(new Drink());
    }

    [HttpPost]
    public ActionResult Drinks(Drink model)
    {
      if(ModelState.IsValid)
      {
        // ... success code
      }
      return View(model);
    } 

ValidationAttributeクラスは標準クラスのままなので、モデルクラスのインスタンス参照を持ってないのは今後もそうだと思うので、今回のような方法でうまいことインスタンスを渡せるオーバーロードを定義することで、対応していくのがいいんじゃないかと思う次第です。

ASP.NET MVC Reference
Brad Wilson: Enterprise Library Validation example for ASP.NET MVC 2
Shaan's Official Blog : New features in ASP.NET MVC 2 Preview

dotnetConf2015 Japan

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