写点什么

DDD 这样落地

用户头像
码农戏码
关注
发布于: 2021 年 05 月 16 日

DDD 这个主题已经写了好多篇文章了,结合最近的思考实践是时候总结一下,对于战略部分有点宏大,现在都是在微服务划分中起着重要作用,暂且总结战术部分



本想搞场 chat,可失败了,那就失败吧,也许现在 DDD 的热度凉了,眼球都到低代码了,对于低代码,我现在只有使用权,还没有发言权,也许明年能写写

DDD 意义

每种理论的诞生都是站在前人的基础之上,总得要解决一些痛点;DDD 自己标榜的是解决复杂软件系统,对于复杂怎么理解,至少在 DDD 本身理论中并没有给出定义,所以是否需要使用 DDD 并没有规定,事务脚本式编程也有用武之地,DDD 也不是放之四海皆准,也就是常说的没有银弹


但重点是每种方法论都得落地,必须要以降低代码复杂度为目标,因此对于“统一语言”、“界限上下文”对于一线码农有点远,那战术绝对是一把利剑


回顾一下,在没有深入 DDD 之前,基本上就是事务脚本式编程,当然还会重构,怎么重构呢?基本也是大方法变小方法+公共方法


随着业务需求越来越多,代码自然伴随增长,就算重构常相伴,后期再去维护时也是力不从心,要么小方法太多,要么方法太大,老人也只能匍匐前行,新人是看得懂语法却不知道语义,这也是程序员常面对的挑战,不是在编写代码,而是在摸索业务领域知识


那怎么办呢?有没有其它模式,把代码写漂亮,降低代码复杂度,真正的可扩展、可维护、可测试呢?


很多人会说面向对象啊,可谁没在使用面向对象语言呢?可又怎样。事实是不能简单的使用面向对象语言,得要有面向对象思维,还得再加上一些原则,如 SOLID


但虽然有了 OOP,SOLID,设计模式,还是逃不脱事务脚本编程,这里面有客观原因,业务系统太简单了,OO 化不值得,不能有了锤子哪里都是钉子;主观原因,长时间的事务脚本思维实践,留在了舒适区,缺乏跳出的勇气


DDD 战术部分给了基于面向对象更向前一步的范式,这就是它的意义




在实践 DDD 过程中,我也一直在寻找基于完美理论的落地方案,追求心中的那个 DDD,常常在理论与实践的落差间挣扎,在此过程中掌握了一些套路,心中也释然了对理论的追求,最近关注到业务架构,看到一张 PPT,更是减少了心中的偏执,这份偏执也是一种对银弹的追求,虽然嘴大多数时候说没有,但身体很诚信



在这张方法融合论里面,DDD 只是一小块,为什么要心中充满 DDD 呢,不都是进阶路上的垫脚石。想起牛人的话,站到更高的维度让问题不再是问题才是最牛的解决问题之道

事务脚本式


