这是一个讲解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中如何做查询操作。

总结

在本文中,我们分别对聚合根的新建、更新和删除的典型请求处理流程做了详细介绍。在这些流程中,我们以聚合根为中心,围绕之形成了恰如其分的软件架构。在下一篇聚合根与资源库中,我们将对聚合根本身的设计与实现做详细讲解。

产品代码都给你看了,可别再说不会DDD(五):请求处理流程的更多相关文章

  1. 瞧一瞧,看一看呐,用MVC+EF快速弄出一个CRUD,一行代码都不用写,真的一行代码都不用写!!!!

    瞧一瞧,看一看呐用MVC+EF快速弄出一个CRUD,一行代码都不用写,真的一行代码都不用写!!!! 现在要写的呢就是,用MVC和EF弄出一个CRUD四个页面和一个列表页面的一个快速DEMO,当然是在不 ...

  2. iOS开发UI篇—从代码的逐步优化看MVC

    iOS开发UI篇—从代码的逐步优化看MVC 一.要求 要求完成下面一个小的应用程序. 二.一步步对代码进行优化 注意:在开发过程中,优化的过程是一步一步进行的.(如果一个人要吃五个包子才能吃饱,那么他 ...

  3. 每一行代码都有记录—如何用git一步步探索项目的历史

    每一行代码都有一块被隐藏了的文档信息. 下面的代码片段不管是谁写的,其第4行因为某些原因要访问一个DOM结点的clientLeft属性,但却对结果不作任何处理.这十分的莫名其妙,你能告诉我他们为什么要 ...

  4. Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!

    本文原题“从实践角度重新理解BIO和NIO”,原文由Object分享,为了更好的内容表现力,收录时有改动. 1.引言 这段时间自己在看一些Java中BIO和NIO之类的东西,也看了很多博客,发现各种关 ...

  5. 圣诞节,把网站所有的js代码都压缩成圣诞树吧。

    本文分两章节,分别讲解如何使用js2image这个库生成可以运行的圣诞树代码 和 js2image的原理. github地址:https://github.com/xinyu198736/js2ima ...

  6. delphi 动态绑定代码都某个控件

    delphi 动态绑定代码都某个控件 http://docwiki.embarcadero.com/CodeExamples/Berlin/en/Rtti.TRttiType_(Delphi)Butt ...

  7. 产品经理都知道MVP,但是它可能不再是产品研发最好的模型了

    产品经理都知道MVP,但是它可能不再是产品研发最好的模型了 孟小白Aspire • 2017-09-01 • 汽车交通 要简单.讨喜.完整,不要最小可行性产品.这对创业公司的第一个产品来说很重要. M ...

  8. appium+python解决每次运行代码都提示安装Unlock以及AppiumSetting的问题

    appium+python解决每次运行代码都提示安装Unlock以及AppiumSetting的问题(部分安卓机型) 1.修改appium-android-driver\lib下的android-he ...

  9. 使用Junit测试一个 spring静态工厂实例化bean 的例子,所有代码都没有问题,但是出现java.lang.IllegalArgumentException异常

    使用Junit测试一个spring静态工厂实例化bean的例子,所有代码都没有问题,但是出现 java.lang.IllegalArgumentException 异常, 如下图所示: 开始以为是代码 ...

  10. 轻松解决 CSS 代码都在一行的问题

    前言 最近在做博客园的界面美化,用的是博客园[guangzan]的开源项目,配置超级简单,只需要复制粘贴代码就好啦. 但在粘贴 CSS 代码时遇到一个问题,那就是所有代码都挤在了一行,没有一点排板的样 ...

随机推荐

  1. 【GiraKoo】C++中static关键字的作用

    C++中static关键字的作用 在程序中良好的使用static,const,private等关键字,对于代码的健壮性有很大的帮助. 本文介绍的就是C++中static关键字的一些常见用法与区别.适合 ...

  2. ARM DMA Controller PL330 使用经验分享

    总体简介 DMAC提供一个AXI主接口来执行DMA传输,并提供两个APB从接口来控制其操作.DMAC采用TrustZone技术,其中一个APB接口运行在secure状态,另一个运行在非secure状态 ...

  3. hadoop 2.7.7 ERROR datanode.DataNode: BlockSender.sendChunks() exception: java.io.IOException: 你的主机中的软件中止了一个已建立的连接。

    最近在测试Hbase在windows上的单机版的功能. 版本:hadoop 2.7.7  hbase 2.0.0 错误: ERROR datanode.DataNode: BlockSender.se ...

  4. 域名配置动态代理后,为什么每次 ping 还是相同的 ip?

    当你配置了域名的动态代理后,ping 命令所显示的 IP 地址不会随着代理服务器的变化而变化. 这是因为 ping 命令使用了 DNS 缓存,它会将域名解析结果缓存到本地,直到缓存过期或者手动清除缓存 ...

  5. /etc/netplan/network-manager-all.yaml 配置服务器ip

    本文为博主原创,转载请注明出处: /etc/netplan 是用于配置 Ubuntu 系统网络接口的目录.在 Ubuntu 中,网络配置的默认工具为  Netplan,而 /etc/netplan 则 ...

  6. NOIP2021游记

    前言: 今年我是以初中生的身份参加的 NOIP,不计奖,不排名,就去试试水. 考得也不好,幸好没计奖. 正文: 早上 7 点: 到LNBS,在旁边吃了早饭,很好吃. 早上 8 点: 校门口照相,然后进 ...

  7. STL-vector(ACM)

    1.长度可变的数组 2.这里不是很懂,v.size() 代码源里说这个v.size()是无符号类型的,使用时要说明类型, 但是我在使用时并没有出现warning,有大佬知道原因吗? 前置板子 3.ve ...

  8. V8是如何执行JavaScript代码的?

    前言 一般来讲,电脑是不能直接运行我们的javascript代码的,它需要一个翻译程序将人类能够理解的编程语言 JavaScript,翻译成机器能够理解的机器语言.目前市面上有很多种 JavaScri ...

  9. 细节决定成败,聊聊JS的类型(上)

    今天我们来讲讲 JavaScript 的内容,在这个部分,我首先想跟你聊一聊类型. JavaScript 类型对每个前端程序员来说,几乎都是最为熟悉的概念了.但是你真的很了解它们吗?我们不妨来看看下面 ...

  10. Task Execution and Scheduling In SpringBoot

    开天辟地 Task Execution and Scheduling In the absence of an Executor bean in the context, Spring Boot au ...