产品代码都给你看了,可别再说不会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中的应用。

posted @ 2023-10-22 12:50  无知者云  阅读(394)  评论(0编辑  收藏  举报