請求處理流程#
在上一篇代碼工程結構中,我們從宏觀層面講到了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);
}
Controller的作用只是為了銜接技術和業務,因此其邏輯應該相對簡單,在本例中,SubmissionController的newSubmission()方法僅僅將請求代理給應用服務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();
}
在以上的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);
}
雖然工廠用于創建聚合根,但并不是直接調用聚合根的構造函數那么簡單,從
SubmissionFactory.createNewSubmission()可以看出,在創建Submission之前,需要根據表單類型檢查是否可以創建新的Submission,而這正是業務邏輯的一部分。因此,工廠也屬于領域模型的一部分,本質上工廠可以認為是一種特殊形式的領域服務。
請求流程的最后,應用服務調用資源庫
submissionRepository.houseKeepSave()完成對新建Submission的持久化。更多關于資源庫的內容,請參考聚合根與資源庫一文。
聚合根更新流程#
對聚合根的更新流程通常可以通過“經典三部曲”完成:
- 調用資源庫獲得聚合根
- 調用聚合根上的業務方法,完成對聚合根的更新
- 再次調用資源庫保存聚合根
此時的請求流經路線為:控制器(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);
}
與“創建聚合根”相似,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);
}
應用服務SubmissionCommandService先通過資源庫SubmissionRepository的byIdAndCheckTenantShip()方法獲取到需要操作的Submission,然后進行權限檢查,再調用Submission.approve()方法完成對Submission的更新,最后調用資源庫SubmissionRepository的houseKeepSave()方法將更新后的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);
}
可以看到,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);
}
在本例中,應用服務SubmissionCommandService并未直接調用聚合根Submission中的方法,而是將Submission作為參數傳入了領域服務SubmissionDomainService的updateSubmission()方法中,在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);
}
可以看到,
SubmissionDomainService.updateSubmission()首先調用業務方法checkAnswers()對表單內容進行檢查(其中便包含上文提到的對手機號或郵箱的重復性檢查),再調用Submission.update()以完成對Submission的更新,相當于SubmissionDomainService對Submission做了業務上的加工。
這里,領域服務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);
}
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);
}
應用服務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));
}
最后,應用服務SubmissionCommandService調用
SubmissionRepository.delete()完成對聚合根的刪除操作。
查詢流程#
在本系列的CQRS一文中,我們將專門講到在DDD中如何做查詢操作。