在WPF/MVVM中使用FluentValidation进行动态验证
本文展示了如何在WPF/MVVM应用程序中,使用FluentValidation和INotifyDataErrorInfo对用户输入进行动态验证。
引言
验证用户输入并提供良好反馈对于任何产品级别的软件应用程序都至关重要。本文将展示如何在WPF/MVVM应用程序中使用FluentValidation
和INotifyDataErrorInfo
,动态验证用户输入——这意味着部分验证规则可以在运行时更改。为本文编写的演示应用程序解决了欧拉项目问题5,并稍作修改——允许用户更改程序需要验证和提供反馈的两个数字,从而展示其能力。
背景
WPF自诞生以来就支持数据绑定管道中的验证——例如,ValidationRule
s、ErrorTemplate
等等。然而,它们也存在一些问题。首先,当您将TextBox.Text
属性绑定到源对象的数字属性(如int
和double
),并且用户输入了一些字母字符或将文本框留空时,绑定引擎会抛出类型转换异常(并默认吞噬它)。正如Josh Smith多年前指出的那样,并非所有用户都能理解因类型转换失败而可能在ToolTip
中显示的默认错误消息,如下图所示
当您将Binding.ValidatesOnExceptions
属性设置为true时,会显示第一个ToolTip
;当设置为false时(即在Binding
语句中不做任何操作),则显示第二个ToolTip
。
您可以通过向Binding
添加带有所需消息的自定义ValidationRule
来覆盖消息,这样它就可以在Binding
引擎尝试类型转换之前停止验证过程。尽管如此,在成功类型转换后,您可能还想添加更多的ValidationRule
。例如,您可能希望将值约束在某个范围内,或者验证其有效范围根据其他属性值动态变化的值。随着验证规则变得越来越复杂,它们不仅会分散在多个类中,而且也更难调试。
正是在那时,我发现了一个由Jeremy Skinners编写的开源库,名为FluentValidation,它允许我们将绑定对象上的所有输入验证放在一个单独的类中,其中每个验证规则都可以以流畅的语法声明式地说明。因此,我决定启动这个项目,看看该库是否能满足以下要求
- 所有验证,包括类型转换异常,都应在单个位置处理。这排除了使用自定义
ValidationRule
s。这也意味着每次验证都必须发生在字符串属性(而不是int或double)的setter函数中。 - 拥有字符串属性的对象——无论是视图模型还是模型——都应该实现
INotifyDataErrorInfo
,而不是旧的IDataErrorInfo
。绑定引擎确实支持IDataErrorInfo
。但要使用它,我们必须将Binding.ValidatesOnDataErrors
属性设置为true。此外,它的Error
属性从未使用。另一方面,Binding
引擎默认支持INotifyDataErrorInfo
,而无需向Binding
添加任何内容。我们所要做的就是在绑定对象中实现该接口。 - 每个与单个UI元素相关的错误消息都应该显示在
ToolTip
中。有问题的UI元素应该像ErrorTemplate
默认做的那样,用红色边框装饰。 - 所有错误消息都应汇总并显示在某个位置,以便用户无需将鼠标悬停在有问题的UI元素上即可立即看到问题,就像Silverlight的ValidationSummary所做的那样,它非常简洁,但由于某些原因并未在WPF中实现。
- 只要用户更改文本,就必须进行验证。这意味着
Binding.UpdateSourceTrigger
属性设置为PropertyChanged。 - 也应支持跨多个属性的验证规则。例如,属性A必须大于属性B + C。
- 应观察验证规则的运行时更改。例如,属性A的允许最大值可以根据运行时的一些其他情况从10变为20。这排除了使用
DataAnnotation
s,其中验证规则作为属性的属性附加,因此是固定的,不能在运行时更改。
结果令人满意,并产生了以下单个类
public class Problem5Validator : FluentValidator<Problem5> { private const string FromProperty = "'N'"; private const string ToProperty = "'M'"; private const string CannotBeLeftBlank = " cannot be left blank."; private const string MustBeValidWholeNumber = " must be a valid whole number."; private const string MustBeLessThan = " must be less than "; private const string MustBeGreaterThan = " must be greater than "; public Problem5Validator() { this.CascadeMode = CascadeMode.StopOnFirstFailure; this.RuleFor(x => x.From) .NotEmpty() .WithMessage(FromProperty + CannotBeLeftBlank) .Must(a => a.IsInteger()) .WithMessage(FromProperty + MustBeValidWholeNumber) .Must( (x, a) => { var from = int.Parse(a); return x.MinFrom <= from && from <= x.MaxFrom; }) .WithMessage("{0} <= " + FromProperty + " <= {1}", x => x.MinFrom, x => x.MaxFrom) .Must( (x, a) => { // We validate this rule only when the "To" parameter is a valid integer. int to; if (int.TryParse(x.To, out to) && x.MinTo <= to && to <= x.MaxTo) { return int.Parse(a) < to; } // If "To" parameter is invalid, we shouldn't show the error message. return true; }) .WithMessage(FromProperty + MustBeLessThan + ToProperty + "."); this.RuleFor(x => x.To) .NotEmpty() .WithMessage(ToProperty + CannotBeLeftBlank) .Must(a => a.IsInteger()) .WithMessage(ToProperty + MustBeValidWholeNumber) .Must( (x, a) => { var to = int.Parse(a); return x.MinTo <= to && to <= x.MaxTo; }) .WithMessage("{0} <= " + ToProperty + " <= {1}", x => x.MinTo, x => x.MaxTo) .Must( (x, a) => { // We validate this rule only when the "From" parameter is a valid integer. int from; if (int.TryParse(x.From, out from) && x.MinFrom <= from && from <= x.MaxFrom) { return int.Parse(a) > from; } // If "From" parameter is invalid, we shouldn't show the error message. return true; }) .WithMessage(ToProperty + MustBeGreaterThan + FromProperty + "."); } }
如您所见,从字符串到整数的类型转换验证,到范围验证,再到与其他属性的关系验证,所有验证都在同一个类中处理。点击“更改验证范围”按钮将更改有效范围并立即重新验证属性。
然而,有意使用字符串属性使我无法利用库的内置验证器,如“GreaterThan()
”和“LessThan()
”,这让我很懊恼。我不得不使用带有lambda的“Must()
”,其中实现了从字符串到整数的显式转换的自定义规则。尽管如此,库的自定义性和可配置性让我能够在不损失太多“流畅性”的情况下完成这项工作。如果属性是数字,或者不需要动态更改验证规则的某些参数,您就不会有这种麻烦。
Using the Code
示例应用程序由三个项目组成——主UI、基础设施和业务。始终使用MVVM。没有使用特定的MVVM框架。基础设施项目中的BindableBase
类和DelegateCommand
类可以很容易地替换为您喜欢的MVVM框架(如Prism和MVVM Light)中的等效类。
现在,让我们开始查看ValidatableBindableBase
类的代码,该类允许继承它的类在setter被调用并赋新值时验证属性
public abstract class ValidatableBindableBase : BindableBase { public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged; public abstract void ValidateAllProperties(); protected virtual bool SetPropertyAndValidateAllProperties<T>( ref T storage, T value, [CallerMemberName] string propertyName = null) { // ReSharper disable once ExplicitCallerInfoArgument var result = this.SetProperty(ref storage, value, propertyName); if (result) { this.ValidateAllProperties(); } return result; } protected virtual void OnErrorsChanged(DataErrorsChangedEventArgs e) { var handler = this.ErrorsChanged; if (handler != null) { handler(this, e); } } }
当属性被设置不同值时,SetPropertyAndValidateAllProperties()
函数会调用必须在派生类中实现的ValidateAllProperties()
。它还包含支持INotifyDataErrorInfo
所需的ErrorsChanged
事件调用器。INotifyPropertyChanged
像往常一样在BindableBase
类中实现。
Problem5 – 模型类
Problem5
是一个继承自ValidatableBindableBase
的模型类
public class Problem5 : ValidatableBindableBase, INotifyDataErrorInfo { private readonly IValidator<Problem5> validator; private string from; private string to; private string result; public Problem5(IValidator<Problem5> validator) { this.validator = validator; this.validator.ErrorsChanged += (s, e) => this.OnErrorsChanged(e); this.ClearResult(); this.MaxFrom = 10; this.MinFrom = 1; this.MaxTo = 100; this.MinTo = 2; this.From = "1"; this.To = "20"; } public int MaxFrom { get; set; } public int MinFrom { get; set; } public int MaxTo { get; set; } public int MinTo { get; set; } public string From { get { return this.from; } set { if (this.SetPropertyAndValidateAllProperties(ref this.from, value)) { this.ClearResult(); } } } public string To { get { return this.to; } set { if (this.SetPropertyAndValidateAllProperties(ref this.to, value)) { this.ClearResult(); } } } public string Result { get { return this.result; } set { this.SetProperty(ref this.result, value); } } public bool HasErrors { get { return this.validator.HasErrors; } } public void Solve() { this.Result = Solver.Solve(int.Parse(this.From), int.Parse(this.To)).ToString("D"); } public IEnumerable GetErrors(string propertyName) { return this.validator.GetErrors(propertyName); } public IList<string> GetAllErrors() { return this.validator.GetAllErrors(); } public override void ValidateAllProperties() { this.validator.Validate(this); } private void ClearResult() { this.Result = string.Empty; } }
它有三个字符串属性(From
、To
和Result
),可以直接数据绑定到UI元素。当然,它可以解决欧拉项目问题 #5,这是本应用程序的业务目的。但是,验证输入属性和实现INotifyDataErrorInfo
接口的责任被委托给验证器对象,该对象在构造函数中声明为IValidator<Problem5>
类型的依赖项,其签名如下
public interface IValidator<in T> : INotifyDataErrorInfo { IDictionary<string, string> Validate(T instance); IList<string> GetAllErrors(); }
这告诉我们,模型类依赖于(或使用)某个不仅实现了INotifyDataErrorInfo
,还实现了两个函数的对象,其中一个函数实际上验证了整个模型。如果错误实际发生变化,验证器应该在验证后触发ErrorsChanged
事件。Problem5
类只是转发它,以便UI可以显示适当的错误消息。
此外,该类还有四个与验证相关的参数——MaxFrom
、MinFrom
、MaxTo
和MinTo
,它们分别指定From
和To
参数的有效范围。或者,您可以将这四个与验证相关的参数实现在Problem5Validator
类中,而不是在模型类中。在这种情况下,您需要更改WithMessage()
中的lambda,例如将“x => x.MinTo
”更改为“x => this.MinTo
”。
FluentValidator<T>类
基础设施项目的FluentValidator<T>
类继承自FluentValidation的AbstractValidator<T>
,同时实现了IValidator<T>
public class FluentValidator<T> : AbstractValidator<T>, IValidator<T> { private readonly Dictionary<string, string> errors = new Dictionary<string, string>(); public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged; public bool HasErrors { get { return this.errors.Count > 0; } } IDictionary<string, string> IValidator<T>.Validate(T instance) { var currentErrors = new Dictionary<string, string>(this.errors); this.ValidateAndUpdateErrors(instance); this.RaiseErrorsChangedIfReallyChanged(currentErrors, this.errors); this.RaiseErrorsChangedIfReallyChanged(this.errors, currentErrors); return this.errors; } public IEnumerable GetErrors(string propertyName) { if (string.IsNullOrEmpty(propertyName)) { // The caller requests all errors associated with this object. return this.GetAllErrors(); } ThrowIfInvalidPropertyName(propertyName); return this.ExtractErrorMessageOf(propertyName); } public IList<string> GetAllErrors() { return this.errors.Select(error => error.Value).ToList(); } protected virtual void OnErrorsChanged(DataErrorsChangedEventArgs e) { var handler = this.ErrorsChanged; if (handler != null) { handler(this, e); } } private static void ThrowIfInvalidPropertyName(string propertyName) { var propertyInfo = typeof(T).GetRuntimeProperty(propertyName); if (propertyInfo == null) { var msg = string.Format("No such property name '{0}' in {1}", propertyName, typeof(T)); throw new ArgumentException(msg, propertyName); } } private void ValidateAndUpdateErrors(T instance) { this.errors.Clear(); var result = this.Validate(instance); if (result.IsValid) { return; } foreach (var err in result.Errors) { this.errors.Add(err.PropertyName, err.ErrorMessage); } } private void RaiseErrorsChangedIfReallyChanged( IEnumerable<KeyValuePair<string, string>> errors1, IReadOnlyDictionary<string, string> errors2) { foreach (var err in errors1) { var propertyName = err.Key; var message = err.Value; if (!errors2.ContainsKey(propertyName) || !errors2[propertyName].Equals(message)) { this.RaiseErrorsChanged(propertyName); } } } private void RaiseErrorsChanged(string propertyName) { this.OnErrorsChanged(new DataErrorsChangedEventArgs(propertyName)); } private IEnumerable ExtractErrorMessageOf(string propertyName) { var result = new List<string>(); if (this.errors.ContainsKey(propertyName)) { result.Add(this.errors[propertyName]); } return result; } }
这个类基本上转换了AbstractValidator<T>
的验证结果,以便INotifyDataErrorInfo
能够理解。AbstractValidator<T>.Validate(T)
返回一个ValidationResult
对象,它是一个在FluentValidation库中定义的类,只有两个成员(IsValid
布尔值和Errors
集合),与WPF中同名的类无关。ValidateAndUpdateErrors()
展示了如何获取验证结果
private void ValidateAndUpdateErrors(T instance) { this.errors.Clear(); var result = this.Validate(instance); if (result.IsValid) { return; } foreach (var err in result.Errors) { this.errors.Add(err.PropertyName, err.ErrorMessage); } }
如果验证结果无效,我们将错误放入Dictionary<string, string>
错误字典中,其中键是属性名称,值是实际的字符串错误消息。
另一方面,INotifyDataErrorInfo
需要三个成员(HasErrors
布尔值,必须返回IEnumerable
的GetErrors(propertyName)
函数,以及ErrorsChanged
事件)。实现HasErrors
很容易——只需返回错误字典是否包含任何内容。实现GetErrors(propertyName)
需要一些思考,因为它有两种模式——一种是propertyName
设置为实际属性名称,另一种是参数设置为string.Empty
,在这种情况下我们必须返回与绑定对象相关的所有错误。实现清晰地区分了它们
public IEnumerable GetErrors(string propertyName) { if (string.IsNullOrEmpty(propertyName)) { // The caller requests all errors associated with this object. return this.GetAllErrors(); } ThrowIfInvalidPropertyName(propertyName); return this.ExtractErrorMessageOf(propertyName); }
实际上,除非您使用带有自定义ValidationRule
的BindingGroup
,否则Binding
引擎总是使用实际属性名称调用GetErrors()
。正如我之前提到的,我决定不使用任何ValidationRule
。所以,这无关紧要。但是拥有一个返回所有错误的方法在其他地方可能会很有用。
RaiseErrorsChangedIfReallyChanged()
实现ErrorsChanged
事件,其中在调用Validate()
前后比较错误字典的内容。
我使用了Validate(T instance)
函数的显式接口实现,因为基类已经有一个具有相同名称但返回类型不同的函数。
Problem5Validator 类
Problem5Validator
继承自FluentValidator<Problem5>
,并声明了与特定客户端对象相关的所有验证规则,如互联网上 FluentValidation 示例和教程中所示。代码已在本文的背景部分展示。
代码中有一些值得一提的地方。
CascadeMode.StopOnFirstFailure
默认情况下,所有附加到特定属性的级联验证器都会执行验证。如果库没有改变模式的能力,那将是完全无用的。如果字符串属性不能被识别为数字,那么验证数字范围和关系的验证器就无法执行。“Must(a => a.IsInteger())
”验证器会在属性不是整数时停止验证。- 访问属性值
“Must()
”验证器允许用户在lambda中访问属性值。 - 访问实例对象
“Must()
”验证器和“WithMessage()
”消息格式化器都允许用户访问实例对象。这就是我如何在运行时访问属性的有效范围,从而实现了DataAnnotation
s无法实现的动态验证。
MainWindowViewModel 类
最后,MainWindowViewModel
类使用适当的验证器实例化Problem5模型对象。当DI容器可用时(如Prism和MVVM Light框架),您可以在其构造函数中将其指定为依赖项。视图模型将对象、两个命令和AllErrors
集合公开给相应的View类——即MainWindow
。每当触发ErrorsChanged
时,它都会替换AllErrors
集合,并通过触发DelegateCommand
的CanExecuteChanged
来控制“Solve”按钮的IsEnabled
属性。
请注意,AllErrors
集合不需要是ObservableCollection<string>
,因为它只是每次错误更改时被新的集合替换,而不是在同一个集合对象中添加/删除项目。
结论
在我看来,避免Binding
引擎的类型转换异常至关重要,以便能够在一个位置处理所有验证并为用户提供一致的错误消息。为此,需要按照Josh Smith的建议,将模型的所有数字属性公开为字符串。这样做时,WPF的ValidationRule
对象就没有用武之地了。相反,FluentValidation库可以很好地处理所有验证,这些验证在绑定属性的setter函数中执行。一旦验证完成,Binding
引擎将与绑定模型/视图模型对象实现的INotifyDataErrorInfo
流畅地工作,这样我们就可以轻松地在ToolTip
和ItemsControl
中显示验证结果。
我感谢 Jeremy Skinners 与我们分享如此优秀的库。
参考
- FluentValidation 主页 - https://github.com/JeremySkinner/FluentValidation/wiki
- Kristian Edlund 的数学博客 - http://www.mathblog.dk/project-euler-problem-5/。我修改了 "SuprDewd" 提交的解决欧拉项目问题 #5 的代码。
历史
2016年1月4日:首次发布