戲說領域驅動設計(十九)——外驗

SKevin發表於2022-03-31

  內驗是針對領域模型自身的驗證,其驗證規則也是由領域模型自已來完成,只是觸發的時機可能在工廠中也可能在建構函式中。與內驗對應的當然就是外驗了,這是用於對使用者的輸入和業務流程的前提或得更專業一點叫“前置條件”的檢驗。如果細化一點,可以將外驗分成兩個情況:使用者輸入和業務流程的前置條件。情況不同驗證的方式也不一樣,下面讓我們展開了細聊。對了,額外多說一句,此處的“內驗”和“外驗”是我為了說明問題所起的名稱,其實叫什麼您只要能和團隊成員說明白就行,名字並不是很重要。

一、基於外部輸入的驗證

  對外部的輸入進行驗證其實很簡單,有多種現成的手段可用比如SpringBoot裡的類庫“hibernate-validator”,引入後直接使用即可。這種驗證方式僅限於檢視模型或簡單型別,不建議在領域模型中也進行使用,會造成BO與基礎設施的強繫結,看過前面內容的您應該知道,減少對基礎設施的依賴是六邊型架構的典型特徵。回到正題,我個人在面對外部輸入的時候,如果是檢視模型,便在模型中直接嵌入驗證程式碼;如果是簡單型別,則將驗證的邏輯交付地一個驗證工具進行。這樣做的好處是業務邏輯中的程式碼量比較少,看起來乾淨;另外就是由於工具是可以複用的,所以減少的程式碼量總的算起來還是不少的,畢竟驗證是一個剛需。熟悉本系列文章的老朋友應該發現我提到了很多次的“程式碼乾淨、整潔”,這個並非是可有可無的要求,而是應當在開發過程中隨時要注意的。在滿足需求的同時有效程式碼越少系統可維護性越高;涉及到工作交接或增加人手等相關工作,這些在IT團隊中非常常見的情況本來成本是不低的,但如果能在程式碼書寫度方面給予重視,成本是可以降下來的。

  基於檢視模型的驗證,通過為所有的模型增加一個支援驗證的基類來實現,具體類可通過對用於驗證的方法進行覆蓋來實現自定義的驗證規則。這種實現簡單明瞭,也不用做太多的額外的工作。雖然說Spring有現成的框架,但我不太喜歡在程式碼上加入各種註解,顯得亂。現實中您可以使用Spring框架所提供的能力,而我在這裡寫出來是為了展示驗證實現的思想。下面的類圖展示了這種設計的類結構。

 

   “Validatable”介面在上一章已經進行了介紹,這裡需要重點說明的是“VOBase”。所有的檢視模型都從它繼承,由於介面“Validatable”的存在使得檢視模型具備了可驗證性。方法“validate()”用於提供具體的驗證邏輯,不過我們在VOBase只是對其做了簡單的實現,畢竟抽象類也沒什麼可進行驗證的。“ApprovalInfo”是一個檢視模型具體類,對“validate()”方法進行了覆蓋並加入了實現邏輯。其實我一度在想,在這裡貼這類簡單的程式碼是不是對您的技術水平有一定的侮辱,不過既然都看到這兒了您就索性多看兩眼,畢竟我們想要強調的驗證思想和意識,看看別人怎麼做的再考慮自身如何提高。

public abstract class VOBase implements Validatable {

    @Override
    public ParameterValidationResult validate() {
        return ParameterValidationResult.success();
    }
}

public class ApprovalInfo extends VOBase {
    @ApiModelProperty(value = "審批人ID", required = true)
    private String approverId;    
    @ApiModelProperty(value = "審批建議", required = true)
    private String comment;    

    @Override
    public ParameterValidationResult validate() {
        if (StringUtils.isEmpty(approverId)) {
            return ParameterValidationResult.failed(OperationMessages.INVALID_APPROVER_INFO);
        }
        if (StringUtils.isEmpty(comment)) {
            return ParameterValidationResult.failed(OperationMessages.INVALID_APPROVAL_COMMENT);
        }
        return ParameterValidationResult.success();
    }    
}

  我們再進一步思考一下,其實無論驗證是針對VO還是基本型別的,本質上都是對引數的驗證,那就完全可以將引數的驗證規則抽象成規則物件,原理同內驗是一樣,只是內驗把規則封裝在了領域物件的內部。而且,上面我只是寫了VO物件驗證的邏輯並沒有進行觸發,也的確需要一個呼叫驗證方法的點。對此,我們設計一個工具類“ParameterValidators”,類圖如下所示。把驗證規則如“VORule”封裝在“ParameterValidators”中,使用者在觸發驗證的時候會迴圈所有內嵌的規則並呼叫規則本身的驗證邏輯。

 

  又有一個我們熟悉的介面“Validatable”,截止到目前已經用到了三次了,感覺這是我的職業生涯中設計最成功的介面,複用度極高。多說一句,其實很多程式設計師在使用介面的時候只是為了用而用,實際上並沒有考慮為什麼、要在什麼場景用。這裡有一個小小的提示:介面的作用是“賦能”,您在設計的時候要從物件能力這個角度去考慮,千萬別用岔了,否則容易出現設計過度的情況。迴歸正文,通過上面的類圖我們可以知道,有多個具體的類實現了“Validatable”介面,比如“StringNotNullRule ”、“VORule”等,“ParameterValidators”則匯聚了這些規則。程式碼片段請看示例。

