領域驅動設計實踐——驗證(一)

SKevin發表於2021-02-05

  領域模型設計為複雜問題的解決提供了一套方法,但其理論往往非常抽象,本系列文單旨在提供一些最佳實踐。您需要首先認識到,軟體的設計過程主觀性很強,我希望能夠提供一個設計思想讓您在入門中有一個感性的認識,莫要陷入到“教條主義”中。

領域驅動設計:強調的是戰略,是巨集觀的,它為複雜業務的解決提供了指導思想。在實踐中,無論是“程式導向”還是“物件導向”的設計方式,都是領域驅動思路的一種實現方式,要根據不同的場景使用不同的方式,請您不要陷入自我懷疑,否認“程式導向”。

  程式研發過程中,往往涉及到驗證。不論是採用哪種實現方式(程式導向的方式或者物件驅動),原則上,公有方法中的第一件事情是驗證引數。何時,何地,如何做驗證是一個開發者要面臨的挑戰。做一個合格的軟體設計師(在這裡,我喜歡用設計師而非碼農或程式設計師,那是對於自己的不尊重),當您想要寫一份乾淨、整潔及可讀性很強的程式碼時,細節上的轉變會讓您的作品看起來更加賞心悅目、更有自信、程式碼可維護性更強。

程式碼是給人看的,不是機器

軟體設計不存在固定的規則,如果您在開發過程始終堅持某些原則就產生了規則

1、基於檢視模型的驗證

  在展開書寫之前,我們需要假設一個非常簡單的業務場景:使用者的每一個操作,都需要為其增加一個操作日誌。在此處,我們遵循如下設計原則:1)涉及新建、更新、複雜查詢業務時,Service層公有方法都接收檢視模型作為引數,而非拆成一個個獨立的引數;2)所有公有方法的要做的第一件事是驗證。檢視模型程式碼如下。

public class OperationLogInfo {
    private String module;
    private String operatorId;
    private String operatorName;
    private String action;

    public String getModule() {
        return module;
    }

    public void setModule(String module) {
        this.module = module;
    }

    public String getOperatorId() {
        return operatorId;
    }

    public void setOperatorId(String operatorId) {
        this.operatorId = operatorId;
    }

    public String getOperatorName() {
        return operatorName;
    }

    public void setOperatorName(String operatorName) {
        this.operatorName = operatorName;
    }

    public String getAction() {
        return action;
    }

    public void setAction(String action) {
        this.action = action;
    }
}

  接下來為Service層程式碼。

@Service
public class OperationLogService {
    private static Logger logger = LoggerFactory.getLogger(OperationLogService.class);
    
    
    /**
     * 儲存操作日誌
     * @param operationLogInfo 操作日誌資訊
     */
    public void save(OperationLogInfo operationLogInfo) {
        try {
            if (operationLogInfo == null) {
                throw new IllegalArgumentException(OperationMessages.NO_OPERATION_LOG);
            }
            if (StringUtils.isEmpty(operationLogInfo.getModule())) {
                throw new IllegalArgumentException(OperationMessages.NO_MODULE_INFO);
            }
            if (StringUtils.isEmpty(operationLogInfo.getOperatorId())) {
                throw new IllegalArgumentException(OperationMessages.NO_OPERATOR_ID_TYPE);
            }
            if (StringUtils.isEmpty(operationLogInfo.getOperatorName())) {
                throw new IllegalArgumentException(OperationMessages.NO_OPERATOR_NAME_TYPE);
            }
            if (StringUtils.isEmpty(operationLogInfo.getAction())) {
                throw new IllegalArgumentException(OperationMessages.NO_ACTION_TYPE);
            }
            OperationLogDataEntity entity = OperationLogDataEntity.of(operationLogInfo);
            this.operationLogDao.save(entity);
        } catch (IllegalArgumentException e) {
            logger.error(e.getMessage(), e);
        } catch (Exception e) {
            logger.error(OperationMessages.SAVE_LOG_FAILED, e);
        }
    }
}

  為了保持程式碼的乾淨,我引入了一個新的類”OperationMessages“,將所有的”報錯資訊“或者”操作提示“以靜態常量的形式儲存在一個統一的地方供後續程式碼引用。如果您的服務中有多個包,建議為每一個包中都加入這樣一個包含常量的類,用於區分不同業務的操作提示。下面為這個常量的程式碼片段。

/**
 * 操作提示
 */
