戲說領域驅動設計(十八)——內驗

SKevin發表於2022-03-29

  驗證在我們現實的生活中非常常見,比如您找工作得先整個面試驗證你的能力是否靠譜;找物件得先驗證下對方的顏值和升值空間。有些工程師寫程式碼從不驗證,我覺得是有三個原因,一是意識不夠,過於相信前端或外部服務;二是個人缺少主動思考的能力;三是團隊負責人的問題,您都當了領導了為什麼不制定一些基本開發規則給團隊樹規矩。實際上,驗證這個事情說簡單也的確不難,不就是個值判斷嗎?可如果想把這個事情做好還真是一個需要值得思考的工作,就和異常的處理一樣,我告訴你就算幹了10年的開發都未必知道怎麼有效的使用異常。程式碼裡中充滿了土味,一看就特Low。所以我們把驗證這個事情單獨的提出來,越是越是簡單的東西想寫好才越難。  

  您應該不知道“物件不變性”這個名字吧?領域模型包括實體與值物件都需要遵循這個規則,就是說不論你對一個領域對像做什麼操作,不論怎麼盤它,其本質應該保持不變。不都說“江沒易改,本性難變”嗎?上述的操作不僅是呼叫物件上的方法,還包括構造物件的過程。有一個例子說“一個沒有角的獨角獸還能稱得上是獨角獸嗎?”,簡單來說就是你需要始終保持領域物件處於合法的狀態或者說是屬性的值不能超出業務規則限制。比如訂單物件:客戶資訊不能為空、價格資訊不能為負數等、訂單項數量要大於0小於100等,不論你在訂單物件上做什麼操作,這些屬性值都不可以超出約束。

  想要保證物件的“不變性”,不能依賴於前端的輸入和資料庫本身的約束,那些基本都不靠譜,最好的方式還是首推“驗證”。針對物件本身是否合法的驗證我稱之為“內驗”。相對的,驗證某個業務先決條件的驗證稱之為“外驗”,因為此時的驗證已經超出了物件本身的規則範圍。既然需要對所有的物件都進行驗證,就應該將其做為一種通用的能力放到OOP程式設計框架中。其實我個人特別不喜歡稱之為“框架”,感覺概念太大了,所以我們就稱呼為基礎類庫吧。這個類庫可以提供一些用於檢驗領域物件是否合法的工具隨用隨取,不用重複的造輪子。

  領域物件內驗的實現思想很簡單:為每個領域物件中加入用於驗證的方法和驗證規則,在物件建立後或持久化前通過呼叫每個物件的驗證方法實現驗證邏輯。您一定要注意前面這句話中所說的觸發驗證方法的時機,別回頭不管什麼場景就呼叫驗證,這叫過度設計,那程式碼會讓人吐的。另外,既然是通用的能力而且用於驗證領域物件,就最好將其放到領域模型的基類中按需在具體類中進行重寫,所以就讓我們從這些基類作為起點開搞。

一、驗證服務基類

  看過前面的文章您應該已經知道了我們在實現驗證的時候使用了一種類“規約模式”,也就是將驗證規則嵌入到領域物件中,並在合適的時機進行驗證方法的呼叫。為此,在設計領域模型基類的時候我們讓其繼承的了一個用於驗證的父類“ValidatableBase”,這個類裡面包含了兩個方法,具體程式碼如下所示。“ValidatableBase”是一個抽象類,實現了介面“Validatable”,這個介面很重要,但凡需要驗證的物件都會實現這個介面。 您可以看一下下面的類圖,說得挺繞其實就三個元件。

 

public interface Validatable {

    /**
     * 驗證
     * @return 驗證結果
     */
    ParameterValidationResult validate();
}
public abstract class ValidatableBase implements Validatable {

    /**
     * 驗證當前領域模型
     * @return 驗證的結果
     */
    final public ParameterValidationResult validate() {
        RuleManager ruleManager = new RuleManager(this);
        this.addRule(ruleManager);
        return ruleManager.validate();
    }

