1. 程式人生 > >應用程式框架實戰十五:DDD分層架構之領域實體(驗證篇)

應用程式框架實戰十五: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進行基本驗證,很明顯無法滿足我們的實際需求。

  現在來假想一個驗證需求,你的老闆是個好人,你們的人力資源系統也是自己開發的,他要求程式設計師老男人的工資不能小於一萬。換句話說,如果是一個程式設計師老男人,他的資訊被儲存到資料庫的時候,工資不能小於一萬,否則就是非法資料。程式設計師老男人這個詞彙很明顯不存在,為了加深你的印象,用它來給你演示業務概念如何被對映到系統中。

  程式設計師老男人包含三個條件:

  1. 職業 == 程式設計師
  2. 年齡 > 40
  3. 性別 == 男

  你為了驗證這個需求,能使用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();

  可以在幾個地方為領域實體設定驗證規則物件。

  1. 領域實體的構造方法中。
  2. 具體的領域實體重寫Validate方法中。
  3. 當工廠建立領域實體(聚合)時。
  4. 領域服務或應用服務呼叫領域實體進行驗證時。

  設定驗證規則的要點是,穩定的驗證規則儘量放到實體中,以方便使用。

  現在還有一個問題是,驗證處理是丟擲一個異常,這個異常的訊息設定為驗證結果集合的第一個訊息。這在大部分時候都夠用了,但是某些時候對錯誤的處理會有所不同,比如你現在要顯示全部驗證失敗的訊息,這時候將要修改框架。所以把驗證的處理提取出來是個不錯的方法。

  定義一個驗證處理的介面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,歡迎有興趣的朋友加入討論。