final public class OperationMessages {
    public static final String NO_OPERATION_LOG = "無操作日誌資訊";
}

   返回到我的應用服務程式碼,嗯……整體來看比較整潔,不過程式碼讀起來不舒服,70%全是驗證,我的業務程式碼已經淹沒在驗證的海洋裡。還好只有4個欄位的資訊,否則……讓我們來優化一下,把驗證類程式碼全部移到一個方法中。

 

@Service
public class OperationLogService {
    private static Logger logger = LoggerFactory.getLogger(OperationLogService.class);
    
    
    /**
     * 儲存操作日誌
     * @param operationLogInfo 操作日誌資訊
     */
    public void save(OperationLogInfo operationLogInfo) {
        try {
            this.validate(operationLogInfo);
            OperationLogDataEntity entity = OperationLogDataEntity.of(operationLogInfo);
            this.operationLogDao.save(entity);
        } catch (IllegalArgumentException e) {
            logger.error(e.getMessage(), e);
        } catch (Exception e) {
            logger.error(OperationMessages.SAVE_LOG_FAILED, e);
        }
    }


    private void validate(OperationLogInfo operationLogInfo) {
        if (operationLogInfo == null) {
            throw new IllegalArgumentException(OperationMessages.NO_OPERATION_LOG);
        }
        if (StringUtils.isEmpty(operationLogInfo.getModule())) {
            throw new IllegalArgumentException(OperationMessages.NO_MODULE_INFO);
        }
        if (StringUtils.isEmpty(operationLogInfo.getOperatorId())) {
            throw new IllegalArgumentException(OperationMessages.NO_OPERATOR_ID_TYPE);
        }
        if (StringUtils.isEmpty(operationLogInfo.getOperatorName())) {
            throw new IllegalArgumentException(OperationMessages.NO_OPERATOR_NAME_TYPE);
        }
        if (StringUtils.isEmpty(operationLogInfo.getAction())) {
            throw new IllegalArgumentException(OperationMessages.NO_ACTION_TYPE);
        }
    }
}

  假如我需要在類”OperationLogInfo“中再加入一個新的欄位,對應的”validate“方法也需要變更。希望你的記性好一點不要忘了這處的變更,要不然可能要開始”事故報告“之旅,誰知道您的DB中是否設定了欄位必填或有某個長度的限制。而外,從資訊內聚的角度來看,把驗證程式碼寫到服務層貌似差了一點,畢竟類”OperationLogInfo“中有什麼欄位,只有它自己最知道,所以我們希望它可以承擔“資訊專家”的角色。因此,我們把驗證的方法上升到DTO模型“OperationLogInfo”中。 此外,考慮到引數驗證大多數時是必需的,所以我們做一個檢視模型的父類。

 

資訊專家”:給物件分配職責時,應該把職責分配給具有完成該職責所需要資訊的那個類

 

檢視模型”:對於檢視模型,相信每個人都會有自己的理解,比較通用的解釋是“承載用於在頁面上顯示的資訊的模型”。但我個人對於檢視模型有另外的解釋。舉一些例子:“相親時,我想要呈現給對方一些關於自己的資訊”,“買書的時候,封面上通常會有一些內容的介紹”。這些都可以被稱之為我的或書的檢視,是某個物件想要讓另外的物件瞭解自身情況的一種資訊載體。假如,訂單模組如果要獲取賬戶模組的資訊,最好可通過獲取對方的檢視模型來實現。這裡面存在一個設計的技巧:涉及到兩個包之間的互動,建議都通過檢視模型來實現;在一個包內的,如果程式碼內聚性很好,使用資料模型也很方便。這種通過檢視模型互動的方式,有利於後續專案的拆分。比如早期專案要求快速研發,訂單模型與賬戶模組在一個單體專案中,後續如果想把這兩個模組分離,由於檢視模型的存在,拆分工作會非常簡易。這裡存在另外一個原則“不同的包之間,只能通過Service去訪問彼此”,不要為了圖省事直接呼叫對方的DAO。此外,有的工程師習慣稱呼“OperationLogInfo”為DTO,DTO其實是一種統稱,資料模型、檢視模型、命令、事件都可稱之為DTO,這樣的叫法比較泛泛。

  新引入的父類叫“VOBase”,類“OperationLogInfo”繼承於它。原因很簡單,“OperationLogInfo”的來源可能是另外的包,也可能是通過REST傳進的引數,是一種資訊的縮影,設計為檢視模型還是比較自然的。