final public class ParameterValidators {
    /**
     * 驗證
     * @throws IllegalArgumentException 引數異常
     */
    public void validate() throws IllegalArgumentException {
        if (this.parameters.isEmpty()) {
            return;
        }
        for (Validatable parameter : this.parameters) {
            ParameterValidationResult validationResult = parameter.validate();
            if (!validationResult.isSuccess()) {
                throw new IllegalArgumentException(validationResult.getMessage());
            }
        }
    }


    /**
     * 增加待驗證的檢視模型
     * @param vo 檢視模型
     * @param messageIfVoIsNull 當檢視模型為空時的提示資訊
     */
    public ParameterValidators addVoRule(VOBase vo, String messageIfVoIsNull) {
        this.parameters.add(new VORule(vo, messageIfVoIsNull));
        return this;
    }


    /**
     * 增加業務模型ID驗證
     * @param targetValue 待驗證引數的值
     * @param errorMessage 錯誤提示
     * @return 引數驗證器
     */
    public ParameterValidators addStringNotNullRule(String targetValue, String errorMessage) {
        this.parameters.add(new StringNotNullRule(targetValue, errorMessage));
        return this;
    }
}

class VORule implements Validatable {
    private VOBase vo;
    private String messageIfVoIsNull;


    @Override
    public ParameterValidationResult validate() {
        if (vo == null) {
            if (StringUtils.isEmpty(messageIfVoIsNull)) {
                return ParameterValidationResult.failed(OperationMessages.INVALID_BUSINESS_INFO);
            }
            return ParameterValidationResult.failed(messageIfVoIsNull);
        }
        ParameterValidationResult validationResult = vo.validate();
        if (!validationResult.isSuccess()) {
            return ParameterValidationResult.failed(validationResult.getMessage());
        }
        return ParameterValidationResult.success();
    }
}

  針對檢視模型的驗證實際上是呼叫了VO物件的驗證邏輯;針對簡單型別的驗證則是設計了一些驗證規則如“StringNotNullRule”。“ParameterValidators”包含了一些“add*”方法,通過呼叫這些方法把待驗證的目標加到本物件中,“validate”會迴圈其內部包含的規則並觸發驗證,一旦有不合法的情況出現則直接丟擲異常。您也可以通過將異常資訊進行匯聚和包裝來統一給出驗證結果。有了這些基礎設施的支撐,我們在業務程式碼中進行引數驗證時會節省很多精力,寫出的程式碼看起來很乾淨、整潔,如下片段所示。

    public CommandHandlingResult terminate(DeploymentResultVO resultVO, Long approvalFormId, OperatorInfo operatorInfo) {
        try {
            ParameterValidators.build()
                    .addVoRule(resultVO, OperationMessages.INVALID_DEPLOYMENT_RESULT)
                    .addVoRule(operatorInfo, OperationMessages.INVALID_OPERATOR_INFO)
                    .addObjectNotNullRule(approvalFormId, OperationMessages.INVALID_APPROVAL_FROM_INFO)
                    .validate();
            ……                
        } catch (IllegalArgumentException | ApprovalFormOperationException e) {
            logger.error(e.getMessage(), e);
            return new CommandHandlingResult(false, e.getMessage(), null);
        }
    }

二、業務流程前置條件驗證

  業務流程前置條件的驗證相對要比引數驗證複雜得多,比如這樣的需求“使用者下訂單前,需要判斷庫存是否大於0且賬戶不能是凍結狀態”,這裡的兩個約束是下單業務的前置條件。如果您仔細分析一下會發現前置驗證的條件驗證不同於引數和物件的驗證:前者一般需要使用其它服務提供或從資料庫中查詢出的資料作為判斷依據;而後者一般是對自身屬性的判斷,不需要使用外部資料,您還真別小看這種不同,它限制了後續驗證的實現方式,後面我們會詳解。上述作為假想的案例,乍一看感覺實現起來應該非常簡單,在使用者建立訂單物件前把庫存資訊和賬戶資訊分別查詢出來,並根據需求進行條件的驗證,程式碼可能是下面這樣的。

