C# API中模型与它们的接口设计详解
作者:无明
关键要点
可变模型应该具备自我验证的能力,并实现验证接口。
在共享对象时(特别是在跨线程共享时),考虑使用不可变模型。
考虑支持MVVM风格UI的单层和多层撤消。
在实现属性变更通知时避免不必要的内存分配。
不要覆盖模型的Equals和GetHashCode方法。
在传统的MVC、MVP、MVVM、Web MVC这些UI模式中,模型是一个公共元素。虽然有很多文章讨论这些架构中的视图和控制器,但几乎无一涉及模型。在本文中,我们将讨论模型本身以及相应的.NET接口。
我想先定义一些术语,这些术语在其他文章中可能有更精确的定义,但对于我们来说这些已经足够了。
数据模型(Data Model)
数据模型时包含数据(即属性和集合)和行为的对象或对象图。数据模型是本文的重点。
数据传输对象(Data Transfer Object,DTO)
DTO是只包含属性和集合的对象或对象图。一个真正的DTO没有任何行为,而且几乎是不可变的。
不过,在使用代码生成工具生成DTO时,通常会使用一些简单的接口(如INotifyPropertyChanged)。
对象图(Object Graph)
一个对象图由一个对象和所有可触及的子对象组成。在讨论数据模型和DTO时,我们所说的对象图都是单向树状结构(循环图是存在的,但它们会对序列化框架造成影响)。
领域模型(Domain Model)
领域模型是描述一组相关数据模型的更高级概念。
实体(Entity)
术语“实体”有许多定义,其中一些与“数据模型”基本相同。随着nHibernate和Entity Framework的流行,这个术语一般是指与数据库表一对一映射的DTO。
基于这个定义,实体可以用属性来修饰,以便更精确地描述数据库列和属性之间的映射关系。它还支持从数据库延迟加载子集合。
虽然可以通过扩展让实体承担数据模型的角色,但在应用业务逻辑之前,将实体映射到单独的数据模型或DTO是更为常见的做法。
业务实体(Business Model)
不要与ORM的实体混淆了,这是数据模型的另一种呈现方式。
不可变对象(Immutable Object)
不可变对象不包含可以改变属性的方法,它本身不是数据模型,但它可能出现在表示静态查找数据的数据模型中。因为它们不能被修改,所以跨多个数据模型共享一个不可变对象是安全的。
数据访问层(Data Access Layer,DAL)
在本文中,DAL包含了服务对象、存储库、直接数据库调用、Web服务调用等。基本上包括了任何用于与外部依赖项(如数据存储)发生交互的东西。
数据模型特征
真正的数据模型是可确定性测试(deterministically testable)的。也就是说,它们只由其他可确定性测试的数据类型组成。这意味着数据模型在运行时不能有任何外部依赖关系。
最后一点很重要。如果一个类在运行时与DAL耦合,那么它就不是数据模型。即使在编译时使用IRepository接口来“解耦”类,也无法消除与外部依赖的关系。
在判断什么是数据模型时,要小心那些“存活实体”。为了支持延迟加载,来自ORM的实体通常会包含一个对数据库上下文的引用。这就又让我们回到了非确定性行为的领域,实体行为的变化取决于上下文状态以及对象的创建方式。
换句话说,数据模型的所有方法都应该是可预测的,而且这种预测只能基于它们的属性值。
在父对象和子对象之间传递消息
父对象和子对象通常需要交互。如果做得不好,可能会导致难以理解的紧密交叉耦合。为了简化问题,请遵循以下三条规则:
- 父对象可以直接与子对象的属性和方法交互。
- 子对象只能通过触发事件与父对象进行交互。
- 对象不能直接与兄弟对象交互,兄弟对象之间的消息必须通过共同的父对象来传递。
基于这样的设计,可以将子对象分解出来,并在没有父对象的情况下对其进行测试。测试本身可以监控只有父对象能够处理的事件。
验证——数据模型唯一必须具备的功能
接下来我想谈谈数据模型可能会实现的可选特性。但在开始之前,我想先讨论每个数据模型必须具备的一个特性:验证。
完全不处理数据的数据模型几乎是不存在的。如果模型是来自文件、外部应用程序或用户界面,就有可能会引入不一致或不合法的值。来自用户界面的问题会更多,因为用户通常需要逐个字段得填写表单。
因为存在这些限制,所以不能在构造函数和属性设置器中使用异常,就像你在其他类中使用异常一样。不过可以验证接口,为错误检查提供一些灵活性。
.NET提供了一些开箱即用的验证接口,不过每个人都有自己特定的需求。
IDataErrorInfo
IDataErrorInfo接口早就可以用了,不过现在基本被弃用,因为它用起来很麻烦。让我们来看看它的属性。
string Error {get;}:这个属性有三个用途:
- 报告对象级别的错误
- 报告所有属性级别的错误
- 通过返回一个空字符串来表示不存在错误
string this[string columnName] {get;}:这个索引器属性将返回属性特定的错误。
正如你所看到的,Error属性做的事情太多了,它将所有东西都拼凑成一个字符串,从而无法区分对象级别和属性级别的验证错误。如果你重新定义它,让它只包含对象级错误,那么就无法知道对象作为整体是否包含错误。
至于索引器,你会怎么调用它?要访问它的唯一方法是将该对象转换成IDataErrorInfovariable。然后,很少有人会期望看到这样的代码:
var nameError = ((IDataErrorInfo)customer)["Name"];
如果你的UI框架需要这个接口,我建议你将它放到一个基类中,并提供更合理的验证API。一旦加入真实的验证逻辑,甚至可以忽略IDataErrorInfo的存在。
INotifyDataErrorInfo的常规定义
我将分两次讨论INotifyDataErrorInfo接口。在本小节中,我将解释本该如何使用INotifyDataErrorInfo,然后在下一个小节解释我认为应该如何使用它。
INotifyDataErrorInfo接口旨在支持Silverlight 4中的异步验证,其基本想法是修改属性会触发服务调用,被调用的服务最终会结束并更新错误状态。
这个接口的唯一属性是bool HasErrors {get;},不过关于如何实现这个属性并没有硬性规定。我们有两个基本选项,但都不可行。
- 阻塞直到异步验证完成,这样会挂起UI。
- 立即返回,这会让调用变得不确定,因为你不知道是否存在挂起的异步验证请求。
如果只是进行一般的显示,只要在发生EventHandler<DataErrorsChangedEventArgs> ErrorsChanged事件时更新HasErrors属性即可。不过,如果你尝试单击“保存”按钮同步检查验证状态,那这就不是一个好办法。
此外,ErrorsChanged理论上可以触发两次:一次是立即触发,另一次是异步验证完成后触发。这可能会产生奇怪的UI效果,因为HasErrors会在两种状态之间切换。
最后是IEnumerable GetErrors(string propertyName)方法,这个方法用于验证属性。不过,你也可以传给它一个null或空字符串来获取对象级验证错误。
它返回的是IEnumerable而不是IEnumerable<ValidationResult>,这让它看起来就像是一个C# 1的接口,而不是泛型。
不过缺乏类型安全并不是唯一的问题,这段话摘自它的文档:
此方法返回一个IEnumerable,在异步验证完成处理之前,可能会发生变化。绑定引擎因此能够在添加、删除或修改错误时自动更新用户界面验证反馈。
如果这个方法返回一个IObservable,或许就没有问题。但是在这种情况下,IEnumerable能够奏效的唯一方法是让它在等待异步验证完成之前阻塞。这样仍然会导致UI挂起。
然后是封装问题。如前所述,数据模型应该完全没有任何外部依赖。属性变化不应直接调用服务,因为这会使该类变得非常难以测试。如果你需要异步验证某些内容,请在控制器或视图模型中执行此操作。
INotifyDataErrorInfo的正确用法
尽管存在缺陷,但INotifyDataErrorInfo已经被用在很多UI框架中,所以我们无法忽略它。所幸的是,我们可以在不破坏兼容性的情况下重新定义它。
HasErrors属性可以在其他属性发生变化时进行同步更新。如果一个类实现了INotifyPropertyChanged,并且值发生变化,就会触发PropertyChanged事件。
不管指定的属性是有效还是无效,都应该触发ErrorsChanged事件。如果对象级验证已经发生变化,则应使用null或字符串触发ErrorsChanged事件。
在新模型中,GetErrors应该始终返回一个支持IEnumerable<ValidationResult>的集合类。ValidationResult类提供了有用的信息,例如哪些属性是验证警告的一部分。这对于一些错误消息来说非常管用,比如“至少需要提供名字/姓氏中的一个”。
基于属性的验证
我们可以使用基于属性的验证完成很多工作,虽然这样并不适合所有的情况。方法是在属性上放置ValidationAttribute的子类。这里有些例子:
- CreditCardAttribute
- EmailAddressAttribute
- EnumDataTypeAttribute
- FileExtensionsAttribute
- PhoneAttribute
- UrlAttribute
- MaxLengthAttribute
- MinLengthAttribute
- RangeAttribute
- RegularExpressionAttribute
- RequiredAttribute
- StringLengthAttribute
要创建自己的验证属性类,只需重写IsValid方法。通常这用于单属性验证,不过也可以通过ValidationContext来访问对象的其他属性。
基于属性的验证的一个优点是,一些框架(比如ASP.NET MVC/WebAPI)已经选定它作为验证接口。因为它是声明式的,所以可以与UI共享验证逻辑。
混合命令式和基于属性的验证
虽然理论上可以使用验证属性来完成所有工作,但有时候使用普通代码可以更容易地实现严格的验证。这样做的原因如下:
- 验证规则涉及多个属性
- 验证规则涉及子对象
- 验证规则不会被其他类或属性重用
命令式验证的一个缺点是它只存在于服务器端,无法像使用基于属性的验证一样自动与UI共享验证逻辑。
命令式验证的另一个限制是它需要使用共享接口,这样才能让应用程序的其余部分通过一致的方式触发验证。
空表单问题
当用户在创建新记录并未填写所有必填字段时,就会出现空表单问题。在显示表单时,你不希望看到每个字段都以红色突出显示。
为了解决这个问题,需要为模型提供两个额外的方法:
- 验证:跨所有字段执行验证,触发类似“required”这样的规则。
- 清除错误:从对象中删除所有已触发的验证错误。
对于这种模型,模型对象将从初始状态开始。如果它在显示给用户之前已经包含了部分值,则应该在向用户显示之前调用清除错误的方法。
当用户修改某个字段时,只验证该字段。然后,在保存之前,可以调用验证方法强制对模型进行全面检查,包括非用户修改的属性。
理论上的验证接口
我认为.NET的验证接口应该看起来像这样:
public interface IValidatable { /// This forces the object to be completely revalidated. bool Validate(); /// Clears the error collections and the HasErrors property void ClearErrors(); /// Returns True if there are any errors. bool HasErrors { get; } /// Returns a collection of object-level errors. ReadOnlyCollection<ValidationResult> GetErrors(); /// Returns a collection of property-level errors. ReadOnlyCollection<ValidationResult> GetErrors(string propertyName); /// Returns a collection of all errors (object and property level). ReadOnlyCollection<ValidationResult> GetAllErrors(); /// Raised when the errors collection has changed. event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged; }
你可以在Tortuga Anchor库中看到这个接口的实现。
IValidatableObject
如果不简要讨论下IValidatableObject接口,那就是我的失职。这个接口只有一个方法IEnumerable<ValidationResult> Validate(ValidationContext validationContext)。
我很喜欢这个方法,因为它可以触发对象的完整验证,所以它可以解决空表单问题。它返回ValidationResult对象,比原始字符串要好得多。
缺点是它接受ValidationContext对象作为参数,而几乎没有人知道如何使用这个类。以下是ValidationContext的属性。
- DisplayName:获取或设置要验证成员的名称。
- Items:获取与此上下文关联的键值对字典。
- MemberName:获取或设置要验证成员的名称。
- ObjectInstance:获取要验证的对象。
- ObjectType:获取要验证的对象类型。
- ServiceContainer:获取验证服务容器。
关于如何使用这些属性并没有相关的指南。例如,什么时候应该设置MemberName属性? DisplayName属性实际上做了什么?字典中应该保存什么以及在验证期间何时可以访问它?
文档中说它“可以通过任何实现IServiceProvider接口的服务添加自定义验证”,但并没有说明IServiceProvider.GetService(Type)方法需要支持哪些类型,因此无法利用此特性。
总而言之,ValidationContext类想要做所有的事情,但由于糟糕的API设计和几乎没有详尽的文档,它变得一无是处。由于没有UI框架使用这个接口,所以没有理由支持它或IValidatableObject接口。
属性变更通知
属性变更通知在很多情况下都很有用,不过更常见的是与MVVM设计模式相关联。属性变更通知通过INotifyPropertyChanged接口公开出来,让模型可以通知关联的UI元素:基础数据发生了变化。我们可以借此做一些有趣的事情,比如在后台进程中更新模型或者在多个视图之间共享模型。
实现属性变更通知最简单的办法是每次在调用属性设置器时触发它们。虽然从技术方面看是可行的,但仍有一些性能方面的影响。
public string Name { get { return m_Name; } set { m_Name = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); } }
在上面的示例中,即使没有不存在任何侦听者,每个属性变更通知让然会分配一个新对象来保存属性名称。如果这些通知频繁发生,则可能会触发不必要的垃圾回收。为了避免这种情况,应该把PropertyChangedEventArgs对象缓存起来。
另一个问题是事件可能是不必要的。如果属性值实际上没有发生改变,就相当于无缘无故地触发屏幕重绘。所以我们需要做一个简单的检查:
static readonly PropertyChangedEventArgs NameProperty = new PropertyChangedEventArgs(nameof(Name)); public string Name { get { return m_Name; } set { if (m_Name == value) return; m_Name = value; PropertyChanged?.Invoke(this, NameProperty); } }
这个过程可能非常繁琐,因此就有了“MVVM框架”,用来减少这些噪音。Get和Set方法与内部字典一起使用,用来维护状态。通过这种方式,可以为我们处理PropertyChangedEventArgs缓存和属性值变更改检查。具体细节会有所不同,但它们或多或少看起来像这个来自Tortuga Anchor的例子。
public string Name { get => Get<string>(); set => Set(value); }
请注意,这种便利性可能会对性能造成一点影响。访问内部字典比使用字段慢,并且值的装箱操作可能会消除缓存PropertyChangedEventArgs所带来的收益。
如果你只编写服务器端代码,可能会想“我没有UI,所以我不需要这些”。如果真是这样,或许你是对的。但有时候使用INotifyPropertyChanged可以简化一些复杂的代码。我建议服务器端开发人员至少将其视为一种选择。
INotifyPropertyChanging
这个是INotifyPropertyChanged的孪生兄弟,会在属性值发生变更之前触发。其目的是让消费者缓存先前的值。LINQ和Entity Framework等ORM框架可能会利用这些信息进行跟踪。
ISupportInitialize/ISupportInitializeNotification
ISupportInitialize的目的是临时禁用属性/集合变更通知、错误验证等。要使用它,请在进行属性变更之前先调用BeginInit。
当调用EndInit时,可以发送一个“everything changed”变更通知。这个是通过使用一个包含null或空属性名称的PropertyChangedEventArgs对象来完成的。
如果希望在初始化完成时收到通知,可以给ISupportInitializeNotification接口添加Initialized事件和IsInitialized属性。
集合变更通知
正如我们需要知道单个属性的变更一样,我们也需要知道整个集合发生的变更。我们可以使用INotifyCollectionChanged接口来解决这个问题。
可惜的是,INotifyCollectionChanged远不如它的名字所暗示的那么强大。从理论上讲,CollectionChanged相关事件可以使用单个事件来告诉我们何时已将整组对象添加到集合中或从集合中删除。但实际上,因为WPF中存在的设计缺陷导致无法实现这样的功能。
INotifyCollectionChanged最著名的实现是ObservableCollection<T>。这个类旨在为每个添加或删除的项目触发一个单独的CollectionChanged事件。在设计WPF时,它假设我们总是会使用ObservableCollection<T>,因此WPF不支持NotifyCollectionChangedEventArgs.NewItems具有多个项目的情况。
由于这个错误,没有人可以实现带有批量更新支持的INotifyCollectionChanged,除非他们100%确定集合类不会被用在WPF中。
因此,我的建议是不要试图从头开始创建自定义集合类。只需使用ObservableCollection<T>或ReadOnlyObservableCollection<T>作为基类,然后在其上添加所需的任何附加特性。
类型安全的集合变更事件
除了没有人使用的功能之外,INotifyCollectionChanged接口的另一个问题是,它不是类型安全的。如果类型对你来说非常重要,则必须执行(理论上)不安全的转换或编写代码来处理永远不会发生的情况。为了解决这个问题,我建议实现这个接口:
/// <summary> /// This is a type-safe version of INotifyCollectionChanged /// </summary> /// <typeparam name="T"></typeparam> public interface INotifyCollectionChanged<T> { /// <summary> /// This type safe event fires after an item is added to the collection no matter how it is added. /// </summary> /// <remarks>Triggered by InsertItem and SetItem</remarks> event EventHandler<ItemEventArgs<T>> ItemAdded; /// <summary> /// This type safe event fires after an item is removed from the collection no matter how it is removed. /// </summary> /// <remarks>Triggered by SetItem, RemoveItem, and ClearItems</remarks> event EventHandler<ItemEventArgs<T>> ItemRemoved; }
这不仅解决了类型安全问题,而且不需要检查NotifyCollectionChangedEventArgs.NewItems的大小。
集合中的属性变更通知
.NET中另一个“缺失的接口”是能够检测集合中某个项目属性何时发生变化。比方说,你有一个OrderCollection类,并且需要在屏幕上显示TotalPrice属性。为了保持这个属性的准确性,你需要知道每个项目的单价何时发生变化。
对于我自己的集合,我经常会公开一个INotifyItemPropertyChanged接口,用于将集合中对象的任意PropertyChanged事件转成单个ItemPropertyChanged事件。
为此,集合需要在将对象添加到集合或从集合中移除时附加和移除事件处理程序。
变更跟踪和撤消
虽然使用不是很频繁,.NET还是提供了专门用于跟踪对象变更的接口,这些接口甚至还提供了撤消功能。
变更跟踪
从表面上看,IChangeTracking接口看起来好像很容易理解:对象发生变化或者没有发生变化。但实际上它有点微妙。
从用户界面角度来看,用户通常想知道的是“这个对象或它的任何子对象是否发生变化了?”
从数据存储角度来看,你希望知道对象本身是否发生了变化。
文档里没有提到这些,因为它没有定义一个子对象是否被认为是“对象内容”的一部分。我个人偏好让IsChanged包含子对象的变化,并为数据存储添加单独的IsChangedLocal属性。
可恢复变更跟踪
IRevertableChangeTracking添加了一个RejectChanges方法来撤消任何挂起的更改。这里存在同样的问题,即这个方法适用于本地对象还是子对象。
我通常假设RejectChanges会遍历对象图,并拒绝所有挂起的变更。但在涉及集合属性时,这可能有点蹊跷,最好是将其封装在类中,而不是尝试构建临时解决方案。
可编辑的对象
与IChangeTracking不同,IEditableObject专门用于UI场景中。具体地说,就是用在提供确定/取消语义的对话框和数据网格中。
在显示对话框或将数据网格切换到编辑模式之前,必须调用BeginEdit来捕捉对象的快照。EndEdit清除快照,而CancelEdit将对象恢复到之前的状态。请注意,大多数数据网格会自动为你调用这些方法。
如果你同时使用了IEditableObject和IRevertableChangeTracking,那么我建议将其实现为两级撤消,并让IEditableObject处于第二级。或者换句话说,在调用RejectChange时同时调用CancelEdit,但不能反过来。
遗失的属性变更接口
在ORM集成中极有可能缺失一些接口。我们可以使用IChangeTracking来告诉ORM是否需要保存给定的记录,但并没有接口告诉我们哪些属性已经发生改变。这意味着ORM需要单独跟踪发生变更的字段,或者假设所有内容都发生变化,并将整个对象重新保存到数据库。
Equals、GetHashCode和IEquatable
这是我建议避免的一系列特性。根据我们的定义,数据模型是可变的。如果它们是不可变的,那么上述的接口都没有任何意义。
问题是你不能使用可变属性来安全地实现GetHashCode和Equals。字典会假设散列码永远不会改变,所以如果一个对象被当作字典的键,就会破坏字典的功能。
此外,对于数据模型来说,Equality究竟意味着什么?它们代表数据库表中的同一行(即主键)?或者两个对象的每个属性都相同?不管你如何回答这个问题,你的团队中的其他人必定会有不同的答案。
如果你觉得必须要有非默认的Equals或GetHashCode实现,请考虑创建一个IEqualityComparer<T>。它不属于数据模型,所以其他人可以理解你的做法是非标准的行为。
同样,你可能希望为排序提供一个或多个Comparer<T>类。
ICloneable
众所周知,我们不应该实现ICloneable接口,因为我们从来都不知道一个对象克隆是深拷贝还是浅拷贝。
当然,这并不意味着你绝对不应该提供克隆方法。如果你选择提供克隆方法,就应该非常清楚地了解被克隆的内容。或者可以将其称为ShallowClone或DeepClone。
总结性思考
模型是构建和理解应用程序的基础。你花在弥补缺口上的时间,比如不一致的命名约定、缺少的特性和不正确实现的接口,最终都会获得回报。
关于作者
Jonathan Allen 在90年代后期开始为一家健康诊所开发MIS项目,将逐步从Access和Excel迁移成为一个企业解决方案。在为金融行业开发自动交易系统五年后,他成为各种项目的顾问,其中包括机器人仓库的用户界面、癌症研究软件的中间层以及大型房地产保险公司的大数据解决方案。在空闲时间,他喜欢学习有关16世纪武术的东西。
查看英文原文:Models and Their Interfaces in C# API Design
总结
以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对脚本之家的支持。