產品程式碼都給你看了,可別再說不會DDD(五):請求處理流程

2023-09-03 12:00:29

這是一個講解DDD落地的文章系列,作者是《實現領域驅動設計》的譯者滕雲。本文章系列以一個真實的並已成功上線的軟體專案——碼如雲https://www.mryqr.com)為例,系統性地講解DDD在落地實施過程中的各種典型實踐,以及在面臨實際業務場景時的諸多取捨。

本系列包含以下文章:

  1. DDD入門
  2. DDD概念大白話
  3. 戰略設計
  4. 程式碼工程結構
  5. 請求處理流程(本文)
  6. 聚合根與資源庫
  7. 實體與值物件
  8. 應用服務與領域服務
  9. 領域事件
  10. CQRS

案例專案介紹

既然DDD是「領域」驅動,那麼我們便不能拋開業務而只講技術,為此讓我們先從業務上了解一下貫穿本文章系列的案例專案 —— 碼如雲(不是馬雲,也不是碼雲)。如你已經在本系列的其他文章中瞭解過該案例,可跳過。

碼如雲是一個基於二維條碼的一物一碼管理平臺,可以為每一件「物品」生成一個二維條碼,並以該二維條碼為入口展開對「物品」的相關操作,典型的應用場景包括固定資產管理、裝置巡檢以及物品標籤等。

在使用碼如雲時,首先需要建立一個應用(App),一個應用包含了多個頁面(Page),也可稱為表單,一個頁面又可以包含多個控制元件(Control),比如單選框控制元件。應用建立好後,可在應用下建立多個範例(QR)用於表示被管理的物件(比如機器裝置)。每個範例均對應一個二維條碼,手機掃碼便可對範例進行相應操作,比如檢視範例相關資訊或者填寫頁面表單等,對錶單的一次填寫稱為提交(Submission);更多概念請參考碼如雲術語

在技術上,碼如雲是一個無程式碼平臺,包含了表單引擎、審批流程和資料包表等多個功能模組。碼如雲全程採用DDD完成開發,其後端技術棧主要有Java、Spring Boot和MongoDB等。

碼如雲的原始碼是開源的,可以通過以下方式存取:

碼如雲原始碼:https://github.com/mryqr-com/mry-backend

請求處理流程

在上一篇程式碼工程結構中,我們從宏觀層面講到了DDD專案的目錄結構,但並未觸及到實際的程式碼。在本文中,我們將深入到程式碼中,逐一講解DDD中對各種請求型別的典型處理流程。

在本系列的DDD概念大白話我們提到,DDD中的所有元件都是圍繞著聚合根展開的,其中有些本身即是聚合根的一部分,比如實體和值物件;有些是聚合根的客戶,比如應用服務;有些則是對聚合根的輔助或補充,比如領域服務和工廠。反觀當下流行的各種軟體架構,無論是分層架構、六邊形架構還是整潔架構,它們都有一個共同點,即在架構中心都有一個核心存在,這個核心正是領域模型,而DDD的聚合根則存在於領域模型之中。

不難看出,既然每種架構中都有為領域模型預留的位置,這也意味著DDD可採用任何一種軟體架構。事實也的確如此,DDD並不要求採用哪種特定架構,如果你真要說DDD專案應該採用某種架構的話,那麼應該「以領域模型為中心的軟體架構」。

如果我們把軟體系統當做一個黑盒的話,其外界是各種形態的使用者端,比如瀏覽器,手機APP或者第三方呼叫方等,盒子內部則是我們精心構建的領域模型。不過,領域模型是不能直接被外界存取的,主要原因有以下兩點:

  • 使用者端的演進和領域模型的演進是不同步的,比如網頁端所需要展示的資訊量比手機端更多,但是他們所使用的領域模型卻是相同的,因此在建模時我們通常會將領域模型和使用者端解耦開來,以利於各自的建模和演進
  • 軟體除了處理領域模型這種業務複雜度之外,還需要處理技術複雜度,以及業務和技術的銜接複雜度,比如有些請求通過HTTP協定完成,而有些則通過RPC完成,因此除了領域模型,我們還需要適配各種形式的外部使用者端

