将中央 Silverlight 业务规则扩展到客户端和服务器端字段级验证






4.45/5 (6投票s)
ViewModel 同时提供 IDataErrorInfo 和集合绑定,用于客户端和服务器端的字段级错误和异常。
引言
下载 Visual Studio 项目 - 67.64 KB通常,会实现 IDataErrorInfo 来支持在用户输入每个字段时,将验证生成的错误消息绑定到 UI。本项目提供了一种简单机制,可以将此绑定扩展到包含来自服务器端验证的错误消息。这样做的好处是,那些在客户端无法检测到的业务规则违规行为,在被服务器检测并报告回来后,可以以字段级的方式显示。这通过在每个错误中包含属性名,扩展了 Michael Washington 在《Central Silverlight Business Rules Validation》文章中通过字符串集合从服务器端报告验证错误的概念。
您可以在上面的屏幕截图中看到一个示例,其中 IDataErrorInfo 被用来显示从服务器端报告的业务规则违规。除了用于提供字段级错误消息绑定的 IDataErrorInfo 实现外,ViewModel 还提供了一个错误集合,设计者可以将其绑定,正如您在屏幕截图底部所看到的那样。
此外,任何意外错误,例如服务器上抛出的异常,也会被报告。例如,假设唯一名称和出生日期规则仅通过 Artists 表中的唯一键强制执行。在这种情况下,您将看到类似下面的屏幕。

