整洁架构能有多整洁? | 码如云文档中心

mikel阅读(373)

来源: 整洁架构能有多整洁? | 码如云文档中心

前段时间,我将一个中大型后端项目顺利地从Spring Boot 2.5 迁移到了Spring Boot 3,整个过程仅花了一天左右时间,在小酌庆祝之余,我开始思考此次迁移之所以能够顺利进行的原因,此时一个概念立即闪现脑海——整洁架构

下来我对项目代码做了个统计,发现了以下两组比较重要的数据:

  1. 在整个代码库中,领域模型(对应上图中的Entities)的代码占比为55%,应用服务(对应上图中的Use Cases)的占比为9%。EntitiesUse Cases合起来可以看作是整个软件系统的业务核心,也就是说业务核心代码占到了整个代码库的一大半。(事实上,我们采用了CQRS架构将写操作和读操作分离了,上述统计数字仅仅统计了写操作部分,如果再加上读操作部分,业务核心的代码占比将更大)
  2. 在整个业务核心(EntitiesUse Cases)中,我们对Spring框架的所有依赖仅包含4个注解:@Transactional@Component@Document@TypeAlias。其中,@Transactional用于数据库事务,@Component用于依赖注入,@Document@TypeAlias用于MongoDB持久化。

这里简单介绍一下项目背景,该项目叫码如云,是一个基于二维码的一物一码管理平台。在技术上,码如云是一个无代码平台,技术栈主要有Java、Spring和MongoDB等。码如云全程采用整洁架构和DDD的思想完成开发,对DDD感兴趣的读者可以参考笔者的DDD落地文章系列

码如云的源代码是开源的,可以通过以下方式访问:

码如云源代码:https://github.com/mryqr-com/mry-backend

在本文中,我将分享码如云在采用整洁架构的过程中的所使用的主要实践方式与原则。

将业务代码内聚在核心模块中 #

无论是大家所熟悉的“低耦合,高内聚”原则,还是DDD中的聚合根概念,都要求与业务逻辑相关的代码具有高度的内聚性,有内聚就有了边界,以此表明业务逻辑与基础设施之间的边界,使得业务逻辑和基础设施可以单独演进,互不影响。

这里所说的业务代码包含了上图中的EntitiesUse Cases,它们所组成的核心模块可以类比于计算机的CPU,其中Entities(领域模型)对应CPU内部的各种二极管和三极管等核心电路,而Use Cases(应用服务)则对应CPU的管脚。CPU的内部电路通过管脚与外界交互,同理,领域模型通过应用服务与外界交互,外界无需关心领域模型的内部是如何实现的,只需通过业务用例向应用服务发起命令请求即可。

要达到高内聚的目的,在实际编码时,领域模型不应该是只有getter和setter的数据容器,而应该是富含业务行为的领域对象,这也是为什么在码如云我们可以做到项目中的大部分代码都集中在核心模型中的原因。

通过分包形成清晰的逻辑边界 #

分包有助于对代码进行归类和分层,以辅助业务复杂度和技术复杂度的分离,边界清晰了,架构自然就整洁了。总结下来,我们形成了以下几种归类,并以此为边界进行分包。

首先,基于DDD社区建议的“先业务,后技术”分包原则,对整个系统按照不同的业务板块进行分包,在码如云中,app(应用)、assignment(任务)等对应不同的业务板块,因此分别为它们创建分包。

然后,针对某个业务板块,再进行技术性分包。以app为例,其下包含以下几种子分包:

  • domain:用于存放项目中最核心的领域模型,包含所有的业务逻辑,对应文首图中的Entities
  • command:用于存放应用逻辑(即DDD中的应用服务),对应文首图中的Use Cases
  • eventhandler:事件处理类,在EDA(Event Driven Architecture,事件驱动架构)中用于处理领域事件
  • infrastructure:与app模块相关的基础设施,比如数据库访问等
  • query:用于数据查询,在CQRS架构中,查询逻辑与业务逻辑分离,因此为查询单独建立分包

对于其它业务板块(比如assignment)而言,其下的分包结构均与app相同。需要注意的是,在这些子分包下,还可以再次进行子包划分,比如可以将一些相互联系紧密的类放到一个子包下。

接收请求时,尽可能早地脱离技术框架 #

为了将技术性代码与业务性代码更好的区分开来,我们建议在接收到请求时,尽可能早地脱离技术框架,否则可能导致业务逻辑分散在系统各个地方,使业务和技术产生强耦合,架构也不再整洁。

比如,在Spring中,请求首先到达Controller,Controller虽然比Servlet更加上层,但是依然是一种基础设施,并且与Spring框架直接相关。在码如云,Controller被建模成了非常薄的一层,薄到仅有一行代码:

//MemberController
@PutMapping(value = "/me/mobile")
public void changeMyMobile(@RequestBody @Valid ChangeMyMobileCommand command,
                           @AuthenticationPrincipal User user) {
    memberCommandService.changeMyMobile(command, user);
}

可以看到,对于“修改手机号”用例而言,MemberController.changeMyMobile()方法在接收到请求后,立即将请求代理给了memberCommandService.changeMyMobile(),然后结束了Controller的使命。MemberCommandService是一个应用服务,表示请求已经进入到了业务的核心。

调用第三方时,尽可能晚地依赖技术框架 #

与接收请求的处理相反,在向外部调用第三方时,我们希望尽可能晚地依赖技术框架,不过所达到的目的却是相同的。比如,在向外发送领域事件时,首先在领域模型中定义一个与基础设施无关的DomainEventPublisher接口:

public interface DomainEventPublisher {
    void publish(List<String> eventIds);
}

该接口的实现类AsynchronousDomainEventPublisher如下:

public class AsynchronousDomainEventPublisher implements DomainEventPublisher {
    private final DomainEventDao domainEventDao;
    private final DomainEventSender domainEventSender;
    private final TaskExecutor taskExecutor;

    @Override
    public void publish(List<String> eventIds) {
        if (isNotEmpty(eventIds)) {
            taskExecutor.execute(() -> {
                List<DomainEvent> domainEvents = domainEventDao.byIds(eventIds);
                domainEvents.forEach(domainEventSender::send);
            });
        }
    }

}

可以看到,即便是在实现类中,我们也看不出底层使用的消息中间件到底是Kafka,还是RabbitMQ,或者其他,为此需要进一步查看接口DomainEventSender 的具体实现类KafkaDomainEventSender

public class KafkaDomainEventSender implements DomainEventSender {
    private final KafkaTemplate<String, DomainEvent> kafkaTemplate;
    private final CommonProperties commonProperties;

    public void send(DomainEvent event) {
        kafkaTemplate.send(commonProperties.getEventTopic(), event);
    }
}

在整个过程中,直到KafkaDomainEventSender才真正对基础设施产生耦合,这也意味着,如果以后需要替换消息队列的话,只需要新增一个DomainEventSender的实现类即可,其他地方可以保持不变。

保证核心模型中立于基础设施 #

核心模型如果太多地依赖于基础设施(比如Spring框架),将导致整个软件架构混乱不清,整洁架构也无从谈起。正如前文所说,核心模型就像一个CPU,将其放到不同品牌的电脑主板中均可使用,并且无需额外的适配,软件的核心模型也应该以此为目标,使之中立于具体的技术框架和基础设施。

在具体实践中,我们主要将Spring当做一个IoC容器来使用,而刻意不使用或者少使用诸如Spring Data和Spring Events这样的框架设施。(声明一下:在基础设施层,虽然我们没有全面使用Spring Data,但是依然使用了Spring Data所提供的比较底层的设施类MongoTemplate;不过在处理领域事件时,我们一点都没有使用Spring的ApplicationEvent,而是全部自己建模,对此请参考我们的领域事件一文)

了解你的线程模型 #

软件有单线程模型和多线程模型之分,不同的线程模型可能对我们的编码和架构有很大的影响。有时,在多线程模型中可用的设施在单线程下可能不再适用。举个例子,在使用Spring Security时,在任何时候都可以通过SecurityContextHolder.getContex()全局方法获取到当前的用户信息,这得益于Java中的ThreadLocal类将用户信息存放在了当前线程的一个全局变量中。这种方式对于Spring MVC这种采用多线程模型的框架来说是可用的,但是对于某些基于单线程的桌面应用则不再可用。假设有一天你需要从Web应用迁移到桌面版应用程序,而又如果你的代码中包含大量的对SecurityContextHolder.getContex()的调用,可能就比较麻烦了。虽然这种迁移可能几乎不会发生,但是却可以从侧面反映出软件架构本身的整洁性和鲁棒性。

在码如云,我们并未采用SecurityContextHolder.getContex(),而是采用了中立于线程架构的方式:将用户上下文显式的传递。

//MemberController
@PutMapping(value = "/me/mobile")
public void changeMyMobile(@RequestBody @Valid ChangeMyMobileCommand command,
                           @AuthenticationPrincipal User user) {
    memberCommandService.changeMyMobile(command, user);
}

可以看到,在MemberController.changeMyMobile()中,我们将从Spring Security中获取到的用户user传递给了应用服务MemberCommandServiceMemberCommandService可以直接访问user而不用调用SecurityContextHolder.getContext()。这样的好处是,一来可以中立于线程模型,二则减少了应用服务对Spring框架的依赖。不过,这种做法也是有代价的,即需要将user在多个方法之间传递,这个多余的方法参数即增加了编码成本,也被一些人认为是对方法签名的污染。总之,没有唯一正确的答案,It depends!只是无论选择哪种方式,我们都需要对程序所处的线程环境心知肚明。

不用追求100%的整洁架构 #

如果严格按照Robert C. Martin在《架构整洁之道》一书中的讲述,我们可能需要分别为domaininfrastucture等分包单独创建子项目并生成各自的jar文件,因为只有这样才能满足该书中所声称的各个组件单独部署和维护的目的。

然而,在实际编码时,我们并不建议这么做的,因为这样做的代价太大,收益却太小。此外,我们也并不建议核心模型要完全地与技术框架解耦,而是尽量少地依赖于技术框架,少到对技术框架的依赖不至于影响我们对业务逻辑的实现即可。

举个例子,前文提到,码如云的核心模型依赖了Spring的4个注解,由于这些依赖都是通过打在类或方法上的注解形式存在,在我们实现业务逻辑的过程中,并不会受到这些注解的影响,因此我们认为这些依赖是可以接受的。此外,假设未来我们要从Spring迁移到Google的Guice框架,此时要对这4个注解进行替换的话,成本不会太高,方案如下:

Spring注解 Guice替换方案 替换成本
@Transactional Guice有自己的Tranctional注解
@Component 使用JSR-330的@Named
@Document@TypeAlias 自己通过MongoDB的Driver实现

可以看到,如果要将码如云从Spring迁移到Guice,对于核心模型来讲,@Transactional@Component注解可以通过简单的文本替换即可完成迁移,只是@Document@TypeAlias需要多花些功夫。

总结 #

整洁架构是一种非常值得采纳的编程思想,对于系统的解耦和维护有实实在在的指导价值,不过我们也没有必要教条式地追求100%的整洁架构,而是在这种思想的引导下,选择适合于自己的整洁架构方案。

后端开发就是CRUD?没那么简单! | 码如云文档中心

mikel阅读(320)

来源: 后端开发就是CRUD?没那么简单! | 码如云文档中心

作为一个后端开发者,不时都能听到这么一种论调:后端开发没什么技术含量,就是CRUD而已。此时,我一般会嘴角抿抿,心里呵呵。

事实上,从某种程度上说这种说法并没错,我们甚至还可以进一步去挖掘一下其背后更深层次的本质:软件就是一个I/O系统,后端开发就是对数据的I/O处理而已,只需能把数据存起来再放出去即可,的确说不上什么高端可言。此外,在国内的大多数程序员所从事的细分行业只能说是“应用软件开发”或者“业务软件开发”,说白了这些成天处理业务逻辑的软件都没什么难的,就是一些低级逻辑而已,这也是为什么很多非计算机专业的学生都可以成功转行为程序员的原因(之一)。

然而,同样一个业务功能,分别让两个工作经验不同的程序员去实现,他们的代码可能完全不一样。有时,经验少的程序员写100行代码就能实现的一个功能,老程序员却需要写500行,因为后者考虑到了对各种边界条件的处理,缓存的使用以及对性能的顾及等。又有时,经验少的程序员写了500行代码实现的一个功能,老程序员只花了100行就实现了,因为后者使用了更加优秀的算法或者采用了能使代码变得更加简洁的工具和原则等。

李书福说:“造车就是一个沙发加四个车轮”。他说的没错,因为这是汽车的某种本质。然而,真正要造好一台汽车,却需要考虑舒适性、加速性、NVH、操控性、通过性等诸多方面的因素。软件也一样,简单的CRUD操作纵然能够满足基本的I/O需求,但是在具体落地时我们还要考虑很多原则和因素以让人能够更好地掌控软件系统,其中包含但不限于:高内聚低耦合、关注点分离、依赖倒置、非功能性需求等等。这里所涉及到的一个基本命题是:软件代码首先是给人脑看的,其次才是给电脑执行的。

在本文中,我们将以一个真实的软件项目 —— 码如云https://www.mryqr.com)为例,系统性的讲解后端在处理请求的过程中所需要顾及的方方面面,你会发现后端开发绝非单纯的CRUD这么简单。

码如云是一个基于二维码的一物一码管理平台,技术上是一个无代码平台,全程采用DDD思想进行开发,对DDD感兴趣的读者可以参考笔者的DDD落地系列文章

接下来,我们将围绕以下业务用例展开讨论:在码如云中,成员(Member)可以更新自己的手机号码,但如果所使用的手机号已经被他人占用,则禁止更新。

整个请求处理的流程如下图所示:

概括来看,整个请求处理流程和我们通常的实践并没有太大的区别。首先,请求到达MemberController,这是Spring MVC处理请求的第一站;然后MemberController调用MemberCommandService完成该业务用例,调用时传入请求数据对象ChangeMyMobileCommand,这里的MemberCommandService在DDD中被称为应用服务MemberCommandService通过MemberRepository获取到对应的Member对象,再通过MemberDomainService(在DDD中被称为领域服务)完成对Member的手机号更新;最后MemberCommandService 调用MemberRepository.save()将更新后的Member对象保存到数据库。

MemberController #

在整个请求处理的过程中,首先通过MemberController接收请求:

@PutMapping(value = "/me/mobile")
@ResponseStatus(OK)
public void changeMyMobile(@RequestBody @Valid ChangeMyMobileCommand command,
                           @AuthenticationPrincipal User user) {
    memberCommandService.changeMyMobile(command, user);
}

源码出处:com/mryqr/core/member/MemberController.java

这里,MemberController.changeMyMobile()方法一共只有5行代码,可不要小瞧这5行代码,在实际编码时我们却需要考虑多个方面的因素:

  1. Spring MVC的Controller是框架直接相关的,DDD讲求业务复杂度与技术复杂度的分离,我们希望自己的代码实现能够尽快的脱离技术框架,因此MemberController只起到了简单的代理作用,也即把请求代理给应用服务MemberCommandService
  2. 对URL的设计是有讲究的,MemberController采用了REST风格的URL,通过HTTP的PUT方法完成对mobile资源(me/mobile)的更新,更多关于REST URL的内容,请参考这里
  3. 同样基于REST原则,更新资源后应该返回HTTP的200状态码,这里通过@ResponseStatus(OK)完成(Spring MVC默认返回的即是200)。
  4. 对于接收到的数据请求对象ChangeMyMobileCommand需要加上@Valid以做数据验证,否则后续对ChangeMyMobileCommand中的各种JSR-303验证将失效。
  5. MemberController需要返回void,也即不返回任何数据,这是因为基于CQRS的原则,任何写数据的操作不能同时查询数据,反之亦然。