接下來,讓我們來看看DDD專案是如何銜接外部請求和內部領域模型的。既然聚合根是領域模型中的一等公民,那麼按照對聚合根的操作型別不同,DDD專案中主要存在以下4種型別的請求:

  • 聚合根建立流程
  • 聚合根更新流程
  • 聚合根刪除流程
  • 查詢流程

咋一看,你可能會說這不就是CRUD麼?本質上這的確是CRUD,但是這裡的CRUD可不是僅僅運算元據庫那麼簡單,你如果閱覽過本系列的上一篇程式碼工程結構的話,便知道在碼如雲中領域模型的程式碼量佔比遠遠高出資料庫存取相關的程式碼量。

本文主要講解DDD對請求的處理流程,並不講解聚合根本身的設計和實現,而是假設聚合根(以及領域模型中的工廠和領域服務等)已經實現就位了,關於聚合根本身的講解請參考本系列的聚合根與資源庫一文。此外,為了突出重點,本文只著重講解請求處理流程的主幹,而忽略與之關係不大的其他細節,比如我們將忽略應用服務中的事務處理和許可權管理等功能,為此讀者可參考應用服務與領域服務

聚合根建立流程

聚合根的建立通常通過工廠類完成,請求流經路線為:控制器(Controller) -> 應用服務(Application Service) -> 工廠(Factory) -> 資源庫(Repository)。

在碼如雲中,當用戶提交表單後,系統後臺將建立一份提交(Submission),這裡的Submission便是一個聚合根物件。在整個「建立Submission」的處理流程中,請求先通過HTTP協定到達Spring MVC中的Controller:

//SubmissionController

@PostMapping
@ResponseStatus(CREATED)
public ReturnId newSubmission(@RequestBody @Valid NewSubmissionCommand command,
                              @AuthenticationPrincipal User user) {
    String submissionId = submissionCommandService.newSubmission(command, user);
    return returnId(submissionId);
}

原始碼出處:com/mryqr/core/submission/SubmissionController.java

Controller的作用只是為了銜接技術和業務,因此其邏輯應該相對簡單,在本例中,SubmissionControllernewSubmission()方法僅僅將請求代理給應用服務SubmissionCommandService即完成了其自身的使命。這裡的NewSubmissionCommand表示命令物件,用於攜帶請求資料,比如對於「建立Submission」來說,NewSubmissionCommand物件中至少應該包含表單的提交內容等資料。命令物件是外部使用者端傳入的資料,因此需要將其與領域模型解耦,也即命令物件不能進入到領域模型的內部,其所能到達的最後一站是應用服務。

處理流程的下一站是應用服務,應用服務是整個領域模型的門面,無論什麼型別的使用者端,只要業務用例相同,那麼所呼叫的應用服務的方法也應相同,也即應用服務和技術設施也是解耦的。

//SubmissionCommandService

@Transactional
public String newSubmission(NewSubmissionCommand command, User user) {
    AppedQr appedQr = qrRepository.appedQrById(command.getQrId());
    App app = appedQr.getApp();
    QR qr = appedQr.getQr();

    Page page = app.pageById(command.getPageId());
    SubmissionPermissions permissions = permissionChecker.permissionsFor(user, appedQr);
    permissions.checkPermissions(app.requiredPermission(), page.requiredPermission());

    Set<Answer> answers = command.getAnswers();
    Submission submission = submissionFactory.createNewSubmission(
            answers,
            qr,
            page,
            app,
            permissions.getPermissions(),
            command.getReferenceData(),
            user
    );

    submissionRepository.houseKeepSave(submission, app);
    log.info("Created submission[{}].", submission.getId());

    return submission.getId();
}

原始碼出處:com/mryqr/core/submission/command/SubmissionCommandService.java