另请参阅
- John Papa 著《Silverlight 4 中使用 IDataErrorInfo 实现验证》
- Michael Washington 著《Central Silverlight Business Rules Validation》
IDataErrorInfo 的机制
如果一个控件的文本属性绑定到实现了 IDataErrorInfo 的类的一个成员,那么该控件就可以绑定到错误信息。当用户更改输入值并离开该字段(例如,通过 Tab 键)时,文本绑定会促使 ViewModel 中相应的属性通过属性设置器进行更新;当设置器随后引发 PropertyChanged 事件时,错误绑定会导致 System.Windows.Data.BindingExpression 调用 ViewModel 的 IDataErrorInfo 实现,以确定该值是否有效,如果无效,则将错误消息(也由 IDataErrorInfo 实现提供)推送到 TextBox。请注意,IDataErrorInfo 只提供错误状态和消息。错误绑定本身是通过 Data.BindingExpression 完成的,因为在文本绑定表达式中,设计者已将 ValidatesOnDataErrors 设置为 True,如下所示。
<TextBox x:Name="txtName" Text="{Binding CurrentArtistBuffer.Name, Mode=TwoWay, ValidatesOnDataErrors=True}"/>
因此,如果 Name 是必填项,并且用户将其更改为空字段后离开,他们将看到如下所示。
我描述的所有内容都构成了客户端验证。本文演示了如何使用相同的 IDataErrorInfo / BindingExpression 模式来显示在服务器端捕获的业务规则违规。
演示
1. 在点击“添加”之前检测到的任何输入错误或业务规则违规都将显示出来。
3. 在服务器端检测到的任何业务规则违规都将被返回并逐个字段显示。服务器端捕获的任何异常都将在页面底部显示。
此项目仅支持 Add 操作,因为我只想专注于验证问题。如果您喜欢这种方法,只需根据现有代码添加新表和代码,就可以轻松地将其修改和扩展以满足您的需求。
项目
我已简要介绍了 IDataErrorInfo 和错误消息绑定是如何工作的,以及可以从本项目获得什么。现在,让我们完成项目的创建,以便我可以结合实际代码和图片进行描述。
数据库
创建一个名为 Music 的数据库,并添加一个名为 Artists 的表。
USE [Music]
GO
/****** Object: Table [dbo].[Artists] Script Date: 07/18/2010 12:00:17 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[Artists](
[ID] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](255) NOT NULL,
[DateOfBirth] [datetime] NULL,
[BirthCity] [nvarchar](255) NULL,
[BirthStateOrProvince] [nvarchar](255) NULL,
[BirthCountry] [nvarchar](50) NULL,
CONSTRAINT [PK_Artists] PRIMARY KEY CLUSTERED
(
[ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
CREATE UNIQUE NONCLUSTERED INDEX [IX_Artists_Name_DateOfBirth] ON [dbo].[Artists]
(
[Name] ASC,
[DateOfBirth] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, IGNORE_DUP_KEY = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
GO
注意:下载文件中包含一个名为 ReadMe_DatabaseSetup.txt 的文件,其中包含该表的脚本。
在 MusicCatalog.Web 站点中添加一个 Linq to SQL 类,名为 Catalog.dbml。
结果是
选择 Server Explorer,然后创建一个到 Music 数据库的连接。
将 Artists 表拖到 Object Relational Designer 表面。
保存并关闭 dbml 文件。
Web 服务
我们将从最底层开始,即实际更新数据库的 Web 服务。它包含一个 Web 方法,名为 AddArtist。但所有 Web 方法的原理都是相同的;除了 Web 方法通常返回的内容外,它还将返回一个 PropertyError 对象集合。对于服务器端找到的每个验证错误,至少会有一个 PropertyError 条目;如果有多个属性涉及,则会有更多。如果错误与特定属性无关,PropertyName 将为 null。
namespace MusicCatalog.Web
{
public class PropertyError
{
public string PropertyName { get; set; }
public string ErrorMessage { get; set; }
public PropertyError()
{
PropertyName = string.Empty;
ErrorMessage = string.Empty;
}
public PropertyError(string name, string message)
{
PropertyName = name;
ErrorMessage = message;
}
}
}
请记住,我们在运行 Web 服务的托管网站上创建了 PropertyError 类。稍后在 Silverlight 项目中创建服务引用时,PropertyError 类会被包含在生成的代码中,因此 Web 服务创建的 PropertyError 可以直接在 Silverlight ViewModel 中使用。
因此,我们的 AddArtist Web 方法将返回一个 ArtistID 对象,而不是仅仅返回一个记录 ID。
namespace MusicCatalog.Web
{
public class ArtistID
{
public int ID { get; set; }
public List<PropertyError>Errors = new List<PropertyError>();
}
}
这是 AddArtist Web 方法。
namespace MusicCatalog.Web
{
[WebService(Namespace = "http://hscoders.org/")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
[System.ComponentModel.ToolboxItem(false)]
public class CatalogService : System.Web.Services.WebService
{
...
[WebMethod]
public ArtistID AddArtist(Artist artist)
{
const string ERR_NAME_AND_DOB_MUST_BE_UNIQUE = "Name and Date of Birth combination must be unique.";
const string ERR_NAME_IS_REQUIRED = "Name is required";
const string NAME_PROPERTY = "Name";
const string DATE_OF_BIRTH_PROPERTY = "DateOfBirth";
ArtistID returnID = new ArtistID();
try
{
if (artist.Name == null || artist.Name.Trim().Length < 1)
returnID.Errors.Add(new PropertyError(NAME_PROPERTY, ERR_NAME_IS_REQUIRED));
else
{
using (CatalogDataContext db = new CatalogDataContext())
{
var selectArtist = from artists in db.Artists
where artists.Name == artist.Name
select artists;
if (artist.DateOfBirth.HasValue)
selectArtist = from artists in selectArtist
where artists.DateOfBirth.HasValue &&
artists.DateOfBirth.Value.Date == artist.DateOfBirth.Value.Date
select artists;
else
selectArtist = from artists in selectArtist
where artists.DateOfBirth.HasValue == false
select artists;
if (selectArtist.Count() > 1)
{
db.Artists.InsertOnSubmit(artist);
db.SubmitChanges();
returnID.ID = artist.ID;
}
else
{
returnID.Errors.Add(new PropertyError(NAME_PROPERTY, ERR_NAME_AND_DOB_MUST_BE_UNIQUE));
returnID.Errors.Add(new PropertyError(DATE_OF_BIRTH_PROPERTY, ERR_NAME_AND_DOB_MUST_BE_UNIQUE));
}
}
}
}
catch (Exception ex)
{
returnID.Errors.Add(new PropertyError(null, ex.Message));
}
return returnID;
}
}
}
创建服务引用
目前托管站点的工作就完成了。现在我们需要创建服务引用,它将充当 Web 服务和 Silverlight 应用程序之间的桥梁。右键单击 MusicCatalog 项目,然后从出现的菜单中选择 Add Service Reference...。
(1) 单击 Discover 按钮 (2) 将 CatalogService 作为 Namespace 输入 (3) 单击 OK 按钮。
这将生成一个名为 CatalogServiceReference 的新命名空间,其中包含 Silverlight 应用程序用于与 WebService 通信的类。其中一个类,CatalogServiceSoapClient,提供了实际的通信机制。要添加一个艺术家,我们从模型类 MusicCatalogModel 调用 CatalogServiceSoapClient 类。
模型 - MusicCatalogModel
using Microsoft.VisualBasic;
using System;
using MusicCatalog.CatalogServiceReference;
using System.ServiceModel;
namespace MusicCatalog
{
public class MusicCatalogModel
{
#region AddArtist
public static void AddArtist(Artist theArtist, EventHandler<AddArtistCompletedEventArgs> completionHandler)
{
CatalogServiceSoapClient client = new CatalogServiceSoapClient();
client.Endpoint.Address = new EndpointAddress(GetBaseAddress());
client.AddArtistCompleted += completionHandler;
client.AddArtistAsync(theArtist);
}
#endregion
#region GetBaseAddress
// GetBaseAddress courtesy of Michael Washington
private static Uri GetBaseAddress()
{
// Get the web address of the .xap that launched this application
string strBaseWebAddress = App.Current.Host.Source.AbsoluteUri;
int PositionOfClientBin =
App.Current.Host.Source.AbsoluteUri.ToLower().IndexOf(@"/clientbin");
// Strip off everything after the ClientBin directory
strBaseWebAddress = Strings.Left(strBaseWebAddress, PositionOfClientBin);
Uri UriWebService = new Uri(String.Format(@"{0}/CatalogService.asmx", strBaseWebAddress));
return UriWebService;
}
#endregion
}
}
您可以看到上面代码中我们引用了 MusicCatalog.CatalogServiceReference。这使我们能够访问 CatalogServiceSoapClient 类。在 AddArtist 方法中,我们创建了该类型的实例并调用了 CatalogServiceSoapClient.AddArtistAsync()。由于该调用是异步的,当服务器上的 AddArtist Web 方法完成时,结果不会在此处返回。我们必须指定结果要返回到哪里,所以在调用之前,我们向 CatalogServiceSoapClient.AddArtistCompleted 事件添加了一个 EventHandler。通过将此 EventHandler 作为参数从调用者传递,Model 与 ViewModel 完全解耦。这是一个重要的点。如果模型添加了自己的方法作为 EventHandler,那么该方法将需要对请求者有某种引用。但如果 EventHandler 只是作为参数传入的某个函数,则模型不知道调用者。
ViewModel - MainPageViewModel
因此,正如您所见,我们在 Model 中的 AddArtist() 方法是被动地将请求发送到 Web 服务。是 ViewModel 知道如何处理它,所以通过将 EventHandler 作为参数传入来在此处获取结果,不仅使两者解耦,而且更方便。
您知道,MusicCatalogModel.AddArtist 接受一个 EventHandler 作为其第二个参数。如果您查看下面的代码中的 MainPageViewModel.AddArtist,您会发现 EventHandler 构成了 AddArtist 方法主体的大部分代码。如果将其视为回调更容易理解,那么就继续;(sender, eventArgs) => 之后的所有内容都构成我们希望在 AddArtist 调用中接收最终结果的回调函数。如果您不熟悉在传递 EventHandler 时使用的这种语法,只需想象 => 之后的所有内容都是您在其他地方编写并命名的方法的主体,并将 MusicCatalogModel.AddArtist() 的整个第二个参数替换为您的方法名,在我看来,请务必学习这种传递方法(即 lambda 和委托)的语法。
using System;
using System.Windows.Input;
using System.ComponentModel;
using System.Collections.ObjectModel;
using MusicCatalog.CatalogServiceReference;
using System.Collections.Generic;
namespace MusicCatalog
{
public class MainPageViewModel : INotifyPropertyChanged
{
...
public void AddArtist(ArtistBuffer theArtistBuffer)
{
ClearStatus();
MusicCatalogModel.AddArtist(theArtistBuffer.RawArtist, (sender, eventArgs) =>
{
// We're going to divide any errors into two lists. We'll make them public when we're done
List<PropertyError> namedList = new List<PropertyError>();
List<PropertyError> anonyList = new List<PropertyError>();
// We're catching all exceptions in the WebMethod, so this should only be non-null if the service timed out
if (eventArgs.Error == null)
{
// Result is typed to ArtistID through type inference. The call to AddArtist accepts an EventHandler that takes
// an argument of type AddArtistCompletedEventArgs, which has a Result property of type ArtistID, which
// is originally defined on the website that hosts the web service and gets mapped here as a class in the
// service reference namespace, CatalogServiceReference
ArtistID returnedArtist = eventArgs.Result;
// Check the Errors collection
if (returnedArtist.Errors == null || returnedArtist.Errors.Count < 1)
{
CurrentArtistID = returnedArtist.ID;
CurrentStatusMessage = "New Artist ID: " + CurrentArtistID.ToString();
}
else
{
// We have errors, split them up. In practice only one list will end up with entries
foreach (PropertyError propError in returnedArtist.Errors)
{
if (propError.PropertyName == null)
anonyList.Add(propError);
else
namedList.Add(propError);
}
bool bBadInput = false;
if (anonyList.Count > 0)
bBadInput = true;
if (namedList.Count > 0)
{
bBadInput = true;
foreach (PropertyError namedError in namedList)
{
// This will trigger IDataErrorInfo binding to controls that have ValidateOnDataErrors = True
theArtistBuffer.ValidateRule(namedError.PropertyName, namedError.ErrorMessage, () => (false));
}
}
// Draw attention to errors
if (bBadInput)
CurrentStatusMessage = "Correct input errors";
}
}
else
{
// Overall web service exception
CurrentException = eventArgs.Error;
}
// Make the lists public
ServerErrors = new ObservableCollection<PropertyError>(anonyList);
PropertyErrors = new ObservableCollection<PropertyError>(namedList);
});
}
}
}
当我们在事件处理程序中收到结果时,我们会更新 View(MainPage)可以绑定的属性和集合。如果调用成功,我们会更新 CurrentArtistID 和 CurrentStatusMessage。但如果失败,我们会将错误信息放入设计者可以绑定的集合中。
eventArgs 参数的类型是 AddArtistCompletedEventArgs。创建服务引用命名空间 CatalogServiceReference 时会生成此类型。我们对 eventArgs 的两个属性感兴趣:Error 和 Result。Error 属性的类型是 Exception。我们应该总是发现 Error 为 null,除非调用超时。如果 Web 方法抛出了任何未处理的异常,Error 属性将被填充为 Exception,但消息将始终是 Error occurred on the Server: Not Found,除了调用超时的情况。由于我们自己处理异常,并且服务器在我们桌面上,我们只能在此处获得错误,如果我们处于调试状态并且让调用超时。
如果 Error 为 null,那么我们应该发现 Result 被填充。Result 的类型是 CatalogServiceReference 命名空间中的 ArtistID。由于 AddArtistCompletedEventArgs 的 Result 属性的类型是 ArtistID,这就是我们在事件处理程序中看到的。编译器通过 MusicCatalogModel.AddArtist() 签名的推断得知这一点。
public static void AddArtist(Artist theArtist, EventHandler<AddArtistCompletedEventArgs> completionHandler)
第二个参数是接受 AddArtistCompletedEventArgs 类型参数的 EventHandler。AddArtistCompletedEventArgs.Result 属性的类型是 ArtistID。因此,回到事件处理程序,我们看到 eventArgs.Result 是一个特定类型,即 ArtistID。
因此,假设我们发现 ArtistID.Errors 集合中有一些条目。它们可以有两种类型:业务规则违规或服务器上意外抛出的异常。后一种情况,PropertyError 对象的 PropertyName 属性将为 null。我将 PropertyError 集合解析为两个不同的列表,并设置 CurrentStatusMessage。然后,通过调用 ArtistBuffer.ValidateRule,我使用每个具有 PropertyName 的 PropertyError 来触发 Data.BindingExpression 以从 IDataErrorInfo 获取任何错误,并将错误推送到适当的文本框,当然,如果设计者已经将其绑定。
foreach (PropertyError namedError in namedList)
{
// This will trigger error message binding to controls that have ValidateOnDataErrors = True
theArtistBuffer.ValidateRule(namedError.PropertyName, namedError.ErrorMessage, () => (false));
}
实现 IDataErrorInfo
在我们的示例项目中,ArtistBuffer 中的属性被绑定到 View(MainPage.xaml)中的 Artist 文本框。通过在 ArtistBuffer 中实现 IDataErrorInfo,我们支持 Data.BindingExpression 调用 IDataErrorInfo 实现来确定每个 ArtistBuffer 属性的有效性并检索相关的错误消息。因此,任何绑定到 ArtistBuffer 属性的 TextBox 都可以将 ValidatesOnDataErrors 设置为 True 来绑定到由该属性验证生成的错误消息。INotifyPropertyChanged 实现也是必需的,以在字段失去焦点时触发 Data.BindingExpression 验证。
namespace MusicCatalog
{
public class ArtistBuffer : INotifyPropertyChanged, IDataErrorInfo
{
这是实现 IDataErrorInfo 所需的代码。public string Error { get { return null; } }
// The 'this' property indexer takes a property name and returns either an error message to be used in IDataErrorInfo binding or
// null if the property value is valid. This information is stored by ValidationHandler when we called ValidationHandler.ValidateRule
// in the setter for the property
public string this[string FieldName]
{
get {return _validation.BrokenRuleExists(FieldName) ? _validation[FieldName] : null; }
}
我们可以忽略 Error 属性,它的目的是处理不适用于特定属性的错误。我们感兴趣的是 IDataErrorInfo.this 索引器。当触发绑定时,会用正在绑定的字段的名称来查询此索引器。如果属性有效,它返回 null;如果无效,它返回一个错误消息。如上所示,我们没有检查字段的当前值,而是调用 _validation.BrokenRuleExists[FieldName]。_validation 对象是 John Papa 创建的 ValidationHandler 类的实例。ValidationHandler
如果我们仔细查看 ArtistBuffer,我们会发现它创建了一个 ValidationHandler。
namespace MusicCatalog
{
public class ArtistBuffer : INotifyPropertyChanged, IDataErrorInfo
{
private ValidationHandler _validation = null;
public ArtistBuffer(Artist artist)
{
_validation = new ValidationHandler();
RawArtist = artist;
}
...
}
}
要使其投入使用,我们只需执行以下操作:
namespace MusicCatalog
{
public class ArtistBuffer : INotifyPropertyChanged, IDataErrorInfo
{
...
public string Name
{
get { return _rawArtist.Name; }
set
{
_validation.ValidateRule("Name", "Name is required", () => (value != null && value.Trim().Length > 0));
_rawArtist.Name = value != null ? value.Trim() : null;
NotifyPropertyChanged("Name");
}
}
...
}
}
在设置器中,我们调用 ValidationHandller.ValidateRule() 并带三个参数。
- 要验证的字段的名称。
- 如果无效,则返回的错误消息。
- 一个返回布尔值的函数,如果字段有效,则返回 true。
// From http://johnpapa.net/silverlight/enabling-validation-in-silverlight-4-with-idataerrorinfo/
namespace MusicCatalog
{
public class ValidationHandler
{
...
public bool ValidateRule(string property, string message, Func<bool> ruleCheck)
{
if (!ruleCheck())
{
this.BrokenRules.Add(property, message);
return false;
}
else
{
RemoveBrokenRule(property);
return true;
}
}
...
}
}
如果我们传入的函数返回 false,ValidationHandler.ValidateRule() 将在 BrokenRules 字典中添加一个条目,其中属性名和消息参数分别作为键和值;否则,它将删除以该名称为键的任何现有条目。现在,当查询 IDataErrorInfo.this 索引器并检查 ValidationHandler.BrokenRuleExists() 时,结果将取决于我们刚刚在设置器中调用的 ValidateRule() 调用是添加了条目还是删除了条目。
从这里学到的重要一点是,这一切都发生在设置器的范围内,所以为了强调这一点,让我们回顾一下事件序列。
- 在设置器中,我们调用 ValidationHandler.ValidateRule()。
- 如果验证失败,ValidateRule 会在其 BrokenRules 字典中添加一个以正在设置的属性名作为键的条目;否则,会将其删除。
- 回到设置器,当 ValidateRule() 返回后,我们设置值并调用 NotifyPropertyChanged。
- 调用 NotifyPropertyChanged 会触发绑定到 ValidatesOnDataErrors = True。
- 这会导致 Data.BindExpression.DataErrorValidationRule 访问 IDataErrorInfo.this 索引器。
- 索引器根据 ValidationHandler.BrokenRuleExists() 返回的值返回一个错误消息或 null。
查看部分代码可能会有帮助。我在 ValidationHandler.ValidateRule() 上设置了一个断点。让我们看看当断点命中断点时发生了什么。
如果您查看调用堆栈,您可以看到 DateOfBirth 属性正在被设置,这是因为 View 中的 TextBox 失去了焦点。
现在我在 MusicBuffer.thi 上设置了一个断点,结果如下。
调用堆栈中的第一行有点令人困惑,直到仔细查看。它不是 DateOfBirth 的 getter,而是 IDataErrorInfo.this 的 getter - 只要看看高亮显示的 source 代码上方的属性名。
您可以看到绑定代码正在以相反的方式被调用。当调用设置器时,这是因为 TextBox 失去了焦点,并且是时候让绑定更新 MusicBuffer.DateOfBirth 属性了。现在,调用 NotifyPropertyChanged 已触发 Data.BindingExpression.GetDataErrorInfoMessage 来调用我们对 IDataErrorInfo.this 的实现,以获取(如果存在)错误消息。然后,尽管我们看不到,控件可能冒泡回到 BindingExpression.DataBindingExpression.UpdateValue,后者继续将错误推送到 TextBox,TextBox 会改变其边框并在用户将鼠标悬停在小三角形上或字段获得焦点时显示错误消息。在这种情况下,2/31/1944 无效,因此当我们调用 ValidateRule 时,它会将 BrokenRule 添加到其字典中,然后当我们调用 "DateOfBirth" 的 ValidationHandler.BrokenRuleExists 时,我们会得到我们最初传递给 ValidateRule 的消息,最终结果是。
现在,有一件事您应该注意。假设我在调试器中,并且我将 DateOfBirth 的输入值更改为另一个无效日期,例如 2/31/1945 并按 Tab 键。当设置器这次调用 ValidationHandler.ValidateRule 时,结果如下。
字典中已经有一个 DateOfBirth 条目,所以当我尝试添加重复键时,它会抛出异常。但是异常是在绑定操作的范围内抛出的,所以它被吞噬在某个地方,并且没有指示存在问题。如果我继续按 F11,接下来我看到的是。
在对话框告诉我有一个未处理的异常之后,它被处理了。从这一点开始,我可以继续,屏幕上不会显示任何错误。接下来,请记住,当我从异常中继续前进后,我看到的下一件事是 Data.BindingExpression 正在获取属性验证错误 IDataErrorInfo,就好像异常从未发生过一样。现在,当直接调用设置器导致相同的异常时,会发生什么。
您可以看到没有绑定在进行。接下来,我得到与上面相同的异常对话框(未显示)。您会记得,当我按 F11 继续时,接下来我看到的是 Data.BindingExpression 被调用 IDataErrorInfo 来获取属性的错误状态。这次当我按 F11 时,我看到。
然后是
如果我继续前进,我看到。
所以要从中吸取的教训是,如果您的任何代码都在绑定操作的上下文中挂钩,那么在该代码中发生的任何故障都会被静默处理。我可能不应该对此感到惊讶。正是同一个原因,实际上,您不能使用实际的 Artist.DateOfBirth 属性来绑定 DateOfBirth TextBox。在文本框中输入的任何无效日期都不会绑定到 Artist.DateOfBirth 属性,这意味着转换在某个点静默失败了。如果您的表接受 null 日期,并且您输入了一个无效的日期,验证将错过它。甚至没有办法测试 DateTime? 类型是否有效。它要么是 null,要么包含一个有效的 DateTime。因此,null 日期会静默地溜走,而不是您认为输入的日期。
这一切都很哲学,但我们应该解决引起这个问题的原因。我们只需将字典的 Add 替换为赋值,如果您只想覆盖旧值,这就可以了。可以设想您想合并消息,但这里没有必要。
namespace MusicCatalog
{
// From http://johnpapa.net/silverlight/enabling-validation-in-silverlight-4-with-idataerrorinfo/
public class ValidationHandler
{
public bool ValidateRule(string property, string message, Func <bool> ruleCheck)
{
if (!ruleCheck())
{
// Add throws an exception if there is already an entry with the same key
// this.BrokenRules.Add(property, message);
// Assignment adds the key if necessary and doesn't throw an exception if it already exists.
// The previous value is overwrittern
this.BrokenRules[property] = message;
return false;
}
else
{
RemoveBrokenRule(property);
return true;
}
}
}
}
服务器错误呢?
现在我已经讨论了 ArtistBuffer 以及它如何实现 IDataErrorInfo 并使用 ValidationHandler;所有这些都解释了客户端验证,但是,关于对 ArtistBuffer.ValidateRule 的调用呢?
namespace MusicCatalog
{
public class ArtistBuffer : INotifyPropertyChanged, IDataErrorInfo
{
//...
public void ValidateRule(string property, string message, Func<bool> ruleCheck)
{
// Cause the property BrokenRule and message to be added to ValidationHandler (or removed)
_validation.ValidateRule(property, message, ruleCheck);
// Cause the binding to take place so the message shows next to controls that set ValidatesOnDataErrors=true;
NotifyPropertyChanged(property);
// We don't want this error to persist as it will interfere with normal client-side validation.
_validation.RemoveBrokenRule(property);
}
//...
}
}
我们调用 ValidationHandler.ValidateRule() 来强制添加 BrokenRule,然后调用 NotifyPropertyChanged 来调用绑定,这将显示错误消息。紧接着,我们调用 RemoveBrokenRule,因为我们无法确定何时清除了条件,所以我们希望它触发一次,然后为客户端验证腾出空间。我发现让 BrokenRule 保持不变会干扰这一点。
现在,我不太确定我如何看待下面的代码。假设唯一键违规已被触发,因此 Name 和 DateOfBirth 字段都有错误。我希望对任一字段的更改都能清除这两个字段的错误条件,或者更确切地说,触发这两个字段的绑定,从而清除错误消息。如果任一字段发生更改,我可以通过调用 NotifyPropertyChanged 来实现这一点,如下面的 Name 设置器所示。我也会在 DateOfBirth 设置器中这样做。您可能会注意到这里的设置器与上面的示例不同。我牺牲了对 value.Trim() 进行重复调用的厌恶,以换取对 _validation.ValidateRule(). 的稍微模糊的处理。
namespace MusicCatalog
{
public class ArtistBuffer : INotifyPropertyChanged, IDataErrorInfo
{
//...
public string Name
{
get { return _rawArtist.Name; }
set
{
if (value != null)
value = value.Trim();
if (_rawArtist.Name == value)
return;
// Substitute null for empty string
bool bNullValue = string.IsNullOrWhiteSpace(value);
_validation.ValidateRule("Name", "Name is required", () => (bNullValue == false));
_rawArtist.Name = bNullValue ? null : value.Trim();
// If an error exists on these two fields because of a unique key violation, changing one of them will clear both because
// we call NotifyPropertyChanged on both.
NotifyPropertyChanged("Name");
NotifyPropertyChanged("DateOfBirth");
}
}
//...
}
}
我如何控制按钮的启用/禁用状态?
IDataErrorInfo 主要提供验证消息绑定的支持。您还需要一种方法让设计者根据 CurrentArtistBuffer 中的所有数据是否有效来控制 Add 按钮的启用状态,这就引出了 ICommand。
正是 ICommand 允许 View 中的控件事件绑定到 View Model 中的操作。当用户在 View、MainPage 中单击 Add 按钮时,我希望 AddArtist 操作在 MainPageViewModel 中执行,因此我提供了一个名为 AddArtistCommand 的公共属性。此属性是对 ICommand 实现的引用。ICommand 方法之一是 CanExecute()。当一个按钮绑定到 ICommand 时,按钮的启用状态会自动绑定到 ICommand.CanExecute() 方法。此外,当单击按钮时,ButtonBase.ExecuteCommand 会最后一次调用 ICommand.CanExecute(),如果返回 true,则调用 ICommand.Execute() 来实际执行操作。
需要注意的是,通过绑定到 CanExecute 来启用“Add”按钮完全独立于 IDataErrorInfo 实现。可能有一种方法可以通过检查 ValidationHandler 的 BrokenRules 来集成两者,但您可能希望用于启用或禁用按钮的代码在不触发错误消息绑定的情况下执行此操作(例如,在表单首次出现时),因此我宁愿咬紧牙关并在 CanExecute() 中进行全面验证。
或者更确切地说,在 CanAddArtist() 中。让我们后退一步,看看 MainPageViewModel 中的 AddArtistCommand 属性。我定义了 AddArtistCommand 属性,并将其声明为 ICommand 类型。我还提供了两个方法:AddArtist 和 CanAddArtist。现在,如果您查看构造函数,您会发现我正在将 AddArtistCommand 属性设置为 DelegateCommand 的实例,并将这两个方法传递给构造函数。构造函数将内部引用 canExecute 和 executeAction 设置为指向这两个方法,以便可以调用它们来实现 ICommand 方法 CanExecute() 和 Execute()。您可以自己编写每个实现,但使用 DelegateCommand 要容易得多。这就是 DelegateCommand,由无与伦比的 John Papa 带来。
这是 MainPageViewModel 中的 AddArtistCommand。
namespace MusicCatalog
{
public class MainPageViewModel : INotifyPropertyChanged
{
public MainPageViewModel()
{
AddArtistCommand = new DelegateCommand(AddArtist, CanAddArtist);
InitializeCurrentArtistBuffer();
}
...
public ICommand AddArtistCommand { get; set; }
public void AddArtist(object param)
{
AddArtist (param as ArtistBuffer);
}
private bool CanAddArtist(object param)
{
ArtistBuffer theArtist = param as ArtistBuffer;
if (theArtist == null)
return false;
if (string.IsNullOrWhiteSpace(theArtist.Name))
return false;
if (theArtist.Name.IndexOf("<Required>") == 0)
return false;
if (string.IsNullOrWhiteSpace(theArtist.DateOfBirth) == false)
{
DateTime dateOfBirth = new DateTime();
if (DateTime.TryParse(theArtist.DateOfBirth, out dateOfBirth) == false)
return false;
}
return true;
}
...
}
}
正如您稍后将看到的,我将 Name 字段初始化为“<Required>”,这是一种粗糙的方法,可以促使错误绑定在用户尝试将其更改为空时生效。如果文本属性一开始为空,那么在不输入任何内容的情况下离开该字段将不会触发绑定,因为值没有改变。这就是为什么 CanAddArtist 在 NameProperty 为“<Required>”时返回 false。
到目前为止,我们已经将按钮的启用状态绑定到 ICommand.CanExecute 方法。但是这是一个方法,而不是一个属性,所以绑定如何知道何时调用它?事实证明,按钮会自动订阅 ICommand.CanExecuteChanged 事件,您可以在此处看到它在此处触发。
在上面的代码中,您可以看到 CanExecute 是在 MainPageViewModel 中调用的,当 CurrentArtistBuffer 中的一个属性发生更改时。对 canExecute 引用的调用执行 CanAddArtist,它验证缓冲区内容并返回 true,在这种情况下,这与 canExecuteCache 的当前值不同,因此会触发 CanExecuteChanged 事件。在下面的代码中,您可以看到 ButtonBase.CanExecuteChanged 捕获该事件并调用 CanExecute。
这是 MainPageViewModel 中实现此目的的代码。
namespace MusicCatalog
{
public class MainPageViewModel : INotifyPropertyChanged
{
...
void CurrentArtistBuffer_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
// This call will cause the buffer to be validated, resulting in a call
// to CanExecuteChanged if validity changes from what it is currently
AddArtistCommand.CanExecute(CurrentArtistBuffer);
RaiseCurrentArtistBufferPropertyChanged();
}
#endregion
private void RaiseCurrentArtistBufferPropertyChanged()
{
NotifyPropertyChanged("CurrentArtistBuffer");
}
#region CurrentArtistBuffer
// CurrentArtistBuffer is the instance of the ArtistBuffer class used for binding from MainPage.xaml.
private ArtistBuffer _currentArtistBuffer = null;
public ArtistBuffer CurrentArtistBuffer
{
get { return _currentArtistBuffer; }
private set
{
if (_currentArtistBuffer != value)
{
_currentArtistBuffer = value;
RaiseCurrentArtistBufferPropertyChanged();
}
}
}
#endregion
#region InitializeCurrentArtistBuffer
private void InitializeCurrentArtistBuffer()
{
if (CurrentArtistBuffer != null)
CurrentArtistBuffer.PropertyChanged -= CurrentArtistBuffer_PropertyChanged;
CurrentArtistBuffer = new ArtistBuffer(new Artist());
CurrentArtistBuffer.Name = "<Required>";
CurrentArtistBuffer.PropertyChanged += new PropertyChangedEventHandler(CurrentArtistBuffer_PropertyChanged);
}
#endregion
}
实际上,我对这段代码有一个问题。在 CurrentArtistBuffer_PropertyChanged 中,MainPageViewModel 必须知道将 CurrentArtistBuffer 传递给 CanExecute()。正如我之前指出的,按钮调用 CanExecute 也会将 CurrentArtistBuffer 传递给 CanExecute()。这不好,来自不同位置的两个调用都必须传递相同的缓冲区才能使此工作正常进行。让我们看看我更倾向于如何做。
Add 按钮应继续将 CurrentArtistBuffer 作为参数传递,并且 CanExecute 应与现在完全相同。我将去掉完全用于触发 CanExecuteChanged 事件(如果有效性发生变化)的内部调用 CanExecute()。相反,我会让绑定在 CurrentArtistBuffer 更改时被触发。甚至还有一个 ButtonBase.OnCommandPropertyChanged 方法专门用于此目的。您可以在属性是全新的时看到它在上面被调用。
您可能会认为将 CurrentArtistBuffer 更改为依赖项属性会有帮助,但事实并非如此。当 ICommand 首次绑定时,CommandParameter 会被包装在一个依赖项属性中,您可以在上面的调用堆栈中看到这一点。长话短说,更改 CurrentArtistBuffer 属性与更改 CurrentArtistBuffer 不同。我可以调用 NotifyPropertyChanged("CurrentArtistBuffer"),但除非对象真的改变了,否则围绕它的 DependencyObject 不会被欺骗。我通过两种方式证明了这一点。如果我从 CurrentArtistBuffer 创建一个新的 ArtistBuffer 对象,然后用它来替换 CurrentArtistBuffer,则会触发绑定。或者更简单地说,如果我保存 CurrentArtistBuffer,然后将其设置为 null,然后将其恢复为原始值,则会触发绑定,实际上是两次。
我认为这两种方法都不实用。
我知道我可以创建一个布尔值,由验证设置,然后传递给 CanExecute 而不是 CurrentArtistBuffer,但这只是将问题移来移去。验证调用仍必须在 MainPageViewModel 中进行才能使布尔值更改,而且,当务之急时,我更喜欢方法操作参数,而不是全局变量。以下是我们最终的模式。
- 绑定会导致 ButtonBase 在页面最初加载时调用 CanExecute,并将 CurrentArtistBuffer 作为参数。在我们的例子中,Name 属性设置为“<Required>”,CanExecute 根据约定将其识别为无效,返回 false,Add 按钮被禁用。
- 用户更改了其中一个文本框中的值并按 Tab 键离开。绑定会导致 ArtistBuffer 中相应的属性设置器更新属性,如果值发生更改,则会引发 PropertyChanged 事件。
- MainPageViewModel 在初始化 CurrentArtistBuffer 时订阅了 ArtistBuffer.PropertyChanged;现在它捕获该事件。CurrentArtistBuffer 的有效性可能已更改,因此它调用 AddArtistCommand.CanExecute(CurrentArtistBuffer);
- CanExecute 将其参数视为一个对象,并调用内部引用 MainPageViewModel.CanAddArtist(param)。
- CanAddArtist 将 param 转换回 ArtistBuffer,然后验证属性并返回 true 或 false 到 CanExecute。如果返回的值与先前的值不同,则会触发 CanExecuteChange 事件。
- Add 按钮在绑定到 CanAddArtistCommand 时自动订阅了 CanExecuteChange,因此 ButtonBase 捕获该事件并响应地调用 CanExecute 以查看当前值,并将 CurrentArtistBuffer 作为参数传递。
- CanExecute 再次调用 AddArtist,它再次验证 CurrentArtistBuffer 的属性,并将结果返回。
- ButtonBase 据此设置按钮的启用状态。
这种方法的优点是,在实际执行操作之前,CurrentArtistBuffer 会被最后验证一次。将代表最近验证结果的布尔值传递给 CanExecute 依赖于属性未更改,并且 MainPageViewModel.AddArtist()(由 Execute() 调用)需要硬编码对 CurrentArtistBuffer 的引用。因此,为了允许 CanExecute 和 Execute 接收 CurrentArtistBuffer 作为参数,我愿意接受 MainPageViewModel.CurrentArtistBuffer_PropertyChanged 也必须将它作为参数传递给 CanExecute 以触发 CanExecuteChanged 事件的需要。不理想,但权衡利弊。
视图 - MainPage
要创建 View,请右键单击 MainPage.xaml 并使用 Expression Blend 打开。
在 Objects and Timeline 中单击 LayoutRoot。
在 Properties/Common Properties 下,单击 'New' DataContext 按钮。
在出现的 Select Object Dialog 中,将其设置为 MainPageViewModel。
创建用户界面。顺便说一句,严格来说,我认为没有任何控件需要名称,但祝您能弄清楚。如果您愿意接受有关何时命名 UIElement 的建议,我会说任何能增加清晰度的时候。有时您可以直接移动事物。我已将 Objects and Timeline 窗口中的所有 TextBoxes 移动,以便每个 TextBlock 都紧跟提供其标题的 TextBlock。
现在将每个 TextBox 的 Text 属性绑定到 MainPageViewModel 中的 CurrentArtistBuffer 属性。我认为拖放是最简单的方法。
在 Objects and Timeline 中使所有 TextBox 可见,然后单击 Data 选项卡,然后只需将 DataContext 中的每个属性拖到您想要绑定它的 TextBox(或 ListBox)上。我再次指出,文本框在 Objects and Timeline 窗口中的顺序可以是任意的。我为了清晰起见而移动了它们——好吧,我撒谎了——我移动它们是为了让箭头看起来更好——事实证明它增加了清晰度。
每个 TextBox 的 Text 属性现在都绑定到 MusicBuffer 中的一个属性。因为 MusicBuffer 实现了 IDataErrorInfo,所以每个 Text 属性也可以绑定到验证错误消息,因此当绑定发生并且值无效时,消息会出现在文本框旁边。然而,实际上通过 Data.BindingExpression 绑定到错误消息还需要一个步骤,那就是在绑定 Text 属性的 xaml 中设置 ValidatesOnDataErrors = true。
<TextBox x:Name="txtName" ...Text="{Binding CurrentArtistBuffer.Name, Mode=TwoWay, ValidatesOnDataErrors=True}"/>
幸运的是,您可以在设计模式下添加此设置。在 Objects and Timeline 中单击文本框,选择 Properties 选项卡,然后单击 txtName Text Property 旁边的高级选项。

选择 Custom Expression。

现在您可以编辑设置 txtName TextBox 的 Text Property 的表达式。
如果出错,Blend 会捕获错误。
最终您希望它看起来像这样。
假设 Name 是必填项,如果用户将其更改为空白然后按 Tab 键离开(并将鼠标悬停在红色小三角形上),他们会看到类似这样的内容。
如果我不想使用 IDataErrorInfo 怎么办?
我们所有验证的显示方面都已针对 IDataErrorInfo 进行了定制。但仅仅因为我们支持它,并不意味着设计者就会使用它。我们需要一种更通用的方法来使 PropertyError 集合可供设计者使用,我们通过这样做来实现。为了让设计者更简单,我将其分解为两个集合:ServerErrors 和 PropertyErrors。因此,在准备这样做之前,我将集合分成两个列表:anonyList 和 namedList,具体取决于 PropertyName 是否为 null。然后,我使用这两个列表来确定如何进行一些错误格式化。
最后,我将它们作为 PropertyErrors 和 ServerErrors 集合提供。我将演示如何在 ListBox 中使用 PropertyErrors。我不会展示 ListBox 的所有代码;如果您想查看,它就在下载文件中。我想专注于绑定的机制。
首先,让我们让设计者能够确定列表是否包含任何条目。
namespace MusicCatalog
{
public class MainPageViewModel : INotifyPropertyChanged
{
//...
#region PropertyErrors
private ObservableCollection<PropertyError> _PropertyErrors;
public ObservableCollection<PropertyError> PropertyErrors
{
get { return _PropertyErrors; }
set
{
if (_PropertyErrors != value)
{
_PropertyErrors = value;
NotifyPropertyChanged("PropertyErrors");
NotifyPropertyChanged("HasPropertyErrors");
}
}
}
public bool HasPropertyErrors
{
get { return PropertyErrors != null ? PropertyErrors.Count > 0 : false; }
}
#endregion
}
}
我添加了 HasPropertyErrors 属性。如果 PropertyErrors 不为 null 且不为空,则返回 true。由于 HasPropertyErrors 直接依赖于 PropertyErrors,因此 PropertyErrors 的设置器会调用 NotifyPropertyChanged(“HasPropertyErrors”),而 HasPropertyErrors 没有设置器。
戴上设计师的帽子,我决定我想要一个 Property Errors 列表框,并且我希望它仅在有 Property Errors 可显示时才可见,我看不到任何方法可以做到这一点;所以回到程序员的穿着,我创建了一个值转换器。(实际上,在观看了一些 Ian Griffiths 的视频后,我放弃了布尔值/可见性问题,现在只会提供一个 Visibility 属性)。
namespace MusicCatalog
{
public class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
return (bool)value ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
return ((Visibility)value) == Visibility.Visible ? true : false;
}
}
}
回到设计师的时尚并思考程序员应该穿得更好,我设置了绑定。
(1) 选择要绑定可见性的 ListBox。
(2) 选择高级选项图标,然后从出现的对话框(未显示)中选择数据绑定。
(3) 在创建数据绑定对话框(显示)中,选择DataContext选项卡。
(4) 在Converter parameter文本框中输入HasPropertyErrors。
(5) 从Value converter下拉列表中选择BoolToVisibilityConverter。如果它没有出现,请点击文本框旁边的浏览 (…) 按钮找到它。
然后单击 OK。
ListBox ItemTemplate
由于它并不真正相关,我将只向您展示如何编辑下载项目中已创建的 ItemTemplate,您应该能够从中找出。
您可以在下面看到如何将 PropertyName 绑定到第一个 TextBlock,完成等效的操作将 ErrorMessage 属性绑定到第二个 TextBlock。
连接 Add 按钮
现在,通过从 Data ContextPanel 拖动,将 View 的 Add 按钮绑定到 ViewModel 的 AddArtistCommand:
确保您看到上面的消息框——这是您不希望看到的:
如果您注意,这很容易避免,但如果您不注意,也很容易错过。
现在将 CurrentArtistBuffer 拖到 Add 按钮。
将出现一个数据绑定对话框。展开下拉列表,选择CommandParameter,然后单击 OK。
Add 按钮现在不仅绑定到命令,而且 ButtonBase 还订阅了 ICommand.CanExecuteChanged 事件,并知道通过调用 ICommand.CanExecute 来响应它,并将 CurrentArtistBuffer 作为参数传递。
这就应该覆盖了。这种方法的主要缺点是您必须在客户端和服务器端重复业务规则;主要优点是它很简单,这使得通过复制本项目中的代码并根据您的数据进行更改来实现它变得容易。我可能会将其扩展为一个完整的 CRUD 应用程序,但就验证而言,我认为它不会在您已看到的内容上增加太多。
关注点
- 客户端和服务器端验证和异常处理
- 通过 Data.BindingExpression 和 IDataErrorInfo 进行的错误消息绑定
- 通过 ValidationHandler 进行的错误持久化
- 在绑定操作中抛出的异常的危险。
- ICommand Wrapper 类 DelegateCommand
- ICommand.CanExecute 实现细节