@Service
public
class OrderService { public void placeOrder(OrderDetail orderDetail, string accountId) { AccountVO account = this.accountService.find(accountId); if (account.getStatus == AccountStatus.FREEZEN) { throw new IllegalOperationException(); } StockVO stock = this.stockService.find(orderDetail.getProductId()); if (stock.getAmount() < 0) { throw new IllegalOperationException(); } Order order = OrderFactory.create(orderDetail); …… } }

  我相信大多數開發都會按上述程式碼的方式進行開發。實際上這種方式有點四不像,“Order”使用了物件導向程式設計而兩個驗證條件是典型的程式導向思維。這裡有三個顯示的問題:1)當前的驗證條件有兩個,如果再加上新的條件呢?比如“下單前,賬戶信用額度要大於0;賬戶餘額要大於0;使用者必須實名認證的;必須是首單使用者等”,我可以一口氣說出幾十種條件,按上述的寫法肯定要包含大量的“if……”,程式碼基本就沒法看了;2)這些前置條件其實是一種業務規則,您把業務規則放到應用服務中是不合理的。因為我們一直強調,應用服務中只做業務流程控制,不應該包含業務邏輯,程式導向的程式碼才會這麼幹;3)這些業務的前置條件沒有複用的可能性。比如“首單使用者”規則,在秒殺訂購場景需要使用;在購買具備優惠活動的產品時也會有需要,所以你不得不在使用的時候把程式碼全複製過來。這種程式碼上線的時候容易,一旦涉及到規則變更,改起來就是個噩夢,你能說得清楚有多少個地方使用了重複的程式碼嗎?

  問題我們已經列舉了出來,那麼如何解決這些問題?我們可以簡單的根據上面所說的三點問題一一解決掉。針對問題一,可以把前置條件的驗證全提到一個服務中或另一個方法中即可解決;針對問題二,可以把這些業務規則獨立出去作為一個個的領域模型,只是我們需要注意前文中說過的這些規則所用的資料來源於外部系統或資料庫,而領域模型是不能使用這些基礎設施的,所以就需要你在構造的時候把這些資訊先從應用服務中提出來;針對問題三,既然能把每個規則封裝成獨立的領域模型,那這些規則就具備了複用性,所以針對問題二的解決方案是一箭雙調的。

  有了解決思路我們就需要考慮一下如何設計實現,既然訂單服務中有這麼多的限制條件,我們可以做一個驗證的的框架,這種框架不僅能用於訂單服務的驗證,如果設計得當也可以在其它服務內部複用,畢竟前置條件驗證是一個剛性需求。另外,框架需要提供驗證所需要的資訊比如進行資料庫查詢,需要組織驗證規則,所以其實現一定是個應用服務。據此,我們的類圖所下所示。

戲說領域驅動設計(十九)——外驗

  這個類圖相對複雜一點,讓我們來解釋一下具體的含義。這裡面有一個似曾相識的老朋友“Validatable”介面,不過這個和前面的不太一樣(其實可以一樣的,只是案例程式碼實現有先後,如果您打算使用本文的設計思想,請儘量實現統一),驗證的方法中多了一個引數“ValidationContext”,這是一個抽象類,需要在具體實現的時候包含用於獲取驗證資料的資訊。以上面的下單場景為例,當然就是賬戶ID“accountId”和訂單詳情“orderDetail”。所以您需要新建一個繼承自“ValidationContext”的具體類並把賬戶ID作為屬性,用於驗證的應用服務使用賬號ID呼叫賬戶服務來獲取賬號資訊。下面程式碼片段為“Validatable”介面的定義以及驗證應用服務的示例。

 

public interface Validatable {
    /**
     * 驗證方法
     * @param validationContext  驗證上下文
     * @throws ValidationException 驗證異常
     */
    void validate(final ValidationContext validationContext) throws ValidationException;
}

public abstract class ValidationServiceBase implements Validatable {

    private ThreadLocal<Validator> validatorThreadLocal = new ThreadLocal<Validator>();

    /**
     * 驗證服務
     *
     * @param validationContext 驗證資訊上下文
     * @throws OrderValidationException 驗證異常
     */
    @Override
    public void validate(final ValidationContext validationContext) throws ValidationException {
        this.validatorThreadLocal.set(new Validator());
        this.buildValidator(this.validatorThreadLocal.get());
        this.validatorThreadLocal.get().validate(validationContext);
    }