在以上的SubmissionCommandService應用服務中,首先做許可權檢查,然後呼叫工廠SubmissionFactory.createNewSubmission()完成Submission的建立,最後呼叫資源庫SubmissionRepository.houseKeepSave()將新建的Submission持久化到資料庫中。從中可見,應用服務主要用於協調各方以完成一個業務用例,其本身並不包含業務邏輯,業務邏輯在工廠中完成。

//SubmissionFactory

public Submission createNewSubmission(Set<Answer> answers,
                                      QR qr,
                                      Page page,
                                      App app,
                                      Set<Permission> permissions,
                                      String referenceData,
                                      User user) {
    if (page.isOncePerInstanceSubmitType()) {
        submissionRepository.lastInstanceSubmission(qr.getId(), page.getId())
                .ifPresent(submission -> {
                    throw new MryException(SUBMISSION_ALREADY_EXISTS_FOR_INSTANCE,
                            "當前頁面不支援重複提交,請嘗試更新已有表單。",
                            mapOf("qrId", qr.getId(),
                                    "pageId", page.getId()));
                });
    }

    //...此處忽略更多業務邏輯

    //只有需要登入的頁面才記錄user
    User finalUser = page.requireLogin() ? user : ANONYMOUS_USER;
    Map<String, Answer> checkedAnswers = submissionDomainService.checkAnswers(answers,
            qr,
            page,
            app,
            permissions);

    return new Submission(checkedAnswers,
            page.getId(),
            qr, app,
            referenceData,
            finalUser);
}

原始碼出處:com/mryqr/core/submission/domain/SubmissionFactory.java

雖然工廠用於建立聚合根,但並不是直接呼叫聚合根的建構函式那麼簡單,從SubmissionFactory.createNewSubmission()可以看出,在建立Submission之前,需要根據表單型別檢查是否可以建立新的Submission,而這正是業務邏輯的一部分。因此,工廠也屬於領域模型的一部分,本質上工廠可以認為是一種特殊形式的領域服務。

請求流程的最後,應用服務呼叫資源庫submissionRepository.houseKeepSave()完成對新建Submission的持久化。更多關於資源庫的內容,請參考聚合根與資源庫一文。

聚合根更新流程

對聚合根的更新流程通常可以通過「經典三部曲」完成:

  1. 呼叫資源庫獲得聚合根
  2. 呼叫聚合根上的業務方法,完成對聚合根的更新
  3. 再次呼叫資源庫儲存聚合根

此時的請求流經路線為:控制器(Controller) -> 應用服務(Application Service) -> 資源庫(Repository) -> 聚合根(Aggregate Root)。

碼如雲中,當表單開啟了審批功能過後,管理員可對Submission進行審批操作,本質上則是在更新Submission。在「審批Submission」的過程中,請求依然是首先到達Controller:

//SubmissionController

@ResponseStatus(CREATED)
@PostMapping(value = "/{submissionId}/approval")
public ReturnId approveSubmission(@PathVariable("submissionId") @SubmissionId @NotBlank String submissionId,
                                  @RequestBody @Valid ApproveSubmissionCommand command,
                                  @AuthenticationPrincipal User user) {
    submissionCommandService.approveSubmission(submissionId, command, user);
    return returnId(submissionId);
}

原始碼出處:com/mryqr/core/submission/SubmissionController.java

與「建立聚合根」相似,SubmissionController直接將請求代理給應用服務SubmissionCommandService.approveSubmission()

//SubmissionCommandService

@Transactional
public void approveSubmission(String submissionId,
                              ApproveSubmissionCommand command,
                              User user) {
    Submission submission = submissionRepository.byIdAndCheckTenantShip(submissionId, user);

    App app = appRepository.cachedById(submission.getAppId());
    Page page = app.pageById(submission.getPageId());
    SubmissionPermissions permissions = permissionChecker.permissionsFor(user,
            app,
            submission.getGroupId());
    permissions.checkCanApproveSubmission(submission, page, app);

    submission.approve(command.isPassed(),
            command.getNote(),
            page,
            user);

    submissionRepository.houseKeepSave(submission, app);

    log.info("Approved submission[{}].", submissionId);
}

