产品代码都给你看了,可别再说不会DDD(八):应用服务与领域服务
这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云(https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。
本系列包含以下文章:
案例项目介绍
既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云(不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过。
码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。
在使用码如云时,首先需要创建一个应用(App),一个应用包含了多个页面(Page),也可称为表单,一个页面又可以包含多个控件(Control),比如单选框控件。应用创建好后,可在应用下创建多个实例(QR)用于表示被管理的对象(比如机器设备)。每个实例均对应一个二维码,手机扫码便可对实例进行相应操作,比如查看实例相关信息或者填写页面表单等,对表单的一次填写称为提交(Submission);更多概念请参考码如云术语。
在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等。
码如云的源代码是开源的,可以通过以下方式访问:
应用服务和领域服务
对于服务类(代码中的各种Service类),想必程序员们都不会陌生,比如在做Spring项目时,在Controller层的后面通常会有一个XxxService
存在。如果对代码职责划分得好一点呢,那么该Service还会协调其他各方完成对请求的处理;而如果代码设计得不那么好呢,估计就是一个Service负责从头到尾的所有了。在DDD中,也有类似的服务类,即应用服务(Application Service)和领域服务(Domain Service),不过DDD对于这些服务类的职责做出了明确的界定,在本文中我们将对此做出详细地讲解。
应用服务
在本系列的前几篇文章中我们讲到,在DDD中领域模型(主要包含聚合根,实体,值对象,工厂等)是软件系统的核心,所有的业务逻辑都发生在其中。在理想情况下,DDD只需要领域模型就够了(毕竟领域驱动嘛)。但是,软件运行于计算机这种基础设施之上,显然不止于领域模型这么简单,至少还应该包含以下方面:
- 数据的网络传输
- 应用协议的解析
- 对业务用例的协调
- 事务处理
- 业务数据的持久化
- 日志
- 认证授权等非业务逻辑类关注点
以Spring MVC为例,在编写代码时我们直接面对的是Controller。在Controller背后,Spring框架和Servlet容器已经为我们处理好了数据的网络传输以及HTTP协议解析等底层设施,此时的Controller似乎已经是一个比较高级的编程对象了。咋一看,我们得到了这么一个场景:一边是Controller,一边是领域模型,何不直接使用Controller调用领域模型完成上述的第3点到7点呢?并非完全不可以,但是直接在Controller中调用领域模型的缺点也非常明显:
- Controller属于Spring框架,依然是一个非常技术性的存在,而上述的第3到7点大多与具体的框架无关,因此更应该作为一个单独的关注点来处理,以达到与具体框架解耦的目的
- 对用例的协调是可以复用的,比如以后需要通过桌面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);
}
对应的应用服务(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
从上面两段代码中,我们可以总结出以下几点:
- Controller的作用非常简单,就一行代码,即调用应用服务,这么做的目的是希望程序尽量早地从技术框架解耦;
- 应用服务
MemberCommandService
遵循DDD中的业务请求处理三部曲原则,即先加载Member
,再调用Member
上的业务方法activate()
,最后调用资源库memberRepository.save(member)
保存Member
,整个过程中,应用服务主要起组织协调作用,并不负责实际的业务逻辑; MemberCommandService
方法上标注了@Transactional
,也即应用服务负责处理事务边界;- 在完成协调工作之前,
MemberCommandService
通过调用user.checkIsTenantAdmin()
来检查操作用户是否为租户管理员,也即应用服务也会负责协调对权限的处理; - 打日志,一个应用服务对应一个独立的业务用例,用例处理完后需要日志记录;
- 从整个上看,应用服务与其所在的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种方式:
- 在应用服务中调用:这种调用不再是简单的协调式调用,而是感知到了业务逻辑的调用,这违背了应用服务的基本原则,因此不应该使用这种方式;
- 将
MemberRepository
作为参数传入Member
中,这的确是一种方式,但是这种方式使得聚合根Member
接受了与业务数据无关的方法参数,是一种API污染,因此我们也不推荐; - 作为一个单独的关注点,另立门户:将这部分逻辑放到一个单独的类中,这个类依然属于领域模型,此时的“另立门户”便是一个领域服务了。
在使用了领域服务后,整个请求的流程稍微有些变化。首先在应用服务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());
}
MemberDomainService
先调用memberRepository.existsByMobile()
判断手机号是否被占用,如被占用则抛出异常,反之才调用Member.changeMobile()
完成实际的状态更新。
在码如云,我们发现多数情况下领域服务的存在都是为了解决类似于本例中的“检查某个值是否重复”这样的场景,比如检查成员邮箱是否被占用,检查分组名称是否重复等。事实上,这类问题被业界广泛讨论过,有兴趣的读者可以参考这里和这里。当然,领域服务远不止处理此类场景,比如有时生成ID是通过某些复杂的算法或者调用第三方完成,此时便可以将其封装在领域服务中。此外,DDD中的工厂可以被认为是一种特殊类型的领域服务。
可以看到,DDD中的应用服务和领域服务分别解决了两个完全不同的问题,他们主要的区别在于:
- 应用服务处于领域模型的外侧,是领域模型的客户(调用方),其作用是协调各方完成业务用例;而领域服务则是属于领域模型的一部分;
- 应用服务不处理业务逻辑,领域服务里全是业务逻辑;
- 每一个业务用例都需要经过应用服务,而领域服务则是一种迫不得已而为之的妥协。
到这里,再去看看自己代码中的那些Service类,是不是可以尝试着对它们归个类了?
总结
在本文中,我们分别对应用服务和领域服务做了展开讲解,包含它们各自产生的逻辑以及它们之间的区别。在实际编码中,通常的编码方式是:从Controller中调用应用服务,应用服务协调各方完成对业务用例的处理,业务逻辑优先放入聚合根中,如果不合适才考虑使用领域服务。在下一篇领域事件中,我们将讲到领域事件在DDD中的应用。
产品代码都给你看了,可别再说不会DDD(八):应用服务与领域服务的更多相关文章
- 瞧一瞧,看一看呐,用MVC+EF快速弄出一个CRUD,一行代码都不用写,真的一行代码都不用写!!!!
瞧一瞧,看一看呐用MVC+EF快速弄出一个CRUD,一行代码都不用写,真的一行代码都不用写!!!! 现在要写的呢就是,用MVC和EF弄出一个CRUD四个页面和一个列表页面的一个快速DEMO,当然是在不 ...
- iOS开发UI篇—从代码的逐步优化看MVC
iOS开发UI篇—从代码的逐步优化看MVC 一.要求 要求完成下面一个小的应用程序. 二.一步步对代码进行优化 注意:在开发过程中,优化的过程是一步一步进行的.(如果一个人要吃五个包子才能吃饱,那么他 ...
- 每一行代码都有记录—如何用git一步步探索项目的历史
每一行代码都有一块被隐藏了的文档信息. 下面的代码片段不管是谁写的,其第4行因为某些原因要访问一个DOM结点的clientLeft属性,但却对结果不作任何处理.这十分的莫名其妙,你能告诉我他们为什么要 ...
- Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!
本文原题“从实践角度重新理解BIO和NIO”,原文由Object分享,为了更好的内容表现力,收录时有改动. 1.引言 这段时间自己在看一些Java中BIO和NIO之类的东西,也看了很多博客,发现各种关 ...
- 圣诞节,把网站所有的js代码都压缩成圣诞树吧。
本文分两章节,分别讲解如何使用js2image这个库生成可以运行的圣诞树代码 和 js2image的原理. github地址:https://github.com/xinyu198736/js2ima ...
- delphi 动态绑定代码都某个控件
delphi 动态绑定代码都某个控件 http://docwiki.embarcadero.com/CodeExamples/Berlin/en/Rtti.TRttiType_(Delphi)Butt ...
- 产品经理都知道MVP,但是它可能不再是产品研发最好的模型了
产品经理都知道MVP,但是它可能不再是产品研发最好的模型了 孟小白Aspire • 2017-09-01 • 汽车交通 要简单.讨喜.完整,不要最小可行性产品.这对创业公司的第一个产品来说很重要. M ...
- appium+python解决每次运行代码都提示安装Unlock以及AppiumSetting的问题
appium+python解决每次运行代码都提示安装Unlock以及AppiumSetting的问题(部分安卓机型) 1.修改appium-android-driver\lib下的android-he ...
- 使用Junit测试一个 spring静态工厂实例化bean 的例子,所有代码都没有问题,但是出现java.lang.IllegalArgumentException异常
使用Junit测试一个spring静态工厂实例化bean的例子,所有代码都没有问题,但是出现 java.lang.IllegalArgumentException 异常, 如下图所示: 开始以为是代码 ...
- 轻松解决 CSS 代码都在一行的问题
前言 最近在做博客园的界面美化,用的是博客园[guangzan]的开源项目,配置超级简单,只需要复制粘贴代码就好啦. 但在粘贴 CSS 代码时遇到一个问题,那就是所有代码都挤在了一行,没有一点排板的样 ...
随机推荐
- GC 分代回收算法
GC 分代回收算法 1.首先了解JVM堆内存是如何分配的. 年轻代内部 生成区 和 S0 S1 的比例 默认情况下是 8:1 :1 堆内存和永久代存储的内容有区别: 堆内存主要存储的是 : 对象, ...
- 什么是ORM (object real mapping)
一.ORM简介 对象关系映射(Object Relational Mapping,简称ORM)模式是一种为了解决面向对象与关系数据库存在的互不匹配的现象的技术.简单的说,ORM是通过使用 ...
- 根据模板动态生成word(二)使用poi生成word
@ 目录 一.准备模板 1.创建模板文件 二.代码实践 1.引入依赖 2.自定义XWPFDocument 2.公用的方法和变量 3.工具类引用的包名 4.段落文本替换 5.图片替换 6.表格替换 7. ...
- 《最新出炉》系列初窥篇-Python+Playwright自动化测试-6-元素定位大法-下篇
1.简介 上一篇主要是讲解我们日常工作中在使用Playwright进行元素定位的一些比较常用的定位方法的理论基础知识以及在什么情况下推荐使用.今天这一篇讲解和分享一下,在日常中很少用到或者很少见的定位 ...
- 【Docker】离线安装
离线安装Docker 1.下载docker 离线安装包 下载地址如下:Index of linux/static/stable/x86_64/ 2.将下载的包上传至服务器上 我这里下载的是20.1 ...
- 详解RISC v中断
声明 本文为本人原创,未经许可严禁转载.部分图源自网络,如有侵权,联系删除. RISC-V 中断与异常 trap(陷阱)可以分为异常与中断.在 RISC v 下,中断有三种来源:software in ...
- 利用InnoStep在VS编译时自动构建安装包
摘要 很多同学在C/S开发领域或多或少都可能会遇到需要制作安装包的场景,打包的工具也是五花八门,例如有NSIS.InstallShield.Wix Toolset.ClickOnce等等,这里以Inn ...
- 使用Locust进行分布式性能测试
Locust是一个强大的性能测试工具,用于评估系统的性能和可扩展性.本文将简洁地介绍使用Locust进行分布式性能测试的步骤和优势. 步骤: 1. 配置测试环境:在主节点和多个从节点上安装相同版本的L ...
- os模块常用操作
作者:Simon0903 链接:https://www.jianshu.com/u/2b4bc3b5e6fc 來源:简书 os.getcwd() #返回当前工作目录 os.chdir(path) #改 ...
- DBSCAN聚类
一.概述 DBSCAN(Density-Based Spatial Clustering of Applications with Noise)是一种基于密度的聚类算法,簇集的划定完全由样本的聚集 ...