NHibernate初学者指南(19):验证复杂业务规则
验证复杂的业务规则,只使用特性是不够的。这种情况下,我们需要其他的办法。
始终执行有效的实体
验证用户输入的数据是否符合实体和值对象要求的一种方式就是拒绝任何违反规则的数据。假设在模型中,实体和值对象总是有效状态,那么我们就可以少写很多错误处理的代码。
那么我们如何能实现一个实体或值对象始终处于有效状态呢?让我们看一个简单的例子:我们的程序有一个person实体。person对象的first name和last name必须始终定义。在前面,我们介绍了一个Name值对象,它由三个属性组成:FirstName,MiddleName和LastName。现在我们往这个类中可以添加一些验证逻辑保证一个person总是有一个完整有效的名字。因为值对象是不变的,它总是通过参数化构造函数构造。在我们的例子中,有三个参数:last,middle和first name。在构造函数中,我们验证first name和last name确实定义了以及是有效的,如下面的代码所示:
public class Name { public Name(string lastName, string middleName, string firstName) { if (string.IsNullOrWhiteSpace(lastName) || lastName.Length < 2 || lastName.Length > 50) throw new ArgumentException("Invalid last name. " + "Must be between 2 and 50 charaters long."); if (string.IsNullOrWhiteSpace(firstName) || firstName.Length < 2 || firstName.Length > 50) throw new ArgumentException("Invalid first name." + "Must be between 2 and 50 charaters long."); LastName = lastName; MiddleName = middleName; FirstName = firstName; } public string LastName { get; private set; } public string MiddleName { get; private set; } public string FirstName { get; private set; } }
注意所有的属性都是只读的,以保证值对象的不变性。
执行实体有效性的另一个例子就是银行账户。程序只能在账户中有足够资金时才能信贷,如下面的代码所示:
public class Account { public decimal Balance { get; private set; } public void Credit(decimal amount) { if (Balance - amount < 0.0m) throw new ArgumentException("Not enough funds."); Balance -= amount; } }
使用验证类
处理复杂验证需求,我们可能想要将验证逻辑放在验证类中。这种情况下,我们使用单一责任原则(SRP),它声明一个类应该有一个且只有一个职责,那就是验证实体。
对于我们的订单系统,我们假设每个订单都必须由员工批准。现在考虑这种情况:1000$以上的订单只能由经理职位的员工批准,而且只有客户没有挂起发票批准才能够成功。这种验证要多步操作,所以实现一个特定验证目的的类更有意义。此外,我们可以将注意力更好的集中在手头的任务上,而不受其他打扰。
在前面的案例中,我们可以调用OrderApprovalValidator验证器类,它内部的逻辑是:
- 验证登录的员工是不是经理。
- 验证下订单的客户没有挂起发票。
这个验证器类的代码如下所示:
public class OrderApprovalValidator { public IEnumerable<string> GetBrokenRules(Order entity) { if (user_is_not_manager) yield return "you are not a manager."; if (customer_has_pending_invoices) yield return "customer has pending invoices"; } // more code, omitted for clarity... }
注意上面的代码包含伪代码,并不完整。
GetBrokenRules返回错误信息的IEnumerable。如果返回的错误集合是空的,那么订单的状态转移是有效的。
下面通过一个例子,详细看一下实现验证的步骤。
验证用户输入
在这个例子中,我们创建一个简单产品库存程序。作为程序的一部分,当添加category或者删除存在的category时验证用户的输入。
1. 在SSMS中创建一个空数据库:AdvancedValidationSample。
2. 在Visual Studio中创建一个WPF应用程序:AdvancedValidationSample。
3. 为项目添加对NHibernate.dll,NHibernate.ByteCode.Castle.dll和FluentNHibernate.dll程序集的引用。
4. 设置项目的Target framework为.NET Framework 4.0.
5. 在项目中,添加一个Category类,代码如下:
public class Category { public Guid Id { get; set; } public string Name { get; set; } }
6. 在项目中,添加一个Product类,代码如下所示:
public class Product { public Guid Id { get; set; } public string Name { get; set; } public Category Category { get; set; } public decimal UnitPrice { get; set; } public int ReorderLevel { get; set; } public bool Discontinued { get; set; } }
7. 在项目中添加一个CategoryMap类,使用FluentNHibernate映射Category实体,代码如下所示:
public class CategoryMap : ClassMap<Category> { public CategoryMap() { Not.LazyLoad(); Id(x => x.Id).GeneratedBy.GuidComb(); Map(x => x.Name).Not.Nullable().Length(50); } }
因为我们不想使用延迟加载,因此使用Not.LazyLoad()声明,所以在Category实体中没有必要将属性声明为virtual。
8. 在项目中添加一个ProductMap类,映射Product实体,代码如下:
public class ProductMap : ClassMap<Product> { public ProductMap() { Not.LazyLoad(); Id(x => x.Id).GeneratedBy.GuidComb(); Map(x => x.Name).Not.Nullable().Length(50); References(x => x.Category).Not.Nullable(); Map(x => x.UnitPrice).Not.Nullable(); Map(x => x.ReorderLevel).Not.Nullable(); Map(x => x.Discontinued).Not.Nullable(); } }
9. 在项目中添加一个接口:IValidator<T>,这个接口由项目中的所有验证器类实现,代码如下所示:
public interface IValidator<in T> where T : class { IEnumerable<string> BrokenRules(ISession session, T entity); }
10. 在项目中添加一个SaveCategoryValidator类,它实现IValidator<T>接口,从名字上就可以看出来它用于验证保存category实体是否是合法状态,如下面的代码所示:
public class SaveCategoryValidator : IValidator<Category> { public IEnumerable<string> BrokenRules(ISession session, Category entity) { if (string.IsNullOrWhiteSpace(entity.Name)) yield return "Name of category must be defined."; if (entity.Name.Length < 2 || entity.Name.Length > 50) yield return "Name of category must be between 2 and 50 characters long"; if (session.Query<Category>() .Where(c => c.Id != entity.Id) .Any(c => c.Name == entity.Name)) yield return "Duplicate category name."; } }
11. 在项目中添加一个DeleteCategoryValidator,实现IValidator<T>接口,它用于验证删除的实体是否被product所引用,代码如下所示:
public class DeleteCategoryValidator : IValidator<Category> { public IEnumerable<string> BrokenRules(ISession session, Category entity) { if (session.Query<Product>() .Any(p => p.Category.Id == entity.Id)) yield return "Cannot delete category that is referenced."; } }
12. 下面添加一个SaveProductValidator,实现IValidator<T>接口。它用于验证product的名字是否定义,长度是否合适,单价至少1分,以及重新订货级别是否是整数(包括0),代码如下所示:
public class SaveProductValidator : IValidator<Product> { public IEnumerable<string> BrokenRules(ISession session, Product entity) { if (!string.IsNullOrWhiteSpace(entity.Name)) yield return "Name of product must be defined."; if (entity.Name.Length < 2 || entity.Name.Length > 50) yield return "Name of product must be between 2 and 50 characters long"; if (session.Query<Product>() .Where(p => p.Id != entity.Id) .Any(p => p.Name == entity.Name)) yield return "Duplicate product name."; if (entity.UnitPrice < 0.01m) yield return "Unit price must be at least 1 cent"; if (entity.ReorderLevel < 0) yield return "Reorder level must be a positive number"; } }
13. 打开App.xaml.cs文件。重写OnStartup方法,添加对CreateSessionFactory方法的调用。代码如下所示:
public partial class App : Application { private const string connString = "server=.;" + "database=AdvancedValidationSample;" + "integrated security=true"; public static ISessionFactory SessionFactory { get; private set; } protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); CreateSessionFactory(); } private static void CreateSessionFactory() { SessionFactory = Fluently.Configure() .Database(MsSqlConfiguration.MsSql2008 .ConnectionString(connString) .ShowSql() ) .Mappings(m => m.FluentMappings .AddFromAssemblyOf<Category>()) .ExposeConfiguration(c => new SchemaExport(c) .Execute(false, true, false)) .BuildSessionFactory(); } }
14. 现在定义一个视图模型,包含我们程序的表示逻辑。在项目中添加一个类:MainViewModel。
15. 在视图类中添加一个公共的ObservableCollection<Category>类型和Category类型的只读属性,代码如下所示:
public ObservableCollection<Category> Categories { get; private set; } public Category SelectedCategory { get; set; }
16. 定义一个SaveCategoryValidator类型和DeleteCategory类型的字段,代码如下所示:
private readonly DeleteCategoryValidator deleteCategoryValidator; private readonly SaveCategoryValidator saveCategoryValidator;
17. 添加构造函数,初始化两个验证字段和categories集合,代码如下所示:
public MainViewModel() { deleteCategoryValidator = new DeleteCategoryValidator(); saveCategoryValidator = new SaveCategoryValidator(); Categories = new ObservableCollection<Category>(); }
18. 添加一个SaveCategory方法,其包含验证代码,如果验证成功,保存SelectedCategory到数据库中,代码如下所示:
public void SaveCategory() { if (SelectedCategory == null) return; using (var session = App.SessionFactory.OpenSession()) using (var tx = session.BeginTransaction()) { var category = session.Get<Category>(SelectedCategory.Id) ?? new Category(); category.Name = SelectedCategory.Name; var brokenRules = saveCategoryValidator .BrokenRules(session, category) .ToArray(); if (brokenRules.Any()) { MessageBox.Show( string.Join(Environment.NewLine, brokenRules), "Validation errors"); return; } session.SaveOrUpdate(category); tx.Commit(); SelectedCategory.Id = category.Id; } }
19. 添加一个方法DeleteCategory,删除一个category,代码如下所示:
public void DeleteCategory() { if (SelectedCategory == null) return; using (var session = App.SessionFactory.OpenSession()) using (var tx = session.BeginTransaction()) { var brokenRules = deleteCategoryValidator .BrokenRules(session, SelectedCategory) .ToArray(); if (brokenRules.Any()) { MessageBox.Show( string.Join(Environment.NewLine, brokenRules), "Validation errors"); return; } session.Delete(SelectedCategory); tx.Commit(); Categories.Remove(SelectedCategory); SelectedCategory = null; } }
20. 现在我们需要一个方法用于添加新的category到categories集合中,并使它为选中的category,代码如下所示:
public void AddNewCategory() { var category = new Category { Name = "(New Category)" }; Categories.Add(category); SelectedCategory = category; }
21. 下面我们需要实现接口INotifyPropertyChanged,当SelectedCategory改变时触发PropertyChanged事件。使MainViewModel实现INotifyPropertyChanged接口,代码如下:
public class MainViewModel : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; // other code... }
22. 添加一个OnPropertyChanged方法,它触发PropertyChanged事件,代码如下所示:
private void OnPropertyChanged(string propertyName) { if (PropertyChanged != null) PropertyChanged(this,new PropertyChangedEventArgs(propertyName)); }
23. 修改SelectedCategory属性,以便当它的值发生变化时能够触发OnPropertyChanged事件,代码如下所示:
private Category selectedCategory; public Category SelectedCategory { get { return selectedCategory; } set { selectedCategory = value; OnPropertyChanged("SelectedCategory"); } }
24. 打开MainWindow.xaml文件,添加如下的代码,设计界面:
<Window x:Class="AdvancedValidationSample.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Inventory System" Height="265" Width="426"> <Grid> <TabControl Name="tabControl1"> <TabItem Name="tabCategories" Header="Categories"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="200" /> <ColumnDefinition Width="*" /> </Grid.ColumnDefinitions> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions> <TextBlock Text="Categories" Grid.Row="0" /> <ListBox Name="lstCategories" Grid.Row="1" Margin="0 0 10 0" SelectedItem="{Binding Path=SelectedCategory}" ItemsSource="{Binding Path=Categories}" DisplayMemberPath="Name" SelectionChanged="OnSelectedCategoryChanged"/> <StackPanel Orientation="Horizontal" Grid.Row="2" Margin="0 5 10 0"> <Button Name="btnNew" Content="_New" Width="50" Click="OnNewCategory"/> <Button Name="btnDelete" Content="_Delete" Width="50" Margin="5 0 0 0" Click="OnDeleteCategory"/> </StackPanel> </Grid> <GridSplitter HorizontalAlignment="Right" VerticalAlignment="Stretch" Width="10"/> <Grid Grid.Column="1"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> <RowDefinition Height="261*" /> </Grid.RowDefinitions> <TextBlock Text="Category Name:"/> <TextBox Name="txtCategoryName" Grid.Row="1" IsEnabled="False" Text="{Binding Path='SelectedCategory.Name'}"/> <Button Name="saveCategory" Content="_Save" Grid.Row="2" IsEnabled="False" Width="50" HorizontalAlignment="Right" Margin="0 5 0 0" Click="OnSaveCategory"/> </Grid> </Grid> </TabItem> <TabItem Name="tabProducts" Header="Products"> <Grid /> </TabItem> </TabControl> </Grid> </Window>
界面的效果如下图所示:
25. 打开MainWindow的后置代码,添加一个MainViewModel的字段,如下面的代码所示:
private readonly MainViewModel viewModel;
26. 在构造函数中初始化viewModel,并将它赋给DataContext,代码如下所示:
public MainWindow() { InitializeComponent(); viewModel = new MainViewModel(); DataContext = viewModel; }
27. 为categories列表框的SelectedItemChanged事件实现事件句柄:OnSelectedCategoryChanged。在这个方法中,我们根据是否有选项选中设置Save按钮和category name文本框的可访问性,如下面的代码所示:
private void OnSelectedCategoryChanged(object sender, SelectionChangedEventArgs e) { var canEdit = lstCategories.SelectedItem != null; saveCategory.IsEnabled = canEdit; txtCategoryName.IsEnabled = canEdit; }
28. 为Save按钮的单击事件实现事件句柄,代码如下:
private void OnSaveCategory(object sender, RoutedEventArgs e) { // needed when user uses accelerator key // to trigger notify property changed event saveCategory.Focus(); viewModel.SaveCategory(); }
29. 为Delete按钮的单击事件实现事件句柄,代码如下:
private void OnDeleteCategory(object sender, RoutedEventArgs e) { viewModel.DeleteCategory(); }
30. 最后为New按钮实现事件句柄。在这个方法中设置category name文本框为焦点和选择内容使编辑更方便,代码如下所示:
private void OnNewCategory(object sender, RoutedEventArgs e) { viewModel.AddNewCategory(); txtCategoryName.Focus(); txtCategoryName.SelectionLength = txtCategoryName.Text.Length; }
31. 运行程序,结果如下图所示:
单击New
输入的Category名称为1个字符
数据库里的内容
到此,我们就完成了我们简单的程序。这个程序重要的部分就是当保存或删除一个category时,category首先被验证。