@RestController@RequestMapping("/")public class CheckoutController {
@Resource private ItemService itemService;
@Resource private InventoryService inventoryService;
@Resource private OrderRepository orderRepository;
@PostMapping("checkout") public Result<OrderDO> checkout(Long itemId, Integer quantity) { // 1) Session管理 Long userId = SessionUtils.getLoggedInUserId(); if (userId <= 0) { return Result.fail("Not Logged In"); } // 2)参数校验 if (itemId <= 0 || quantity <= 0 || quantity >= 1000) { return Result.fail("Invalid Args"); }
// 3)外部数据补全 ItemDO item = itemService.getItem(itemId); if (item == null) { return Result.fail("Item Not Found"); }
// 4)调用外部服务 boolean withholdSuccess = inventoryService.withhold(itemId, quantity); if (!withholdSuccess) { return Result.fail("Inventory not enough"); } // 5)领域计算 Long cost = item.getPriceInCents() * quantity;
// 6)领域对象操作 OrderDO order = new OrderDO(); order.setItemId(itemId); order.setBuyerId(userId); order.setSellerId(item.getSellerId()); order.setCount(quantity); order.setTotalCost(cost);
// 7)数据持久化 orderRepository.createOrder(order);
// 8)返回 return Result.success(order); }}
复制代码


这是经典式编程,入参校验、获取数据、逻辑计算、数据存储、返回结果,每一个 use case 基本都是这样处理的,套路就是取数据、计算数据、存数据;当然,有时我们常把中间的一块放到 service 中。随着 use case 越来越多,会把一些重复代码提取出来,比如 util,或者公共的 service method,但这些仍然是一堆代码,可读性、可理解性还是很差,这两个很差,那可维护性就没法保证,更不用提可扩展性,为什么?因为这些代码缺少了灵魂。何为灵魂,业务模型。


对于事务脚本式也有模型,单只有数据模型,而没有对象模型。模型是对业务的表达,没有了业务表达能力的代码,人怎么能读懂


而 DDD 在领域模型方式就有很强的表达能力,当然在编码时也不会以数据流向为指导。先写 Domain 层的业务逻辑,然后再写 Application 层的组件编排,最后才写每个外部依赖的具体实现,这就是 Domain-Driven Design,其实这类似于 TDD,谁驱动谁就得先行

反 DDD

任何事物都是过犹不及,如文章开头所述,没有银弹,千万别因为 DDD 的火热而一股脑全身心投入 DDD,不管场景是否适合,都要 DDD;犹如设计模式,后面出现了大量的反模式。


错误的抽象比没有抽象伤害力更大

DDD 分层

Interface 层

对于这一层的作用就是接受外部请求,主要是 HTTP 和 RPC,那也就依赖于具体的使用技术,是 spring mvc、还是 dubble


在 DDD 正统分层里面是有这一层的,但实践时,像我们的 controller 却有好几种归类


一、User Interface 归属于大前端,不在后端服务,后端服务从 application 层开始


二、正统理论,就是放在 interface 层


三、controller 毕竟是基于具体框架实现,在六边形架构中就是是个 adapter,归于 Infrastructure 层


对于以上三种归类,都有实践,都可以,但不管怎么归属,他的属性依然是 Interface


对于 Interface 落地时指导方针:


  1. 统一返回值,interface 是对外,这样可以统一风格,降低外部认知成本

  2. 全局异常拦截,通过 aop 拦截,对外形成良好提示,也防止内部异常外溢,减少异常栈序列化开销

  3. 日志,打印调用日志,用于统计或问题定位

  4. 遵循 ISP,SRP 原则,独立业务独立接口,职责清晰,轻便应对需求变更,也方便服务治理,不用担心接口的逻辑重复,知识沉淀放在 application 层,interface 只是协议,要薄,厚度体现在 application 层


@Datapublic class Result<T> {     /** 错误码 */    private Integer code;     /** 提示信息 */    private String msg;     /** 具体的内容 */    private T data;}
复制代码

Application 层

应用层主要作用就是编排业务,只负责业务流程串联,不负责业务逻辑


application 层其实是有固定套路的,在之前的文章有过阐述,大致流程:


application service method(Command command) {    //参数检验    check(command);        Aggregate aggregate = repository.findAggregate(command);        //复杂的需要domain service    aggregate.operate(command);        repository.saveOrUpdate(aggregate);        publish(event);        return DTOAssembler.to(aggregate);    }
复制代码

业务流程 VS 业务规则

对于这两者怎么区分,也就是 application service 与 domain service 的区分,最简单的方式:业务规则是有 if/else 的,业务流程没有


现在都是防御性编程,在 check(command)部分,会做很多的 precondition


比如转帐业务中,对于余额的前提判断:


public void preDebit(Account account, double amount) {    double newBalance = account.balance() - amount;    if (newBalance < 0) {      throw new DebitException("Insufficient funds");    }}
复制代码


这算是业务规则还是业务流程呢?这一段代码可以算是 precondition,但也是业务规则的一部分,颇有争议,但没有正确答案,只是看你代码是否有复用性,目前我个人倾向于放在业务规则中,也就是 domain 层

厚与薄

常人讲,application service 是很薄的一层,要把 domain 做厚,但从最开始的示例,发现其实 application service 特别多,而 domain 只有一行代码,这不是 application 厚了,domain 薄了


对于薄与厚不再于代码的多与少,application 层不是厚,而是编排多而已,逻辑很简单,一般厚的 domain 大多都是有比较复杂的业务逻辑,比如大量的分支条件。一个例子就是游戏里的伤害计算逻辑。另一种厚一点的就是 Entity 有比较复杂的状态机,比如订单

出入参数

先讲一个代码示例:


从 controller 接受到请求,传入 application service 中,需要做一层转换,controller 层


示例一段创建目录功能的对象转换:


@Datapublic class DirectoryDto extends BaseRequest {
private long id; @NotBlank @ApiModelProperty("目录编号") private String directoryNo; @NotBlank @ApiModelProperty("目录名称") private String directoryName;
private String directoryOrder; private String use; private Long parentId;
}
com.jjk.application.dto.directory.DirectoryDto to(com.jjk.controller.dto.DirectoryDto directoryDto);
复制代码


创建目录,入参只需要 directoryNo,directoryName,为了少写代码,把编辑目录(directoryDto 中带了 id 属性),response(directoryDto 包含了目录所有信息)都揉合在一个 dto 中了


这样就会有几个问题:


  1. 违背 SRP,创建与编辑两个业务功能却混杂在了一个 dto 中

  2. 相对 SRP,更大的问题是业务语义不明确,DDD 中一个优势就是要业务语义显示化


怎么解决呢?


引入 CQRS 元素:


  • Command 指令:指调用方明确想让系统操作的指令,其预期是对一个系统有影响,也就是写操作。通常来讲指令需要有一个明确的返回值(如同步的操作结果,或异步的指令已经被接受)

  • Query 查询:指调用方明确想查询的东西,包括查询参数、过滤、分页等条件,其预期是对一个系统的数据完全不影响的,也就是只读操作


这样把创建与编辑拆分,CreateDirectoryCommand、EditDirectoryCommand,这样有了明确的”意图“,业务语义也相当明显;其次就是这些入参的正确性,之前事务脚本代码中大量的非业务代码混杂在业务代码中,违背 SRP;可以利用 java 标准 JSR303 或 JSR380 的 Bean Validation 来前置这个校验逻辑,或者使用 Domain Primitive,既能保证意图的正确性,又能让 application service 代码清爽


而出参,则使用 DTO,如果有异常情况则直接抛出异常,如果不需要特殊处理,由 interface 层兜底处理


对于异常设计,可根据具体情况处理,整体由业务异常 BusinessException 派生,想细化可以派生出 DirectoryNameExistException,让 interface 来定制 exception message,若无需定制使用默认 message

Domain 层

domain 层是业务规则的集合,application service 编排业务,domain service 编排领域;


domain 体现在业务语义显现化,不仅仅是一堆代码,代码即文档、代码即业务;要达到高内聚就得充分发挥 domain 层的优势,domain 层不单单是 domain service,还有 entity、vo、aggregate


domain 层是最最需要拥抱变化的一层,为什么?domain 代表了业务规则,业务规则来自于需求,日常开发中,需求是经常变化的


我们需要逆向思维,以往我们去封装第三方服务,解耦外部依赖,大多数时候是考虑外部的变化不要影响自身,而现实中,更多的变化来自内部:需求变了,所以我们应该更多关注一个业务架构的目标:独立性,不因外部变化而变化,更要不因自身变化影响外部服务的适应性


在《DDD 之 Repository》中指出 Domain Service 是业务规则的集合,不是业务流程,所以 Domain Service 不应该有需要调用到 Repo 的地方。如果需要从另一个地方拿数据,最好作为入参,而不是在内部调用。DomainService 需要是无状态的,加了 Repo 就有状态了。domainService 是规则引擎,appService 才是流程引擎。Repo 跟规则无关


也就是 domain 层应该是一个纯内存操作,不依赖外部任何服务,这样提高了 domain 层的可测试性,拥抱变化的底气也来自于完整的 UT,而 application 层 UT 全部得 mock

Infrastructure 层

Infrastructure 层是基础实施层,为其他层提供通用的技术能力:业务平台,编程框架,持久化机制,消息机制,第三方库的封装,通用算法,等等


Martin Fowler 将“封装访问外部系统或资源行为的对象”定义为网关(Gateway),在限界上下文的内部架构中,它代表了领域层与外部环境之间交互的出入口,即:


gateway = port + adapter


这一点契合了六边形架构


在实际落地时,碰到的问题就是 DIP 问题,Repository 在 DDD 中是在 Domain 层,但具体实现,如 DB 具体实现是在 Infrastructure 层,这也是符合整洁架构,但 DDD 限界上下文可能不仅限于访问数据库,还可能访问同样属于外部设备的文件、网络与消息队列。为了隔离领域模型与外部设备,同样需要为它们定义抽象的出口端口,这些出口端口该放在哪里呢?如果依然放在领域层,就很难自圆其说。例如,出口端口 EventPublisher 支持将事件消息发布到消息队列,要将这样的接口放在领域层,就显得不伦不类了。倘若不放在位于内部核心的领域层,就只能放在领域层外部,这又违背了整洁架构思想


这个问题张逸老师提出了菱形架构,后面的章节中再论述


再次比较 interface 与 infrastructure,在前面讲述到 controller 的归属,其实就隐含了 interface 与 infra 的关联,这两者都与具体框架或外部实现相关,在六边形架构中,都归属为 port 与 adapter


我一般的理解:从外部收到的,属于 interface 层,比如 RPC 接口、HTTP 接口、消息里面的消费者、定时任务等,这些需要转化为 Command、Query,然后给到 App 层。


App 主动能去调用到的,比如 DB、Message 的 Publisher、缓存、文件、搜索这些,属于 infra 层


所以消息相关代码可能会同时存在 2 层里。这个主要还是看信息的流转方式,都是从 interface -> Application -> infra

整洁架构


一个好的架构应该需要实现以下几个目标:


  1. 独立于框架:架构不应该依赖某个外部的库或框架,不应该被框架的结构所束缚

  2. 独立于 UI:前台展示的样式可能会随时发生变化

  3. 独立于底层数据源:无论使用什么数据库,软件架构不应该因不同的底层数据储存方式而产生巨大改变

  4. 独立于外部依赖:无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化

  5. 可测试:无论外部依赖什么样的数据库、硬件、UI 或服务,业务的逻辑应该都能够快速被验证正确性


这几项目标,也对应我们对 domain 的要求:独立性和可测试;我们的依赖方向必须是由外向内

DIP 与 Maven

要想实现整洁架构目标,那必须遵循面向接口编程,达到 DIP


<modules>    <module>assist-controller</module> <!-- controller -->    <module>assist-application</module> <!-- application -->    <module>assist-domain</module> <!-- domain -->    <module>assist-infrastructure</module> <!-- infrastructure -->    <module>assist-common</module> <!-- 基础common -->    <module>starter</module> <!-- 启动入口及test --></modules>
复制代码


在使用 maven 构建项目时,整个依赖关系是:starter -> assist-controller -> assist-application -> assist-domain -> assit-infrastructure


domain 层并不是中心层,为什么呢?为什么 domain 不在最中心?


主要是存在一个循环依赖问题:repository 接口在 domain 层,但现实在 infra 层,可从 maven module 依赖讲,domain 又是依赖 infra 模块,domain 依赖 infra 的原由是因为前文所述


DDD 限界上下文可能不仅限于访问数据库,还可能访问同样属于外部设备的文件、网络与消息队列。为了隔离领域模型与外部设备,同样需要为它们定义抽象的出口端口,这些出口端口该放在哪里呢


按此划分 module,这些出口端口都放在了 infra 层,当 domain 需要外部服务时,不得不依赖 infra module


对此问题的困惑持续很久,一直认为菱形架构是个好的解决方案,但今年跟阿里大佬的交流中,又得到些新的启发


EventPublisher 接口就是放在 Domain 层,只不过 namespace 不是 xxx.domain,而是 xxx.messaging 之类的


像 repsoitory 是在 Domain 层,但是从理论上是 infra 层,混淆了两个概念一个是 maven module 怎么搞,一个是什么是 Domain 层


以 namespace 区分后,得到的依赖关系就是 DIP 后的 DDD


菱形架构

上文中多次提到菱形架构,这是张逸老师发明的,去年项目中,我一直使用此架构


一是解决了上文中的 DIP 问题,二是整个架构结构清晰职责明确


简单概述一下:




把六边形架构与分层架构整合时,发现六边形架构与领域驱动设计的分层架构存在设计概念上的冲突


出口端口用于抽象领域模型对外部环境的访问,位于领域六边形的边线之上。根据分层架构的定义,领域六边形的内部属于领域层,介于领域六边形与应用六边形的中间区域属于基础设施层,那么,位于六边形边线之上的出口端口就应该既不属于领域层,又不属于基础设施层。它的职责与属于应用层的入口端口也不同,因为应用层的应用服务是对外部请求的封装,相当于是一个业务用例的外观。


根据六边形架构的协作原则,领域模型若要访问外部设备,需要调用出口端口。依据整洁架构遵循的“稳定依赖原则”,领域层不能依赖于外层。因此,出口端口只能放在领域层。事实上,领域驱动设计也是如此要求的,它在领域模型中定义了资源库(Repository),用于管理聚合的生命周期,同时,它也将作为抽象的访问外部数据库的出口端口。


将资源库放在领域层确有论据佐证,毕竟,在抹掉数据库技术的实现细节后,资源库的接口方法就是对聚合领域模型对象的管理,包括查询、修改、增加与删除行为,这些行为也可视为领域逻辑的一部分。


然而,限界上下文可能不仅限于访问数据库,还可能访问同样属于外部设备的文件、网络与消息队列。为了隔离领域模型与外部设备,同样需要为它们定义抽象的出口端口,这些出口端口该放在哪里呢?如果依然放在领域层,就很难自圆其说。例如,出口端口 EventPublisher 支持将事件消息发布到消息队列,要将这样的接口放在领域层,就显得不伦不类了。倘若不放在位于内部核心的领域层,就只能放在领域层外部,这又违背了整洁架构思想。


如果我们将六边形架构看作是一个对称的架构,以领域为轴心,入口适配器和入口端口就应该与出口适配器和出口端口是对称的;同时,适配器又需和端口相对应,如此方可保证架构的松耦合。




<modules> <module>assist-ohs</module> <!-- ohs --> <module>assist-service</module> <!-- domain --> <module>assist-acl</module> <!-- acl --> <module>starter</module> <!-- 启动入口及test --></modules>
复制代码


这有点类似《DDD 之形》中提到的端口模式,把资源库 Repository 从 domain 层转移到端口层和其它端口元素统一管理,原来的四层架构变成了三层架构,对 repository 的位置从物理与逻辑上一致,相当于扩大了 ACL 范围


这个架构结构清晰,算是六边形架构与分层架构的融合体,至于怎么选择看个人喜爱

Event

相对 Event Source,这儿更关注一下 event 的发起,是不是需要区分应用事件和领域事件


根据 application 的套路,会 publish event,那在 domain service 中要不要 publish event 呢?


Domain Event 更多是领域内的事件,所以应该域内处理,甚至不需要是异步的。Application 层去调用消息中间件发消息,或调用三方服务,这个是跨域的。


从目前的实践来看,直接抛 Domain Event 做跨域处理这件事,不是很成熟,特别是容易把 Domain 层的边界捅破,带来完全不可控的副作用


所以结合 application,除了 Command、Query 入参,还需要 Event 入参,处理事件

总结

本文主要是按 DDD 分层,介绍各层落地时的具体措施,以及各层相应的规范,引入 CQRS 使代码语义显现化,通过 DIP 达到整洁架构的目标


对于 domain 层,有个重要的 aggregate,涉及模型的构建,千人千模,但 domain 层的落地是一样的


在业务代码中有几个比较核心的东西:抽象领域对象合并简单单实体逻辑,将多实体复杂业务规则放到 DomainService 里、封装 CRUD 为 Repository,通过 App 串联业务流程,通过 interface 提供对外接口,或者接收外部消息


其实不论使用 DDD,还是事务脚本,合适的才是最好的,任何方法论都得以降低代码复杂度为目的

发布于: 2021 年 05 月 16 日阅读数: 234
用户头像

码农戏码

关注

公众号【码农戏码】作者 2019.07.17 加入

首席架构师

评论

发布
暂无评论
DDD这样落地