ChangeMyMobileCommand #

命令对象ChangeMyMobileCommand用于封装请求数据,之所以称之为命令(Command)是因为一个请求就像外界向软件系统发起了一次命令一样,这里的Command正是来自于CQRS中的“C”。

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class ChangeMyMobileCommand implements Command {
    @Mobile
    @NotBlank
    private final String mobile;

    @NotBlank
    @VerificationCode
    private final String verification;

    @NotBlank
    @Password
    private final String password;

    @Override
    public void correctAndValidate() {
        //用于JSR-303无法完成的验证逻辑,但是又不能包含业务逻辑
    }
}

源码出处:com/mryqr/core/member/command/ChangeMyMobileCommand.java

ChangeMyMobileCommand 对象主要充当数据容器的作用,其中一个比较重要的任务是完成数据的初步验证。具体实践时需要考虑以下几个方面:

  1. Command对象通常是不变的(Immutable),在编码时应将建模为一个值对象,为此我们使用了Lombok中的@Value@Builder@AllArgsConstructor(access = PRIVATE)达到此目的。
  2. 对Command对象中的每一个字段,都需要判断是否需要做验证,有些字段可以通过简单的JSR-303内建注解完成验证,比如mobile字段中的@NotBlank,而更复杂的验证则需要自行实现JSR-303的ConstraintValidator接口,比如mobile字段的@Mobile注解。
  3. 对于Command对象,还需要特别注意其中的容器类字段,比如ListSet等,需要对这些字段做非null检查(@NotNull),以消除后续代码在引用这些字段时有可能的空指针异常NullPointerException
  4. 对于更加复杂的验证,比如需要对多个字段进行关联性验证,通过自定义JSR-303可能比较麻烦,此时可以自定义Command接口,通过实现该接口的correctAndValidate()方法完成验证目的。
  5. 对于字符串类字段来说,任何时候都需要通过@Size注解对其长度进行限制,除非其他注解中已经包含了此限制。

MemberCommandService #

应用服务(ApplicationService或者CommandService)是领域模型的门面,任何对领域模型的请求都需要通过应用服务中的公有方法完成。更多关于应用服务的讲解,请参考我们DDD文章系列中的这一篇

@Transactional
public void changeMyMobile(ChangeMyMobileCommand command, User user) {
    mryRateLimiter.applyFor(user.getTenantId(), "Member:ChangeMyMobile", 5);

    String mobile = command.getMobile();
    verificationCodeChecker.check(mobile, command.getVerification(), CHANGE_MOBILE);

    Member member = memberRepository.byId(user.getMemberId());
    memberDomainService.changeMyMobile(member, mobile, command.getPassword());
    memberRepository.save(member);
    log.info("Mobile changed by member[{}].", member.getId());
}

源码出处:com/mryqr/core/member/command/MemberCommandService.java

在DDD中,应用服务应该是很薄的一层,因为它不能包含业务逻辑,而主要是起协调的作用,另外事务边界、鉴权等操作也会放在应用服务中。在实现时,应该考虑以下几个方面:

  1. 应用服务不能包含业务逻辑,这也是很多CRUD程序员经常犯的一个错误。举个例子,在本例中,如果成员的手机号已经被占用,则禁止更新手机号,这是一个典型的业务逻辑,因此不应该在MemberCommandService 中完成,而应该放到领域模型中。通常来说,应用服务遵循请求处理“三部曲”原则:(1)获取需要处理的领域对象(本例中的Member),(2)对领域对象进行处理(memberDomainService.changeMyMobile()),(3)将更新后的领域对象保存回数据库(memberRepository.save())。
  2. 应用服务中的公共方法应该与业务用例一一对应,而每个业务用例又对应一个数据库事务,因此应用服务应该是事务的边界,也即Spring的@Transactional注解应该打在应用服务的公用方法上。
  3. 与Controller一样,应用服务中负责写操作的方法不能返回查询数据,而负责查询的方法不能更改数据。
  4. 应用服务应该是独立于技术框架(本例的Spring)的,如果把领域模型比作CPU中的芯片,那么应用服务便是CPU引脚,整个CPU放到不同的电脑主板(类比到技术框架)中均能正常使用。不过,在实际的编码过程中,我们做了一些妥协,比如在本例中,@Transactional 则是来自于Spring的,不过总的原则是不变的,即应用服务(以及其所包围着的领域模型)尽量少地依赖于技术框架。
  5. 一些非业务性的功能也应该在应用服务中完成,比如对请求的限流(本例中的mryRateLimiter ),限流处理原本可以放到技术框架中统一处理的,不过由于码如云是一个SaaS软件,需要对不同的租户单独限流,因此我们将其放在了应用服务这一层。
  6. 一般来讲,对权限的检查也可以放在应用服务中;不过不同的人对此有不同的看法,有人认为权限也属于业务逻辑,因此应该放到领域模型中,而另外有人认为权限不是业务逻辑,应该被当做一个单独的关注点来处理。在码如云,我们选择了后者,并且将对权限的处理放到了应用服务中。

MemberRepository #

资源库(Repository)的、可以认为是对数据库的封装和抽象,有些类似于DAO(Data Access Object),不过它们最大的区别是资源库是与DDD中的聚合根一一对应的,只有聚合根对象才“配得上”拥有资源库,而DAO则没有此限制。更多关于资源库的内容,可以参考这里

public interface MemberRepository {
    boolean existsByMobile(String mobile);
    Member byId(String id);
    Optional<Member> byIdOptional(String id);
    Member byIdAndCheckTenantShip(String id, User user);
    boolean exists(String arId);
    void save(Member member);
    void delete(Member member);
}

源码出处:com/mryqr/core/member/domain/MemberRepository.java

在实现资源库时,应该考虑以下几个方面:

  1. 只对聚合根对象创建相应的资源库,并且其操作的对象是以聚合根为单位的。
  2. 资源库不能包含太多的查询方法,大量的查询操作可能意味着对领域模型的污染,此时可以考虑通过CQRS将查询操作绕过资源库单独处理。
  3. 资源库通常分为接口类和实现类,接口类是属于领域模型的一部分,而实现类则应该放到基础设施中,落地时接口类应该放到domain分包下,而实现类应该放到infrastructure分包下,这也意味着,资源库的实现是“可插拔”的,即如果将来要从MySQL迁移到MongoDB,那么只需要新添加一个基于MongoDB的资源库实现类即可,其他地方可以不变。
  4. 资源库中不能包含业务逻辑,其完成的功能只限于将数据从内存同步到数据库,或者反之。

MemberDomainService #

与应用服务不同的是,领域服务(DomainService)属于领域模型的一部分,专门用于处理业务逻辑,通常被应用服务所调用。在本例中,我们使用MemberDomainService 对“手机号是否已经被占用”进行检查:

public void changeMyMobile(Member member, String newMobile, String password) {
    if (!mryPasswordEncoder.matches(password, member.getPassword())) {
        throw new MryException(PASSWORD_NOT_MATCH, "修改手机号失败,密码不正确。", "memberId", member.getId());
    }

    if (Objects.equals(member.getMobile(), newMobile)) {
        return;
    }

    if (memberRepository.existsByMobile(newMobile)) {
        throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS, "修改手机号失败,手机号对应成员已存在。",
                mapOf("mobile", newMobile, "memberId", member.getId()));
    }

    member.changeMobile(newMobile, member.toUser());
}

源码出处:com/mryqr/core/member/domain/MemberDomainService.java

在实践时,使用领域服务应该考虑到以下几个方面:

  1. 领域服务不是必须有的,而是只有当领域模型(准确的讲是聚合根)无法完成某些业务逻辑时才出现的,是“不得已而为之”的结果。在本例中,检查“手机号是否被占用”需要进行跨聚合(Member)的操作,光凭当事的Member是无法做到这一点的,此外这种检查有属于业务逻辑的一部分,因此我们创建一种可以处理业务逻辑的服务(Service)类来解决,这个服务类即是领域服务。在很多项中,应用服务和领域服务揉杂在一起,功能倒是实现了,但是各组件之间的耦合也加深了,导致的结果是软件在未来的演进中将变得越来越复杂,越来越困难。
  2. 领域服务的职责最多只到更新领域模型在内存中的状态,而不包含保存领域模型的职责,比如在本例中,MemberDomainService 并不调用memberRepository.save(member)来保存Member,而是由应用服务MemberCommandService负责完成。这样做的好处是将领域服务建模为一个仅仅操作领域模型的“存在”,使其职责更加的单一化。

Member #

领域对象(Domain Object)是业务逻辑的主要载体,同时包含了业务数据和业务行为。在本例中,Member对象则是一个典型的领域对象,在DDD中,Member也被称为聚合根对象。Member对象实现修改手机号的代码如下:

public void changeMobile(String mobile, User user) {
    if (Objects.equals(this.mobile, mobile)) {
        return;
    }

    this.mobile = mobile;
    this.mobileIdentified = true;
    raiseEvent(new MobileChangedEvent(this.getId(), mobile));
}

源码出处:com/mryqr/core/member/domain/Member.java

在实现领域对象时,应该考虑以下几个方面:

  1. 忘掉数据库,不要预设性地将领域模型中的字段与数据库中的字段对应起来,只有这样才能够做到架构的整洁性以及基础设施中立性,正如Bob大叔所说,数据库是一个细节
  2. 领域模型应该保证数据一致性,比如在修改订单项时,订单的价格也应该相应的变化,那么此时所有相关的处理逻辑均应该在同一个方法中完成。在本例中,手机号修改了之后,应该同时将Member标记为“手机号已记录”状态(mobileIdentified ),因此对mobileIdentified 的修改应该与对mobile的修改放在同一个chagneMyMobile()方法中。在DDD中,这也称为不变条件(Invariants)。
  3. 在实现领域逻辑的过程中,还会随之产生领域事件(Domain Event),由于领域事件也是领域模型的一部分,因此一种做法是领域对象在完成业务操作之后,还应发出领域事件,即本例中的raiseEvent(new MobileChangedEvent(this.getId(), mobile));更多关于领域事件的内容,请参考这里
  4. 领域对象不能持有或引用其他类型的对象,包括应用服务,领域服务,资源库等,因为领域对象只是根据业务逻辑的运算完成对业务数据的更新,也即领域对象应该建模为POJO(Plain Old Java Object)。
  5. 同理于应用服务,Member.changeMobile()方法是个写操作,不能返回任何数据。

总结 #

在文本中我们看到,哪怕是一个诸如“用户修改手机号”这样简单的需求,在整个实现过程中需要考虑的点也达到了将近30个,真实情况只会多不会少,比如我们可能还需要考虑性能、缓存和认证等众多非功能性需求等。因此,后端开发绝非CRUD这么简单,而是需要将诸多因素考虑在内的一个系统性工程,还是那句话,有讲究的编程并不是一件易事。

产品代码都给你看了,可别再说不会DDD(十):CQRS | 码如云文档中心

mikel阅读(401)

来源: 产品代码都给你看了,可别再说不会DDD(十):CQRS | 码如云文档中心

这是一个讲解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

CQRS #

CQRS(Command Query Responsibility Segregation)直译成中文叫命令查询职责分离,可不要被这个读起来有些拗口的名字吓到了,事实上就是读写分离的意思,不过这里的读写分离和我们通常所理解的数据库级别的读写分离是两个不同的概念,CQRS指的读写分离是指在应用程序内部的代码级别的读写分离,在本文中,我将对此做出详细解释。

简单来讲,CQRS的提出是基于这么一种现象:软件中写数据的操作和读数据的操作是两个很不一样的过程,它们各有各的特点,因此可以并且应该将它们作为两个单独的关注点分别进行处理。“写数据”的过程也被称为“命令(Command)”,即表示外界通过向软件发送一些列的命令达到更新软件内部数据的目的,比如更新用户偏好设置、向电商网站下单等;“读数据”的过程也被称为“查询(Query)”,即从软件中获取数据,比如查看订单信息等。读和写的不同主要体现在以下几个方面:

  • 业务逻辑的运用主要是在写数据一侧,也就是说,我们在本系列的其他文章中讲到的聚合根,实体,值对象,领域服务等领域模型中的概念主要用于“写数据”的过程,相比之下“读数据”一侧的业务逻辑则相对较少,主要是数据展现逻辑;
  • 读数据是幂等的,即无论通过什么方式,都不应该修改系统中的数据,也即读数据相对安全,而在写数据时则需要始终保证数据的正确性和一致性,否则将导致严重Bug;
  • 导致读数据和写数据过程发生变更的归因不同,对写数据侧的变更主要基于业务逻辑的变化,而读数据侧的变更则更多基于UI需求的变化,比如根据不同的屏幕尺寸返回不同的数据等;
  • 读数据和写数据的频率往往各不相同,对于多数业务来说写数据的频率往往低于读数据的频率。

事实上,读写分离这种思想早在上世纪80年代末便由Bertrand Meyer提出,在他的《Object-Oriented Software Construction》一书中指出:

Every method should either be a command that performs an action, or a query that returns data to the caller, but never both. (一个方法要么作为一个“命令”执行一个操作,要么作为一次“查询”向调用方返回数据,但两者不能共存。)

可以看出,Bertrand Meyer所谓的读写分离主要用于对象中的方法(Method),而CQRS将这种思想扩大到了软件架构层面,接下来让我们分别看看CQRS中的各种读写分离模式。

流程分离 #

