文章摘要: 從物件中刪除所有已觸發的驗證錯誤資料模型(Data Model) 資料模型時包含資料(即屬性和集合)和行為的物件或物件圖
關鍵要點
- 可變模型應該具備自我驗證的能力,並實現驗證介面。
- 在共享物件時(特別是在跨執行緒共享時),考慮使用不可變模型。
- 考慮支援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
此外,ErrorsChanged理論上可以觸發兩次:一次是立即觸發,另一次是非同步驗證完成後觸發。這可能會產生奇怪的UI效果,因為HasErrors會在兩種狀態之間切換。
最後是IEnumerable GetErrors(string propertyName)方法,這個方法用於驗證屬性。不過,你也可以傳給它一個null或空字串來獲取物件級驗證錯誤。
它返回的是IEnumerable而不是IEnumerable
不過缺乏型別安全並不是唯一的問題,這段話摘自它的文件:
此方法返回一個IEnumerable,在非同步驗證完成處理之前,可能會發生變化。繫結引擎因此能夠在新增、刪除或修改錯誤時自動更新用戶界面驗證反饋。
如果這個方法返回一個IObservable,或許就沒有問題。但是在這種情況下,IEnumerable能夠奏效的唯一方法是讓它在等待非同步驗證完成之前阻塞。這樣仍然會導致UI掛起。
然後是封裝問題。如前所述,資料模型應該完全沒有任何外部依賴。屬性變化不應直接呼叫服務,因為這會使該類變得非常難以測試。如果你需要非同步驗證某些內容,請在控制器或檢視模型中執行此操作。
INotifyDataErrorInfo的正確用法
儘管存在缺陷,但INotifyDataErrorInfo已經被用在很多UI框架中,所以我們無法忽略它。所幸的是,我們可以在不破壞相容性的情況下重新定義它。
HasErrors屬性可以在其他屬性發生變化時進行同步更新。如果一個類實現了INotifyPropertyChanged,並且值發生變化,就會觸發PropertyChanged事件。
不管指定的屬性是有效還是無效,都應該觸發ErrorsChanged事件。如果物件級驗證已經發生變化,則應使用null或字串觸發ErrorsChanged事件。
在新模型中,GetErrors應該始終返回一個支援IEnumerable
基於屬性的驗證
我們可以使用基於屬性的驗證完成很多工作,雖然這樣並不適合所有的情況。方法是在屬性上放置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. ReadOnlyCollectionGetErrors(); /// Returns a collection of property-level errors. ReadOnlyCollection GetErrors(string propertyName); /// Returns a collection of all errors (object and property level). ReadOnlyCollection GetAllErrors(); /// Raised when the errors collection has changed. event EventHandler ErrorsChanged; }
你可以在 Tortuga Anchor 庫中看到這個介面的實現。
IValidatableObject
如果不簡要討論下IValidatableObject介面,那就是我的失職。這個介面只有一個方法IEnumerable
我很喜歡這個方法,因為它可以觸發物件的完整驗證,所以它可以解決空表單問題。它返回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(); 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
由於這個錯誤,沒有人可以實現帶有批量更新支援的INotifyCollectionChanged,除非他們100%確定集合類不會被用在WPF中。
因此,我的建議是不要試圖從頭開始建立自定義集合類。只需使用ObservableCollection
型別安全的集合變更事件
除了沒有人使用的功能之外,INotifyCollectionChanged介面的另一個問題是,它不是型別安全的。如果型別對你來說非常重要,則必須執行(理論上)不安全的轉換或編寫程式碼來處理永遠不會發生的情況。爲了解決這個問題,我建議實現這個介面:
////// This is a type-safe version of INotifyCollectionChanged /// ///public interface INotifyCollectionChanged { /// /// This type safe event fires after an item is added to the collection no matter how it is added. /// ///Triggered by InsertItem and SetItem event EventHandler> ItemAdded; /// /// This type safe event fires after an item is removed from the collection no matter how it is removed. /// ///Triggered by SetItem, RemoveItem, and ClearItems event EventHandler> 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
同樣,你可能希望為排序提供一個或多個Comparer
ICloneable
衆所周知,我們不應該實現ICloneable介面,因為我們從來都不知道一個物件克隆是深拷貝還是淺拷貝。
當然,這並不意味著你絕對不應該提供克隆方法。如果你選擇提供克隆方法,就應該非常清楚地瞭解被克隆的內容。或者可以將其稱為ShallowClone或DeepClone。
總結性思考
模型是構建和理解應用程式的基礎。你花在彌補缺口上的時間,比如不一致的命名約定、缺少的特性和不正確實現的介面,最終都會獲得回報。
關於作者
Jonathan Allen 在90年代後期開始為一家健康診所開發MIS專案,將逐步從Access和Excel遷移成為一個企業解決方案。在為金融行業開發自動交易系統五年後,他成為各種專案的顧問,其中包括機器人倉庫的用戶界面、癌症研究軟體的中間層以及大型房地產保險公司的大資料解決方案。在空閒時間,他喜歡學習有關16世紀武術的東西。
檢視英文原文: Models and Their Interfaces in C# API Design