    /**
     * 增加驗證規則
     * @param ruleManager 驗證規則管理器
     */
    protected void addRule(RuleManager ruleManager) {

    }
}
public abstract class DomainModel extends ValidatableBase {
   protected void addRule(RuleManager ruleManager) { }
}

  “ValidatableBase”類中的方法“validate”用於觸發模型的驗證。方法“addRule”用於將驗證規則加入到一個包含了驗證規則列表的物件“RuleManager”中,所以你可根據需要決定是否在具體類中進行方法的重寫,比如上面的“DomainModel”中我就對它進行了覆蓋。當觸發驗證的時候,只需要遍歷這個“RuleManager”物件中的每個規約並將驗證結果合併即可實現統一驗證的目的,RuleManager程式碼可參看如下片段。

public class RuleManager implements Validatable {

    //規則擁有者
    private DomainModel owner;

    //規則列表
    private List<Rule> rules = new ArrayList<Rule>();


    /**
     * 增加規則
     * @param rule 規則物件
     */
    public void addRule(Rule rule){
        if(rule != null){
            rules.add(rule);
        }
    }

    public RuleManager(DomainModel owner){
        this.owner = owner;
    }

    /**
     * 執行驗證,呼叫規則的驗證方法來執行具體的驗證。
     * @return 驗證結果
     */
    public ParameterValidationResult validate(){
        CompositeParameterValidateResult result = new CompositeParameterValidateResult();
        for(Rule rule : this.rules){
            //針對嵌入式物件的驗證
            if (rule instanceof EmbeddedObjectRule){
                EmbeddedObjectRule embeddedObjectRule = (EmbeddedObjectRule) rule;
                ParameterValidationResult validationResult = embeddedObjectRule.getTarget().validate();
                if(!validationResult.isSuccess()){
                    result.addValidationResult(new ParameterValidationResult(false, validateHandlingResult.getMessage()));
                }
                continue;
            }
            ParameterValidationResult ruleVerifyResult = rule.validate();
            if(!ruleVerifyResult.isSuccess()){
                result.fail();
                result.addValidationResult(new ParameterValidationResult(false, errorMessage));
            }
        }
        return result;
    }
}

  這裡面其實最有意思也最值得一說的是“EmbeddedObjectRule”這一段,其用於對內嵌物件進行驗證。所謂的內嵌物件是指包含於其它物件內部的領域物件,比如下面程式碼片段中的“contact”就是一個巢狀物件。我們驗證領域物件的時候不僅要驗證每個簡單型別的屬性,還需要驗證其中嵌入的其它物件。通過這種方式,就可以實現一層層的驗證,使得每個屬性都能被檢驗到。在上面程式碼中另外一個有意思的地方是這段“ParameterValidationResult ruleVerifyResult = rule.validate();”,您會發現真正執行驗證操作的其實是“Rule”物件,這些是我們預定好的一組規則,當然您也可以通過實現“Rule”介面自行加入新的規則。使用預定義規則的方式能加速開發的速度,讓我們拎包即可入住。由“Rule”做驗證其實是OOP中使用較為頻繁的方式,把責任分配的非常明確,十分有利用擴充套件。

public class Order extends EntityModel<Long> {
    private String name;
    private Contact contact;

    protected Order(Long id, String name, Contact contact) throws OrderCreationException {
        super(id);
        this.name = name;
        this.contact = contact;
    }

    @Override
    protected void addRule(RuleManager ruleManager) {
        super.addRule(ruleManager);
        ruleManager.addRule(new EmbeddedObjectRule("contact", this.contact));        
    }

    public String getName() {
        return name;
    }

    public Contact getContact() {
        return contact;
    }
}

  驗證規則定義了待驗證目標需要滿足什麼樣的規範,由於規則間有一些通用的屬性,所以我們在設計的時候首先會引入一個“RuleBase”基類,所有的規則都會從他繼承。“RuleBase”實現了“Rule”介面,而“Rule”也對前面我們說過的“Validatable”進行了擴充套件。類圖與程式碼如下所示,其實也是三個元件。

 

 

   