原始碼出處:com/mryqr/core/submission/command/SubmissionCommandService.java

應用服務SubmissionCommandService先通過資源庫SubmissionRepositorybyIdAndCheckTenantShip()方法獲取到需要操作的Submission,然後進行許可權檢查,再呼叫Submission.approve()方法完成對Submission的更新,最後呼叫資源庫SubmissionRepositoryhouseKeepSave()方法將更新後的Submission儲存到資料庫。這裡的重點在於:需要保證所有的業務邏輯均放在Submission.approve()中:

//Submission

public void approve(boolean passed,
                    String note,
                    Page page,
                    User user) {

    if (isApproved()) {
        throw new MryException(SUBMISSION_ALREADY_APPROVED,
                "無法完成審批,先前已經完成審批。",
                "submissionId", this.getId());
    }

    this.approval = SubmissionApproval.builder()
            .passed(passed)
            .note(note)
            .approvedAt(now())
            .approvedBy(user.getMemberId())
            .build();

    raiseEvent(new SubmissionApprovedEvent(this.getId(),
            this.getQrId(),
            this.getAppId(),
            this.getPageId(),
            this.approval,
            user));

    addOpsLog(passed ?
            "審批" + page.approvalPassText() :
            "審批" + page.approvalNotPassText(), user);
}

原始碼出處:com/mryqr/core/submission/domain/Submission.java

可以看到,Submission.approve()先檢查Submission是否已經被審批過了,如果尚未審批才繼續審批操作,審批過程還會發出「提交已審批」(SubmissionApprovedEvent)領域事件(更多關於領域事件的內容,請參考本系列的領域事件一文)。Submission.approve()中的程式碼量雖然不多,但是卻體現了核心的業務邏輯:「已經完成審批的提交不能再次審批」。

當然,並不是所有的業務用例都適合「經典三部曲」,有時聚合根自身無法完成所有的業務邏輯,此時我們則需要藉助領域服務(Domain Service)來完成請求的處理。比如,常見的使用領域服務的場景是需要進行跨聚合查詢的時候。此時的請求流經路線則為:控制器(Controller) -> 應用服務(Application Service) -> 資源庫(Repository) -> 聚合根(Aggregate Root) ->領域服務(Domain Service)。

在碼如雲中,管理員可以對既有的Submission進行編輯更新,但是由於更新時可能涉及到檢查手機號或者郵箱等控制元件填值的唯一性,因此在更新時需要跨Submission進行查詢,此時光靠Submission自身便無法完成了,為此我們可以建立領域服務SubmissionDomainService用於跨Submission操作:

//SubmissionCommandService

@Transactional
public void updateSubmission(String submissionId,
                             UpdateSubmissionCommand command,
                             User user) {

    Submission submission = submissionRepository.byIdAndCheckTenantShip(submissionId, user);
    AppedQr appedQr = qrRepository.appedQrById(submission.getQrId());
    App app = appedQr.getApp();
    QR qr = appedQr.getQr();

    Page page = app.pageById(submission.getPageId());
    SubmissionPermissions permissions = submissionPermissionChecker.permissionsFor(user,
            app,
            submission.getGroupId());
    permissions.checkCanUpdateSubmission(submission, page, app);

    submissionDomainService.updateSubmission(submission,
            app,
            page,
            qr,
            command.getAnswers(),
            permissions.getPermissions(),
            user
    );

    submissionRepository.houseKeepSave(submission, app);
    log.info("Updated submission[{}].", submissionId);
}

原始碼出處:com/mryqr/core/submission/command/SubmissionCommandService.java

