應用程式框架實戰十五:DDD分層架構之領域實體(驗證篇)
在應用程式框架實戰十四:DDD分層架構之領域實體(基礎篇)一文中,我介紹了領域實體的基礎,包括標識、相等性比較、輸出實體狀態等。本文將介紹領域實體的一個核心內容——驗證,它是應用程式健壯性的基石。為了完成領域實體的驗證,我們在前面已經準備好了驗證公共操作類和異常公共操作類。
.Net提供的DataAnnotations驗證方法非常強大,Mvc會自動將DataAnnotations特性轉換為客戶端Js驗證,從而提升了使用者體驗。但是客戶端驗證是靠不住的,因為很容易繞開介面向服務端提交資料,所以服務端必須重新驗證。換句話說,服務端驗證才是必須的,客戶端驗證只是為了提升使用者體驗而已。
為了在服務端能夠進行驗證,Mvc提供了ModelState.IsValid。
[HttpPost]
public ActionResult 方法名( 實體名 model ) {
if ( ModelState.IsValid == false ) {
//驗證失敗就返回,可能會新增錯誤訊息,也可能要轉換為客戶端能識別的訊息格式
}
//驗證成功就執行後面的程式碼
}
在控制器裡寫if ( ModelState.IsValid == false )判斷有幾個問題,下面進行一些討論。
第一,可能誤導初學者,導致分層不清。
從分層架構的角度來講,驗證屬於業務層,在DDD分層架構就是領域層。觀察ModelState.IsValid可以發現,這句程式碼並不是在定義驗證規則,而是呼叫驗證。在控制器上直接呼叫驗證可能並不是什麼問題,但初學者可能會認為,既然可以在控制器上呼叫ModelState.IsValid進行驗證,那麼其它驗證程式碼也可以放到控制器上。
[HttpPost]
public ActionResult 方法名( 實體名 model ) {
if ( ModelState.IsValid == false ) {
//驗證失敗就返回
}
if ( model.A > 1 ) {
//驗證失敗就返回
}
if ( model.B > 2 ) {
//驗證失敗就返回
}
//驗證成功就執行後面的程式碼
}
觀察上面程式碼,model.A > 1 已經將本屬於領域層的驗證定義規則洩露到表現層來了,因為這句程式碼訪問了實體的屬性,所謂驗證規則,就是對實體屬性值進行某些約束。
既然可以在控制器上寫驗證,那麼就會有人在這裡寫業務邏輯,所以到了後面,DDD分層架構如同虛設。
第二,錯誤的驗證時機可能導致驗證失敗。
考慮這樣的場景,如果實體中某些屬性需要呼叫特定方法來產生結果,當提交到控制器操作時,這些屬性還是空值,由於還沒有呼叫特定方法,所以呼叫ModelState.IsValid可能導致驗證失敗。
可以看出,這其實是因為驗證的時機不對,驗證幾乎一定要在某些操作之後來進行,比如初始化操作,當然你可以在呼叫ModelState.IsValid之前呼叫特定方法,但這會導致分層不清的問題。
打個比方,實體中有一個訂單號,它是一個字串型別,並且添加了[Required]特性,需要呼叫某個方法來建立訂單號,當訂單實體被提交到控制器操作時,呼叫ModelState.IsValid就會失敗,因為訂單號現在是空值。當然你可以把生成訂單號的操作提前到建立訂單介面之前,這樣再提交過來就沒問題了,在這個例子上一般是可行的,但有些操作你可能無法提前。
第三,無法保證驗證完整性,可能需要多次驗證。
很多時候,DataAnnotations無法滿足我們的需求,所以我們還需要為特定業務需求寫一些定製的驗證程式碼。而ModelState.IsValid只能驗證DataAnnotations特性,所以這時候驗證通過意義不大,因為你需要在後面再驗證一次。當然你可以通過一些手段進行擴充套件,讓ModelState.IsValid能夠驗證你的特定規則,但沒有多大必要,因為表現層在分層上的要點就是儘量不要寫程式碼。
第四,導致冗餘程式碼。
現在來觀察每個ModelState.IsValid判斷都幹了些什麼工作,一般都會轉換成客戶端的特定訊息,比如某種格式的Json,然後返回給客戶端顯示出來。為了這樣一個簡單的功能,需要在大量的方法上新增這個判斷嗎?更好的方法是把這個判斷抽象到控制器基類,由基類來進行處理,其它地方有錯誤丟擲異常就可以了。這樣可以得到一個統一的異常處理模型,並且消除了大量冗餘程式碼。從這裡也可以看出,打造你的應用程式框架,總是從這些不起眼的地方著手,反覆考慮每個判斷,每行程式碼是不是可以消滅,把儘量多的東西抽象到框架中,這樣在開發過程中更多工作就會自動完成,不斷提煉可以讓你的工作越來越輕鬆。
綜上所述,在表現層進行驗證並不是一個好方法,執行驗證可以在應用層,而定義驗證就一定要在領域層。下面開始介紹如何對領域實體進行驗證支援。
現在有一個員工實體,叫Employee,如下所示。
/// <summary>
/// 員工
/// </summary>
public class Employee : EntityBase {
/// <summary>
/// 姓名
/// </summary>
[Required( ErrorMessage = "姓名不能為空" )]
public string Name { get; set; }
/// <summary>
/// 性別
/// </summary>
[Required( ErrorMessage = "性別不能為空" )]
public string Gender { get; set; }
/// <summary>
/// 年齡
/// </summary>
[Range(18,50,ErrorMessage = "年齡範圍為18歲到50歲")]
public int Age { get; set; }
/// <summary>
/// 職業
/// </summary>
[Required(ErrorMessage = "職業不能為空")]
public string Job { get; set; }
/// <summary>
/// 工資
/// </summary>
public double Salary { get; set; }
}
為了簡單起見,我把一些東西簡化了,比如性別用列舉更好,但用了字串型別,而年齡根據出生年月推斷會更好等等。這個例子只是想說明驗證的方法,所以不用考慮它的真實性。
可以看見,在員工實體的屬性上添加了一些DataAnnotations特性,這些特性保證了基本的驗證。現在定義了驗證規則,那麼怎麼執行驗證呢?前面已經說了,用ModelState.IsValid雖然可以實現這個功能,但不是最優方法,所以我們要另謀出路。
執行驗證的最簡單方法可能長成這樣:employee.Validate(),employee是Employee的例項,Validate是Employee中的一個例項方法。
注意,現在我們在領域實體中定義了一個方法,這可能會打破你平時的習慣和認識。多年的習慣可能讓你對實體的認識就是,只有一堆屬性的物件。現在要把思維轉變過來,這個轉變至關重要,它是你進入面向物件開發的第一步。
想想看,你現在要進行驗證,應該上哪才能找到這個能執行驗證的方法呢?如果它不在實體中,那麼它可能在表現層,也可能在應用層,還可能在領域服務中,當然還有可能不存在,都還沒人實現呢。
所以我們需要給業務邏輯安家,這樣才能幫你統一的管理業務邏輯,並提供唯一的訪問點。這個家最好的地方就是實體本身,因為屬性全都在這裡面,屬性上執行的邏輯也全部放進來,就能實現物件級別的高內聚。當屬性和邏輯發生變化時,對外的方法介面可能不變,這時候所有變化引起的影響就被限制在實體內部,這樣就達到了更低的耦合。
下面,我們來實現Validate方法。
首先考慮,這個方法應該被定義在哪呢?是不是每個實體上都定義一個,由於驗證對於絕大部分實體都是必須的功能,所以需要定義到層超型別上,即EntityBase。
再來考慮一下Validate的方法簽名。需要一個返回值嗎,比如bool值,我在之前的文章已經討論了返回bool值來指示是否驗證通過不是一個好方法,所以我們現在返回void。那麼方法引數呢?由於現在是直接在實體上呼叫,所以引數也不是必須的。
/// <summary>
/// 驗證
/// </summary>
public void Validate() {
}
為了實現這個方法,我們必須要能夠驗證實體上的DataAnnotations特性,這在前面的驗證公共操作類已經準備好了。我們在Util.Validations名稱空間中定義了IValidation介面,並使用企業庫實現了這個介面。
考慮在EntityBase的Validate方法中該如何獲得IValidation的例項呢?依賴程度最低的方法是使用構造方法注入。
/// <summary>
/// 領域實體
/// </summary>
/// <typeparam name="TKey">標識型別</typeparam>
public abstract class EntityBase<TKey> {
/// <summary>
/// 驗證器
/// </summary>
private IValidation _validation;
/// <summary>
/// 標識
/// </summary>
[Required]
public TKey Id { get; private set; }
/// <summary>
/// 初始化領域實體
/// </summary>
/// <param name="id">標識</param>
/// <param name="validation">驗證器</param>
protected EntityBase( TKey id, IValidation validation ) {
Id = id;
_validation = validation;
}
}
在外部通過構造方法把需要的驗證器例項傳進來,這樣甚至不需要在Util.Domains中引用任何程式集。這看起來很誘人,但不要盲目的追求低耦合。考慮驗證器的穩定性,這應該非常高,你基本不會隨便換掉它,更不會動態更換它。再看構造方法,多了一個引數,這會導致實體使用起來非常困難。所以為了不必要的擴充套件性犧牲易用性,並不划算。
另一種方法是通過Validate方法的引數注入,這樣可能要好些,但還是會讓方法在呼叫時變得難用。
應用程式框架只是給你或你的團隊在小範圍使用的,它不像.Net Framework或第三方框架在全球範圍使用,所以你沒有必要追求非常高的擴充套件性,如果發生變化導致你需要修改應用程式框架,你開啟來改一下也不是啥大問題,因為框架和專案原始碼都在你的控制範圍內,不見得非要達到OCP原則。當然,如果發生變化的可能性高,你還是需要考慮降低依賴。在依賴性和易用性間取捨,一定要根據實際情況,不要盲目追求低耦合。
另外再考慮每個實體可能需要更換不同的驗證器嗎?如果需要,那就得引入工廠方法模式。由於這個驗證器只是用來驗證DataAnnotations特性的,所以沒這必要。
那麼直接在EntityBase中new一個Validation例項好不好呢?嘿嘿,這我也只能說要求太低了。一個折中的方案是使用簡單靜態工廠,如果需要更換驗證器實現,你就把這個工廠開啟來改改,其它地方不動,一般來講這已經夠用。
為Util.Domains引用Util.Validations.EntLib程式集,並在Util.Domains中新增ValidationFactory類。
using Util.Validations;
using Util.Validations.EntLib;
namespace Util.Domains {
/// <summary>
/// 驗證工廠
/// </summary>
public class ValidationFactory {
/// <summary>
/// 建立驗證操作
/// </summary>
public static IValidation Create() {
return new Validation();
}
}
}
在EntityBase類中新增Validate方法。
/// <summary>
/// 驗證
/// </summary>
public void Validate() {
var result = ValidationFactory.Create().Validate( this );
if ( result.IsValid )
return;
throw new Warning( result.First().ErrorMessage );
}
我們在Validate方法中將領域實體本身傳入Validation例項中進行驗證,獲得驗證結果以後,判斷如果驗證失敗就丟擲異常,這裡的異常是我們在上一篇定義的異常公共操作類Warning,這樣我們就知道是業務上發生了錯誤,可以把這個丟擲的訊息顯示給客戶。
完成了上面的步驟以後,就可以進行基本的驗證了。但是隻能用DataAnnotations進行基本驗證,很明顯無法滿足我們的實際需求。
現在來假想一個驗證需求,你的老闆是個好人,你們的人力資源系統也是自己開發的,他要求程式設計師老男人的工資不能小於一萬。換句話說,如果是一個程式設計師老男人,他的資訊被儲存到資料庫的時候,工資不能小於一萬,否則就是非法資料。程式設計師老男人這個詞彙很明顯不存在,為了加深你的印象,用它來給你演示業務概念如何被對映到系統中。
程式設計師老男人包含三個條件:
- 職業 == 程式設計師
- 年齡 > 40
- 性別 == 男
你為了驗證這個需求,能使用DataAnnotations特性嗎,也許你真的可以,但是大部分人都做不到,哪怕做到也異常複雜。
為了實現這個功能,你可能在呼叫了Validate()方法之後,緊接著進行判斷。
employee.Validate();
if ( employee.Job == "程式設計師" && employee.Age > 40 && employee.Gender == "男" && employee.Salary < 10000 )
throw new Warning( "程式設計師老男人的工資不能低於1萬" );
如果你呼叫Validate是在應用層,這下好了,把驗證邏輯洩露到應用層去了,很快,你的分層架構就會亂成一團。
時刻記住,只要是業務邏輯,你就一定要放到領域層。驗證是業務邏輯的一個重要組成部分,這就是說,沒有驗證,業務邏輯可能是錯的,因為進來的資料不在合法範圍。
現在把這句判斷移到Employee實體,最合適的地方就是Validate方法中,但這個方法是在基類EntityBase上定義的,為了能夠給基類方法新增行為,可以把EntityBase中的Validate方法設為虛方法,這樣子類就可以重寫了。
基類EntityBase中的Validate方法修改如下。
/// <summary>
/// 驗證
/// </summary>
public virtual void Validate() {
var result = ValidationFactory.Create().Validate( this );
if ( result.IsValid )
return;
throw new Warning( result.First().ErrorMessage );
}
在Employee實體中重寫Validate方法,注意必須呼叫base.Validate(),否則對DataAnnotations的驗證將丟失。
public override void Validate() {
base.Validate();
if ( Job == "程式設計師" && Age > 40 && Gender == "男" && Salary < 10000 )
throw new Warning( "程式設計師老男人的工資不能低於1萬" );
}
對於應用層來講,它並不關心具體怎麼驗證,它只知道呼叫employee.Validate()就行了。這樣就把驗證給封裝了起來,為應用層提供了一個清晰而簡單的API。
一般說來,DataAnnotations和重寫Validate方法新增自定義驗證可以滿足大部分領域實體的驗證需求。但是,如果驗證規則很多,而且很複雜,會發現重寫的Validate方法很快變成一團亂麻。
除了程式碼雜亂無章之外,還有一個問題是,業務概念被淹沒在大量的條件判斷中,比如Job == "程式設計師" && Age > 40 && Gender == "男" && Salary < 10000這個條件實際上代表的業務概念是程式設計師老男人的工資規則。
另一個問題是,有些驗證規則只在某些特定條件下進行,直接固化到實體中並不合適。
當驗證變得逐漸複雜時,就需要考慮將驗證從實體中拆分出來。將一條驗證規則封裝到一個驗證規則物件中,這就是規約模式在驗證上的應用。規約的概念很簡單,它是一個謂詞,用來測試一個物件是否滿足某些條件。規約的強大之處在於,將一堆相關的條件表示式封裝起來,清晰的表達了業務概念。
把程式設計師老男人的工資規則提取到一個OldProgrammerSalaryRule類中,如下所示。
/// <summary>
/// 程式設計師老男人的工資驗證規則
/// </summary>
public class OldProgrammerSalaryRule {
/// <summary>
/// 初始化程式設計師老男人的工資驗證規則
/// </summary>
/// <param name="employee">員工</param>
public OldProgrammerSalaryRule( Employee employee ) {
_employee = employee;
}
/// <summary>
/// 員工
/// </summary>
private readonly Employee _employee;
/// <summary>
/// 驗證
/// </summary>
public bool Validate() {
if ( _employee.Job == "程式設計師" && _employee.Age > 40 && _employee.Gender == "男" && _employee.Salary < 10000 )
return false;
return true;
}
}
上面的驗證規則物件,通過構造方法接收業務實體,然後通過Validate方法進行驗證,如果驗證失敗就返回false。
返回bool值的一個問題是,錯誤描述就拿不到了。為了獲得錯誤描述,我把返回型別從bool改成ValidationResult。
using System.ComponentModel.DataAnnotations;
namespace Util.Domains.Tests.Samples {
/// <summary>
/// 程式設計師老男人的工資驗證規則
/// </summary>
public class OldProgrammerSalaryRule {
/// <summary>
/// 初始化程式設計師老男人的工資驗證規則
/// </summary>
/// <param name="employee">員工</param>
public OldProgrammerSalaryRule( Employee employee ) {
_employee = employee;
}
/// <summary>
/// 員工
/// </summary>
private readonly Employee _employee;
/// <summary>
/// 驗證
/// </summary>
public ValidationResult Validate() {
if ( _employee.Job == "程式設計師" && _employee.Age > 40 && _employee.Gender == "男" && _employee.Salary < 10000 )
return new ValidationResult( "程式設計師老男人的工資不能低於1萬" );
return ValidationResult.Success;
}
}
}
驗證規則物件雖然抽出來了,但是在哪呼叫它呢?最好的地方就是領域實體的Validate方法,因為這樣應用層將非常簡單。
為了能夠在領域實體的Validate方法中呼叫驗證規則物件,需要將驗證規則新增到該實體中,這可以在Employee中增加一個AddValidationRule方法。
/// <summary>
/// 員工
/// </summary>
public class Employee : EntityBase {
//構造方法和屬性
/// <summary>
/// 驗證規則集合
/// </summary>
private List<OldProgrammerSalaryRule> _rules;
/// <summary>
/// 新增驗證規則
/// </summary>
/// <param name="rule">驗證規則</param>
public void AddValidationRule( OldProgrammerSalaryRule rule ) {
if ( rule == null )
return;
_rules.Add( rule );
}
/// <summary>
/// 驗證
/// </summary>
public override void Validate() {
base.Validate();
foreach ( var rule in _rules ) {
var result = rule.Validate();
if ( result == ValidationResult.Success )
continue;
throw new Warning( result.ErrorMessage );
}
}
}
如果另一個領域實體需要使用驗證規則,就要複製程式碼過去改一下,這顯然是不行的,所以需要把新增驗證規則抽到基類EntityBase中。為了支援這個功能,首先要為驗證規則抽象出一個介面,程式碼如下。
using System.ComponentModel.DataAnnotations;
namespace Util.Validations {
/// <summary>
/// 驗證規則
/// </summary>
public interface IValidationRule {
/// <summary>
/// 驗證
/// </summary>
ValidationResult Validate();
}
}
在EntityBase中新增AddValidationRule方法,並修改Validate方法,程式碼如下。
/// <summary>
/// 驗證規則集合
/// </summary>
private readonly List<IValidationRule> _rules;
/// <summary>
/// 新增驗證規則
/// </summary>
/// <param name="rule">驗證規則</param>
public void AddValidationRule( IValidationRule rule ) {
if ( rule == null )
return;
_rules.Add( rule );
}
/// <summary>
/// 驗證
/// </summary>
public virtual void Validate() {
var result = ValidationFactory.Create().Validate( this );
foreach ( var rule in _rules )
result.Add( rule.Validate() );
if ( result.IsValid )
return;
throw new Warning( result.First().ErrorMessage );
}
現在讓OldProgrammerSalaryRule實現IValidationRule介面,應用層可以像下面這樣呼叫。
employee.AddValidationRule( new OldProgrammerSalaryRule( employee ) );
employee.Validate();
可以在幾個地方為領域實體設定驗證規則物件。
- 領域實體的構造方法中。
- 具體的領域實體重寫Validate方法中。
- 當工廠建立領域實體(聚合)時。
- 領域服務或應用服務呼叫領域實體進行驗證時。
設定驗證規則的要點是,穩定的驗證規則儘量放到實體中,以方便使用。
現在還有一個問題是,驗證處理是丟擲一個異常,這個異常的訊息設定為驗證結果集合的第一個訊息。這在大部分時候都夠用了,但是某些時候對錯誤的處理會有所不同,比如你現在要顯示全部驗證失敗的訊息,這時候將要修改框架。所以把驗證的處理提取出來是個不錯的方法。
定義一個驗證處理的介面IValidationHandler,這個驗證處理介面有一個Handle的處理方法,接收一個驗證結果集合的引數,程式碼如下。
/// <summary>
/// 驗證處理器
/// </summary>
public interface IValidationHandler {
/// <summary>
/// 處理驗證錯誤
/// </summary>
/// <param name="results">驗證結果集合</param>
void Handle( ValidationResultCollection results );
}
由於只需要在特殊情況下更換驗證處理實現,所以定義一個預設的實現,程式碼如下。
/// <summary>
/// 預設驗證處理器,直接丟擲異常
/// </summary>
public class ValidationHandler : IValidationHandler{
/// <summary>
/// 處理驗證錯誤
/// </summary>
/// <param name="results">驗證結果集合</param>
public void Handle( ValidationResultCollection results ) {
if ( results.IsValid )
return;
throw new Warning( results.First().ErrorMessage );
}
}
為了能夠更換驗證處理器,需要在EntityBase中提供一個方法SetValidationHandler,程式碼如下。
/// <summary>
/// 驗證處理器
/// </summary>
private IValidationHandler _handler;
/// <summary>
/// 設定驗證處理器
/// </summary>
/// <param name="handler">驗證處理器</param>
public void SetValidationHandler( IValidationHandler handler ) {
if ( handler == null )
return;
_handler = handler;
}
在EntityBase構造方法中初始化_handler = new ValidationHandler(),並修改Validate方法。
/// <summary>
/// 驗證
/// </summary>
public virtual void Validate() {
var result = ValidationFactory.Create().Validate( this );
foreach ( var rule in _rules )
result.Add( rule.Validate() );
if ( result.IsValid )
return;
_handler.Handle( result );
}
最後,用提取方法重構來改善一下Validate程式碼。
/// <summary>
/// 驗證
/// </summary>
public virtual void Validate() {
var result = GetValidationResult();
HandleValidationResult( result );
}
/// <summary>
/// 獲取驗證結果
/// </summary>
private ValidationResultCollection GetValidationResult() {
var result = ValidationFactory.Create().Validate( this );
Validate( result );
foreach ( var rule in _rules )
result.Add( rule.Validate() );
return result;
}
/// <summary>
/// 驗證並新增到驗證結果集合
/// </summary>
/// <param name="results">驗證結果集合</param>
protected virtual void Validate( ValidationResultCollection results ) {
}
/// <summary>
/// 處理驗證結果
/// </summary>
private void HandleValidationResult( ValidationResultCollection results ) {
if ( results.IsValid )
return;
_handler.Handle( results );
}
注意,這裡添加了一個Validate( ValidationResultCollection results )虛方法,這是一個鉤子方法,提供它的目的是允許子類向ValidationResultCollection中新增自定義驗證的結果。它和重寫Validate()方法的區別是,如果重寫Validate()方法,那麼你將需要自己處理驗證,而Validate( ValidationResultCollection results )方法將以統一的方式被handler處理。
這樣,我們就實現了驗證規則定義與驗證處理的分離。
最後,再對這個小例子完善一下,可以將“程式設計師老男人”這個概念封裝到Employee的一個方法中。
/// <summary>
/// 是否程式設計師老男人
/// </summary>
public bool IsOldProgrammer() {
return Job == "程式設計師" && Age > 40 && Gender == "男";
}
OldProgrammerSalaryRule驗證規則的實現修改為如下程式碼。
/// <summary>
/// 驗證
/// </summary>
public ValidationResult Validate() {
if ( _employee.IsOldProgrammer() && _employee.Salary < 10000 )
return new ValidationResult( "程式設計師老男人的工資不能低於1萬" );
return ValidationResult.Success;
}
這樣不僅概念上更清晰,而且當多個地方需要對“程式設計師老男人”進行驗證時,還能體現出更強的封裝性。
由於程式碼較多,完整程式碼就不貼上了,如有需要請自行下載。
如果你有更好的驗證方法,請一定要告訴我,等我理解以後分享給大家。
.Net應用程式框架交流QQ群: 386092459,歡迎有興趣的朋友加入討論。