public interface Validatable {

    /**
     * 驗證
     * @return 驗證結果
     */
    ParameterValidationResult validate();
}


public abstract class VOBase implements Validatable {

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

    public String toJson() {
        return JsonUtils.toJson(this);
    }
}

  變更後的“OperationLogInfo”和“OperationLogService”程式碼如下。

public class OperationLogInfo extends VOBase {
    private String module;
    private String operatorId;
    private String operatorName;
    private String action;
    
    
    @Override
    public ParameterValidationResult validate() {        
        if (StringUtils.isEmpty(operationLogInfo.getModule())) {
            return ParameterValidationResult.failed(OperationMessages.NO_MODULE_INFO);
        }
        if (StringUtils.isEmpty(operationLogInfo.getOperatorId())) {
            return ParameterValidationResult.failed(OperationMessages.NO_OPERATOR_ID_TYPE);
        }
        if (StringUtils.isEmpty(operationLogInfo.getOperatorName())) {
            return ParameterValidationResult.failed(OperationMessages.NO_OPERATOR_NAME_TYPE);
        }
        if (StringUtils.isEmpty(operationLogInfo.getAction())) {
            return ParameterValidationResult.failed(OperationMessages.NO_ACTION_TYPE);
        }                

        return ParameterValidationResult.success();
    }

    //省略其它get、set方法
}

 

@Service
public class OperationLogService {
    private static Logger logger = LoggerFactory.getLogger(OperationLogService.class);
    
    
    /**
     * 儲存操作日誌
     * @param operationLogInfo 操作日誌資訊
     */
    public void save(OperationLogInfo operationLogInfo) {
        try {
            if (operationLogInfo == null) {
                throw new IllegalArgumentException(OperationMessages.NO_OPERATION_LOG);
            }
            ParameterValidationResult validation = operationLogInfo.validate();
            if (!validation.isSuccess()) {
                throw new IllegalArgumentException(validation.getMessage());
            }
            OperationLogDataEntity entity = OperationLogDataEntity.of(operationLogInfo);
            this.operationLogDao.save(entity);
        } catch (IllegalArgumentException e) {
            logger.error(e.getMessage(), e);
        } catch (Exception e) {
            logger.error(OperationMessages.SAVE_LOG_FAILED, e);
        }
    }


    //其它程式碼省略
}

  如果您有“程式碼強迫證”,可以將此處的驗證放到下面另起一個方法。不過此處的分離與前面設計的分離意義不同,我們引入了“資訊專家”(OperationLogInfo)的概念,把驗證的責任進行了約束。

內驗:基於“資訊專家”理論,將驗證的過程放到待驗證的物件中

基於檢視模型的內驗設計,建議:1)可使用Spring框架提供的驗證框架;2)不可以在驗證方法中引入其它的Service、DAO、遠端呼叫工具等,要保證檢視模型的純粹。

   2、基於業務模型的驗證

  基於領域模型的驗證,通過使用一些很小的設計技巧可以實現非常優雅的驗證。很多工程師喜歡使用如“AXON”這類框架,覺得使用起來非常的酷。實際上,應用模型驅動時我個人不是特別建議使用那類開源框架,一是依賴性太強;二是框架為了支撐各類模式,設計的非常複雜,造成您的程式碼效能不是很高。此外,無論是EDA還是CARS模式,都屬於區域性模式,不要在系統中全面應用。一些邏輯簡單的場景使用程式導向設計效果很好;複雜的業務則要根據其業務形態使用不同的設計模式。

   引入模型驅動,說明您的業務比較複雜,那設計出的物件也不會很簡單。如何保證一個物件的合法性是您需要首先考慮的內容。物件的生成,一個是通過外部引數新建立,另外則通過查詢資料庫進行載入。無論是哪種方式,資料都是不可信的。所以,驗證規則一定是非常非常的多,那麼是否有一種方式能讓我們專注於業務開發,而非為驗證頭痛呢?這裡面引入了兩個問題:1)如何驗證;2)何時驗證。

  2.1、業務模型內驗的實現

  業務模型的內驗,可以通過引入一些小的設計技巧完成。如果把程式碼的所有實現細節全部都展現出來,對於閱讀者來說也是一件比較痛苦的事情,所以在此進行一些簡化,僅貼一些核心程式碼供參考。此處的業務場景為“服務部署審批”流程,簡單來說就是每一次的服務上線需要通過一輪輪的稽核,只有都通過後方能進行實施。