在本例中,應用服務SubmissionCommandService並未直接呼叫聚合根Submission中的方法,而是將Submission作為引數傳入了領域服務SubmissionDomainServiceupdateSubmission()方法中,在SubmissionDomainService完成了對Submission的更新後,SubmissionCommandService再呼叫SubmissionRepository.houseKeepSave()方法將Submission儲存到資料庫中。SubmissionDomainService.updateSubmission()實現如下:

//SubmissionDomainService
    
public void updateSubmission(Submission submission,
                             App app,
                             Page page,
                             QR qr,
                             Set<Answer> answers,
                             Set<Permission> permissions,
                             User user) {

    Map<String, Answer> checkedAnswers = checkAnswers(answers,
            qr,
            page,
            app,
            submission.getId(),
            permissions);

    Set<String> submittedControlIds = answers.stream()
            .map(Answer::getControlId)
            .collect(toImmutableSet());

    submission.update(submittedControlIds, checkedAnswers, user);
}

原始碼出處:com/mryqr/core/submission/domain/answer/SubmissionDomainService.java

可以看到,SubmissionDomainService.updateSubmission()首先呼叫業務方法checkAnswers()對錶單內容進行檢查(其中便包含上文提到的對手機號或郵箱的重複性檢查),再呼叫Submission.update()以完成對Submission的更新,相當於SubmissionDomainServiceSubmission做了業務上的加工。

這裡,領域服務SubmissionDomainService的職責範圍僅包含對聚合根Submission的更新,並不負責持久化Submission,持久化的職責依然在應用服務SubmissionCommandService上。這種方式的好處在於:(1)與「經典三部曲」保持一致,將所有持久化操作均集中到應用服務中,不至於過於分散;(2)使領域服務的職責儘量單一。

聚合根刪除流程

聚合根刪除流程相對簡單,此時的請求流經路線為:控制器(Controller) -> 應用服務(Application Service) -> 資源庫(Application Service) -> 聚合根(Aggregate Root) 。

刪除請求首先到達Controller:

//SubmissionController

@DeleteMapping(value = "/{submissionId}")
public ReturnId deleteSubmission(@PathVariable("submissionId") @SubmissionId @NotBlank String submissionId,
                                 @AuthenticationPrincipal User user) {
    submissionCommandService.deleteSubmission(submissionId, user);
    return returnId(submissionId);
}

原始碼出處:com/mryqr/core/submission/SubmissionController.java

Controller將請求進一步代理給應用服務SubmissionCommandService

//SubmissionCommandService

@Transactional
public void deleteSubmission(String submissionId, User user) {
    Submission submission = submissionRepository.byIdAndCheckTenantShip(submissionId, user);
    Group group = groupRepository.cachedById(submission.getGroupId());
    managePermissionChecker.checkCanManageGroup(user, group);

    submission.onDelete(user);
    submissionRepository.delete(submission);
    log.info("Deleted submission[{}].", submissionId);
}

原始碼出處:com/mryqr/core/submission/command/SubmissionCommandService.java

應用服務SubmissionCommandService通過SubmissionRepository載入出需要刪除的Submission後,再呼叫Submission.onDelete()以完成刪除前的一些操作,在本例中onDelete()將發出「提交已刪除」(SubmissionDeletedEvent)領域事件:

//Submission
    
public void onDelete(User user) {
    raiseEvent(new SubmissionDeletedEvent(this.getId(),
            this.getQrId(),
            this.getAppId(),
            this.getPageId(),
            user));
}

原始碼出處:com/mryqr/core/submission/domain/Submission.java

最後,應用服務SubmissionCommandService呼叫SubmissionRepository.delete()完成對聚合根的刪除操作。

查詢流程

在本系列的CQRS一文中,我們將專門講到在DDD中如何做查詢操作。

總結

在本文中,我們分別對聚合根的新建、更新和刪除的典型請求處理流程做了詳細介紹。在這些流程中,我們以聚合根為中心,圍繞之形成了恰如其分的軟體架構。在下一篇聚合根與資源庫中,我們將對聚合根本身的設計與實現做詳細講解。