    /**
     * 構建驗證器
     * @param validator 驗證器
     */
    protected abstract void buildValidator(Validator validator);
}

  我們前面說過了,用於驗證的服務是一個應用服務,所以我們為這個服務設計了一個基類,也就是上面的“ValidationServiceBase”,方法“validate”用於觸發驗證邏輯;方法“buildValidator”用於在其中加入待驗證的規則,注意:這些規則是領域模型。這裡引入了一個新的物件“Validator”,作為驗證規則的容器裡面包含了“ValidationSpecificationBase”型別物件的列表。在觸發ValidationServiceBase.validate()方法時,會呼叫Validator.validate(),後者會遍歷Validator中的驗證規則“ValidationSpecificationBase”再呼叫每個規則的validate()方法。不論是“ValidationServiceBase”、“Validator”還是“ValidationSpecificationBase”,由於實現了“Validatable”介面,所以都會包含方法“validate()”,具體程式碼如下所示。

public class Validator implements Validatable {
    //訂單驗證規則列表
    private List<ValidationSpecificationBase> specifications = new ArrayList<ValidationSpecificationBase>();

    /**
     * 驗證方法
     * @param validationContext  驗證上下文
     * @throws ValidationException 驗證異常
     */
    @Override
    public synchronized void validate(final ValidationContext validationContext) throws ValidationException {
        Iterator<ValidationSpecificationBase> iterator  = this.specifications.iterator();
        while (iterator.hasNext()) {
            ValidationSpecificationBase validationSpecification = iterator.next();
            validationSpecification.validate(validationContext);
        }
        clearSpecifications();
    }
}
public abstract class ValidationSpecificationBase implements Validatable {

}

public class AccountBalanceSpec extends ValidationSpecificationBase {
    private Customer customer;
    
    public AccountBalanceSpec(Customer customer) {
        this.customer = customer;
    }
    
    @Override
    protected void validate(ValidationContext validationContext) throws OrderValidationException {        
        if (this.customer.getBalance == 0) {
            throw new OrderValidationException();
        }
    }    
}

  有了上述的基本型別作支撐,我們就可以在業務程式碼中加入用於驗證的領域模型和用於驗證的應用服務,案例中的“判斷賬戶餘額”驗證規則可參看上面程式碼“AccountBalanceSpec”的實現(再提示一次:這是一個領域模型)。那麼餘下的就是看如何設計用於驗證的應用服務了,程式碼如下片段。

@Service
public class OrderValidationService extends ValidationServiceBase {
    /**
     * 構建驗證器
     *
     * @param validator 驗證器
     */
    @Override
    protected void buildValidator(Validator validator) {
        Customer customer = this.constructAccount(validator);
        //賬號狀態驗證
        validator.addSpecification(new AccountBalanceSpec(customer));
     //可加入其它驗證規則 }
private Customer constructAccount(Validator validator) { String accountId = (OrderValidationContext)validator.getContext(); //通過呼叫遠端服務查詢賬戶資訊
    AccountVO = ……
     //構建客戶資訊
     Customer customer = ……
     return customer;
} }

  有了驗證服務,我們就可以按如下程式碼的方式實現下單場景的驗證。對比一下前面的那種四不像的方式,您覺得這種方式是不是要好得多。

@Service
public class OrderService {
    @Resource
    private OrderValidationService orderValidationService;
    
    public void placeOrder(OrderDetail orderDetail, string accountId) {
        OrderValidationContext context = new OrderValidationContext(orderDetail, accountId);
        this.orderValidationService.validate(context);
        Order order = OrderFactory.create(orderDetail);
        ……
    }    
}

 總結

  本章程式碼有點多,如果您一遍沒整明白,可以多看幾次。為了減少程式碼的量,我閹割了部分內容,所以如果出現對應不上的情況是正常的。最重要的是您得學會一種物件導向程式設計的思想和解決問題的思路。我在前面的文章中提過二級驗證,此處的兩級就是指外驗與內驗。另外多提一句,兩能驗證只適用於命令類的方法。查詢直接通過引數驗證即可,不需要這麼複雜的判斷。這裡其實暗含一個思想:在設計命令類方法的時候務必要保持謹慎的態度,做到足夠的驗證是對自己的一種保護。截止到本章結束,我們已經總結了驗證相關的知識。如果您在回顧一下內容就會發現通過這兩種驗證,您在寫業務程式碼也就是編寫業務模型中的程式碼的時候,根本不用判斷這個欄位是否為空,那個欄位是否資料不對;下沉到比如DAO層也不用再寫驗證相關的程式碼,因為DAO的上層是BO,資料是否正確在BO中已經進行了保障。

相關文章