//業務模型:部署審批單
public
class DeploymentApprovalForm extends ApprovalFormBase { private LocalDateTime deploymentDate; private ProcessStatus status = ProcessStatus.DRAFTING; private PhaseType currentPhase = PhaseType.DRAFTING; DeploymentApprovalForm(Long id, String name, ApplierInfo applierInfo, LocalDateTime createdDate, LocalDateTime updatedDate, List<ApprovalNodeBase> nodes, LocalDateTime deploymentDate, ProcessStatus status, PhaseType currentPhase) { //程式碼省略 } @Override protected void addRule(RuleManager ruleManager) { super.addRule(ruleManager); ruleManager.addRule(new ObjectNotNullRule("status", this.status, OperationMessages.INVALID_STATUS)); ruleManager.addRule(new NotEqualsRule("status", this.status, ProcessStatus.UNKNOWN, OperationMessages.INVALID_STATUS)); ruleManager.addRule(new ObjectNotNullRule("currentPhase", this.currentPhase, OperationMessages.INVALID_PHASE)); ruleManager.addRule(new NotEqualsRule("currentPhase", this.currentPhase, PhaseType.UNKNOWN, OperationMessages.INVALID_PHASE)); ruleManager.addRule(new ObjectNotNullRule("deploymentDate", this.deploymentDate, OperationMessages.INVALID_DEPLOYMENT_DATE)); } //程式碼省略 }

  上面的“部署審批單”領域模型中,方法“addRule”為內驗的具體實現,定義在父類中。

public abstract class ApprovalFormBase extends EntityModel<Long> {
    private String name;
    private ApplierInfo applierInfo;
    
    //程式碼省略
}

public abstract class EntityModel<TID extends Comparable> extends DomainModel implements Versionable, Deletable {

    //ID
    private TID id;
    
    //程式碼省略
}

public abstract class DomainModel extends ValidatableBase {

    /**
     * 初始化當前狀態
     */
    public void initializeToNewCreation() {

    }
    
    
    //程式碼省略
}

public abstract class ValidatableBase implements Validatable {

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

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

    }
}

   “ObjectNotNullRule”為驗證規則,定義在框架中,程式碼如下所示。通過定義不同型別的驗證規則如“NotEqualsRule”、“RegexRule”等,可以讓驗證的實現變得非常靈活。如果您願意,甚至可以定義如“and”,"or"這類的邏輯表示式。這裡的驗證邏輯使用了設計模式中的“規約模式”,建議找相關文章進行了解。

final public class ObjectNotNullRule extends RuleBase<Object> {

    /**
     * 規則基類
     *
     * @param nameOfTarget 驗證目標的名稱
     * @param target       驗證的目標
     * @param errorMessage 當規驗證失敗時的錯誤提示資訊
     */
    public ObjectNotNullRule(String nameOfTarget, Object target, String errorMessage) {
        super(nameOfTarget, target, errorMessage);
    }

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

  通過引入一些簡單的設計模式,可以將一些通用的功能進行封裝,這樣您的程式碼就會變得更加純粹。開發過程中,我很喜歡用一個詞來描述自己的程式碼,包括“整潔”,“純粹”,“高可讀性”,希望您也有類似的規則,讓程式設計工作視為一項藝術。

  2.1、業務模型內驗的實現

  此處有了驗證,您也許會問:“那我什麼時候呼叫呢,每一次儲存的時候?每一次操作物件的時候?”,如果真的這樣,您會發現程式碼中有許多重複的東西。在開發過程中如果發現了重複程式碼通常有兩類處理方式:1)封裝為類中單獨的方法;2)將方法提升至父類中。而驗證方法的呼叫時機,用這兩類方式都不太適合。領域模型的驗證通常是在應用服務中,不太好為所有的應用服務都設計出通用的可繼承的驗證方法。而如果我們仔細分析一下,到底何時真的需要進行驗證,您會發現呼叫時機其實是可列舉的:1)從資料庫中載入已有模型;2)模型新建立後;3)經過一系列的模型操作後,模型被最終持久化時。您也許會問,我在業務模型的方法中可能要判斷一些類欄位是否為空或者是否有合適的值等,是不是每次都要呼叫驗證方法?答案是否定的,因為在上面的三個環節中您已經保證了整體物件的合法性,這是任何業務操作的前提,在後續的業務執行過程中就不必做額外的驗證。當然,如果是引數的驗證則需要您在程式碼中實現,因為引數不是物件本身的屬性,不屬於內驗範圍。