最简单的读写分离模式莫过于读写流程的分离了,事实上这也是我们一直在用的一种方式,是的没错,你已经在用CQRS了。为此,让我们来看看一个具体的例子,在码如云中,有权限的成员(Member)可以更新表单(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 submissionPermissions = submissionPermissionChecker.permissionsFor(user,
            app,
            submission.getGroupId());

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

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

源码出处:com/mryqr/core/submission/command/SubmissionCommandService.java

应用服务方法SubmissionCommandService.updateSubmission()通过调用领域服务SubmissionDomainService.updateSubmission()完成对表单的更新,然后再通过SubmissionRepository.houseKeepSave()完成对表单的持久化。

在查看表单详情时的应用服务代码如下:

//SubmissionQueryService

public QDetailedSubmission fetchDetailedSubmission(String submissionId, User user) {
    Submission submission = submissionRepository.byIdAndCheckTenantShip(submissionId, user);

    //将领域对象Submission转为展现对象QDetailedSubmission
    return toSubmissionDetail(submission, user);
}

源码出处:com/mryqr/core/submission/query/SubmissionQueryService.java

SubmissionQueryService.fetchDetailedSubmission()方法中,先获取到需要查询的表单聚合根对象Submission,然后调用toSubmissionDetail()Submission转换为展现对象QDetailedSubmission

在上述2个代码例子中,写数据和读数据使用了不同的应用服务方法,也即流程分离了。你可能会说“我平时就是这么做的呀!”,的确如此,这种方式正是大家平时的编码实现,但是这里我们更希望强调的原则在于:写数据的SubmissionCommandService.updateSubmission()返回的是void,也即不会返回任何数据,而读数据的SubmissionQueryService.fetchDetailedSubmission()则只是获取数据而未修改任何数据。

此外,虽然SubmissionCommandServiceSubmissionQueryService均表示应用服务,但是在编码实现中被分成了2个单独的类以示分离。事实上,在码如云我们在代码的分包层面也做了相应的对读写分离的支持,所有与写数据相关的代码被组织在了command包下,而所有与读数据相关的代码则被放在了query包下。

在查询数据时,先获取到聚合根对象Submission,再将其转化为展现对象QDetailedSubmission,也就是说读数据和写数据的过程共享了同一个聚合根对象Submission。这种方式对于简单的查询场景没有多大问题,但是对于一些复杂的查询场景来说并不合适,一是使得读数据侧对写数据侧存在依赖,二是在跨表查询的时候,需要将多个聚合根对象分别从数据库中加载到内存,导致对数据库的多次访问,在高并发场景下,这可能影响系统性能。

模型分离 #

既然业务逻辑主要作用于写数据侧,而读数据侧主要处理的是展现逻辑,那是不是在读数据时可以绕过领域模型(上例中的Submission)呢?当然可以,这就是模型分离。模型分离的主要特点是:在写数据时,依然严格按照领域模型对业务逻辑的请求处理流程,但是在读数据时,可以绕过领域模型,直接从数据库创建相应的读模型对象。落到编码层面,在写数据侧可能需要通过ORM等工具完成对聚合根的持久化,但是在读数据侧则不见得,我们全然可以通过直接的SQL语句从数据库中加载所需查询的数据。

在码如云,租户管理员可以查看租户下所有的成员,其查询实现如下:

//MemberQueryService

public PagedList<QListMember> listMyManagedMembers(ListMyManagedMembersQuery queryCommand, User user) {
    String tenantId = user.getTenantId();
    Pagination pagination = pagination(queryCommand.getPageIndex(), queryCommand.getPageSize());
    String departmentId = queryCommand.getDepartmentId();
    String search = queryCommand.getSearch();

    Query query = new Query(buildMemberQueryCriteria(tenantId, departmentId, search));
    long count = mongoTemplate.count(query, Member.class);
    if (count == 0) {
        return pagedList(pagination, 0, List.of());
    }

    query.skip(pagination.skip()).limit(pagination.limit()).with(sort(queryCommand));
    
    //绕过Member,直接将从数据库中查到的数据创建为QListMember
    query.fields().include("name").include("avatar").include("role").include("mobile")
            .include("wxUnionId").include("wxNickName").include("email")
            .include("active").include("createdAt").include("departmentIds");
    
    List<QListMember> members = mongoTemplate.find(query, QListMember.class, MEMBER_COLLECTION);
    return pagedList(pagination, (int) count, members);
}

源码出处:com/mryqr/core/member/query/MemberQueryService.java

可以看到,在查询成员列表时,直接通过mongotTemplate(码如云使用的是MongoDB)将从数据库中所查询到的数据创建为了读模型QListMember,省去了加载Member并从Member转化为QListMember的过程。

数据源分离 #

模型分离可以解决很大一部分读写分离的问题,不过它依然是一种相对简单的CQRS实现方式,对于更加复杂的查询场景来说则显得有些力不从心,主要有以下原因:

  1. 模型分离事实上只是代码层面模型的分离,底层的数据库模型并未分离,依然是读写共享的,对于主要服务于写数据一侧的数据库来说,可能由于对读数据一侧的“照料不周”而无法满足某些查询需求;
  2. 模型分离只能用于在同一个进程空间之内的查询,也即所查询的数据均位于同一个数据库的场景,但是对于诸如微服务这种需要跨进程查询的情况则无法满足,比如对于一个采用微服务架构的电商系统,在用户首页需要同时查看用户基本信息和积分,但是前者位于“用户”服务中,而后者来自于“积分”服务,此时需要分别从2个服务中获取数据并返回给前端;
  3. 查询所需数据不一定能够直接映射到数据库中的字段,而是有可能需要做一些额外的加工,比如将省份(province)城市(city)详细地址(detailAddress)拼接为最终的地址值等。

数据源分离便是用来解决这个问题的,在这种方式下,我们为数据查询侧单独创建一个数据库,这个数据库存在的目的仅仅是为了方便查询用,可以说是为读数据侧量身定制的,该数据库中的数据依然来自于写数据一侧,只是经过了一些预先的加工,比如根据查询端(前端)所需摒弃了一些无用的字段,或者将多个字段合并成单个字段便于前端的直接显示等。那么,数据又如何从写数据一侧传递到读数据一侧呢?答案是领域事件

在写数据时,对业务数据的变更将通过领域事件的形式发布到消息队列(Kafka)中, 读数据侧作为一个独立的模块通过消费这些领域事件完成对读模型数据库的相应更新,之后在查询数据时,则采用与“模型分离”相似的模式直接从数据库构建读模型,最后返回给查询方(前端)。

在技术栈的选择上,读数据侧的数据库不必与写数据库保持一致,比如写数据侧可以采用诸如MySQL这种强事务一致性的数据库(为了保证业务数据的正确性),但是读数据侧可以采用更有利于数据查询的数据库,比如ElasticSearch等。

事实上,以上3种CQRS的实现模式并不是彼此互斥的,而是可以同时存在,哪种方式相对简单则采用哪种方式。比如,在码如云我们便同时采用了3种方式。

总结 #

CQRS即是读写分离的意思,它将软件中的写数据过程和读数据过程分开处理,各司其职,是一种可以在很大程度上简化软件架构的编程模式。在这种模式下,写数据的过程严格遵循DDD的各种原则,而读数据的过程则可以绕开DDD中的领域模型(主要是聚合根),直接从数据库构建需要查询的数据模型。根据具体场景的不同,可以采用不同的CQRS实现模式。

产品代码都给你看了,可别再说不会DDD(九):领域事件 | 码如云文档中心

mikel阅读(301)

来源: 产品代码都给你看了,可别再说不会DDD(九):领域事件 | 码如云文档中心

这是一个讲解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

领域事件 #

领域事件(Domain Event)中的“事件”即事件驱动架构(Event Driven Architecture, EDA)中的“事件”之意。事件驱动架构被广泛地用于计算机的硬件和软件中,DDD也不例外。狭义地理解“事件”,你可能会认为不就是诸如Kafka或者RabbitMQ之类的消息队列(Message Queue)么?可不止那么简单,在本文中,我们将对DDD中领域事件的建模、产生、发送和消费做详细讲解。

为了方便读者直观概括性地了解领域事件的全景,我们先将从事件发布到消费整个过程中的关键节点展示在下图。在阅读过程中,读者可返回该图进行对应。

领域事件建模 #

领域事件表示在领域模型中已经发生过的重要事件,主要用于软件中各个组件、模块、子系统甚至与第三方系统之间的数据同步和集成。所谓“重要”,指的是所发出的事件会引起进一步的动作,以此形成更大范围的业务闭环。举个例子,在电商系统中,“用户已下单”则是一个领域事件,它可能会进一步引起支付、物流、积分等一些列后续业务动作。

当然,对于“重要”的定义是相对的,需要视实际所处业务场景而定。例如,在码如云中,用户可以自行更改头像,整个业务闭环到此为止,因此我们并没有为此创建相应的领域事件;不过,对于其他一些系统来说,用户更新了头像后,可能需要将头像信息同步到另外的子系统,那么此时便可发出“用户头像已更新”事件,其他子系统通过订阅监听该事件完成头像数据的同步。

领域事件的命名一般采用“XX已XX”的形式,前一个“XX”通常表示一个名词,后一个“XX”则表示一个动词,比如“订单已支付”、“表单已提交”等。在实际建模时,通常先建立一个公共基类DomainEvent,其他实际的事件类均继承自该基类。

//DomainEvent

public abstract class DomainEvent {
    private String id;//事件ID
    private DomainEventType type;//事件类型

    //状态,CREATED(刚创建),PUBLISH_SUCCEED(已发布成功), PUBLISH_FAILED(发布失败)
    private DomainEventStatus status;

    private Instant raisedAt;//事件产生时间

    protected DomainEvent(DomainEventType type,) {
        requireNonNull(type, "Domain event type must not be null.");
        this.id = newSnowflakeId();
        this.type = type;
        this.raisedAt = now();
    }
}

源码出处:com/mryqr/core/common/domain/event/DomainEvent.java

领域事件基类DomainEvent包含了事件标识id,事件类型type,事件状态status,以及事件产生的时间raisedAt,根据自身情况还可以添加更多的公共字段,比如事件产生时的操作人等。

具体的事件类继承自DomainEvent,以“成员已创建(MemberCreatedEvent)”事件为例:

//MemberCreatedEvent

public class MemberCreatedEvent extends DomainEvent {
    private String memberId;

    public MemberCreatedEvent(String memberId) {
        super(MEMBER_CREATED);
        this.memberId = memberId;
    }
}

源码出处:com/mryqr/core/member/domain/event/MemberCreatedEvent.java

领域事件中应该包含恰如其分的数据信息,且所包含的信息应该与其所产生时的上下文强相关。比如本例中,MemberCreatedEvent事件对应新成员已创建的业务场景,此时最重要的是记录下这个新成员的唯一标识memberId。又比如,对于“成员修改自己姓名”的业务场景,其所发出的“成员姓名已更新”事件MemberNameChangedEvent则应该同时包含修改前的姓名oldName和修改后的姓名newName

//MemberNameChangedEvent

public class MemberNameChangedEvent extends DomainEvent {
    private String memberId;
    private String newName;
    private String oldName;

    public MemberNameChangedEvent(String memberId, String newName, String oldName) {
        super(MEMBER_NAME_CHANGED);
        this.memberId = memberId;
        this.newName = newName;
        this.oldName = oldName;
    }
}

源码出处:com/mryqr/core/member/domain/event/MemberNameChangedEvent.java

这里有两个需要注意的问题,第一个是对于“成员已创建”事件MemberCreatedEvent来说,除了唯一的memberId字段之外,为什么不包含其他信息呢,比如创建成员时所输入的姓名、邮箱和电话号码等,这些信息不也是和场景强相关的吗?这个问题涉及到事件驱动架构的架构模式问题,通常来说有2种模式: (1)事件作为通知机制 (2)事件携带状态转移(Event Carried State Transfer)

对于第(1)种“事件作为通知机制”来说,领域事件主要起到一个通知作用,事件消费方在得到通知后需要反过来调用事件发布方提供的API以获取更多的业务数据,这种方式主要用于处理一些数据同步的场景,优点是可以保证任何时候事件的消费者都能获取到最新的数据,而不用担心事件的延迟消费或者乱序消费等问题,这种方式的缺点是增加了一次额外的API调用,并且在事件的发送方和消费方之间多了一层耦合。

对于第(2)种“事件携带状态转移”来说,事件消费方无需额外的API调用,而是从事件本身中即可获取到业务数据,降低了系统之间的耦合,通常用于比单纯的数据同步更复杂的业务场景,不过缺点则是可能导致消费方所获取到的数据不再是最新的,举个例子,对于“成员姓名已更新”事件(MemberNameChangedEvent)来说,假设成员的姓名先后更新的2次,首先将newName更新为“张三”,然后更新为“李四”,但是由于消息机制的不确定性等原因,可能更新为“李四”的事件先于“张三”事件而到达,最终导致的结果是消费方中成员的姓名依然为“张三”,而不是最新的“李四”,当然可以通过更多的手段来解决这个问题,比如消费方可以对事件产生的时间进行检查,如果发现事件产生的时间早于最近一次已处理事件的产生时间,则不再处理,不过这样一来引入了一些新的成本。

至于选择哪一种架构模式,并不是一个确定性的问题,开发团队需要根据自身系统的业务场景以及自身的团队情况做出决定。在码如云,我们选择了第(1)种,即将事件作为通知机制,因为码如云系统中的领域事件多数是用来处理纯粹的事件同步的。

另一个问题是,对于“成员姓名已更新”事件(MemberNameChangedEvent)来讲,一般来说消费方更关心变更后的姓名newName,谁会去关心那个老姓名oldName呢?这样一来是不是可以将oldName删除掉?答案是否定的,因为事件的发布者应该是一个“独善其身”式的存在,应该按照自身的业务场景行事,而不应该因为消费方不需要而省略掉与上下文强相关的信息。

领域事件的产生 #

使用领域事件的一种直接做法是:在应用服务(Application Service)中产生事件并发布出去。例如,对于“成员更新姓名”的用例来讲,对应的应用服务MemberCommandService实现如下:

@Transactional
public void updateMyName(UpdateMemberNameCommand command, User user) {
    Member member = memberRepository.byId(user.getMemberId());
    String oldName = member.getName();
    String newName = command.getName();
    
    member.updateName(newName, user);
    memberRepository.save(member);

    MemberNameChangedEvent event = new MemberNameChangedEvent(member.getId(), newName, oldName);
    eventPublisher.publish(event);

    log.info("Member name updated by member[{}].", member.getId());
}

源码出处:com/mryqr/core/member/command/MemberCommandService.java

这里,在更新了成员的姓名之后,即刻调用事件发布器eventPublisher.publish()将事件发送到消息队列(Redis Stream)中。虽然这种方式比较流行,但它至少存在2个问题:

  1. 领域事件本应属于领域模型的一部分,也即应该从领域模型中产生,而这里却在应用服务中产生
  2. 对聚合根(本例中的Member)的持久化和对事件的发布可能导致数据不一致问题

对于第1个问题,我们可以采用“从领域模型中返回领域事件”的方式:

@Transactional
public void updateMyName(UpdateMemberNameCommand command, User user) {
    Member member = memberRepository.byId(user.getMemberId());
    String oldName = member.getName();
    String newName = command.getName();

    MemberNameChangedEvent event = member.updateName(newName, user);
    memberRepository.save(member);
    eventPublisher.publish(event);

    log.info("Member name updated by member[{}].", member.getId());
}

源码出处:com/mryqr/core/member/command/MemberCommandService.java

在本例中,Member.updateName()方法不再返回void,而是返回领域事件MemberNameChangedEvent,然后由eventPublisher.publish(event)发布。更多关于此种方式的讨论,请参考这篇文章

这种方式保证了领域事件是从领域模型中产生,也即解决了第1个问题,但是依然存在第2个问题,接下来我们详细解释一下第2个问题。第2个问题中所谓的“数据一致性”,表示的是将聚合根保存到数据库和将领域事件发布到消息队列之间的一致性。由于数据库和消息队列属于异构的数据源,要保证他们之间的数据一致性需要引入分布式事务,比如JTA(Java Transaction API)。但是分布式事务通常是比较重量级的,再加上当下的诸多常见消息队列均不支持分布式事务(比如Kafka),因此我们并不建议使用分布式事务来解决这个问题。不过不要担心,有人专门研究过这个问题的解决方案,并形成了一种设计模式——Transactional Outbox。概括来说,这种方式将一个分布式事务的问题拆解为多个本地事务,并采用“至少一次投递(At Least Once Delivery)”原则保证消息的发布。具体来讲,发布方在与业务数据相同的数据库中为领域事件创建相应的事件发布表(Outbox table),然后在保存业务数据的同时将所产生的事件保存到事件发布表中,由于此时二者都属于同一个数据库的本地事务所管辖,因此保证了“业务操作”与“事件产生”之间的一致性。此时的代码变成了:

@Transactional
public void updateMyName(UpdateMemberNameCommand command, User user) {
    Member member = memberRepository.byId(user.getMemberId());
    String oldName = member.getName();
    String newName = command.getName();

    MemberNameChangedEvent event = member.updateName(newName, user);
    memberRepository.save(member);
    eventStore.save(event);

    log.info("Member name updated by member[{}].", member.getId());
}

源码出处:com/mryqr/core/member/command/MemberCommandService.java

此例和上例唯一的区别在于:先前的eventPublisher.publish(event)被替换成了eventStore.save(event),也即应用服务不再将事件直接发布出去,而是将事件保存到数据库中,之后,另一个模块将从数据库中读取事件并发布(对此我们将在下文进行讲解)。

然而,这种方式依然有个缺点:每个需要产生领域事件的场景都需要应用服务先后调用repository.save()eventStore.save(),导致了代码重复。有没有一种“一劳永逸”的方法呢?答案是有的,为此请允许我们隆重地介绍处理领域事件的一枚“银弹”——在聚合根中临时保存领域事件,然后在资源库中同时保存聚合根和领域事件到数据库。开玩笑的啦,“银弹”这个梗,我们怎么可能不给自己留点后路呢?虽然不是“银弹”,但是这种方式的确有其好处,在码如云,我们采用了这种方式,算得上是屡试不爽了。在这种方式下,首先需要在聚合根的基类中完成与领域事件相关的各种设施,包括创建临时性的事件容器events以及通用的事件产生方法raiseEvent()

//AggregateRoot

@Getter
public abstract class AggregateRoot implements Identified {
    private String id;
    private String tenantId;

    private List<DomainEvent> events;//领域事件列表,用于临时存放完成某个业务流程中所发出的事件,会被BaseRepository保存到事件表中

    //此处省略其他代码

    protected void raiseEvent(DomainEvent event) {//将领域事件添加到临时性的events容器中
        allEvents().add(event);
    }

    public void clearEvents() {//清空所有的事件,在聚合根落库之前需要完成此操作
        this.events = null;
    }

    private List<DomainEvent> allEvents() {
        if (events == null) {
            this.events = new ArrayList<>();
        }

        return events;
    }
}

源码出处:com/mryqr/core/common/domain/AggregateRoot.java

在聚合根基类AggregateRoot中,events字段用于临时保存聚合根中所产生的所有事件,各实际的聚合根类通过调用raiseEvent()events中添加事件。比如,对于“成员修改姓名”用例而言,Member实现如下:

//Member

public void updateName(String name, User user) {
    if (Objects.equals(this.name, name)) {
        return;
    }

    String oldName = this.name;
    this.name = name;
    raiseEvent(new MemberNameChangedEvent(this.getId(), name, oldName));
}

源码出处:com/mryqr/core/member/domain/Member.java

这里,聚合根Member不再返回领域事件,而是将领域事件通过AggregateRoot.raiseEvent()暂时性地保存到自身的events中。之后在保存Member时,资源库的公共基类BaseRepositorysave()方法同时完成对聚合根和领域事件的持久化:

//MongoBaseRepository

public void save(AR it) {
    requireNonNull(it, "AR must not be null.");

    if (!isEmpty(it.getEvents())) {
        saveEvents(it.getEvents());
        it.clearEvents();
    }

    mongoTemplate.save(it);
}

源码出处:com/mryqr/common/mongo/MongoBaseRepository.java

这里了的AR是表示所有聚合根类的泛型,在save()方法中,首先获取到聚合根中的所有领域事件,然后通过saveEvents()方法将它们保存到发布事件表中,最后通过mongoTemplate.save(it)保存聚合根。需要注意的是,在这种方式下,AggregateRoot中的events字段是不能被持久化的,因为我们需要保证每次从数据库中加载出聚合根时events都是空的,为此我们在saveEvents()保存了领域事件后,立即调用it.clearEvents()将所有的领域事件清空掉,以免领域事件随着聚合根一道被持久化到数据库中。

到目前为止,我们对领域事件的处理都还没有涉及到与任何消息中间件相关的内容,也即事件的产生是一个完全独立于消息队列的关注点,此时我们不用关心领域事件之后将以何种形式发布出去,Kafka也好,RabbitMQ也罢。除了关注点分离的好处外,这种解耦也使得系统在有可能切换消息中间件时更加的简单。

领域事件的发布 #

对于上文中的“在应用服务中通过eventPublisher.publish()直接发布事件”而言,对事件的产生和发布是同时完成的;但是对于“在聚合根中临时性保存领域事件”的方式来说,它只解决了事件的产生问题,并未解决事件的发布问题,在本小节中,我们将详细讲解在这种方式下如何发布领域事件。

事件的发布方应该采用“发射后不管(Fire And Forget)”的原则,即发布方无需了解消费方是如何处理领域事件的,甚至都不需要知道事件被哪些消费方所消费。

在将业务数据和领域事件同时保存到数据库之后,接下来的事情便是如何将领域事件发布出去了。在发布事件时,应该从数据库的事件发布表中加载领域事件,然后通过消息中间件的API将事件发送出去,这里需要解决以下2个问题:

  1. 什么时候启动对领域事件的发布?
  2. 如何处理发布失败的情况?

对于第1个问题,需要数据库事务执行完毕之后,也即保证领域事件落盘之后,才可进行对事件的发布,显然从应用服务中发布并不满足此条件(因为@Transactional注解是打在应用服务上的,应用服务的方法在执行过程中事务尚未结束),除此之外便只有Controller了,但是如果在Controller中发布领域事件又会导致需要在每个Controller中都重复调用事件发布逻辑的代码。有没有其他办法呢?有,一是可以通过AOP的方式在每个Controller方法执行完毕之后启动对事件的发布,另一种是通过Spring框架提供的HandlerInterceptor对每个HTTP请求进行拦截并启动对事件的发布,在码如云中,我们采用了HandlerInterceptor的方式:

public class DomainEventHandlingInterceptor implements HandlerInterceptor {
    private final DomainEventPublisher eventPublisher;

    @Override
    public void postHandle(HttpServletRequest request,
                           HttpServletResponse response,
                           Object handler,
                           ModelAndView modelAndView) {

        //从数据库中加载所有尚未发布的事件(status=CREATED或PUBLISH_FAILED)并发布
        eventPublisher.publishEvents();
    }
}

源码出处:com/mryqr/common/event/publish/interception/DomainEventHandlingInterceptor.java

这里,DomainEventHandlingInterceptorpostHandel()方法将在每个HTTP请求完成之后运行,eventPublisher.publishEvents()并不接受任何参数,其实现逻辑是从数据库中加载出所有尚未发送的事件并发布(可以通过DomainEventstatus来判断事件是否已经发送)。

这种方式依然不完美,因为即便一个请求中没有任何事件产生,也将导致一次对数据库的查询操作,如果有种方式可以记住请求中所产生的事件ID,然后再针对性的发送相应的事件就好了,答案是有的:使用Java的ThreadLocal(粗略可以理解为线程级别的全局变量)记录下一次请求中所产生的事件ID。为此,需要在BaseRepository对事件落库的时候将所有的事件ID记录到ThreadLocal中:

//MongoBaseRepository

private void saveEvents(List<DomainEvent> events) {
    if (!isEmpty(events)) {
        domainEventDao.insert(events);//保存事件到数据库
        ThreadLocalDomainEventIdHolder.addEvents(events);//记录事件ID以备后用
    }
}

源码出处:com/mryqr/common/mongo/MongoBaseRepository.java

这里的ThreadLocalDomainEventIdHolder.addEvents()将使用ThreadLocal将本次请求中的所有事件ID记录下来以备后用。ThreadLocalDomainEventIdHolder实现如下:

//ThreadLocalDomainEventIdHolder

public class ThreadLocalDomainEventIdHolder {
    private static final ThreadLocal<LinkedList<String>> THREAD_LOCAL_EVENT_IDS = withInitial(LinkedList::new);

    public static void clear() {
        eventIds().clear();
    }

    public static void remove() {
        THREAD_LOCAL_EVENT_IDS.remove();
    }

    public static List<String> allEventIds() {
        List<String> eventIds = eventIds();
        return isNotEmpty(eventIds) ? List.copyOf(eventIds) : List.of();
    }
    
    public static void addEvents(List<DomainEvent> events) {//添加事件ID
        events.forEach(ThreadLocalDomainEventIdHolder::addEvent);
    }

    public static void addEvent(DomainEvent event) {//添加事件ID
        LinkedList<String> eventIds = eventIds();
        eventIds.add(event.getId());
    }

    private static LinkedList<String> eventIds() {
        return THREAD_LOCAL_EVENT_IDS.get();
    }
}

源码出处:com/mryqr/common/event/publish/interception/ThreadLocalDomainEventIdHolder.java

现在,线程中有了已产生事件的ID,接下来便可在DomainEventHandlingInterceptor获取这些事件ID并发布对应事件了:

//DomainEventHandlingInterceptor

public class DomainEventHandlingInterceptor implements HandlerInterceptor {
    private final DomainEventPublisher eventPublisher;

    @Override
    public void postHandle(HttpServletRequest request, 
                           HttpServletResponse response,
                           Object handler, 
                           ModelAndView modelAndView) {
        
        List<String> eventIds = ThreadLocalDomainEventIdHolder.allEventIds();
        try {
            eventPublisher.publish(eventIds);
        } finally {
            ThreadLocalDomainEventIdHolder.remove();
        }
    }
}

源码出处:com/mryqr/common/event/publish/interception/DomainEventHandlingInterceptor.java

在发送事件时,可以采用同步的方式,也可以采用异步的方式,同步方式即事件的发送与业务请求的处理在同一个线程中完成,这种方式可能导致系统响应时间延长,在高并发场景下可能影响系统吞吐量,因此一般建议采用异步方式,即通过一个单独的线程池完成对事件的发布。异步发送的代码如下:

public class AsynchronousDomainEventPublisher implements DomainEventPublisher {
    private final TaskExecutor taskExecutor;
    private final DomainEventJobs domainEventJobs;

    @Override
    public void publish(List<String> eventIds) {
        if (isNotEmpty(eventIds)) {
            taskExecutor.execute(domainEventJobs::publishDomainEvents);
        }
    }
}

源码出处:com/mryqr/common/event/publish/AsynchronousDomainEventPublisher.java

可以看到,AsynchronousDomainEventPublisher通过TaskExecutor完成了事件发布的异步化。不过需要注意的是,这种使用ThreadLocal来记录事件ID的方式只适合于基于线程的Web容器,比如Servlet容器,而对于Webflux则不支持了。

在通过DomainEventJobs.publishDomainEvents()发送领域事件时,先通过DomainEventDao.tobePublishedEvents()获取到尚未发布的领域事件,然后根据时间产生顺序进行发送。另外,由于多个线程可能同时执行事件发送逻辑,导致事件的发生顺序无法得到保证,因此我们使用了分布式锁LockingTaskExecutor来保证某个时刻只有事件发送任务可以工作。

// DomainEventJobs

    public int publishDomainEvents() {
        try {
            //通过分布式锁保证只有一个publisher工作,以此保证消息发送的顺序
            TaskResult<Integer> result = lockingTaskExecutor.executeWithLock(this::doPublishDomainEvents,
                    new LockConfiguration(now(), "publish-domain-events", ofMinutes(1), ofMillis(1)));
            Integer publishedCount = result.getResult();
            return publishedCount != null ? publishedCount : 0;
        } catch (Throwable e) {
            log.error("Error while publish domain events.", e);
            return 0;
        }
    }

    private int doPublishDomainEvents() {
        int count = 0;
        int max = 10000;//每次运行最多发送的条数
        String startEventId = "EVT00000000000000001";//从最早的ID开始算起

        while (true) {
            List<DomainEvent> domainEvents = domainEventDao.tobePublishedEvents(startEventId, 100);
            if (isEmpty(domainEvents)) {
                break;
            }

            for (DomainEvent event : domainEvents) {
                redisDomainEventSender.send(event);
            }

            count = domainEvents.size() + count;
            if (count >= max) {
                break;
            }
            startEventId = domainEvents.get(domainEvents.size() - 1).getId();//下一次直接从最后一条开始查询
        }

        return count;
    }

源码出处:com/mryqr/common/event/DomainEventJobs.java

事件发布有可能不成功,比如消息队列连接不上等原因,此时我们则需要建立事件兜底机制,即在每次请求正常发布事件之外,还需要定时(比如每2分钟)扫描数据库中尚未成功发布的事件并发布。

    @Scheduled(cron = "0 */2 * * * ?")
    public void houseKeepPublishDomainEvent() {
        int count = domainEventJobs.publishDomainEvents();
        if (count > 0) {
            log.info("House keep published {} domain events.", count);
        }
    }

源码出处:com/mryqr/common/scheduling/SchedulingConfiguration.java

这也意味着我们需要记录每一个事件的发布状态status。在事件发布到消息中间件之后,更新事件的状态:

public class RedisDomainEventSender {
    private final MryObjectMapper mryObjectMapper;
    private final MryRedisProperties mryRedisProperties;
    private final StringRedisTemplate stringRedisTemplate;
    private final DomainEventDao domainEventDao;

    public void send(DomainEvent event) {
        try {
            String eventString = mryObjectMapper.writeValueAsString(event);
            ObjectRecord<String, String> record = StreamRecords.newRecord()
                    .ofObject(eventString)
                    .withStreamKey(mryRedisProperties.getDomainEventStream());
            stringRedisTemplate.opsForStream().add(record);
            domainEventDao.successPublish(event);
        } catch (Throwable t) {
            log.error("Error happened while publish domain event[{}:{}] to redis.", event.getType(), event.getId(), t);
            domainEventDao.failPublish(event);
        }
    }
}

源码出处:com/mryqr/common/event/publish/RedisDomainEventSender.java

这里,当事件发布成功后调用domainEventDao.successPublish(event)将事件状态设置为“发布成功”(status=PUBLISH_SUCCEED),反之将事件状态设置为“发布失败”(status=PUBLISH_FAILED)。事实上,将status放在DomainEvent上并不是一种好的实践,因为这里的status主要用于发布方,对消费方来说则无端地多了一个无用字段,更好的方式是在发布方另行创建一张数据库表来记录每个事件的发布状态。不过,在码如云,由于我们采用了单体架构,事件的发布方和消费方均在同一个进程空间中,为了方便实用起见,我们做出了妥协,即依然将status字段保留在DomainEvent中。

有趣的是,这里的RedisDomainEventSender让我们再次陷入了分布式事务的困境,因为发送事件需要操作消息中间件,而更新事件状态需要操作数据库。在不使用分布式事务的情况下(我们也不想使用),此时的代码对于“事件发布成功 + 数据库落库成功”来讲是皆大欢喜的,但是依然无法排除有很小的概率导致事件发送成功了但是状态却为得到更新的情况。要解决这个问题,我们做了一个妥协,即事件发布方无法保证事件的“精确一次性投递(Exactly Once)”,而是保证“至少一次投递(At Least Once)”。假设在事件发布成功之后,由于种种原因导致事件的状态未得到更新,即依然为CREATED状态,那么稍后,当事件兜底机制启动时,它将加载系统中尚未发布的事件进行发布,其中就包含状态为CREATED的事件,进而导致事件的重复投递。

“至少一次投递”将更多的负担转嫁给了事件的消费方,使得事件发送方得以全身而退,在下文中我们将讲到对事件的消费。

领域事件的消费 #

事件消费的重点在于如何解决发布方的“至少一次投递”问题。举个例子,假设在电商系统中,订单子系统发布了“订单已成交”(OrderPlacedEvent)事件,积分子系统消费这个事件时会给用户新增与订单价格等额的积分,但是对事件的“至少一次投递”有可能导致该事件被重复投递进而导致重复给用户积分的情况产生。解决这个问题通常有2种方式:

  1. 将消费方自身的处理逻辑设计为幂等的,即多次执行和一次执行的结果是相同的
  2. 消费方在数据库中建立一个事件消费表,用于跟踪已经被消费的事件

第1种方式是最理想的,消费方不用引入额外的支撑性机制,但是这种方式对消费方的要求太高,并不是所有场景都能将消费方本身的处理逻辑设计为幂等。因此,实践中主要采用第2种方式。

在消费事件时,通过DomainEventConsumer类作为事件处理的统一入口,其中将遍历所有可以处理给定事件的DomainEventHandler,这些DomainEventHandler中包含对事件的实际处理逻辑:

public class DomainEventConsumer {
    private final List<DomainEventHandler> handlers;
    private final DomainEventDao domainEventDao;

    public DomainEventConsumer(List<DomainEventHandler> handlers, DomainEventDao domainEventDao) {
        this.handlers = handlers;
        this.handlers.sort(comparingInt(DomainEventHandler::priority));
        this.domainEventDao = domainEventDao;
    }

    //所有能处理事件的handler依次处理,全部处理成功记录消费成功,否则记录为消费失败;
    //消费失败后,兜底机制将重新发送事件,重新发送最多不超过3次
    public void consume(DomainEvent domainEvent) {
        log.info("Start consume domain event[{}:{}].", domainEvent.getType(), domainEvent.getId());

        boolean hasError = false;
        MryTaskRunner taskRunner = newTaskRunner();

        for (DomainEventHandler handler : handlers) {
            try {
                if (handler.canHandle(domainEvent)) {
                    handler.handle(domainEvent, taskRunner);
                }
            } catch (Throwable t) {
                hasError = true;
                log.error("Error while handle domain event[{}:{}] by [{}].",
                        domainEvent.getType(), domainEvent.getId(), handler.getClass().getSimpleName(), t);
            }
        }

        if (taskRunner.isHasError()) {
            hasError = true;
        }

        if (hasError) {
            domainEventDao.failConsume(domainEvent);
        } else {
            domainEventDao.successConsume(domainEvent);
        }
    }
}

源码出处:com/mryqr/core/common/domain/event/DomainEventConsumer.java

对于事件处理器DomainEventHandler而言,其地位与应用服务相当,也即它并不处理具体的业务逻辑,而是代理给领域模型进行处理。举个例子,在码如云,当成员姓名更新后,系统中所有记录该成员姓名的聚合根均需要做相应同步,此时“成员姓名已更新”(MemberNameChangedEvent)事件对应的处理器为:

//MemberNameChangedEventHandler

public class MemberNameChangedEventHandler implements DomainEventHandler {
    private final MemberRepository memberRepository;

    @Override
    public boolean canHandle(DomainEvent domainEvent) {
        return domainEvent.getType() == MEMBER_NAME_CHANGED;
    }

    @Override
    public void handle(DomainEvent domainEvent, MryTaskRunner taskRunner) {
        MemberNameChangedEvent event = (MemberNameChangedEvent) domainEvent;
        memberRepository.byIdOptional(event.getMemberId())
                .ifPresent(memberRepository::syncMemberNameToAllArs);
    }
}

源码出处:com/mryqr/core/member/eventhandler/MemberNameChangedEventHandler.java

可以看到,DomainEventHandler并没有直接完成对姓名的同步,而是将其代理给了领域模型中的MemberRepository,因此DomainEventHandler也应该是很薄的一层。另外,DomainEventHandler是与消息中间件无关的,不管底层使用的是Kafka还是RabbitMQ,DomainEventHandler是不用变化的。

总结 #

在DDD中,领域事件是用于解耦各个模块(子系统)的常用方式。另外,领域事件的产生、发布和消费彼此也是解耦的。产生领域事件时,通过本地事件发布表表保证事件产生和业务操作之间的数据一致性,然后通过“至少一次投递”的方式发布事件,消费方通过本地事件消费表的方式保证事件消费的幂等性。在整个发布和消费的过程中,只有少数几处存在对消息中间件(Redis Stream)的依赖,其他地方,包括发布方对事件的产生以及持久化,消费方的各个事件处理器(DomainEventHandler)均是中立于消息基础设施的。在下一篇CQRS中,我们将对DDD中的读写分离模式进行讲解。

产品代码都给你看了,可别再说不会DDD(八):应用服务与领域服务 | 码如云文档中心

mikel阅读(419)

来源: 产品代码都给你看了,可别再说不会DDD(八):应用服务与领域服务 | 码如云文档中心

这是一个讲解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

应用服务和领域服务 #

对于服务类(代码中的各种Service类),想必程序员们都不会陌生,比如在做Spring项目时,在Controller层的后面通常会有一个XxxService存在。如果对代码职责划分得好一点呢,那么该Service还会协调其他各方完成对请求的处理;而如果代码设计得不那么好呢,估计就是一个Service负责从头到尾的所有了。在DDD中,也有类似的服务类,即应用服务(Application Service)和领域服务(Domain Service),不过DDD对于这些服务类的职责做出了明确的界定,在本文中我们将对此做出详细地讲解。

应用服务 #

在本系列的前几篇文章中我们讲到,在DDD中领域模型(主要包含聚合根,实体,值对象,工厂等)是软件系统的核心,所有的业务逻辑都发生在其中。在理想情况下,DDD只需要领域模型就够了(毕竟领域驱动嘛)。但是,软件运行于计算机这种基础设施之上,显然不止于领域模型这么简单,至少还应该包含以下方面:

  1. 数据的网络传输
  2. 应用协议的解析
  3. 对业务用例的协调
  4. 事务处理
  5. 业务数据的持久化
  6. 日志
  7. 认证授权等非业务逻辑类关注点

以Spring MVC为例,在编写代码时我们直接面对的是Controller。在Controller背后,Spring框架和Servlet容器已经为我们处理好了数据的网络传输以及HTTP协议解析等底层设施,此时的Controller似乎已经是一个比较高级的编程对象了。咋一看,我们得到了这么一个场景:一边是Controller,一边是领域模型,何不直接使用Controller调用领域模型完成上述的第3点到7点呢?并非完全不可以,但是直接在Controller中调用领域模型的缺点也非常明显:

  1. Controller属于Spring框架,依然是一个非常技术性的存在,而上述的第3到7点大多与具体的框架无关,因此更应该作为一个单独的关注点来处理,以达到与具体框架解耦的目的
  2. 对用例的协调是可以复用的,比如以后需要通过桌面GUI(比如JavaFx)来实现的话,其协调逻辑和此时的Controller是相同的,总不至于再拷一份源代码过去吧

由此可以看出,在技术性的Controller和业务性的领域模型之间,还应该有一个值得被当做单独关注点的存在。而另一方面,从领域模型本身来说,它只是业务知识在软件中的表达,并不负责直接处理外界请求,而是需要有一个门面性的存在来协助它。综合起来,在DDD中我们将这个“存在”称之为应用服务

先来看个关于应用服务的例子,在码如云中,租户的管理员可以对成员(Member)进行启用或禁用操作,以启用成员为例,此时的Controller代码如下:

//MemberController

@PutMapping(value = "/{memberId}/activation")
public void activateMember(@PathVariable("memberId") @NotBlank @MemberId String memberId,
                           @AuthenticationPrincipal User user) {
    memberCommandService.activateMember(memberId, user);
}

源码出处:com/mryqr/core/member/MemberController.java

对应的应用服务(MemberCommandService)代码如下:

//MemberCommandService

@Transactional
public void activateMember(String memberId, User user) {
    user.checkIsTenantAdmin();
    Member member = memberRepository.byIdAndCheckTenantShip(memberId, user);
    member.activate(user);
    memberRepository.save(member);
    log.info("Activated member[{}].", memberId);
}

源码出处:com/mryqr/core/member/command/MemberCommandService.java

从上面两段代码中,我们可以总结出以下几点:

  1. Controller的作用非常简单,就一行代码,即调用应用服务,这么做的目的是希望程序尽量早地从技术框架解耦;
  2. 应用服务MemberCommandService遵循DDD中的业务请求处理三部曲原则,即先加载Member,再调用Member上的业务方法activate(),最后调用资源库memberRepository.save(member)保存Member,整个过程中,应用服务主要起组织协调作用,并不负责实际的业务逻辑;
  3. MemberCommandService方法上标注了@Transactional,也即应用服务负责处理事务边界;
  4. 在完成协调工作之前,MemberCommandService通过调用user.checkIsTenantAdmin()来检查操作用户是否为租户管理员,也即应用服务也会负责协调对权限的处理;
  5. 打日志,一个应用服务对应一个独立的业务用例,用例处理完后需要日志记录;
  6. 从整个上看,应用服务与其所在的Spring框架是解耦的。

应用服务是领域模型的门面 #

在DDD中,领域模型并不直接接收外界的请求,而是通过应用服务向外提供业务功能。此时的应用服务就像酒店的前台一样,对外面对客户,对内则将客户的请求代理派发给内部的领域模型。应用服务将核心的领域模型和外界隔离开来,可以说应用服务是在“呵护”着领域模型。

既然应用服务只是起协调代理的作用,也意味着应用服务不应该包含过多的逻辑,而应该是很薄的一层。另外,应用服务是以业务用例为粒度接收外部请求的,也即应用服务类中的每一个共有方法即对应一个业务用例,进而意味着应用服务也负责处理事务边界,使得对一个业务用例的处理要么全部成功,要么全部失败。对应到实际编码过程中,@Tranactional注解并不是想怎么打就怎么打的,而是主要应该打到应用服务上。

应用服务应该与框架无关 #

应用服务要做到与技术框架无关,因为应用服务向外代表着业务用例,而业务用例不因框架的变化而变化,当我们把应用服务放到诸如Spring MVC这种Web框架中,它能正常工作,当我们将它迁移到桌面GUI程序中,它也应该可以正常工作。从这个角度,可以将应用服务比作电子元器件,比如CPU,一个CPU在华硕的主板上可以正常使用,将其转插到微星主板中也是可以的。

这里有个需要讨论的点是@Transactional,这个注解是属于Spring框架的,将其打在了到应用服务上,这不违背了“应用服务与框架无关”的原则吗?严格上来讲,的确如此,但是这个妥协我们认为是可以做的,原因如下:(1)@Transational注解是打在应用服务方法之上的,并不直接侵入应用服务的方法实现内部,因此这种侵入性并不会导致应用服务中逻辑的混乱,替换的成本也不高;(2)@Transational本是通过Spring的AOP实现,如果的确不想使用,可以在Controller中调用应用服务的地方使用Spring的TranactionalTemplate类完成,或者另行封装一个TransactionWrapper之类的东西供Controller调用,这样一来咱们的应用服务就的确和Spring框架没任何关系了,但是从务实的角度考虑,这种做法有些得不偿失。就上例而言,如果的确有一天你需要像电脑更换CPU那样将系统从Spring迁移到Guice框架,通过简单的适配便达到目的了。

领域服务 #

领域服务虽然和应用服务都有“服务”二字,但是它们并没有多少联系,分别在不同的DDD岗位上各司其职,并且源自于两种完全不同的逻辑推演。

在本系列的前几篇文章中,我们知道了领域模型中最重要的概念是聚合根对象,理想情况下我们希望所有的业务逻辑都发生在聚合根之中,在实际编码中我们也是朝着这个目标行进的。但是,理想和现实始终是存在差距的,在有些情况下将业务逻辑放到聚合根中并不合适,于是我们做个妥协,将这部分业务逻辑放到另外的地方——领域服务

还是来看个实际的例子,在码如云中,成员(Member)可以修改自己的手机号,在修改手机号时,需要判断新手机号是否已经被他人占用。这里的“检查手机号是否被占用”是一种跨聚合根的业务逻辑,单单凭当事的Member自身是否无法完成的,因为该Member无法感知到其他Member的状态。另外,“手机号不能重复”这种逻辑恰恰是一种业务逻辑,应该属于领域模型的一部分。

让我们将思考问题的方式反过来,通过自底向上的方式再看看,要实现跨聚合根的检查,无论如何是需要访问数据库的,这落入了资源库(Repository)的职责范畴,为此我们在Member对应的资源库MemberRepository中实existsByMobile(mobileNumber)方法用于检查一个手机号mobileNumber是否已经被占用。接下来的问题在于,对该方法的调用应该由谁完成?此时至少有3种方式:

  1. 在应用服务中调用:这种调用不再是简单的协调式调用,而是感知到了业务逻辑的调用,这违背了应用服务的基本原则,因此不应该使用这种方式;
  2. MemberRepository作为参数传入Member,这的确是一种方式,但是这种方式使得聚合根Member接受了与业务数据无关的方法参数,是一种API污染,因此我们也不推荐;
  3. 作为一个单独的关注点,另立门户:将这部分逻辑放到一个单独的类中,这个类依然属于领域模型,此时的“另立门户”便是一个领域服务了。

在使用了领域服务后,整个请求的流程稍微有些变化。首先在应用服务MemberCommandService中, 我们不再遵循经典的请求处理三部曲,而是通过调用领域服务MemberDomainService来更新Member的状态:

//MemberCommandService

@Transactional
public void changeMobile(ChangeMyMobileCommand command, User user) {
    Member member = memberRepository.byId(user.getMemberId());
    memberDomainService.changeMobile(member, command.getMobile(), command.getPassword());
    memberRepository.save(member);
    log.info("Mobile changed by member[{}].", member.getId());
}

源码出处:com/mryqr/core/member/command/MemberCommandService.java

这里,应用服务MemberCommandService在加载到对应的Member对象后,将该Member传递给了领域服务MemberDomainService.changeMobile(),并期待着这个领域服务会干正确的事情(即更新Member的手机号)。最后,应用服务再调用memberRepository.save()将更新后的Member对象保存到数据库中。在这个过程中,应用服务的“将请求代理给领域模型”这种结构并没有发生变化,并且也无需关心领域服务的内部细节。事实上,此时对请求的处理依然是三部曲,只是其中的第2步从“调用聚合根上的业务方法”变成了“调用领域服务上的业务方法”。

领域服务MemberDomainService的实现如下:

//MemberDomainService

public void changeMobile(Member member, String newMobile) {
    if (Objects.equals(member.getMobile(), newMobile)) {
        return;
    }

    if (memberRepository.existsByMobile(newMobile)) {
        throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS,
                "修改手机号失败,手机号对应成员已存在。",
                mapOf("mobile", newMobile, "memberId", member.getId()));
    }

    member.changeMobile(newMobile, member.toUser());
}