public interface Rule extends Validatable {
    /**
     * 與操作
     * @param rule 目標規則
     * @return 與後的規則
     */
    Rule and(Rule rule);

    /**
     * 或操作
     * @param rule 目標規則
     * @return 或後的規則
     */
    Rule or(Rule rule);
}
public abstract class RuleBase<TTarget extends DomainModel> implements Rule {

    //驗證的目標
    private TTarget target;
    //驗證目標的名稱
    private String nameOfTarget;
    //當規驗證失敗時的錯誤提示資訊
    private String customErrorMessage = GlobalConstants.EMPTY_STRING;

    /**
     * 規則基類
     * @param nameOfTarget 驗證目標的名稱
     * @param target 驗證的目標
     */
    protected RuleBase(String nameOfTarget, TTarget target){
        this(nameOfTarget, target, new String());
    }

    /**
     * 與操作
     * @param rule 目標規則
     * @return 與後的規則
     */
    @Override
    public Rule and(Rule rule) {
        return new AndRule(this, (RuleBase)rule);
    }

    /**
     * 或操作
     *
     * @param rule 目標規則
     * @return 或後的規則
     */
    @Override
    public Rule or(Rule rule) {
        return new OrRule(this, (RuleBase)rule);
    }
}

  “RuleBase”類裡除了包含了共用屬性外,還實現了兩個邏輯操作“與”和“或”,也就是說您可以實現規則的組合,比如我們要求:使用者名稱稱不能為空且長度小於等於30,就可以使用下面程式碼表示,這樣寫比較優雅。

new ObjectNotNullRule("name", this.name).and(new LE("name", this.name.length(), 30))

  通過上面提到的驗證規則框架,我們就可以開始著手建立一些具體的規則 ,下面展示了“物件不為空”規則的程式碼片段,這裡面需要特別關注的是方法“validate”,用於執行實際的驗證邏輯。類似“大於”規則,可以通過使用“compareTo”方法實現。

public class ObjectNotNullRule extends RuleBase<DomainModel> {

    /**
     * 獲取驗證失敗時預設的錯誤提示資訊
     */
    @Override
    protected String getDefaultErrorMessage() {
        return String.format("%s為空物件", this.getNameOfTarget());
    }

    /**
     * 物件非空規則
     * @param nameOfTarget 驗證目標的名稱
     * @param target       驗證的目標
     */
    public ObjectNotNullRule(String nameOfTarget, DomainModel target) {
        this(nameOfTarget, target, GlobalConstants.EMPTY_STRING);
    }

    /**
     * 執行驗證
     * @return 驗證是否成功
     */
    @Override
    public ParameterValidationResult validate() {
        if(this.getTarget() == null){
            return ParameterValidationResult.failed(null);
        }
        return ParameterValidationResult.success();
    }
}

  到目前為止我們已經展示了內驗所具備的一切條件,現在我們就可以在領域模型中加入各類驗證規則了。下面的程式碼片段以上面的“ObjectNotNullRule”規則為例展示瞭如何在業務程式碼中設定驗證規則。這樣的程式碼是不是看起來非常的漂亮?至少不用寫一堆的“if……else”。

public class Order extends EntityModel<Long> {
    private String name;
    private Contact contact;

    @Override
    protected void addRule(RuleManager ruleManager) {
        super.addRule(ruleManager);
        ruleManager.addRule(new EmbeddedObjectRule("contact", this.contact));
        ruleManager.addRule(new ObjectNotNullRule("name", this.name));
    }
}