關於物件的建立,模型驅動的設計方式通過有兩類方法:1)通過物件的建構函式,適合引數很少的場景;2)通過物件工廠。實踐中發現,“實體型別”模型的建立都比較複雜,要求的內嵌物件和引數通常比較多,所以工廠的方式非常常見。

  我們繼續分析上面三個驗證時機。在此,我們為專案加入限制:業務模型的建立必須通過工廠。這樣的話,1)和2)兩個場景中都可以將驗證方法放至物件工廠中,保證我們建立物件的過程或者返回一個合法的物件或者直接拋建立異常。下面程式碼展示了實現細節。

final public class DeploymentApprovalFormFactory {
    public final static DeploymentApprovalFormFactory INSTANCE = new DeploymentApprovalFormFactory();


    private DeploymentApprovalFormFactory() {
    }


    public DeploymentApprovalForm create(DeploymentApprovalFormInfo deploymentApprovalFormInfo)
            throws DeploymentApprovalFormCreationException {
        if (deploymentApprovalFormInfo == null) {
            throw new DeploymentApprovalFormCreationException(OperationMessages.INVALID_APPROVAL_FROM_INFO);
        }
        //程式碼省略
        PhaseType currentPhase = PhaseType.getPhaseType(deploymentApprovalFormInfo.getCurrentPhase());
        if (currentPhase == PhaseType.UNKNOWN) {
            currentPhase = PhaseType.DRAFTING;
        }
        //程式碼省略
        DeploymentApprovalForm deploymentApprovalForm = new DeploymentApprovalForm(deploymentApprovalFormInfo.getId(),
                deploymentApprovalFormInfo.getName(), applier, createdDate, updatedDate, nodes, deploymentDate, status,
                currentPhase);
        ParameterValidationResult validationResult = deploymentApprovalForm.validate();
        if (!validationResult.isSuccess()) {
            throw new DeploymentApprovalFormCreationException(validationResult.getMessage());
        }
        return deploymentApprovalForm;
    }
}

  “DeploymentApprovalFormInfo”這個物件是一個檢視模型,其值可以來自前端,也可以在“Repository”中通過呼叫“DAO”從資料庫中查詢資訊後構建。

  針對場景3),如果我們每次持久化時都顯示的呼叫一次驗證,會出現大量的重複的程式碼。所以,我們引入了一個新的模式“工作單元”,工作單元是在使用物件導向設計時非常實用的一種模式,下面程式碼給出片段,建議在網上找一些文章進行詳細學習。

public abstract class UnitOfWorkBase implements UnitOfWork {

    private static final Logger logger = LoggerFactory.getLogger(UnitOfWorkBase.class);
    

    /**
     * 提交所有改變的物件至事務
     *
     */
    @Override
    public CommitHandlingResult commit() {
        CommitHandlingResult result = new CommitHandlingResult();
        try {
            this.validate();
            this.persist();
        } catch(ValidationException e) {
            logger.error(e.getMessage(), e);
            result = new CommitHandlingResult(false, e.getMessage());
        } catch(Exception e) {
            logger.error(e.getMessage(), e);
            result = new CommitHandlingResult(false, OperationMessages.COMMIT_FAILED);
        } finally {
            this.clear();
        }
        return result;
    }
    
    
    //驗證物件
    protected void validate() throws ValidationException {
        CompositeParameterValidateResult result = new CompositeParameterValidateResult();        
        for (EntityModel entityModel : this.entityModels) {
            ParameterValidationResult validationResult = entityModel.validate();
            if (!validationResult.isSuccess()){
                result.addValidationResult(validationResult);
                result.fail();
            }
        }
        if (!result.isSuccess()) {
            throw new ValidationException(result.getMessage(), result);
        }
    }
    
    //程式碼省略
}

 

基於業務模型的內驗設計,建議:1)不可以使用框架的驗證框架,會產生強依賴;2)不可以在驗證方法中引入其它的Service、DAO、遠端呼叫工具等,會破壞您的架構完整性。通常情況下,您在使用模型驅動的設計方式時,應用的是一個“洋蔥”架構,業務模型居於架構的核心中,其它的元件依賴於模型而非反向的依賴。

  本文介紹了驗證模式中的“內驗證”,後續會針對“外驗證”進行說明。

 

相關文章