源码出处:com/mryqr/core/member/domain/MemberDomainService.java

MemberDomainService先调用memberRepository.existsByMobile()判断手机号是否被占用,如被占用则抛出异常,反之才调用Member.changeMobile()完成实际的状态更新。

在码如云,我们发现多数情况下领域服务的存在都是为了解决类似于本例中的“检查某个值是否重复”这样的场景,比如检查成员邮箱是否被占用,检查分组名称是否重复等。事实上,这类问题被业界广泛讨论过,有兴趣的读者可以参考这里这里。当然,领域服务远不止处理此类场景,比如有时生成ID是通过某些复杂的算法或者调用第三方完成,此时便可以将其封装在领域服务中。此外,DDD中的工厂可以被认为是一种特殊类型的领域服务。

可以看到,DDD中的应用服务和领域服务分别解决了两个完全不同的问题,他们主要的区别在于:

  1. 应用服务处于领域模型的外侧,是领域模型的客户(调用方),其作用是协调各方完成业务用例;而领域服务则是属于领域模型的一部分;
  2. 应用服务不处理业务逻辑,领域服务里全是业务逻辑;
  3. 每一个业务用例都需要经过应用服务,而领域服务则是一种迫不得已而为之的妥协。

到这里,再去看看自己代码中的那些Service类,是不是可以尝试着对它们归个类了?

