在上一篇代码工程结构中,我们从宏观层面讲到了DDD项目的目录结构,但并未触及到实际的代码。在本文中,我们将深入到代码中,逐一讲解DDD中对各种请求类型的典型处理流程。
在本系列的DDD概念大白话我们提到,DDD中的所有组件都是围绕着聚合根展开的,其中有些本身即是聚合根的一部分,比如实体和值对象;有些是聚合根的客户,比如应用服务;有些则是对聚合根的辅助或补充,比如领域服务和工厂。反观当下流行的各种软件架构,无论是分层架构、六边形架构还是整洁架构,它们都有一个共同点,即在架构中心都有一个核心存在,这个核心正是领域模型,而DDD的聚合根则存在于领域模型之中。
不难看出,既然每种架构中都有为领域模型预留的位置,这也意味着DDD可采用任何一种软件架构。事实也的确如此,DDD并不要求采用哪种特定架构,如果你真要说DDD项目应该采用某种架构的话,那么应该“以领域模型为中心的软件架构”。
如果我们把软件系统当做一个黑盒的话,其外界是各种形态的客户端,比如浏览器,手机App或者第三方调用方等,盒子内部则是我们精心构建的领域模型。不过,领域模型是不能直接被外界访问的,主要原因有以下两点:
接下来,让我们来看看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中如何做查询操作。