二、驗證觸發的時機

  驗證觸發的時機是需要重點說明和解釋的內容。通過上面的程式碼您應該可以看出來每個領域模型無論是實體還是值物件都會包含一個叫作“validate”的公有方法,既然是公有就代表您可以隨意的使用,所以如果不加以限制程式碼就會變得特別髒……像我這種有程式碼潔癖的人是無論如何不能忍受的,所以我們需要確定觸發驗證的時機,這裡給的答案很簡單:物件構造完成時。物件構造包括使用建構函式和物件工廠兩種方式,一旦不合法就直接丟擲異常,因為不合法的物件是一個畸形兒不能該被創造出來,一般情況下也不允許創造出來後做二次加工使其合法。直白一點就是說你只能使用一行程式碼構造物件比如“new BusinessEntity()”或“BusinessEntityFactory.create(),比較建議使用工廠的方式建立物件以避免在建構函式中拋異常”,如果成功就返回目標物件失敗則直接報錯,第十七章中我展示過一個“OrderFactory”的案例,您可以翻看一下。

  領域物件的建立其實也只會出現在兩個時機中:新建及反序列化時。針對新建做驗證是因為引數來源於使用者或其它服務的輸入,這些是不可信任的;而反序列化時進行驗證的原因也很簡單,我們在將物件序列化時它其實是合法的,不過一旦儲存到比如資料庫中就不可控了,您知道誰手賤把資料給改了或由於錯誤執行了某些指令碼造成資料變質了。您不能或也不應該只依賴於資料庫本身的驗證規則來保障資料的正確性,使用關係型資料庫還好一點,使用如MongoDB這種的,那隻能看運氣了。再說了,業務物件的驗證屬於業務程式碼要處理的,您把這個責任推給資料庫就不合適了。

  被成功建立後的物件,您就可以為所欲為的進行操作了,包括最後的持久化階段也不需要進行二次驗證(如果我在前面的文章中提及到物件在持久化時進行驗證的話,請務必注意這種後驗的方式很不友好。比如訂單中的客戶資訊由於意外被置成了“null”,如果不進行構造時的檢測,您在使用這個資訊的時候就可能拋NPE)。這種說法應該沒讓您驚呆了吧?也許您可能認為這種說法非常的荒唐,我給您解釋一下為什麼。

  首先,我們的前提是物件建立後是合法的,這個在前面已經說過,使用建構函式或工廠進行保障;第二,由於有了聚合及聚合根的概念,您不可能繞過聚合根而直接修改其聚合內部的物件。比如使用者實體包含了一個值物件“實名資訊”,我們在修改這個資訊的時候不應該繞過使用者物件而直接對其引用或修改。假如此時的使用者是被凍結的狀態,修改實名資訊是沒有意義的,違反了“客戶凍結”時的業務操作限制;而通過讓客戶物件提供修改的方法,就可以在修改前加一些驗證對操作進行限制,也就是說“只能通過聚合根修改聚合”的原則進一步保障了物件的合法性。當然了,您也可以在修改前先把客戶資訊查詢出來判斷一下狀態再做變更邏輯,但這種方式會造成業務規則不夠內聚,而且這也是典型的程式導向的程式設計思維。第三點,我假設您在呼叫領域物件的公有方法時已經進行了引數的驗證,如果出現違反業務規則的情況則可直接丟擲一個業務異常,比如“凍結的使用者不能修改實名資訊”這個規則,您的程式碼可能會按如下方式寫。其實第三條的假設就不應該存在,誰寫公有方法的時候不驗證啊?

public class Account extends EntityModel<Long> {
    public void changeRealName(string name, string idCard) throws RealNameModificationException {
        if (this.status == AccountStatus.FREEZEN) {
            throw new RealNameModificationException();
        }
        ……
    }
}

  綜上三條所述,已經覆蓋了您使用領域物件時涉及修改的所有場景,每一步都對物件的不變性進行了保障,那建立好的領域物件不就是您手中的小白羊嗎?盤它的時候根本而不用擔心它不服。

總結

  物件的內驗是一種驗證物件合法性的手段,條條大路通羅馬,在實踐中其實有多種驗證的方式可採用,您所關注的其實應該是它的思想。還是要多提醒一句,你應該知道在DDD中要以聚合為儲存單元、事務單元,其實應該還需要多加一條:驗證單元,上述所說的驗證是以聚合為單位的而非某一個實體或值物件。在實踐中您需要多去思考物件的合法性,雖然說不太可能一下子都想全了,但要有一個驗證意識。這樣的程式碼安全性才高。其實不論是做什麼樣的系統,應該對安全抱有敬畏的態度,今天多想一點,明天您就少吃點虧。

相關文章