总结 #

在本文中,我们分别对应用服务和领域服务做了展开讲解,包含它们各自产生的逻辑以及它们之间的区别。在实际编码中,通常的编码方式是:从Controller中调用应用服务,应用服务协调各方完成对业务用例的处理,业务逻辑优先放入聚合根中,如果不合适才考虑使用领域服务。在下一篇领域事件中,我们将讲到领域事件在DDD中的应用。

产品代码都给你看了,可别再说不会DDD(七):实体与值对象 | 码如云文档中心

mikel阅读(320)

来源: 产品代码都给你看了,可别再说不会DDD(七):实体与值对象 | 码如云文档中心

这是一个讲解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

实体与值对象 #

在本系列的上一篇聚合根与资源库中,我们讲到了聚合根的设计与实现,事实上聚合根本身即是一种实体(Entity),在本文中我们将对实体以及与之相对立的值对象(Value Object)做展开讲解。

在对聚合根的深入分析中,我们发现其中存在两种类型的对象,一种是具有生命周期的对象(比如成员(Member)),另一种是只起描述作用的对象(比如地址(Address)),前者称为实体,后者称为值对象,充分认识这两种对象之间的区别,对DDD落地有着举足轻重的作用。我们希望达到的目的是,将尽量多的概念建模为值对象,因为值对象比实体更加简单。

实体的生命周期意味着实体具有从产生到消亡的整个过程,这个过程往往比较漫长。比如,在码如云中,成员(Member)对象的生命周期可能超过几年甚至几十年的时间。相比之下,值对象不存在生命周期可言。为了讲解更加直观,让我们来分别看看值对象和实体的例子。在码如云中,地址(Address)即是一个值对象:

//Address

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class Address {
    private final String province; //省份
    private final String city; //城市
    private final String district; //区县
    private final String address; //详细地址

    //......此处省略更多代码

}   

源码出处:com/mryqr/core/common/domain/Address.java

聚合根成员(Member)则是一个实体对象:

//Member

@Getter
@Document(MEMBER_COLLECTION)
@TypeAlias(MEMBER_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Member extends AggregateRoot {
    private String name;//名字
    private Role role;//角色
    private String mobile;//手机号
    private String email;//邮箱
    private IdentityCard identityCard;//身份证
    
    //...此处省略更多代码

}

源码出处:com/mryqr/core/member/domain/Member.java

咋一看,实体和值对象似乎没有什么区别,都是Java对象而已,但事实上,实体和值对象在唯一标识、相等性和可变性等方面均存在很大的区别。

唯一标识 #

值对象的“描述性作用”也意味着它无需唯一标识(即ID)即可完成其使命,而实体则恰恰相反。在本例中,值对象Address没有ID,而实体Member的唯一标识则存在于其父类AggregateRootid字段中:

//AggregateRoot

@Getter
public abstract class AggregateRoot implements Identified {
    private String id;//聚合根ID
    private String tenantId;//租户ID

    //...此处省略更多代码

}

源码出处:com/mryqr/core/common/domain/AggregateRoot.java

更多关于AggregateRoot的内容,请参考本系列的聚合根与资源库一文。

在DDD中,所有的聚合根都是实体对象,但并不是所有的实体都是聚合根,不过从实践上来看了,绝大多数的实体对象都是聚合根。因此,在DDD项目中最常见的情况是:作为实体对象的聚合根包含了大量的值对象。

对于聚合根而言,由于已经是领域模型中的顶层对象,其唯一标识应该是全局唯一的;而对于聚合根下的其他实体而言,由于其作用范围被限制在了聚合根内部,因此对应的唯一标识在聚合根下唯一即可。比如,在码如云中,一个应用(App)包含了多个页面(Page),App是聚合根,PageApp下的实体,App的ID必须全局唯一,而Page的ID在其所属的App下唯一即可。

实体的唯一标识可以有多种方式生成,有些业务数据天然即是唯一标识,比如对于人员来说,身份证号即可直接用于唯一标识。不过需要注意的是,只有那些不变的业务字段才能用于唯一标识,否则,当这些业务字段发生更新时,所有引用它的地方都需要做相应更新。更多的时候,我们建议采用一个无业务含义的ID作为唯一标识,比如UUID或者通过雪花算法生成的ID等,又由于UUID的无序性在大数据量场景下可能存在性能问题,因此我们更偏向于雪花算法ID。

有些技术框架可以设置延后对实体ID的生成,比如Hibernate和数据库自增ID等,在DDD中,我们强烈建议不要采用这些方式,因为这些方式所创建出来的实体对象直到保存到数据库的最后一刻都是非法的,更好的方式是在新建实体时即为之设置ID。

在码如云中,我们通过雪花算法为聚合根生成ID,并且在构造函数中完成了对ID的赋值,以达到在新建时即为ID赋值的目的。比如,在Member对象的其中一个构造函数中,我们调用了newMemberId()为新成员生成ID:

//Member

//创建Member
public Member(String name, String mobile, User user) {
    super(newMemberId(), user);
    this.name = name;
    this.mobile = mobile;
    
    //...此处省略更多代码
}

//通过雪花算法生成成员ID
public static String newMemberId() {
    return "MBR" + newSnowflakeId();
}

源码出处:com/mryqr/core/member/domain/Member.java

有时,为了一些纯技术上原因,我们需要为值对象设置ID。比如,如果采用通过ORM框架持久化租户(Tenant),则需要将Tenant中的发票地址(invoiceAddress)保存到一张单独的数据库表中,由于数据库表之间需要有外键关联,因此需要将Address继承自一个层超类IdentifiedValueObject,在IdentifiedValueObject中包含有用于数据库表外键关联的id字段。

此时的Tenant实现如下:

//Tenant

@Getter
@Document(TENANT_COLLECTION)
@TypeAlias(TENANT_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Tenant extends AggregateRoot {
    private String name;//租户名称
    private InvoiceTitle invoiceTitle;//发票抬头
    private Address invoiceAddress;//发票地址

    //...此处省略更多代码

}

源码出处:com/mryqr/core/tenant/domain/Tenant.java

层超类IdentifiedValueObject实现如下:

//IdentifiedValueObject

public abstract class IdentifiedValueObject {
    private String id;
}

此时的Address继承自IdentifiedValueObject

//Address

public class Address extends IdentifiedValueObject {
    private final String province;

    //...此处省略更多代码

}

需要强调的是,以上“为值对象设置ID”的做法仅仅是一种技术上的实践,不能将其与业务相混淆,为此我们引入了一个层超类IdentifiedValueObject将与技术相关的内容作为一个单独的关注点来处理,从而实现了技术与业务的隔离。不过,在码如云,由于我们采用了MongoDB,从而避开了ORM,因此不存在本例中的问题。

相等性判断 #

实体对象通过ID进行相等性判断,而值对象通过其自身携带的属性进行相等性判断。举个例子,对于一对双胞胎而言,每人都是一个实体对象,由于二人的身份证号(唯一标识)是不同的,因此无论二人长得多么的相像,均不能认为是同一个人;相反,对于其中某一人来说,哪怕是整容到面目全非,也依然是同一个人,因为其ID始终没变。又比如,对于常见的值对象货币(Currency)而言,其价值通过其面值决定,因此一张刚从印钞厂出来的崭新百元大钞和一张沾满了细菌的百元纸币是可以等值互换的,因为它们所携带的面值是相同的。

在编码实践上,最显著的区别是值对象需要实现equals()hashCode()方法,而实体则不需要。在码如云中,我们通过Lombok为值对象自动生成equals()hashCode()方法,比如对于存储身份证信息的IdentityCard,其实现为:

//IdentityCard

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class IdentityCard {
    private String number;
    private String name;
}

源码出处:com/mryqr/core/member/domain/IdentityCard.java

其中的@Value注解隐式地为IdentityCard对象实现了equals()hashCode()方法。

可变性 #

实体和值对象的另一个区别是:实体对象是可变的(Mutable),而值对象是不可变的(Immutable)。对于实体对象而言,我们可以通过调用其上的方法直接更改其状态;而对于值对象而言,如果需要改变其状态,我们只能创建一个新的值对象,然后在新对象中包含改变后的状态。

对实体对象的直接状态变更比较好理解,这里重点讲一讲对值对象的不可变性的编码处理。对于值对象Address,如果我们需要修改其下的详细地址,具体的实现如下:

//Address

//修改详细地址
public Address changeTo(String detailAddress) {
    return Address.builder()
            .province(this.province)
            .city(this.city)
            .district(this.district)
            .address(detailAddress)
            .build();
}

源码出处:com/mryqr/core/common/domain/Address.java

这里,我们并未直接修改Address对象的address属性,而是新建了一个Address对象,然后将无需修改的字段(比如provice)原封不动地拷贝到新对象中,而将需要修改的字段(address)在新对象中设置为传入的最新值,最后返回这个新建的对象。

不可变性要求值对象必须满足以下约束:

  • 不能有共有的setter方法,否则外界可以直接修改其内部的状态
  • 不能有导致内部状态变化的共有方法

值对象的好处 #

本文一开始就提到我们应该将尽量多的对象建模为值对象,因为它比实体更加的简单,事实上值对象有多种好处。

首先,因为值对象是不可变的,所以不可变对象所拥有的好处值对象都有,比如使得对程序的调试和推理更加的简单,线程安全等。

其次,值对象作为一个概念上的整体(Conceptual Whole),它将与之相关的业务逻辑包含在其内部,不仅体现了内聚性,也增加了业务表达力,而这正是DDD所提倡的,比如对于本文中的Address,你是希望直接操作4个原始字段(provincecitydistrictaddress)呢,还是操作一个Address对象呢?

另外,值对象由于也包含了业务逻辑,因此可以完成自我验证,这样无论何时我们拿到一个值对象时,都可以相信这是一个合法的对象,而不用在值对象之外再做验证。

例如,在码如云中,定位信息被存放在Geopoint值对象中:

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class Geopoint {
    private static final float EARTH_RADIUS_METERS = 6371000;
    private final Float longitude;//经度
    private final Float latitude;//纬度

    public float distanceFrom(Geopoint that) {
        return distanceBetween(this.longitude, this.latitude, that.longitude, that.latitude);
    }

    private float distanceBetween(float lng1, float lat1, float lng2, float lat2) {
        double dLat = Math.toRadians(lat2 - lat1);
        double dLng = Math.toRadians(lng2 - lng1);
        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
                Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
                        Math.sin(dLng / 2) * Math.sin(dLng / 2);
        return (float) (EARTH_RADIUS_METERS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
    }

    public boolean isPositioned() {
        return longitude != null && latitude != null;
    }
}

源码出处:com/mryqr/core/common/domain/Geopoint.java

可以看到,Geopoint将经度longitude和纬度latitude封装在一起,成为一个概念上的整体。外部调用方无需单独处理经度和纬度数据,而是直接通过这个整体性的Geopoint对象即可完成对定位信息的操作。此外,distanceFrom()distanceBetween都是包含业务逻辑的方法,符合“行为饱满的领域对象”原则。再则,通过isPositioned()方法使得Geopoint可以自行完成业务验证。

角色可变 #

实体和值对象的划分并不是固定不变的,而是根据其所处的限界上下文决定的。一个概念在一个上下文中是一个实体对象,但是在另外的上下文中则可能是一个值对象。比如,对于上文中的货币Currency,在日常的的交易活动中,货币很明显应该被建模为一个值对象,因为在对其抽象之后我们忽略了货币的颜色,编号,新旧程度等属性,而只关注其面值。但是,如果哪天央行要做一个系统来管理每一张货币(比如对每张货币进行位置跟踪),那么则需要根据货币的编号进行管理,此时的货币则变成了一个实体对象。

总结 #

实体和值对象是领域对象中的两种不同类型的对象,它们在唯一标识、相等性和可变性等方面均存在不同。在DDD项目中,所有的聚合根均是实体,但是在实际建模过程中,由于值对象在不变性等方面的好处,我们应该尽量将业务概念建模为值对象。在下文应用服务与领域服务中,我们将对应用服务和领域服务做详细讲解。

产品代码都给你看了,可别再说不会DDD(六):聚合根与资源库 | 码如云文档中心

mikel阅读(337)

来源: 产品代码都给你看了,可别再说不会DDD(六):聚合根与资源库 | 码如云文档中心

这是一个讲解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中其它组件均可看作是对聚合根的支撑或辅助。在本文中,我们将对聚合根以及与之密切相关的资源库(Repository)做详细的讲解。

聚合根是什么 #

DDD概念大白话一文中,我们讲到了“什么是聚合根”,这里再重复一下。聚合根中的“聚合”即“高内聚,低耦合”中的“内聚”之意;而“根”则是“根部”的意思,也即聚合根是一种统领式的存在。事实上,并不存在一个教科书式的对聚合根的理论定义,你可以将聚合根理解为一个系统中最重要最显著的那些名词,这些名词是其所在的软件系统之所以存在的原因。为了给你一个直观的理解,以下是几个聚合根的例子:

  • 在一个电商系统中,一个订单(Order)对象表示一个聚合根
  • 在一个CRM系统中,一个客户(Customer)对象表示一个聚合根
  • 在一个银行系统中,一次交易(Transaction)对象表示一个聚合根

你可能会问,软件中的概念已经很多了,为什么还要搞出个聚合根的概念?我们认为这里至少有2点原因:

  1. 聚合根遵循了软件中“高内聚,低耦合”的基本原则
  2. 聚合根体现了一种模块化的原则,模块化思想是被各个行业所证明的可以降低系统复杂度的一种思想。所谓的DDD是“软件核心复杂性应对之道”,也即这个意思,它将软件系统在人脑中所呈现地更加有序和简单,让人可以更好地理解和管控软件系统。

在实际项目中识别聚合根时,我们需要对业务有深入的了解,因为只有这样你才知道到底哪些业务逻辑是内聚在一起的。这也是我们一直建议程序员和架构师们不要一味地埋头于技术而要多关注业务的原因。

事实上,如果让一个从来没有接触过DDD的人来建模,十有八九也能设计出上面的订单、客户和交易对象出来。没错,DDD绝非什么颠覆式的发明,依然只是在前人基础上的一种进步而已,这种进步更多的体现在一些设计原则上,对此我们将在下文进行详细阐述。

聚合根基类 #

在代码实现层面,一般的实践是将所有的聚合根都继承自一个公共基类AggregateRoot

//AggregateRoot

@Getter
public abstract class AggregateRoot implements Identified {
    private String id;//聚合根ID
    private String tenantId;//租户ID
    private Instant createdAt;//创建时间
    private String createdBy;//创建人的MemberId
    private String creator;//创建人姓名
    private Instant updatedAt;//更新时间
    private String updatedBy;//更新人MemberId
    private String updater;//更新人姓名
    private List<DomainEvent> events;//临时存放领域事件
    private LinkedList<OpsLog> opsLogs;//操作日志

    @Version
    @Getter(PRIVATE)
    private Long _version;//版本号,实现乐观锁

    //...此处省略了AggregateRoot中行为方法

    @Override
    public String getIdentifier() {
        return id;
    }
}

源码出处:com/mryqr/core/common/domain/AggregateRoot.java

AggregateRoot中,包含聚合根ID(id)、创建信息(createdAtcreatedBy)和更新信息(updatedAtupdatedBy)等数据。租户ID(tenantId)用于标定聚合根所在的租户(码如云是一个多租户系统)。另外,events用于临时性存放聚合根中所产生的领域事件,我们将在领域事件一文中对此所详细解释。

实际的聚合根继承自AggregateRoot,例如,在码如云中,分组(Group)聚合根的实现如下:

@Getter
@Document(GROUP_COLLECTION)
@TypeAlias(GROUP_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Group extends AggregateRoot {
    private String name;//名称
    private String appId;//所在的app
    private List<String> managers;//管理员
    private List<String> members;//普通成员
    private boolean archived;//是否归档
    private String customId;//自定义编号
    private boolean active;//是否启用
    private String departmentId;//由哪个部门同步而来
    
    //...此处省略了Group的行为方法
}

源码出处:com/mryqr/core/group/domain/Group.java

聚合根基本原则 #

从上面的代码例子可以看出,聚合根只是普通的Java对象而已,真正使之成为聚合根的是一些特定的设计原则。

内聚性原则 #

这个原则不用我们再细讲了吧,估计你在大学里就学过,只举个例子,对于上面的分组Group对象来说,管理员managers、普通成员members以及启用标志active均是Group不可分割的属性,这些属性独立于Group是无法存在的。

对外黑盒原则 #

对外黑盒原则讲的是,聚合根的外部(也即聚合根的调用方或客户方)不需要关心聚合根内部的实现细节,而只需要通过调用聚合根向外界暴露的共有业务方法即可。具体表现为,外部对聚合根的调用只能通过根对象完成,而不能调用聚合根内部对象上的方法。举个例子,在码如云中,管理员可以向分组(Group)中添加成员,具体的实现代码如下:

//Group

public void addMembers(List<String> memberIds, User user) {
    if (isSynced()) {
        throw new MryException(GROUP_SYNCED,
                "无法添加成员,已设置从部门同步。",
                "groupId", this.getId());
    }

    this.members = concat(members.stream(), memberIds.stream())
            .distinct()
            .collect(toImmutableList());
    
    addOpsLog("设置成员", user);
}

源码出处:com/mryqr/core/group/domain/Group.java

这里,外部在向分组中添加成员时,需要调用Group上的addMembers()方法,该方法知道将memberIds添加到自身的members字段中,这个过程对外部是不可见的。与之相对的另一种方式是,外部调用法先拿到Membermembers引用,然后由外部自行向members中添加memberIds

//外部调用方

@Transactional
public void addGroupMembers(String groupId, List<String> memberIds, User user) {
    Group group = groupRepository.byIdAndCheckTenantShip(groupId, user);

    if (group.isSynced()) {
        throw new MryException(GROUP_SYNCED,
                "无法添加成员,已设置从部门同步。",
                "groupId", group.getId());
    }

    List<String> members = group.getMembers();
    members.addAll(memberIds);
    groupRepository.save(group);

    log.info("Added members{} to group[{}].", memberIds, groupId);
}

源码出处:com/mryqr/core/group/command/GroupCommandService.java

这种方式是一种反模式,存在以下缺点:

  • 外部需要了解Group的内部结构,背离了对外黑盒原则,本例中,外部通过group.getMembers()获取到了Group内部的members属性
  • 聚合根内部的业务逻辑泄漏到了外部,背离了内聚性原则,本例中,对group.isSynced()的调用原本应该放在Group中的,结果却由外部承担了该职责

在对外黑盒原则的指导下,聚合根自然形成了一个边界,它站在这个边界上向外声明:“我所包围着的内部的所有均由我负责,如果谁想访问我的内部,直接访问是被禁止的,只能通过我这个“根”来访问。”

不变条件原则 #

不变条件(Invariants)表示聚合根需要保证其内部在任何时候均处于一种合法的状态(也即数据一致性需要得到保证),一个常见的例子是订单(Order)中有订单项(OrderItem)和订单价格(Price),当订单项发生变化时,其价格应该随之发生变化,并且这两种变化应该在订单的同一个业务方法中完成。这一点是好理解的,既然聚合根对外是一个黑盒,那么外界便不会负责给你聚合根擦屁股,你聚合根自己需要保证自身的正确性。

在码如云中,应用管理员可以向分组(Group)中添加分组管理员。这其中有层隐含意思是,既然分组管理员也是分组成员,那么在添加分组管理员的同时需要一并将其添加到分组成员中,具体实现代码如下:

//Group

public void addManager(String memberId, User user) {
    if (!this.members.contains(memberId)) {
        this.members = concat(members.stream(), Stream.of(memberId))
                .distinct()
                .collect(toImmutableList());
    }

    this.managers = concat(this.managers.stream(), Stream.of(memberId))
            .distinct()
            .collect(toImmutableList());
    
    raiseEvent(new GroupManagersChangedEvent(this.getId(), this.getAppId(), user));
    
    addOpsLog("添加管理员", user);
}

源码出处:com/mryqr/core/group/domain/Group.java

在本例的添加分组管理员addManager()方法中,我们除了向managers中添加成员外,还保证了该成员也出现在members中。这里的“分组管理员也是分组成员”即是一种不变条件,我们需要在聚合根内部保证不变条件不被破坏,因为不变条件往往意味着核心的业务逻辑。

通过ID引用其他聚合根原则 #

当一个聚合根需要引用另一个聚合根时,并不需要维持对另一聚合根的整体引用,而是只需通过ID进行引用即可。这个原则的出发点是:聚合根和聚合根之间是一种平级关系,并不是隶属关系,每个聚合根本身是一个相对独立的模块,其与其他聚合根的关系应该通过ID这种松耦合的方式进行引用,如果整体引用则更像是一种包含关系。

在码如云中,分组(Group)通过appId引用其所属的应用(App),通过departmentId引用所同步的部门(Department),而在managersmembers字段中,则是以memberId引用相应成员(Member):

@Getter
@Document(GROUP_COLLECTION)
@TypeAlias(GROUP_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Group extends AggregateRoot {
    private String name;//名称
    private String appId;//所在的app
    private List<String> managers;//管理员
    private List<String> members;//普通成员
    private boolean archived;//是否归档
    private String customId;//自定义编号
    private boolean active;//是否启用
    private String departmentId;//由哪个部门同步而来
   
    //...省略其他代码
}

源码出处:com/mryqr/core/group/domain/Group.java

与基础设施无关原则 #

既然整个领域模型与基础设施无关,那么位于领域模型之内的聚合根自然也不能与基础设施相关,这样好处是将业务复杂度与技术复杂度解耦开来,让业务模型可以独立于技术设施而完成自身的演变。比如,假设一个项目需要从Spring框架迁移到Guice框架,此时如果能够保证领域模型与基础设施的无关性,那么对领域模型的迁移过程讲变得非常简单,基本上无需修改任何代码直接拷贝到新的项目中即可。

事实上,码如云尚未完全做到这一点,从上面的例子中可以看到,AggregateRootGroup对Spring框架中的@Version@Document@TypeAlias3个与持久化相关的注解存在引用。如需解决这个问题,可以考虑在领域模型之外另建专门用于数据库访问的持久化对象(Persistence Model)。但是,引入持久化对象是有成本的,比如需要维护领域对象与持久化对象之间的相互转化等。在码如云,我们选择了妥协,一方面考虑到持久化对象的成本,另一方面我们也预见在将来要迁移出Spring框架的几率是非常小的。不过,除了前面提到的3个注解之外,码如云中的聚合根可以做到对基础设施没有任何其他引用。关于持久化对象,在Stackoverflow上有过非常有意义的讨论,读者可自行阅览。

跨聚合根用例 #

通常来讲,一个业务用例只会操作一个(或一种)聚合根。但有时,一个业务用例可能会导致多个(或多种)聚合根对象的更新,此时可分两种情况:

  1. 如果聚合根位于不同的进程空间(比如不同的微服务)中,那么解决方式一是可以使用事件驱动架构(EDA),二是通过全局事务(比如JTA)完成。基于全局事务的性能和效率低下等问题,DDD社区一般建议采用事件驱动架构,即在一个进程空间中只对其包含的聚合根进行操作,然后通过向其他进程空间发送事件通知的方式,使得其他进程空间做相应的聚合根更新。
  2. 如果聚合根位于同一个进程空间,此时依然可以选择事件驱动架构,但是另一种更简单实用的方式是直接同时更新多个聚合根,毕竟此时对所有聚合根的更新均处于同一个本地事务中。

码如云是一个单体系统,因此属于以上的第2种情况,我们根据聚合根之间的业务紧密程度的不同,在有些场景下选择了同时更新多个聚合根,在另一些场景下则选择通过事件驱动机制解决。比如,在“创建实例”的用例中,除了创建实例(QR)之外,还需要创建该实例对应的码牌(Plate),由于“有实例就必有码牌”,因此它们之间是紧密联系的,故在码如云中我们选择了在同一个本地事务中同时更新实例和码牌:

//QrCommandService
    
@Transactional
public CreateQrResponse createQr(CreateQrCommand command, User user) {
    String name = command.getName();
    String groupId = command.getGroupId();

    Group group = groupRepository.cachedByIdAndCheckTenantShip(groupId, user);
    String appId = group.getAppId();
    App app = appRepository.cachedById(appId);

    PlatedQr platedQr = qrFactory.createPlatedQr(name, group, app, user);
    QR qr = platedQr.getQr();
    Plate plate = platedQr.getPlate();

    //同时保存QR和Plate
    qrRepository.save(qr);
    plateRepository.save(plate);

    log.info("Created qr[{}] of group[{}] of app[{}].",
            qr.getId(), groupId, appId);

    return CreateQrResponse.builder()
            .qrId(qr.getId())
            .plateId(plate.getId())
            .groupId(groupId)
            .appId(appId)
            .build();
}

源码出处:com/mryqr/core/group/command/GroupCommandService.java

可以看到,在用例方法createQr()中,我们先后调用qrRepository.save(qr)plateRepository.save(plate)分别完成了对QRPlate的持久化。

如果你希望了解事件驱动架构相关的知识,请参考本系列的领域事件一文。

资源库 #

在DDD中,资源库(Repository)以聚合根为单位完成对数据库的访问。这里的重点是“以聚合根为单位”,也即只有聚合根才配得上拥有资源库(毕竟在DDD中大家都是围绕着聚合根转的嘛),其他对象(比如非聚合根实体)是没有对应资源库的,这也是资源库和DAO最大的区别。在编码实现时,资源库方法所接受的参数和返回的数据都应该是聚合根对象,例如,在码如云中,成员(Member)聚合根对应的资源库定义如下:

public interface MemberRepository {
    Member byId(String id); //返回聚合根

    Optional<Member> byIdOptional(String id); //返回聚合根

    Member byIdAndCheckTenantShip(String id, User user); //返回聚合根

    void save(Member member); //聚合根作为参数

    void delete(Member member); //聚合根作为参数
}

源码出处:com/mryqr/core/member/domain/MemberRepository.java

行业中这么一个现象,很多程序员在面对一个新的业务需求时,首先想到的是如何设计数据库的表结构,然后再编写业务代码。在DDD中,这是一种反模式,既然是“领域驱动”,那么我们首先应该关心的是如何业务建模,而不是数据库建模。事实上,正如Robert C. Martin在《整洁架构》一书中所说,数据库只是一个实现细节而已,不应该成为软件建模的主体。

资源库的作用,在于它在业务复杂度和技术复杂度之间做了一层很好的隔离,让我们可以独立地看待软件的业务模型而不受技术设施的影响。从本质上讲,资源库做的事情只是实现数据在内存和磁盘之间相互传输而已。在编程实现业务逻辑的时候,我们只需关心内存中的那个聚合根对象即可,当聚合根对象的状态由于业务操作发生了改变之后,再调用资源库将新的聚合根状态同步到磁盘中完成持久化,在调用时我们假设并相信资源库一定可以完成其自身的使命。

@Transactional
public void addGroupManager(String groupId, String memberId, User user) {
    Group group = groupRepository.byIdAndCheckTenantShip(groupId, user);

    group.addManager(memberId, user);
    
    groupRepository.save(group);
    
    log.info("Added manager[{}] to group[{}].", memberId, groupId);
}

源码出处:com/mryqr/core/group/command/GroupCommandService.java

在上例的“向分组中添加管理员”用例中,首先通过资源库GroupRepositorybyIdAndCheckTenantShip()方法得到聚合根Group对象,然后再完成后续操作。这里的addGroupManager()无需知道Group是如何加载的,甚至不用知道后台使用的是MySQL还是MongoDB或是其他,反正通过调用GroupRepository.byIdAndCheckTenantShip()可以得到一个完整合法的Group对象即可。

在资源库中,最重要的方法有以下3个:

public interface GroupRepository {
    
    Group byId(String id);
    
    void save(Group group);
    
    void delete(Group group);
}

源码出处:com/mryqr/core/member/domain/MemberRepository.java

其中,byId()用于根据ID获取指定聚合根,save()用于保存聚合根,delete()则用于删除聚合根。除此之外,资源库中还可以包含更多的查询方法,比如在GroupRepository中还包含以下方法:

//根据部门ID查找分组
List<Group> byDepartmentId(String departmentId);

//根据ID查找分组,返回Optional
Optional<Group> byIdOptional(String id);

//根据ID查找分组,同时检查租户
Group byIdAndCheckTenantShip(String id, User user);

源码出处:com/mryqr/core/member/domain/MemberRepository.java

需要注意的是,这里的查询方法指的是在实现业务逻辑的过程中需要做的查询操作,并不是为了前端显示那种纯粹的查询,因为纯粹的查询操作不见得一定要放到资源库中,而是可以作为一个单独的关注点通过CQRS解决。

在DDD项目中,通常将资源库分为接口类和实现类,将接口类放置在领域模型domain包中,而将实现类放置在基础设施infrastructure包中,这种做法有2点好处:

  1. 通过依赖反转,使得领域模型不依赖于基础设施
  2. 实现资源库的可插拔性,比如未来需要从MongoDB迁移到MySQL,那么只需创建新的实现类即可

总结 #

在本文中,我们讲到了作为DDD核心的聚合根的设计原则及实现,其中包含内聚原则、对外黑盒原则和不变条件原则等。此外,我们也对与聚合根密切相关的资源库做了讲解。在下一篇实体与值对象中,我们将讲到实体和值对象之间的区别,以及各自的典型编码实践。

产品代码都给你看了,可别再说不会DDD(五):请求处理流程 | 码如云文档中心

mikel阅读(338)

来源: 产品代码都给你看了,可别再说不会DDD(五):请求处理流程 | 码如云文档中心

这是一个讲解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(四):代码工程结构 | 码如云文档中心

mikel阅读(278)

来源: 产品代码都给你看了,可别再说不会DDD(四):代码工程结构 | 码如云文档中心

这是一个讲解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的认识搭建出了一套适合于自身的工程结构,我们认为对于多数项目也是适用的,在本文中,我们将对此做详细讲解。

在上一篇战略设计中我们提到,码如云是一个单体项目,其通过Java分包的方式划分出了3个限界上下文,即3个模块。对于正在搞微服务的读者来说,可不要被“单体”二字吓跑了,本文所讲解的绝大多数内容既适合于单体,也适合于微服务。

以上是码如云工程的目录结构,在根分包src/main/java/com/mryqr下,分出了coreintegrationmanagement3个模块包,分别对应“核心上下文”、“集成上下文”和“后台管理上下文”,对于微服务系统来说,这3个分包则不存在,因为每个分包都有自己单独的微服务项目,也即DDD的限界上下文和微服务存在一一对应的关系。与这3个模块包同级的还有一个common包,该包并不是一个业务模块,而是所有模块所共享的一些基础设施,比如Spring的配置、邮件发送机制等。在src目录下,还包含testapiTestgatling三个目录,分别对应单元测试,API测试和性能测试代码。此外,deploy目录用于存放与部署相关的文件,doc目录用于存放项目文档,gradle目录则用于存放各种Gradle配置文件。

分包原则:先业务,后技术 #

在以上提及的各种模块包中,程序员们最为关注的估计是core包之下应该如何进一步分包了,因为core是整个项目的核心业务模块。

在做分包时,一个最常见的反模式是将技术分包作为上层分包,然后在各技术分包下再划分业务包。DDD社区更加推崇的分包方式是“先业务,后技术”,即上层包先按照业务进行划分,然后在各个业务包内部可以再按照技术分包。

在码如云的core模块包中,首先是基于业务的分包,包含app、 assignment等几十个包,其中的app对应于应用聚合根,而assignment对应于任务聚合根,也即每一个业务分包对应一个聚合根。在每个业务分包下再做技术分包,其中包含以下子分包:

  • command:用于存放应用服务以及命令对象等,更多相关内容请参考应用服务与领域服务
  • domain:用于存放所有领域模型,更多相关内容请参考聚合根与资源库
  • eventhandler:用于存放领域事件处理器,更多相关内容请参考领域事件;
  • infrastructure:用于存放技术基础设施,比如对数据库的访问实现等;
  • query:用于存放查询逻辑,更多相关内容请参考CQRS

在这些分包下,可以根据实际情况进一步分包。

这种“先业务,后技术”的分包方式有以下好处:

  • 业务直观:所有的业务模块被放在一起,并且处于一个分包级别中,让人一眼即可全景式地了解一个软件项目中的所有业务。事实上,Robert C. Martin(Bob大叔)提出了一个概念叫尖叫架构(Screaming Architecture)讲的就是这个意思。尖叫即“哇的一声”的意思,比如当你看到一栋房子时,你会说“哇,好一栋漂亮的房子!”,也即你一眼就能识别出这是一套房子。
  • 便于导航:当你要查找一个功能时,你首先想到的一定是该功能属于哪个业务板块,而不是属于哪个Controller,因此你可以先找到业务分包,然后顺藤摸瓜找到相应的功能代码。
  • 便于迁移:每一个业务包都包含了从业务到技术的所有代码,因此在迁移时只需整体挪动业务包即可,比如,如果码如云以后要迁移到微服务架构,那么只需将需要迁出的业务包整体拷贝到新的工程中即可。

在以上子分包中,domain分包应是最大的一个分包,因为其中包含了所有的领域模型以及业务逻辑。在码如云项目的app业务包下,各个子分包所包含的代码量统计如下:

可以看到,domain包中所包含的代码量远远超过其他所有分包的总和。当然,我们并不是说所有DDD项目都需要满足这一点,而是强调在DDD中领域模型应该是代码的主体。

接下来,让我们来看看各个子分包中都包含哪些内容,首先来看domain分包:

domain分包中,最重要的当属App聚合根了,除此之外还包含领域服务AppDomainService,工厂AppFactory和资源库AppRepository。这里的AppRepository是一个接口,其实现在infrastructure分包中。基于内聚原则,有些密切联系的类被放置在了下一级子分包中,比如attributepage分包等。值得一提的是,用于存放领域事件的event包也被放置在了domain下,因为领域事件也是领域模型的一部分,不过领域事件的处理器类则放在了与domain同级的eventhandler包中,我们将在 领域事件中对此做详细讲解。

command包用于放置应用服务以及请求数据类,这里的“command”即CQRS中的“C”,表示外界向软件系统所发起的一次命令。

command包中,应用服务AppCommandService用于接收外界的业务请求(命令)。AppCommandService接收的输入参数为Command对象(以“Command”为后缀),Command对象通过其名称表达业务意图,比如CopyAppCommand用于拷贝应用(这里的“应用”表示业务上的应用聚合根),CreateAppCommand用于新建应用

eventhandler用于存放领域事件的处理器类,这些类的地位相当于应用服务,它们并不是领域模型的一部分,只是与应用服务相似起编排协调作用。

infrastructure用于存放基础设施类,主要包含资源库的实现类:

query用于存放与数据查询相关的类,这里的”query”也即CQRS中的“Q”,我们将在本系列的CQRS中对此做详细讲解。

自动化测试 #

自动化测试包含单元测试、API测试和性能测试。在API测试中,数据库和消息队列等基础设施均通过本地Docker完成搭建,测试时先启动整个Spring进程,然后模拟前端向各个API发送真实业务请求,最后验证返回结果,如果遇到有需访问第三方系统的情况,则通过Stub类进行代替。码如云采用的是“API测试为主,单元测试为辅”的测试策略,其API测试覆盖率达到了90%,所有的业务用例和重要分支都有API测试覆盖,单元测试主要用于测试领域模型,对于诸如应用服务、Controller以及事件处理器等结构性设施则不作单元测试要求,因为这些类并不包含太多逻辑,对这些类的测试可以消化在API测试中。

总结 #

本文主要讲解了DDD代码工程的典型目录结构,我们推荐通过“先业务,后技术”的方式进行分包,这样使得项目所体现的业务更加的直观。此外,在DDD项目中,领域模型应该是整个项目的主体,所有的领域对象和业务逻辑均应该包含在domain包下。在下文请求处理流程中,我们将对DDD项目中请求处理的全流程进行详细讲解。

产品代码都给你看了,可别再说不会DDD(三):战略设计 | 码如云文档中心

mikel阅读(297)

来源: 产品代码都给你看了,可别再说不会DDD(三):战略设计 | 码如云文档中心

这是一个讲解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的战略设计原本包含哪些内容。战略设计中包含领域、子域、通用语言和限界上下文等概念。领域(Domain)表示一个行业中所发生的一切业务;子域(Subdomain)则表示领域中细分之后的子业务,是比领域更小的概念,子域又可细分为核心子域(Core Domain)、支撑子域(Supporting Domain)和通用子域(Generic Domain);通用语言(Ubiquitous Language)表示在领域中所有人员都使用一套相同的语言进行沟通交流;限界上下文(Bounded Context)则表示由通用语言所形成的上下文边界。读到这里,你是不是感觉好像什么都说了,又感觉什么都没说?

事实上不难看出,无论是从领域到子域,还是从通用语言到限界上下文,其中都体现了一种“分”的思想,这种思想也正是整个计算机科学中的一种基本思想——分治法(devide and conquer)。作为顶层设计的DDD战略设计来讲,这种“分”的思想的落地不正是我们在软件架构图中所看到的那些方块么?不正是软件的模块化划分么?

有了以上认识,再让我们来重新审视一下战略设计中的各种概念。软件中有些模块是业务的核心,对应着DDD中的“核心域”的概念,比如电商系统中的订单模块;有些模块对核心模块起支撑作用,对应DDD中“支撑域”,比如电商系统中的积分模块;而有些模块是通用性质的,对应着DDD中的“通用域”,比如登录管理模块。限界上下文可以看做是子域落地后的概念,因此通常与子域存在一一对应的关系,也即一个限界上下文表示一个模块。限界上下文可以这么理解:在DDD中,允许在不同的模块中存在相同名称的对象,但是它们在各自的上下文中所表示的含义是不同的,这也是“限界”一词的由来。举个例子,在电商系统中,存在交易模块和物流模块,它们都包含“订单(Order)”对象,但是交易模块中的订单和物流模块中的订单所承载的业务含义是不一样的,在交易模块中我们更专注订单的价格、数量和折扣等,而在物流模块中我们则更关注订单的重量、体积和物流状态等。

说DDD的战略设计只是模块化划分并不是要贬低战略设计的意思,事实上恰恰相反,战略设计很重要。DDD的开山鼻祖Eric Evans曾经说,如果让他重新撰写《领域驱动设计》那本书,他会将原书中的很大部分全部撕掉,然后用于撰写与限界上下文相关的内容,从此也可见战略设计的重要性。但是,我们希望做的是让读者认清其中的本质,毕竟DDD本身是一种实践性很强的学问,我们对DDD的认识不应该停留在对概念的咬文爵字上,而是真正能够产出高质量的软件。

事实上,软件的模块化划分是一个非常古老的概念,它伴随着软件的诞生而诞生,其萌芽至少可以追溯到世界上第一台通用电子计算机ENIAC的发明者之一约翰·皮斯普·埃克特(J. Presper Eckert)在一篇研究穿孔纸带的论文中所提到的“Decomposition(分解)”。

后来,软件的模块化经道格拉斯·麦克罗伊(Douglas McIlroy)和布莱德·考克斯(Brad Cox,Objective-C发明人)等人得到了进一步发展。如果我们再将眼光放开阔一些,你会发现模块化的思想存在于各个行业中,比如船舶、桥梁、建筑以及航空等领域。

因此,模块化对于接受了现代工业文明洗礼的我们来说,并不是一个陌生的词汇。然而,难点并不在于如何定义模块,而在于如何划分模块。在DDD中,这是一个见仁见智众说纷纭的话题,为此,让我们从一个小故事展开。

一个2岁的幼儿,从来没有看到人的头像简易画(下图中左边的图片),但是当你问他那是什么的时候,他可能会说“人人”。这是为什么?

幼儿能够辨认出他从来没有看到过的东西,是因为他拥有两种能力:经验抽象。他虽然没有看到过人头像简易画,但是他之前一定看到过真实的人,此所谓经验,也即我们过去所经历的事情;而他能够将人像简易画和真实的人对等起来,则是因为人类与身俱来的抽象能力。此二者,恰恰是我们划分软件模块所需要的东西,并且人人皆有。因此,你并不需要一套专门的学问来指导你完成DDD的战略设计,你需要的依然是那些在日常工作生活中我们始终在使用着的技能。

但是,经验有多有少,抽象有深有浅,导致不同的人所划分出来的模块形态也不一样。为了做好DDD战略设计,你需要有充足的经验以及对业务的深入了解。那么,经验到底到底多少算多呢?5年工作经验够不够?10年又够不够?这种按照年限来区分经验多寡的方式是不合适的,一个10年工作经验的架构师,他可能在这10年内一直在重复性地做着一件事情,而一个3年工作经验的程序员,却可能已经经历过很多项目、技术以及行业。因此,经验是根据你在自己所处的行业中所耕耘的深度和广度来计算的,而非时间。

你可能会认为经验这个东西太不可名状了,无法提炼出一套有据可循理论框架出来,的确没有。然而,这正是软件被称之为艺术的原因,它让每个人都有属于其自己的发挥空间,况且还有大哲学家和大科学家为你背书,你还那么不自信到要去追求一个咨询师没把你教会而你自己也没学会的所谓的理论框架吗?坦诚点,自信点,自豪地告诉别人:“我通过自己对业务的深入了解,外加自己的从业经验和抽象能力,搞定了DDD的战略设计!”

让我们来看个例子吧,搜索功能是多数应用网站都有的功能,在一些应用中,搜索可以简单到只是做个正则匹配的小功能点,此时的搜索固然称不上一个模块,而在另一些应用中,比如大型电商网站,搜索包含了多条件多方式查询等众多内容,其后台的软件架构和技术栈甚至都是专门设计的,此时的搜索功能你哪怕找一个毕业生来设计估计结果都是一个独立模块。这里,从功能点到功能模块的变化过程中,没有什么量化工具和理论框架可言,说得直白点,这就是架构师的一个主观认知而已。但是,这个认知却是重要的,因为它体现了架构师对于一个问题的抽象能力,以及对于软件边界的识别能力。什么是架构呢,一种解释是软件架构是项目中的资深程序员们对某个问题所达成的统一认识而已。

理论框架虽然没有,但是指导性的原则还是有的,以下原则是程序员们耳熟能详的编程原则,将其用在模块化划分上依然成立:

  • 高内聚,低耦合原则
  • 关注点分离原则
  • 单一职责原则

说到DDD,我们可能不得不说一下微服务,因为一般的理解是DDD因为微服务的兴起而重新被业界重视。事实上,DDD和微服务的关系被牵强式地放大了,DDD之于微服务,无外乎“DDD的限界上下文可以用于指导微服务的划分”,然而,在我们把限界上下文理解为模块后,这种说法也就不值得再成为一个单独的命题了。DDD的意义在于“DDD之于软件”,而不是“DDD之于微服务”。

码如云,我们采用了单体架构而非微服务,但这并不影响我们划分限界上下文(模块),我们通过Java分包的方式来划分模块。

码如云的模块关系并不复杂,仅包含3个顶层模块,一个是核心上下文(模块),其中包含各种核心的业务实体,比如应用和实例等,每个业务实体均被建模为一个聚合根;第二个是后台管理上下文(模块),用于码如云的后台运营,包含客户关系、投诉管理和订单管理等;第三个是集成上下文(模块),用于处理与第三方的API集成。在前文中我们提到,登录功能可以被看做是通用子域而建模为一个独立的模块,但是在码如云中我们并未这么划分,而是将登录功能消化在了核心上下文中,因为其粒度尚未大到需要独立为一个模块的程度。

总结 #

很多简单的东西被人为的复杂化了,当资深人士们还在高谈阔论微服务和SOA的区别时,Robert C. Martin(Bob大叔)站出来说,这俩就是一个东西。当下的DDD同样也在遭受着“被复杂化”的境遇,软件(至少企业级应用软件)向来的实践性是非常强的,结果从业者们自己把自己搞不会了,实则不应该呀!在本文中,我们说DDD的战略设计只是在解决软件的模块化划分的问题,并不是要贬低战略设计,而是希望读者看到战略设计的本质,进而从DDD中得到实实在在的好处,也推动这个行业可以健康地,不要那么浮夸式地发展。