Skip to content

深入浅出 DDD

1.概览

整体的思路是这样的:

  • 从使用 DDD 的原因出发,逐步讲解 DDD 的战略思想与战术实践;
  • 理论之后,讲解如何搭建 DDD 的项目,以及如何落地实践 DDD;
  • 最后,讲如何将系统从 MVC 架构迁移至 DDD 架构。

1

2.DDD 的定义和作用

MVC架构

MVC,全称 Model View Control(模型-视图-控制器),其分层定义如下。

M(模型层/ DAO 层) :业务数据载体层。

V(视图层/ Controller 层) :展现给用户的数据表示层。

C(控制层/ Service 层) :接受 V(视图层)传递过来的请求进行业务逻辑处理,并将处理后的 M 层(模型层)数据返回给 V(视图层)。

MVC的优势和不足

优势

  • 分层简单易懂;
  • 层级逻辑替换方便;
  • 可以降低层与层之间的依赖;
  • 有利于标准化;
  • 利于层与层逻辑的复用。

不足

随着系统功能迭代,业务功能越来越丰富之后,控制层里面对于业务逻辑处理的代码也越来越多,维护成本也越来越高:

  • 工程体积庞大;
  • 需求迭代会出现明明是类似的功能,却无法复用代码逻辑的情况;
  • 重复的代码很多;
  • 大类(几千上万行)随处可见;
  • 出了 Bug 不敢直接修复,只能继续往上贴逻辑;
  • 代码可读性很差,不加注释你都难以理解业务逻辑;
  • 功能之间耦合严重,改了一个 Bug,莫名其妙又出现了好几个其他的 Bug;
  • 外部系统 RPC 接口修改,大范围影响到本系统的逻辑;

原因是控制层就像个万能容器,什么代码都往里面写。业务逻辑不是跟着业务模型走的,而是在现有数据模型的情况下,或者先设计数据模型的情况下去迭代了业务需求。业务模块之间的边界被淡化,控制层内逻辑只要能实现需求,想怎么写就怎么写,没有一个规范与规约。

DDD 定义

较官方的介绍

  • Domain Driven Design(领域驱动设计);
  • 六边形架构模型;
  • 任何项目相关的人员都能理解的通用语言;
  • ...

稍具体的理解

化抽象为具体,结合示意图和业务示例来看看 DDD 到底是什么。

在使用 MVC 架构进行开发时,用户需求从被提出到落地,会经历下图的生命周期:

1

用户需求会被生命周期每层参与人理解转化,特别是研发需求在数据库结构这一层转化后,将业务以主观臆断行为进行了转化。一旦业务边界划分模糊,考虑不全,大量的逻辑补充堆积到了代码层实现,变得越来越难维护,到处是 if/else,“祖传代码”就此诞生了。

为了解决上述问题,DDD 所要做的就是(也可以结合下图来理解):

通过事件风暴消除信息不对称,让业务相关人员都参与设计,确定每个业务领域的职责边界; 将常规 MVC 三层架构中自底 (数据模型) 向上的设计方式做一个反转,以业务为主导,自顶 (业务模型) 向下地进行业务领域划分; 将大的业务需求进行拆分,建立业务领域模型,分而治之。

DDD 与 MVC 的区别

1

这里以电商订单场景为例分析。假如我们现在要做一个电商下单的需求,这会涉及到用户选定商品、下订单、支付订单、订单发货等步骤。

MVC 架构

常见的做法是在分析好业务需求之后,就开始设计表结构了,订单表、支付表、商品表等。然后编写业务逻辑,但这仅仅是第一个版本的需求。不久后功能迭代了,订单支付后可以取消,下单的商品可以退换货,那是不是又需要进行加表?紧跟着对应的实现逻辑也需要修改?功能不断迭代,代码就不断地层层往上叠。

DDD 架构

首先进行业务边界划分,这里面核心是订单,那么订单就是这个业务领域里面的聚合逻辑体现。支付、商品信息、地址等都是围绕着订单展开。订单本身的属性确定之后,地址等信息只是一个属性的体现。当你将订单的领域模型构建好之后,后续的逻辑边界与表结构设计也就随之而来了,功能点无非就是对订单聚合内的业务逻辑编排组合罢了,让业务逻辑的实现最原子化。

领域驱动的失血模型、缺血模型、充血模型、胀血模型对比分析

失血模型仅仅包含数据的定义和getter/setter方法,业务逻辑和应用逻辑都放到服务层中,这种类在java中叫POJO,在.Net 中叫POCO

贫血模型中包含了一些业务逻辑,但不包含依赖持久层的业务逻辑。这部分依赖于持久层的业务逻辑将会放到服务层中。可以看出,贫血模型中的领域对象是不依赖于持久层的。

充血模型中包含了所有的业务逻辑,包含依赖于持久层的业务逻辑。所以,使用充血模型的领域层是依赖于持久层,简单表示就是UI层—>服务层->领域->持久层

胀血模型就是把和业务逻辑不想关的其他应用逻辑(如授权,事务等)都放到领域模型中,我感觉胀血模型反而是另一种的失血模型,因为服务层消失了,领域层干了服务层的事,到头了还是什么都没变。

1.失血模型(entity就是个POJO) 2.贫血模型(entity与dao层无关) 3.充血模型(entity直接与dao层交互) 3.胀血模型(取消service,只有entity和dao)

通常的3层架构,表示层,业务层,数据层。表示层和数据层都好理解,就是把数据加工层模型表示,或者通过表示层的数据加工成模型,并保存。这里的关键是对模型的加工有两种加工,简单加工,复杂加工。简单加工就是set/get复杂加工就是指更复杂的加工逻辑,关于以上的4中*血模型,处理差别的关键就在于对复杂处理逻辑的处理放置的位置。

1

1.失血模型

失血模型中,domain object只有属性的get set方法的纯数据类,所有的业务逻辑完全由Service层来完成的,由于没有dao,Service直接操作数据库,进行数据持久化。

service:  肿胀的服务逻辑

model:只包含get set方法

显然失血模型service层负担太重,一般不会有这种设计。

2.贫血模型

贫血模型中,domain ojbect包含了不依赖于持久化的原子领域逻辑,而组合逻辑在Service层。

service :组合服务,也叫事务服务

model:除包含get set方法,还包含原子服务(如获得关联model的id)

dao:数据持久化

贫血模型比较常见,其问题在于原子服务往往不能直接拿到关联model,因此可以把这个原子服务变成直接用关联modelRepo拿到关联model,这就是充血模型。

3.充血模型

充血模型中,绝大多业务逻辑都应该被放在domain object里面,包括持久化逻辑,而Service层是很薄的一层,仅仅封装事务和少量逻辑,不和DAO层打交道。

service :组合服务 也叫事务服务

model:除包含get set方法,还包含原子服务和数据持久化的逻辑

充血模型的问题也很明显,当model中包含了数据持久化的逻辑,实例化的时候可能会有很大麻烦,拿到了太多不一定需要的关联model。

4.胀血模型

胀血模型取消了Service层,只剩下domain object和DAO两层,在domain object的domain logic上面封装事务。

一般来说失血模型和胀血模型不常见,多采用贫血模型。

贫血模型和充血模型的差别在于,领域模型是否要依赖持久层,贫血模型是不依赖的,而充血模型是依赖的。

DDD 原理

DDD 如何解决 MVC 痛点

业务的交互方式要分为两种:系统内部交互,系统与外部交互。

MVC 分层下,不论是系统内交互还是系统与外部交互,逻辑都是按照功能点被杂糅在一起。Service 层臃肿且条理不清晰。

DDD 核心思想是什么呢?解耦与内聚!建立领域模型形成聚合根,将原先散落在 Service 层的业务逻辑收拢到领域模型内部,变成充血模型,聚合即为业务。

下面来看看 DDD 是如何处理这两种交互方式的。

系统内部交互

DDD 的价值观里面,任何业务都是某个业务领域模型的职责体现。为了完成某一个需求功能,将核心的业务逻辑定义在领域内部,应用服务层编排调用领域中的业务方法来实现功能点的需求。也就是说,业务功能是领域所供的能力的组合。

这样,每个领域只会做自己业务边界内的事情,最小细粒度地去定义需求的实现。原先模型层空空的贫血模型摇身一变,变成了充血模型。进到应用服务层,你的代码就是你的业务逻辑。逻辑清晰,可维护性高!

系统与外部交互

假如微服务体系下,有一个下订单的需求。在通过订单服务下订单前,需要先请求用户服务获取下单用户的个人信息,如下图,用户服务在版本 A 时获取用户详情的接口是 interfaceA,版本 B 时换成 interfaceB。那么就会出现,需要修改订单服务中获取用户信息的逻辑。如果类似的逻辑散落在系统的很多地方,就会出现外部系统的业务逻辑变更,造成了本系统的大量依赖变更。

1

从上面的例子可知,系统内部完成业务逻辑可能会与外部系统进行交互,而此时外部系统一旦发生逻辑变更,将会影响到任意一个系统内依赖外部系统的逻辑。

为了解决这个痛点问题,DDD 通过定义适配器包装对外部系统的依赖。系统内部直接依赖适配器,由适配器去调用外部接口,减小外部系统的变动对本系统业务逻辑的影响。

1

DDD 的优势

从业务出发,自顶向下设计系统,优先考虑领域模型,而不是切割数据和行为,告别贫血模型;

领域设计简化复杂业务,内聚逻辑实现,准确传达业务规则,分而治之;

应用服务层的编排即展示了业务逻辑,增强了代码的可读性与可维护性;

消除业务参与人员的信息不对称,提升协助效率;

将外部系统等不可控因素转化为可控因素,减小系统间依赖;

适合于业务复杂的中台化的系统设计。

DDD 的适用范围

适配 DDD 的系统是中大规模系统,业务可持续迭代,可预见的业务逻辑复杂性的系统。

因为DDD 的结构不像 MVC 结构那么简单,分层更加复杂。中小规模的系统,本身业务体量小,功能单一,选择 MVC 架构无疑是最好的。 消除信息不对称的成本比较大,需要多方人员协作讨论业务模型。迭代快的小系统不如直接使用 MVC 做好代码规范能够更快地上线。 项目化交付的系统,研发周期短,一天到晚按照甲方的需求定制功能(这种本身业务需求边界就不清晰,功能的可持续迭代性就很差,而且这种系统一般就是一口价买卖),这种也最好选择 MVC。

总而言之,还不了解 DDD 或者 系统功能简单 或者 业务探索阶段,就选择 MVC;其他时候就酌情考虑 DDD。

3.战略设计:业务内聚与解耦

领域与子域

领域是 DDD 架构落地设计的核心。

在 DDD 中,领域本身并不是一个学术性很强的概念,任何边界明确的业务都能被称为领域。

比如,一个电商平台中,订单、物流、支付等都是这个平台的领域。

针对一个领域做二次划分它就是子域了。领域和子域都是相对的概念。

如果把电商平台看成一个大的电商领域,那么订单、物流这些就是它的子域。但如果把订单看成一个领域,那么商品、订单明细等就是它的子域。

把电商系统看成一个大领域,根据功能职责划分为订单子域、物流子域等。

分布式系统中,往往我们把这种细粒度划分出来的子域看成微服务。把微服务看成一个大的领域范畴,微服务内部的小模块就是我们的子子域。按照这种方式我们可以建立起一个领域树。

对于同一父级领域而言,根据子域在父级领域下的业务价值又可以将子域划分为核心域支撑域通用域

  • 核心域

核心域是业务系统的核心,它是业务系统核心价值的体现。核心域的划分标准是根据系统的定位而决定的。

比如,把桃子树看成一个系统,如果它存在果园中,那么桃子是它的核心域;如果它存在于花园中,那么桃花是它的核心域。

  • 支撑域

这种子域它本身没有核心域对于业务价值那么突出,但是业务系统根据核心域开展业务时又需要依赖它。

比如,安全气囊对于车辆而言,它不会成为车辆这个系统的核心卖点,但是它如果没有,一定会影响到车辆的价值。并且不同的车型,安全气囊的规格(比如大小)也是不一样的,这就是支撑域的业务定制性,强业务相关,但又非核心。

  • 通用域

通用域的核心诉求是稳定与高兼容性,它能够被移动至其他的领域下。

比如,在订单领域中,用户与权限就是它的通用域。同样的,这个子域能够在几乎不修改核心逻辑的情况下被应用至物流领域中。

限界上下文与通用语言

从具体的语义环境出发去思考了核心域的划分是导致差异的主要原因。而这种具体语义环境就是上下文。

限界上下文意味着特定的、具有明确边界的语义环境,定义了领域的业务边界。

桃子是核心域时,它的上下文是果园;桃花是核心域时,它的上下文是花园。

花园跟果园在各自的上下文中开展业务,不会互相入侵上下文。花园的农夫不会去果园养花,果园的农夫不会去花园养果子,这就是不同上下文之间的边界。

在同一个限界上下文中,我们对于领域内所有内容的认知应该都是一致的,需要有一套通用语言来消除项目相关的人员对领域内的业务逻辑、流程处理规则、专业术语的信息差。

通用语言表示着对领域内的一切动词、名词、形容词达到了一致的认知。

比如,我们在果园的限界上下文里认为桃树是用来生产桃子的,而不是用来开桃花的。

上下文映射图

在电商领域中下有订单领域、物流领域等子域。商品这个属性在订单上下文与物流上下文中都是存在的,只是在不同的上下文中地位不同而已。

商品的信息会随着业务的进行从订单上下文流转到物流上下文。这种上下文之间的协作模式可以用上下文映射图表示。

上下文映射图的种类

1. 合作关系

A、B 两个限界上下文是为了完成某一功能建立起合作关系。同时成功,同时失败,合作的频率与它们的耦合程度是成正比的。如果它们之间的耦合程度愈演愈烈,则需要考虑是否两个限界上下文应该合并,它们本身就是一个上下文。

1

2. 共享内核

如果两个上下文在各自开展业务的过程中都需要使用到一个公有的能力点,则将这个公有的逻辑子集给抽离出来共享,类似于基础工具能力。这个子集的变化将影响所有被关联的限界上下文内部逻辑。

这里需要注意共享内核与通用域的区别。共享内核的定位是工具基础能力,是为了提供领域完成业务所需要的能力。通用域本质上还是一个子域,它可以去使用共享内核,而共享内核不能关联通用域。

1

3. 客户方-供应方开发

这种在上下游依赖关系的系统中比较常见,它们由两个不同的团队维护。上游需求开发完,下游使用上游提供的能力再进行开发。

1

4. 追随者

类似于客户方-供应方开发模式,但是上游不提供能力,只提供模型。

5. 防腐层

上下文 A 与上下文 B 之间不直接进行交互,而是通过定义一个防腐接口进行交互。防腐层是把防腐能力定义在了调用方。调用方上下文 A 依赖防腐层的接口的标准,而被调用方上下文 B 给接口所提供的逻辑上下文 A 无法直接感知。

DDD 中防腐层是系统内上下文与系统外上下文交互的最主要手段。

1

6. 开放主机服务

与防腐层有点类似,防腐层是把防腐能力定义在了调用方,而开放主机是在被调用方定义了调用规则或者接口协议,由调用方来调用标准接口。

1

7. 发布语言

类似于开放主机服务与防腐层的逻辑,只不过把规范定义在两个上下文之间,它们之间的协作通过统一的标准进行交互。比如,上下文 A 与上下文 B 之间通过 MQ 中间件进行交互。

1

8. 另谋他路

这种关系模式在大型应用系统特别常见。两个上下文之间毫无关联,独立开展业务。

1

9. 大泥球

这种模式在历史项目中比较常见,业务迭代事件长,内部逻辑复杂,业务边界梳理困难。为了不让这种情况往外扩散,把这个系统当成一个黑盒子,只用它提供的接口能力,不严格定义它内部逻辑边界

1

架构风格

DDD 的六边形架构 六边形架构

在这个架构下,领域模型是逻辑处理的出入口,应用服务层是业务功能点的出入口,是整个系统对外的门面。

一切外部输入均需要通过应用服务层来处理,再通过应用服务层返回。

这种方式下,我们平等地看待例如 Web、RPC、MQ 等外部服务,认为它们都属于用户接口层。

所有的外部服务通过应用服务提供的接口来访问领域模型。

4.战术设计:战略思想的落地和实践

聚合、聚合根、实体与值对象

领域/子域是 DDD 战略设计中最核心的业务体现。

对应到代码层面,领域/子域的概念的呈现方式是聚合。

为了描述聚合内部的属性,DDD 定义了实体与值对象的概念。

最后,领域的逻辑呈现要在一个限界上下文中才有意义,必然要有一个概念来包括领域的逻辑与定义业务的边界,这个就是聚合根。

实体

实体是描述某一可连续变化的物体。它是具有生命周期的,并且可以通过唯一标识来确定是否为同一个实体。

实体 = 唯一标识 + 生命周期(可以理解为属性可变)

比如,现在有两个长相一模一样的双胞胎分别叫张三与张四。他们是独立的个体,大家不会因为他们长得一样而认为他们是同一个人。他们刚出生的时候什么都不会,随着年纪的增长,张三成为了科学家,张四成为了企业家。但是他们并不会因为各自的身份属性变更,而导致他们不是张三与张四了,因为本质上他们这个人的唯一标识在成长过程中一直未改变。

值对象

它与实体定位正好相反,如果一个物体一旦被生成之后就具备不可变性,并且只要它们的属性值一致就可以认为它们是同一个物体

值对象 = 不变性 + 通过属性判断相等(没有唯一标识)

比如,双十一我们在淘宝购买商品的订单,订单中会包含地址,地址就是典型的值对象。只要省、市、区与详细地址一致,就判断它是同一个地址,并且这个地址一旦确认下来之后就不会产生属性的变更。修改地址实际我们是给了一个新的地址直接去替换的原来的地址。如果需要对值对象作出修改,那就整体替换。

聚合

聚合是业务和逻辑紧密关联的实体和值对象组合而成的基本单元。

聚合是领域的抽象体现,包含了当前领域内的一切事务。它在代码层面主要呈现的方式是模块的划分。

下图定义了一个用户领域,可以划分出一个用户的聚合包,把专属于用户领域的内容放在 ddd.domain.aggregate.user 这个包下。

1

聚合根

聚合根则是聚合的管理者,负责协调实体和值对象完成共同的业务逻辑。

聚合是领域的抽象体现,聚合根就是领域的具象体现,它是一种特殊的实体。

聚合根内部定义了当前领域需要的业务属性(实体与值对象),并且包含了该领域内所有的业务逻辑定义。

比如订单这个领域,它的具象体现就是订单聚合根。订单聚合根内部包括了订单明细实体、地址值对象等各种属性。在订单聚合根内部定义了订单领域的业务逻辑方法。

聚合根 = 领域强关联的实体、值对象 + 核心业务逻辑

用一个图书馆的模型来说明聚合和聚合根的区别:

假设我们有以下实体和它们之间的关系:

  • Book(图书)实体:代表图书馆中的一本书,包含书名、作者等信息。
  • Library(图书馆)实体:代表整个图书馆,包含多本图书。
  • Patron(读者)实体:代表图书馆中的读者,可以借阅图书。

在这个例子中,可以将图书(Book)和读者(Patron)作为聚合的一部分,其中图书和读者之间可能存在一对多的关系。而图书馆(Library)可以被视为一个聚合,它包含了多本图书和多位读者。

在这个情景中,Library(图书馆)就是聚合根。它是整个聚合的核心对象,负责管理图书和读者之间的关系。外部系统或用户只能通过图书馆来访问和操作其中的图书和读者,以保证数据的一致性和完整性。

因此,在这个例子中,Book和Patron是聚合的一部分,而Library是整个聚合的根。

通过这种方式来组织实体之间的关系,我们可以更清晰地定义每个对象的职责和边界,提高代码的可维护性和可扩展性。

实体、值对象与聚合根的关系

包含关系

聚合根内部能够包含 N 个实体与 N 个值对象,它们作为聚合根的属性。

1

生命周期关系

聚合根里面包含了实体与值对象。

也就是说实体的生命周期是捆绑着聚合根的,由聚合根来维护。

而值对象不存在生命周期,只能被整体替换。

标识关系

聚合根本身就是实体,它的 ID 就是它的唯一标识。

但是实体的唯一标识是仅针对当前聚合根而言的,就像商品实体能够被订单聚合关联,也能被物流聚合关联。

值对象在聚合内部的唯一性通过属性相等判断实现。

建立实体、值对象与聚合根关联

以新建用户,新建过程中需要给赋予角色这个需求为例,分别根据角色的不同定位来划分关联关系。

角色非独立维护

整个系统中的角色不是独立开展的业务,比如我们定义了一个角色的枚举类,系统的用户只能关联这个枚举类对应的角色。

这个时候,角色在用户聚合根内就是值对象,因为此时角色满足了不变性与属性判断相等这两个条件。

Details
java
/**
 * 用户聚合根
 *
 */
public class User implements AggregateRoot {

    /**
     * 用户id
     */
    private Long id;

    //省略非关键属性

    /**
     * 角色值对象
     */
    private List<Role> roles;

  
    //省略业务逻辑方法

}

/**
 * 角色值对象
 */
public class Role implements ValueObject<Role> {

    /**
     * 角色code
     */
    private String code;
    
     /**
     * 角色名称
     */
    private String name;
    
    /**
     * 比较角色相等
     *
     * @param role 角色
     * @return
     */
    @Override
    public boolean sameValueAs(Role role){
        return Objects.equals(this,role);
    }
}
角色独立维护

如果角色本身可以独立开展业务,比如系统内管理员可以新增自定义角色,新增用户的时候可以关联到这个角色。超级管理员可以修改角色的名称,此时查看用户关联角色信息时应该是修改后的角色名。

很明显,这种情况下,角色本身在用户聚合根内是一个可以变的状态,并且如果用户需要感知到角色的可变,只能通过角色的不可变的唯一标识去感知。这种情况下,角色在用户内就是实体。

Details
java
/**
 * 用户聚合根
 *
 */
public class User implements AggregateRoot {

    /**
     * 用户id
     */
    private Long id;

    //省略非关键属性

    /**
     * 角色实体,这里也可以直接是
     * private List<Long> roleIds;
     * 包装成POJO,业务语义更强,表示这是实体,区分于本身领域内部的基础业务字段
     */
    private List<Role> roles;

  
    //省略业务逻辑方法

}

/**
 * 角色实体
 */
public class Role implements Entity {

    /**
     * 角色id
     */
    private Long id;
}

应用服务与领域服务

根据划分后的领域,我们能够确定领域的具象体现——聚合根。

此时,原子化的业务逻辑都被定义在了聚合根内部,这也是 DDD 所推崇的解耦与内聚思想。

一个聚合根只代表了一个领域的业务,而我们系统的功能体现往往是多个领域聚合协作的,对应了战略设计里面的上下文协作。

为了完成这种协作逻辑,战术设计中定义了应用服务层与领域服务层。

应用服务

应用服务可以看作是一个流程编排引擎,它本身不承担任何业务逻辑处理。

应用服务可以理解为功能用例层,比如新建用户,这个功能就应该定义在应用服务层。但是新建用户是一个比较繁琐的流程,比如涉及到关联角色等业务逻辑处理。

这些业务逻辑处理应该被定义在用户聚合根内部,而应用服务只负责调用定义在聚合根内部的方法就好了,屏蔽的业务逻辑的具体实现。

应用服务表象定位与 MVC 中的 Service 比较像,但是 Service 内部充满了功能点的逻辑处理,而应用服务相对来说是比较薄的一层,它只做逻辑编排。

参数校验、聚合根方法调用、外部服务调用、持久化聚合根等与业务流程走向相关,业务逻辑无关的代码均可定义在此处。

应用服务是整个系统的门面,也是六边形架构中的出入口,外部服务通过访问应用服务提供的接口来执行功能用例。

领域服务

领域服务(Domain Services)是领域驱动设计中的一个重要概念,用于表示领域中的一种服务,它封装了一些领域规则或者业务逻辑,不能归类到任何特定的实体或值对象中。

领域服务通常被设计成无状态的,独立于领域实体和聚合的概念,用于处理跨实体之间的操作或领域规则。

虽然应用服务与聚合根逻辑几乎已经覆盖了功能点的实现,但是有时还是会出现这样的业务场景:

A 聚合根需要做一个原子化的逻辑处理,但是这个逻辑处理需要 B 聚合根的逻辑协作才能完成。

这种场景的实现方式有两种。

第一种就是在应用服务内先调用 A 聚合处理一下,再调用 B 聚合处理一下,最后再调用 A 聚合收尾逻辑。这种方式符合 DDD 思想,但是对应到应用服务,我明明是一个很原子化的 A 聚合的逻辑处理,居然有三行代码。而这段逻辑会被好几个功能点调用,每次为了完成这个逻辑我就要写三行代码,显然逻辑的原子化不够突出,还容易出 Bug。

第二种就是应用服务与聚合根都各退一步,在它们中间抽象一层领域服务。把 A、B 聚合协作逻辑定义到 A 的领域服务内,应用服务调用 A 领域服务即可,这样在应用服务上看这段逻辑就很清晰了。

领域服务其实是对业务的一种妥协,理想情况下是没有领域服务的。一旦出现了领域服务,一定要确定好这是否在执行一个特别显著的、专属于某个领域的原子化业务逻辑。滥用领域服务很有可能会演化为逻辑又定义在 Service 状况。

例如,订单下单后需要触发用户增长积分和赠送优惠券的功能,这种跨实体操作通常可以通过领域服务来实现。

通过引入领域服务,您可以将处理用户增长积分和赠送优惠券的逻辑抽象成一个服务,避免将这些逻辑直接耦合在应用服务或实体中。

另外,通过发布领域事件来解耦相关的领域活动是一种良好的实践:订单创建成功后发布一个创建订单的领域事件,让需要感知这个事件的服务(如增长积分和赠送优惠券服务)进行监听并处理。这样可以在一定程度上解耦不相关的领域活动,提高系统的灵活性和可扩展性。

在领域驱动设计中,确保领域事件的发布与功能点的事务一致性,同时允许领域事件的处理与功能点事务解耦是非常重要的。这种方式可以确保系统在处理跨实体操作时具有更好的可维护性和健壮性,同时提供更好的用户体验和系统性能。

仓储

我们知道为了内聚业务逻辑,应用服务层编排的都是聚合根的业务逻辑,也就是说我们一直在应用服务内操作的都是领域模型。

但是领域模型是针对于业务层面的,而领域模型处理完业务之后需要通过数据层存储。

数据层对应的是数据模型,为了桥接数据模型与领域模型,DDD 在战术设计中提出了仓储的概念。

仓储(Repository)是一种模式,用于封装对领域实体(Entity)和聚合(Aggregate)的持久化操作。

仓储的定位就是持久化聚合与检索聚合。让应用服务专注逻辑编排,聚合根专注逻辑处理,不用关心领域模型的持久化方式与存储介质。

Details

仓储在DDD中扮演着以下几个重要角色:

抽象数据访问:仓储定义了一组通用的接口方法,用于对领域对象进行数据的增删改查操作。通过仓储,领域模型不需要关心数据是如何持久化的,只需调用仓储提供的接口来与数据访问层进行交互。

领域对象的集中访问点:仓储提供了一个统一的访问点,让应用程序可以轻松地管理和操作领域对象。通过仓储,应用程序可以获取领域对象的实例,对其进行操作,并将修改持久化到数据库中。

保持领域对象的纯洁性:仓储可以帮助领域对象维持其纯洁性,使其只包含业务逻辑而不包含数据访问代码。领域对象可以通过仓储来获取所需的数据,而不涉及与数据库直接交互的代码。

实现领域模型的一致性:通过仓储,可以确保领域对象的持久化和检索操作是基于事务的,保证领域模型在数据操作时的一致性和完整性。

总的来说,仓储是实现数据访问逻辑与领域模型之间解耦的重要工具,在DDD中扮演着连接领域模型和持久化数据之间的桥梁角色。通过良好的仓储设计,可以使领域模型更具灵活性、可测试性和可维护性。

事件模型

为了解决这个耦合严重的鸡肋点,DDD 的战术设计中提出了事件模型。

比如下订单后,给用户增长积分与赠送优惠券的需求。下单完成后,发布一个下单完成的领域事件,让需要感知这个事件的服务自行监听并处理,忽略不相关的领域活动。

领域事件的发送成功应该与功能点的事务是一致的,但是领域事件的处理结果不应该与功能点事务一致。

我下订单成功了,发送了创建订单事件,但是积分增长失败了,这时如果让订单生成失败,这显然是不合理的。

5.什么是事件风暴

事件风暴的方法论本身不是单纯为了 DDD 而生的,但是它是 DDD 在自顶向下领域建模过程中必不可少的分析步骤。

通过事件风暴,我们能够得到业务参与人总结出的业务聚合与聚合所承载的逻辑功能,进而分析得到业务聚合所包含的业务属性。完成业务建模后,就可以根据业务模型去设计数据模型了。

这种自顶而下的分析模式从业务角度出发,让数据模型更加适配业务模型,而不是常规 MVC 设计下的数据模型去套用业务模型。

常规的业务需求到功能代码的转换流程:

1

INFO

用户的需求经过层层的转换才被转发到了研发人员这里,研发人员又会根据会议中获取到的需求自我转化功能的实现。

存在的问题是,一个需求经过了这么多道的层层转化,需求在每一层都会经过不同人的理解转化,会导致信息不对称问题的出现。

落地 DDD 的过程来看,有两个问题是最困难且最重要的:

一个是界定出一个系统中有多少个聚合,即划分多少个业务模型;

另一个是界定出每个聚合之间的限界上下文,即划分清楚领域的业务边界。

为了解决以上两个痛点问题,一种被叫作事件风暴的轻量型系统分析方法被提出,用于消除需求分析与同步过程中的信息不对称。

1.事件风暴的概念及流程

事件风暴是一套 Workshop研讨会(类似于头脑风暴)的方法。它以事件为出发点,通过多人协作来划分业务领域与业务边界。

事件风暴的分析过程就像在讲述一个个的用户故事。

通过一个个的用户故事来统一开发人员、业务人员、UX、测试等项目参与者对业务流程的认知,这包括关键的流程、核心的业务规则、系统不同模块的使用。

其次是帮助开发人员梳理清楚领域模型与业务边界。

那么用户故事又是怎么分析出来的呢?

下图是事件风暴对于用户故事的分析的最简核心流程:

1

  • 事件: 代表了某一个业务行为,是事件风暴中的核心概念,所有的分析都以事件为核心展开。描述的形式为“宾语+动词”的过去式。例如,合同已被签署、资料已被上传,等等。使用橙色的便利贴标示。

  • 命令/动作: 表示产生事件的对象,执行了动作之后就会产生相应的事件。例如,“签署合同”命令导致“合同已被签署”事件。使用蓝色的便利贴标示。

  • 角色/执行者: 表示产生命令的对象。例如,顾客执行“签署合同”动作,这里的顾客即为角色/执行者。使用黄色便利贴标示。

也就是说,事件风暴的核心流程就是:用户执行了命令,从而产生了事件。

当然上面的只是一个理想化的最简业务流程。但事实上,一个业务系统的业务逻辑绝不是这么简单的。

比如,“合同已被签署”事件发生后,需要通知财务系统触发“发起扣款”动作;根据签署合同的类别产生“发送优惠券”的动作;以上两种动作都会导致产生新的事件。

下图是事件风暴对于用户故事的分析的完整流程:

1

这是上面最简核心流程图的延伸,不过除了角色/执行者、命令/动作、事件这三个核心要素外,还多出了策略/业务规则、数据/读模型和外部系统这三者。

  • 策略/业务规则: 当产生事件时,需要进行某些业务相关的规则校验,例如“合同已被签署后”事件,根据签署合同的类别产生“发送优惠券”的动作。使用粉色便利贴标示。

  • 数据/读模型: 事件产生后的另一个结果往往是呈现用户所关心的数据在系统界面。例如,当用户执行“签署合同”的命令之后,生成了“合同已被签署”事件,此时呈现在用户面前的应该是被签署后的合同信息。这样的数据我们使用读模型表示。使用绿色便利贴标示。

  • 外部系统: 事件并不一定由执行者执行命令产生,也可能由一个外部系统产生。例如,“合同已被签署”事件完成后通知给财务系统,财务系统触发“发起扣款”动作,产生“扣款已完成”事件。使用红色便利贴标示。

因此,在分析一个业务系统前,首先要做的就是搞清楚我们想要的业务结果(事件)是什么,从事件出发开始反推产生事件的动作、外部因素与业务规则。再根据动作进行反推分析本系统内的动作汇聚发起点的业务汇聚在何处。

汇聚点即为某一个业务领域的聚合,一个个事件与动作的组合就是领域的业务逻辑,根据业务逻辑来设计领域所需要的属性。

2.事件风暴的开展事项

了解完事件风暴的概念及相关核心要素后,你肯定也很好奇应该如何来开展事件风暴。一般来说,事件风暴主要包括参与人员、准备工作和建模讨论这三个大的事项。

  • 第一个事项,参与人员。 事件风暴采用 Workshop研讨会 的方式。任何与项目相关的业务人员、架构人员、研发人员等都可以参与其中。

  • 第二个事项,准备工作。 需要准备一面大的画板或者墙,以及数张不同颜色的便利贴(包括蓝色、黄色、红色、橙色、绿色、粉色、紫色),不同颜色的便利贴对于事件风暴有不同的意义。

  • 第三个事项,建模讨论。 常规情况下还是由产品经理先讲解自己梳理的需求点,划分事件,以事件为中心点扩散推导出第一个版本的用户故事。与会人员对于上面张贴出的流程进行头脑风暴,对于需要补充的流程节点使用特定颜色的便利贴进行张贴。讨论结束后,对于事件风暴结果进行拍照或者以其他记录方式存档。

3.案例演示

这里我们以电商分期购车订单业务场景为例来看看事件风暴的分析流程,如下图:

1

这里以“购车合同已签署”事件为例,重点看如何分析业务流程。

  1. 确定当前事件触发的动作:签署购车合同。

  2. 签署事件发生后通知合同服务保存签署结果,由于合同服务是外部系统,且后续逻辑不会回调,故不延展分析。

  3. 签署合同发生后通知金融订单服务,金融订单处理自己系统业务逻辑后,回调本系统逻辑,本系统调用启动金融 task 任务动作。

  4. 动作分析完成则进行反推,触发当前动作的执行者是用户,而能够签署合同的前提是存在订单,订单才是签署合同的业务载体。至此已经得到了业务的聚合为订单。

  5. 签署购车合同完成后将产生签署后的合同,用户可通过查看订单中的合同信息查询到,此处即为读模型。

按照上面的思考与探索方式,根据约定的事件进行反推与逻辑归并,最后将得到业务领域聚合。

比如,上图的分期购车逻辑将会反推得到订单领域,再根据订单所承载的业务逻辑得到订单的业务属性(从上可得知订单合同签署状态与关联的合同信息是订单的业务属性)。订单的业务属性被敲定后,即可自顶向下将业务模型开始转化为数据模型。

6.分层架构:功能实现的技术指导

下面将从 MVC 的三层架构出发介绍了六边形架构的演变过程,并且说明了六边形架构通过依赖倒置来纯粹化领域层依赖。另外,还分别介绍了传统分层架构、灰度分层架构与能力分层架构在不同业务场景与实际应用场景下优缺点。

从灰度分层架构和能力分层架构它们的分层图就能看出来,这两种分层的区别点就在于 Interface 层与能力层。而我也说了领域模型设计合理,业务边界足够清晰的情况下,是不会出现领域服务的。也就是说 Interface 层与能力层都可以去除,这就变成了传统分层架构。

灰度分层架构相比较能力分层架构在领域模型使用上的灵活性更强,如果团队成员对 DDD 理解深刻、业务理解够好,更建议使用这种。

能力分层架构在层与层之间的职责分割上更加明确,并且能力层还能扩展出其他的一些前置处理。对于成员较多、DDD 理解不是特别深刻的团队而言,这种强结构化分层架构更加合适。

六边形架构演进之路

MVC 的分层架构,如下图所示:

1

从上往下依次对应了用户接口层、业务逻辑层与数据服务层。

它的显著优势就是:结构足够简单,不管业务简单还是业务复杂的系统都能往上套。

因为本质上它的分层思想是工程化分包思想,而不是业务化分包思想。

为了将纯工程化思想转化为业务驱动架构思想,DDD 提出了六边形架构来解决日益庞大的系统维护困难的问题。

但是 DDD 也不是一口气就提出了六边形架构,准确地说 DDD 的架构分层模型不止六边形架构这一种。DDD 的架构在市面上被说到比较多的就是四层架构、五层架构与六边形架构。

四层架构

那么为什么最终我们在使用 DDD 的时候,基本上都是选择六边形架构而不是四层架构或者五层架构呢?下面以四层架构为例阐述其中的演变过程。

MVC 直接映射 DDD 分层图:

1

它的分层思想依赖关系即符合了 DDD 的在战术设计上的分层,又跟 MVC 的分层极为类似。

从上往下,用户接口层对应了 MVC 中的 Controller 层,MVC 中的 Service 层被拆分成了应用层(用于编排逻辑)和领域层(实际业务逻辑编写),最底层的基础设施层对应了 MVC 中的 Dao 层。

乍一看,这个分层思想好像很合理,与 MVC 的分层思想不冲突,而且我们也能按照 DDD 的思想去开展业务。但是我们从层级依赖上来看一下,上层依赖下层。在 MVC 的分层下,我们通常会认为越在下面的层,它距离实际的功能点的逻辑是越来越远的。也就是说一些通用的工具类、系统配置、消息发送接收配置、外部接口调用封装等通用型的功能都会被集中定义到基础设施层中。而这时,领域层却依赖了基础设置层,让本应该纯粹只处理限界上下内文的领域受到了外部服务或者一些配置的污染。而且我相信一旦有了基础设施这样一个大杂烩层之后,总会有那么几个人,把一些本应该放在领域里面的逻辑定义在了基础设施里面,逐渐你的架构就又开始退化。

六边形架构

为了解决这个问题,世界级编程大师 Robert C. Martin 提出了改进四层架构的思想:依赖倒置。他认为:

高层模块不应该依赖于底层模块,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。

在四层架构的世界中,上级需要做什么事情都是需要下级实际拥有这个能力,上级直接调用才能完成。

而依赖倒置之后,只要下级定义了能力的接口,上级就可以通过依赖注入的方式来直接注入接口,调用接口方法即可。而下级对应的接口逻辑实现,被放置在基础设施层,提到了最上层,如下图所示:

1

这就是所谓的六边形架构,如下图所示:

六边形架构

从外往里看,领域模型(对应领域层)完全独立,可以自由地开展自己的业务。应用服务包含了领域服务进行逻辑编排,完成功能点的业务组装。并且应用服务作为业务系统的统一门面,提供各种适配的接口给外部来访问。

传统分层架构

如果最初在落地六边形架构的时候,使用的是传统的 DDD 代码分层方式,即在 Spring 的项目中,Maven 的模块依赖 Infrastructure Module 依赖Application Module,Application Module依赖了Domain Module。

项目结构如下图所示:

1

  • Infrastructure(基础设施层): 提供系统运转的非业务逻辑的基础能力,支撑系统运转。

  • User Interface(用户接口层): 我们平等地认为 Controller、RPC、MQ 等都属于外部用户输入,放置在用户接口层。

这里没有按照依赖倒置图把用户接口层放在基础设施层的下面,考虑到用户接口层已经是外部的输入了,里面是请求转发至应用服务层,逻辑定义非常薄。没必要为了形式而把接口定义在用户接口层,把逻辑放在基础设施层。

  • Application(应用服务层): 编排领域层业务逻辑、参数校验、事务控制等。

  • Domain(领域层): 核心领域层,定义与领域相关的一切内容,包括聚合根、实体、值对象等。

灰度分层架构

传统的分层架构在大多数业务场景下是没有问题的。但在实际业务中,领域层的领域服务为了完成原子化的业务逻辑难免会依赖应用服务。

为了保证领域层足够纯粹,增加了 Interface(灰度层) 这一层,在里面定义了领域层需要调用依赖的接口,在基础设施中去实现调用应用服务。

这一层应用服务与领域服务都可以调用。Interface 层在标准的 DDD 代码分层中是没有的,是为了应对特别复杂的业务流程而增加的。

代码分层如下:

1

这样分层之后,系统处理外部请求的流程就变成了如下图所示的情形:

1

从流程图调用上来看好像没有什么问题,非常符合 DDD 的六边形架构思想。如果共同维护系统的小伙伴对于业务的认知高度一致,且对 DDD 的分层思想了解得比较清楚的情况下,这种方式非常好。

不过实际情况中,很有可能某个逻辑都不需要领域服务介入,但是因为 Interface 是灰度层,里面什么都能放,它可以把大量的业务逻辑都定义在 Interface 层处理。久而久之,Interface 层的逻辑会迭代得越来越多,退化成了 Service。

能力分层架构

思考一下传统六边形架构在应对复杂业务场景时可能会出现的逻辑混乱问题:Interface 层是为了解决领域服务在处理原子化逻辑的时候,可能出现依赖其他应用服务或者领域服务能力的情况,但是它又可能导致代码混乱的问题,看上去好像发生死锁了。

我们跳出来看一下这个问题,本质上 Interface 层的存在是为了解决领域服务处理原子化逻辑时对外部的依赖问题。那么我们是不是解决掉这个原子化逻辑定义的位置就好了呢?

大多数情况下,领域服务是不可能存在的。它存在的场景是为了包装一个多领域协作的单领域原子化逻辑,如果放在应用服务中,好几行逻辑调用不能突出原子化。

在编写 MVC 架构下 Service 的代码时,为了包装一个显著的逻辑我们的方式是定义一个私有的方法。在 DDD 里面这么做显然是可以的,我们可以定义一个私有方法去包装这个原子化逻辑,主方法的逻辑就很清晰。

这段逻辑如果其他的应用服务也需要使用,(注意,有人会把私有方法变成公有方法开放出去,但是这个逻辑只能被系统内部所使用,外部根本用不到,违背了应用服务层的对外定义),同时为了防止这段逻辑暴露,我们定义了一个中间层 -- 能力层。它介于领域层与应用层之间,用于表达原子化的领域逻辑,它的编码规范与应用服务一致,即只能编排逻辑。

最终架构就变成了下图这样:

1

比如,我现在要实现新建用户这个需求。

可以有两种方式:

一种是直接在应用服务内编排完你的新建用户逻辑;

另外一种就是定义一个新建用户的能力层,A 应用服务可以调用这个能力层完成用户新建,B 应用服务也可以调用能力层完成用户新建。

能力层的调用与被调关系如下图所示:

1

每层的调用关系为:

强制:应用服务编排能力层与聚合逻辑; 强制:能力层编排能力层与聚合逻辑; 建议:应用服务之间不互相调用; 强制:能力层之间可以互相调用; 强制:能力层不调用应用服务层。

7.分层详解:如何明确各层级的使用场景与方式

应用服务层

应用服务是比较“薄”的一层,但是它却能包含参数校验、权限控制、事务控制与逻辑编排这么多的功能。

Details

如果我们在 MVC 的分层逻辑里面去实现一个新增用户的需求,简略的代码应该是这样的:

java
@Override
@Transactional(rollbackFor = Exception.class)
public void create(CreateUserDTO dto){
  //校验用户是否存在
  if(Objects.nonNull(userMapper.getByUserName(dto.getUserName()))){
    throw new RunableException("用户名不可重复");
  }
  
  //构造出数据模型
  UserPO po = new UserPO();               
  BeanUtils.copyProperties(user,po);
  
  //对前端传过来的密码进行解密
  省略一大串解密校验逻辑...

  //存储用户
  userMapper.insert(po);

  //在操作记录中插入新建用户事件
  recordService.insert(RecordFacroty.userCreateRecord(dto));
}

Service 把所有的逻辑一口气处理完了。它的编码流程可以分为以下几个步骤:

参数校验; 数据模型构造; 复杂业务逻辑处理; 落库; 调用需要感知用户新增的 Service 的方法。

这种写法的问题: 前端传过来的参数转化成数据模型是一个可大可小的过程,如果前端给了 3 个字段,你却需要根据三个字段解析得到 5 个字段并赋值给数据模型,那上面的第二步的代码就会变得很长了。

比如前端传给你一个 Tag 标签是:hello:你好,对应到数据模型 TagEn:hello 与 TagCn:你好。这种解析字段逻辑多了,会导致本身的逻辑不够突出。

复杂的业务逻辑处理被叠在了一起。假如解密逻辑要 10 行代码,赋权逻辑要 20 行代码,其他逻辑加起来在 100 行代码,你的方法就会变得特别长。而且解密逻辑、赋权逻辑无法被复用了。

落库过程直接操作底层的数据模型,如果表结构变更了,是不是相关联代码逻辑都要被级联修改?

现在只有操作记录需要感知用户新增,如果还有更多的其他 Service 需要感知呢?再一个个加方法吗?我明明是在新增用户,为了要做一些与我用户领域无关的逻辑处理。

使用 DDD 的应用服务:应用服务内部所有的代码都没有处理业务逻辑,而是在编排业务逻辑的节点,最后组装出一个功能点。 应用服务层的“薄”就是体现在这里。

判断代码是不是跟业务流程分支走向相关的,如果是,那就是编排逻辑;如果不是,那就是业务逻辑。

比如参数校验逻辑,一旦校验失败,当前功能点的执行就退出了,流程终止。

比如 Service 中处理密码的 10 行代码,这 10 行代码是为了做密码处理,而不是让整个业务流程往下走,因此这个方法逻辑应该被定义在聚合根内部。

比如 Service 最后调用其他服务,这个调用其实本身与你的流程走向是没有关系的。你的业务逻辑已经处理完成了,你们的事务也应该是独立的,你操作记录无论新增成功还是失败都不应该影响到我用户新增。

最后再来说一个特别容易产生误区的点:应用服务中能否使用 if/else、switch 这种条件判断语句?

Details
java
  @Override
  @Transactional(rollbackFor = Exception.class)
  public void create(CreateUserCommand command){
ValidationUtil.isTrue(Objects.isNull(userQueryApplicationService.detail(command.getUserName())),"user.user.name.is.exist");
    //工厂创建用户
    User user = command.toUser(command);
    //调用领域逻辑
    user.method1();
    user.method2();
    user.method3();
    //存储用户
    User save = userRepository.save(user);
    //发布用户新建的领域事件
    domainEventPublisher.publishEvent(new UserCreateEvent(save));
  }

我们新建用户的流程如下:

通过固定的转换方法或者工厂类新建出我们的领域模型;

调用领域模型内部方法去处理类似加解密逻辑等与用户领域模型强相关的业务逻辑;

调用仓储直接存储领域模型,屏蔽底层的数据模型;

发送用户新增领域事件,让需要感知到的其他领域自行监听事件,解耦用户新增与其他不管的领域处理逻辑。

Details

再来说一个特别容易产生误区的点:应用服务中能否使用 if/else、switch 这种条件判断语句?

很多同学为了追求应用服务层的编排纯粹性,只会在代码中使用终止流程的 if 判断,比如:

java
if(Objects.nonNull(userMapper.getByUserName(dto.getUserName()))){
    throw new RunableException("用户名不可重复");
}

他们认为 if/elseswitch 这种条件判断语句是表示着业务逻辑判断,都应该被放在领域逻辑中。

其实这个想法没有错,但是在实际应用过程中,你就会发现怎么代码写起来这么别扭。

那是因为应用服务层失去了逻辑编排功能,变成了逻辑组合功能。这恰恰与应用服务的设计相悖。

因此,这样的 if/else 是允许存在的。

java
if (Objects.equeals(user.getSex(),"man")) {
    // 男性专用新建通道
} else if (Objects.equeals(user.getSex(),"woman")) {
    // 女性专用新建通道
}

根据用户性别的不同,采用不同的新建策略。我们从整体来看这个 if/else,无法给它一个原子化的业务含义,实际的逻辑处理在 if/else 内部。

而这样的 if/else 是不允许不在的。

java
if (Objects.equeals(user.getSex(),"man")) {
    user.setTag("男性用户");
} else if (Objects.equeals(user.getSex(),"woman")) {
    user.setTag("女性用户");
}

根据用户性别的不同,给用户打上不同的标签。我们从整体来看这个 if/else,它能够被赋予业务含义,给用户打标。

领域服务层

领域模型需借助其他领域模型的能力来完成当前领域模型的原子化业务逻辑,为了不污染领域模型,建立领域服务来充当桥梁。

跟应用服务一样,领域服务层也有自己的规约:

  • 领域服务之间允许互相调用;
  • 领域服务入参仅为基础变量(比如 String)或者聚合根。

为什么需要领域服务

举个例子:现在有用户与角色两个聚合。新增用户时,需要关联角色,需要根据角色的类别设置用户的类型标签。

java
//工厂或者转换方法获取用户聚合User

// 调用角色仓储拿到角色聚合根;
Role role = roleRepository.ById(user.getRole().getId());
// 角色聚合根根据属性创建标签;
String roleTag = role.createRoleTag();
// 用户聚合根绑定标签。
user.bindTag(roleTag);

// 现在两个聚合根协作只需要 2、3 两步,但是如果中间的协作步骤很复杂呢?在应用服务中一大串的编排其实只是为了完成一个原子化的逻辑。
//省略后续处理

这段逻辑显然不能被放到用户的聚合根中,因为这里关联到了角色的聚合根,而用户聚合根本身应该是纯粹的,不能突破它本身的限界上下文。

为了凸显出这个逻辑,我们在聚合根与应用服务之间插入一个中间方——领域服务来完成这个事情。

有了领域层之后应用服务层的代码就可以变成:

java
userDomainService.bindTag(user,roleRepository.ById(user.getRole().getId()));

领域服务中我们可以这样定义:

java
@Service
public class UserDomainServiceImpl implements UserDomainService{
  
  /**
     * 绑定用户标签
     * @param user 用户聚合根
     * @param role 角色聚合根
     */
  @Override
  public void bindTag(User user,Role role){
    // 用户聚合根与角色聚合根协作完成了用户领域下强业务的原子化逻辑处理。
    String roleTag = role.createRoleTag();
    user.bindTag(roleTag);
  }

}

Interface(灰度层)

领域服务属于领域逻辑的一部分,因此它应该被放在 Domain 的 Maven 包下。

而在传统分层架构中,如下图所示:

1

应用服务层是属于领域层上面的。在将 MVC 架构迁移至 DDD 的过程中,如果发现不可避免地会出现领域服务调用应用服务的场景。(当然纯新建的系统,领域划分明确的情况下,不可能出现这种场景,如果出现了,一定是领域划分有问题。)

java
@Service
public class UserDomainServiceImpl implements UserDomainService{
  
  //这里其实无法引用成功,因为Domain包在Application包下层
  @Autowired
  UserApplicationService userApplicationService;
  
  /**
     * 绑定用户标签
     * @param user 用户聚合根
     * @param role 角色聚合根
     */
  @Override
  public void bindTag(User user,Role role){
    String roleTag = role.createRoleTag();
    user.bindTag(roleTag);
    //假如绑定后需要调用应用服务处理
    userApplicationService.method();
  }

}

为了解决这个反向依赖的问题,我在 Domain 层下面加了一个 Interface 层——灰度层。层级结构变为下图:

1

在灰度层中定义能力支撑的接口:

java
@Service
public class UserDomainServiceImpl implements UserDomainService{
  
  @Autowired
  UserInterface userInterface;
  
  /**
     * 绑定用户标签
     * @param user 用户聚合根
     * @param role 角色聚合根
     */
  @Override
  public void bindTag(User user,Role role){
    String roleTag = role.createRoleTag();
    user.bindTag(roleTag);
    //方法变换如下
    userInterface.method();
  }

}

而 UserInterface 接口的实现类放在基础设施层。因为基础设施层在应用服务层上层,自然可以调用到应用服务的逻辑了。这样既保护了领域层本身分层的纯净性,又保证了业务逻辑的顺畅关联。

能力层

能力层其实是一个小型的应用服务,只不过是以原子化能力维度而存在的。

灰度分层架构虽然灵活性很高,但是特别容易导致写出类似于 MVC 分层中 Service 的代码。

为了解决这个问题我们引入了能力层来解决这个问题,架构图如下:

1

能力层作为应用服务与领域的中间层,能够被不同的应用服务调用,应用服务之间的通信也可以通过能力层来完成:

1

能力层 VS 领域服务层

从功能维度上来看它们没有区别,都是为了完成某一聚合下的原子逻辑。

但是从使用上来看,领域服务是领域逻辑,也就是说可以处理逻辑代码,而能力层本质上还是应用服务,因此我们还是需要遵循应用服务的编写规范来写。

应用服务层代码:

java
//工厂或者转换方法获取用户聚合User

userBindAbility.executeAbility(user);

//省略后续处理

能力层代码:

java
public class UserBindAbility extends BaseAbility<User>{

    @Autowired
    UserDomainService userDomainService;
    
    @Autowired
    RoleRepository roleRepository;

    @Override
    public void execute(User user) {
    
        Role role = roleRepository.ById(user.getRole().getId());
        
        userDomainService.bind(user,role);
    }
}

仓储落地:串联数据模型与领域模型的桥梁

领域模型的存取在 DDD 中其实也是一种防腐思想的体现,对外提供领域模型,对内转化数据模型。

利用仓储层来屏蔽底层的数据模型,让应用服务层可以专注处理本身的业务逻辑。

而查询,则可以走单独的 CQRS 模式,不必与领域模型进行捆绑。根据数据保存在多少个存储介质,可以抽离出一层查询仓储,让数据查询组装逻辑在查询应用服务中更加凸显且清晰。

仓储层是 DDD 落地过程中最容易理解但又是最容易体现出其价值的一层,隔离领域模型与业务模型,让领域模型脱离了存储介质、硬件等束缚专注于业务逻辑处理。

什么是仓储

INFO

为每种需要全局访问的对象类型创建一个对象,这个对象就相当于该类型的所有对象在内存中的一个集合的“替身”。

通过一个众所周知的接口来提供访问。

提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。

提供根据具体标准来挑选对象的方法,并返回属性值满足查询标准的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。

只为那些确实需要直接访问的 Aggregate 提供 Repository。

让客户始终聚焦于型,而将所有对象存储和访问操作交给 Repository 来完成。

通俗来讲就是:当领域模型一旦建立之后,你不应该关心领域模型的存取方式;

仓储就相当于一个功能强大的仓库,你告诉它唯一标识,例如用户ID,它就能把你想要的数据组装成领域模型一口气返回给你。

存储时也一样,你把整个用户领域模型给它,至于它怎么拆分,放到什么存储介质(DB、Redis、ES 等),这都不是你业务应该关心的事。你完全信任仓储能帮助你完成数据管理工作。

为什么要用仓储

1. 贫血模型的缺点

难以维护模型的完整性与一致性

模型内部所有属性都可以通过公有的 set 与 get 方法访问。业务调用方可以随意操作模型属性,模型属性的关联逻辑无法在内部达到一致,一旦业务方调用错误,甚至有可能造成模型的属性缺失。

比如,商品、商品数目、总价之间的关联关系是强业务、高内聚的逻辑。订单总价应该是商品*商品数目自动算出来的,而不是在 Service 层手动 set 一个总价进去。后续还可能涉及到折扣类型的逻辑,一旦调用方维护错误,就无法保证订单模型的数据一致性了。

代码逻辑重复

业务校验逻辑与公有规则计算逻辑是很容易被同业务或者强关联的不同业务所复用的,这部分的代码在不同的方法中可能会被维护多份,一旦逻辑变更,需要一一修改,繁琐且出现 Bug 概率变高。

比如数据的校验逻辑,A 版本的时候满足规则 A 就好了,B 版本的时候需要满足规则 B 和规则 C 了,但是这个时候校验的规则逻辑已经散落在各个业务逻辑里面了,特别容易漏改而出现 Bug。

代码的健壮性差

由于系统自底向上设计,功能点以底层数据库模型为基础进行业务逻辑开发,所以一旦数据模型变更,一连串关联逻辑均需要变更。

强依赖底层实现

系统强依赖中间件、存储介质、三方服务等提供的数据或者能力进行业务开发,这将导致实际功能的业务逻辑不够突出与逻辑捆绑性强。

本身你的 Service 是为了做一个功能,但是进到代码一看,遍地是各种 Redis、ES、MySQL 的取数、设值、发送消息等非强语义型代码。核心业务逻辑不够突出,维护成本变大。而且,一旦中间件或者三方服务能力变更,对应逻辑将被捆绑着维护,出 Bug 概率变高。

2. 领域模型与数据模型

为什么我们的系统大多是基于贫血模型开发的呢?
数据库思维

大多数 MVC 架构下的业务系统均是自底向上开发与维护的,业务逻辑都被转变成了数据库的数据。写业务变成了写数据库,这也是为什么很多程序员觉得自己每天写的代码都是 CRUD,毫无技术可言,甚至都说不清楚系统的业务逻辑是什么。

贫血模型“简单”

贫血模型的优势在于一旦你确定了表结构,你的模型属性也被确定了,只是表字段的映射而已。所有的业务都在围绕着数据库表而展开,但是一旦业务逻辑变更,表结构无法满足,那么对业务的影响是灾难性的。

脚本思维

CRUD 的代码为了将数据修改成业务想要的模样,所做的操作在很多时候都是机械性的。业务代码就像是维护数据库的脚本,业务逻辑就像是“胶水”,把各个脚本给串联起来。

区分领域模型与数据模型

避免贫血模型要在代码里区分数据模型和领域模型。

  • 数据模型, 仅仅只是一个底层的数据结构,也就是传统的 ER 模型,内部没有任何业务逻辑;

  • 领域模型, 模型本身即是业务逻辑的体现,基于该模型的原子化业务逻辑均是内聚在模型内部的

在真实代码结构中,数据模型和领域模型实际上会分别在不同的层里,数据模型只存在于数据层,领域模型在领域层,而衔接了这两层的关键对象,就是仓储。

仓储所要做的就是让业务专注于自己的逻辑处理,防腐了数据模型变更对于领域模型的影响,让领域模型可以不受存储介质限制来定义业务属性,能够独立开展业务。

如何落地仓储

1. 落地流程

前端参数->领域模型->数据模型转换流程如下图: 1

转换主要包含以下三个关键步骤:

  • 第一个,入参指令化。 增删改的入参有两种类型:一种是直接参数,另一种是 Command。Command 表示指令,需要完成一个变更行为。参数的方式是因为有的场景实在太简单了,只需要一两个参数,可以不做方法包装。但是我这里还是建议你只要是对数据做增删改操作,入参哪怕只有一个参数也包装成一个Command。代码的语义化更强,方法作用一目了然。

  • 第二个,Command 转聚合。 Command 参数仅仅为用户交互层的外部输入,最后业务逻辑的处理还是需要转换成聚合来完成。如果是新增类型的执行,转换逻辑简单的情况下,在 Command 内部定义一个 toDamain 的方法转化;转换逻辑复杂的情况下,则使用工厂类去新建聚合。

  • 第三个,Converter 衔接模型。 聚合是针对业务而存在的充血模型,虽然在大多数领域建立完成后,它的属性可以跟表字段一一对应起来。但是它们的系统定位还是不同的,桥接领域模型和数据模型的桥梁就是 Converter。

INFO

一个指令转化为存储介质数据的流程: 首先,参数或者 Command 通过转换方法或者工厂类初始化领域模型;

然后,领域模型在应用服务层编排完成业务逻辑处理;

接着,调用仓储传入领域模型;

最后,仓储内部根据传入的领域模型使用 Converter 转换成数据模型进行数据保存。

2. 仓储规范

聚合和仓储之间是一一对应的关系。仓储只是一种持久化的手段,不应该包含任何业务操作。从抽象角度看,不同业务的仓储的对外呈现方式应该是一致的,因此,仓储也有它自己对外呈现的统一规范。

  • 第一,统一接口方法,无底层逻辑。仓储的接口严格意义上只有 save、saveAndFlush、delete、byId 方法。比如,领域模型的修改新增均使用统一的 save 方法,仓储负责将领域模型保存至存储介质中。

  • 第二,出入参仅为领域模型与唯一ID。仓储对外暴露操作的是领域模型,并且它的接口是存在于领域层的,无法感知到底层的数据模型。这个在工程分包上就会做依赖限制,保障仓储的功能统一性。

  • 第三,避免一个仓储走天下。类似于 Spring Data、JPA 这样的 ORM 框架会提供通用的仓储接口,通过注解实现接口访问数据库的能力。通用的仓储接口本身就违背了仓储层设计的初衷,业务模型与数据模型又被捆绑在一起。并且如果后续数据的存储介质发生改变,比如 MySQL 转 ES,或者查询 DB 前,走一下缓存,扩展极为困难。

  • 第四,仓储只做模型的转换,不处理业务逻辑。首先要清楚的是,仓储是存在基础设施层的,并不会去依赖上层的应用服务、领域服务等。仓储内部仅能依赖 Mapper、ES、Redis 这种存储介质包装框架的工具类。比如 save 动作,仅对传入的聚合根进行解析放入不同的存储介质,你想放入 Redis、数据库还是 ES,由 Converter 来完成聚合根的转换解析。同样,从不同的存储介质中查询得到的数据,交给 Converter 来组装。 1

  • 第五,仓储内尽量不控制事务。你的仓储用于管理的是单个聚合,事务的控制应该取决于业务逻辑的完成情况,而不是数据存储与更新情况,除非业务要求的直接刷库场景。

3. CQRS(Command Query Responsibility Segregation)命令查询职责分离

1

图的左侧增删改(指令性)数据模型走了 DDD 模型,而图的右侧查询(查询性)则从应用服务层直接穿透到了基础设施层。

从数据角度来看,增删改数据非幂等操作,任何一个动作都能对数据进行改动,称为危险行为。

而查询,不会因为你查询次数的改变,而去修改到数据,称为安全行为。

而往往功能迭代过程中,数据修改的逻辑还是复杂的,因此建模也都是针对于增删改数据而言的。

查询数据的原则
  • 原则一:构建独立查询仓储。查询仓储与 DDD 中的仓储应该是两个类,互相独立。查询仓储可以根据用户需求、研发需求来自定义仓储返回的数据结构,不限制返回的数据结构为聚合,可以是限界范围内的任意自定义结构。

  • 原则二:不要越权。不要在查询仓储内中做太多的 SQL 逻辑,查询逻辑应该在功能点的 queryApplicationService 中体现。

  • 原则三:利用好 Assembler。 类似于首页,一个接口可能返回的数据来源于不同的领域,甚至有可能不是自己本身业务服务内部的。这种复杂的结果集,交给 Assembler 来完成最终结果集的组装与返回。结构足够简单的情况下,用户交互层(Controller、MQ、RPC)甚至可以直接查询仓储的结果进行返回。

4. ORM 框架选型

JAVA中目前主流使用的 ORM 框架就是 MyBatis 与 JPA。国内使用 MyBatis 多,国外使用 JPA 多。

MyBatis 是一个半自动框架(当然现在有 MyBatis-Plus 的存在,MyBatis 也可以说是跻身到全自动框架里面了),国内使用它作为 ORM 框架是主流。为什么它是主流?因为它足够简单,设计完表结构之后,映射好字段就可以进行开发了;另外,XML 的支持也让数据库操作更加简单,业务逻辑可以用“胶水”一个个粘起来。

JPA 是一个全自动框架。在架构支持上,JPA 直接支持实体嵌套实体来定义 DO,这个在领域模型建立上就优于 MyBatis,能够直观地感知领域模型内实体、值对象与数据模型的映射关系。

仍然可以将领域模型的定义与 ORM 框架的应用分离,单独定义 Converter 去实现领域模型与数据模型之间的转换,当然,如果是新系统或者迁移时间足够多,我还是推荐使用JPA的。

Demo 演示

以用户的增删改查为例演示如何使用仓储进行领域模型与数据模型的访问。

需求描述,用户领域有四个业务场景:

  • 新增用户;
  • 修改用户;
  • 删除用户;
  • 用户数据在列表页分页展示。

1. 领域模型

点击查看代码
java
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User implements AggregateRoot {

    /**
     * 用户id
     */
    private Long id;

    /**
     * 用户名
     */
    private String userName;

    /**
     * 用户真实名称
     */
    private String realName;

    /**
     * 用户手机号
     */
    private String phone;

    /**
     * 用户密码
     */
    private String password;

    /**
     * 用户地址-值对象
     */
    private Address address;

    /**
     * 用户单位实体
     */
    private Unit unit;

    /**
     * 角色实体
     */
    private List<Role> roles;

    /**
     * 创建时间
     */
    private LocalDateTime gmtCreate;

    /**
     * 修改时间
     */
    private LocalDateTime gmtModified;

    /**
     * 根据角色id设置角色信息
     *
     * @param roleIds 角色id
     */
    public void bindRole(List<Long> roleIds){
        this.roles = roleIds.stream()
                .map(Role::new)
                .collect(Collectors.toList());
    }

    /**
     * 设置角色信息
     *
     * @param roles
     */
    public void bindRole(String roles){
        List<Long> roleIds = Arrays.stream(roles.split(",")).map(Long::valueOf).collect(Collectors.toList());
        this.roles = roleIds.stream()
                .map(Role::new)
                .collect(Collectors.toList());
    }

    /**
     * 设置用户地址信息
     *
     * @param province
     * @param city
     * @param county
     */
    public void bindAddress(String province,String city,String county){
        this.address = new Address(province,city,county);
    }

    /**
     * 设置用户单位信息
     *
     * @param unitId
     */
    public void bindUnit(Long unitId){
        this.unit = new Unit(unitId);
    }

}

2. DDD 仓储

增删改这种指令性的操作需要严格遵守 DDD 的仓储规约。

应用服务统一调用仓储接口传递与获取领域模型,仓储实现借助 Converter 转换领域模型与数据模型进行持久化。

(1)仓储接口定义

仓储接口的定义主要分为以下三个注意点:

  • 定义顶层的抽象接口,是所有业务仓储接口的父类,内部仅包含四个方法;

  • 定义业务仓储接口继承顶层抽象接口,表示为用户聚合仓储层;

  • 仓储的接口属于领域范畴,与聚合根应该在同一个业务包下。

点击查看代码
java
public interface UserRepository extends Repository<User,Long> {
}

/**
 * 基础仓储接口
 *
 * 
 */
public interface Repository<AGGREGATE, ID extends Serializable>{

    /**
     * 删除
     *
     * @param id
     */
    void delete(ID id);

    /**
     * 按id查找
     *
     * @param id
     * @return
     */
    AGGREGATE byId(ID id);

    /**
     * 保存或更新聚合根
     *
     * @param aggregate
     * @param <S>
     * @return
     */
    <S extends AGGREGATE> S save(S aggregate);


    /**
     * 保存或更新聚合根【直接刷表】
     *
     * @param aggregate
     * @param <S>
     * @return
     */
    default <S extends AGGREGATE> S saveAndFlush(S aggregate){
        return aggregate;
    }


}
(2)数据模型与业务模型 Converter

仓储层与外部的交互均通过领域模型与领域的唯一标识。

但是在仓储实现类内部,需要将领域模型进行解析保存到存储介质,这个转换的动作就是交给 Converter 处理。

点击查看代码
java
/**
 * 用户转换器
 *
 */
public class UserConverter {

    /**
     * 数据模型转领域模型
     *
     * @param po
     * @return
     */
    public static User deserialize(UserPO po) {
        User user = User.builder()
                .id(po.getId())
                .userName(po.getUserName())
                .realName(po.getRealName())
                .phone(po.getPhone())
                .password(po.getPassword())
                .gmtCreate(po.getGmtCreate())
                .gmtModified(po.getGmtModified())
                .build();
        user.bindUnit(po.getUnitId());
        user.bindRole(po.getRoleIds());
        user.bindAddress(po.getProvince(),po.getCity(),po.getCounty());
        return user;
    }

    /**
     * 领域模型转数据模型
     *
     * @param user
     * @return
     */
    public static UserPO serializeUser(User user){
        UserPO po = new UserPO();
        BeanUtils.copyProperties(user,po);
        po.setCity(user.getAddress().getCity());
        po.setCounty(user.getAddress().getCounty());
        po.setProvince(user.getAddress().getProvince());
        po.setUnitId(user.getUnit().getId());
        //设置角色id
        String roleIds = user.getRoles().stream().map(Role::getId).map(String::valueOf).collect(Collectors.joining(","));
        po.setRoleIds(roleIds);
        return po;
    }

}
(3)仓储接口实现

仓储的实现类通过依赖反转的方式来提供对存储介质的操作,因此它应该被定义在基础设施层,属于资源的提供方,对领域层屏蔽内部实现。

前三个方法是对仓储的增删改操作,事务由应用服务调用方自己来控制事务。

第四个方法为只要应用服务调用,通过 Spring 的事务传播机制,直接刷库保存。

这种要看特定的业务场景,比如我一个方法逻辑链路很长,每一步都会有一个节点状态,成功一个节点应该刷一个节点的数据入库,而不是全部成功或者全部失败。

点击查看代码
java
/**
 *
 * 用户领域仓储
 *
 */
@Repository
public class UserRepositoryImpl implements UserRepository {

    @Autowired
    private UserMapper userMapper;

    @Override
    public void delete(Long id){
        userMapper.deleteById(id);
    }

    @Override
    public User byId(Long id){
        UserPO user = userMapper.selectById(id);
        if(Objects.isNull(user)){
            return null;
        }
        return UserConverter.deserialize(user);
    }

    @Override
    public User save(User user){
        UserPO userPo = UserConverter.serializeUser(user);
        if(Objects.isNull(user.getId())){
            userMapper.insert(userPo);
        }else {
            userMapper.updateById(userPo);
        }
        return UserConverter.deserialize(userPo);
    }

}

3. 查询

查询不需要切合领域模型,自己组合想要的数据,不在仓储中做过多的逻辑处理即可。认为它只是一个没有感情的仓库,无法理解你的业务展示逻辑。

查询的应用服务直接调用查询仓储提供的接口获取数据,然后在应用服务内组装单个查询仓储或者多个查询仓储的数据返回给用户交互层。

当然,如果你可以预知你的查询逻辑不会进行存储介质的变更,你也可以直接在查询应用服务内直接操作 Mapper 组装你想要的数据,不为了走 DDD 的形式,而凭空多出一层数据转发。

但是如果你的数据源需要操作多个存储介质(Redis、DB、MySQL 等),那就必须抽离出原子的取数逻辑放置在仓储层,由查询应用服务来处理业务和组装数据。

否则在查询应用服务层洋洋洒洒的一大堆代码就是为了从各个存储介质中取数,反而查询、组装业务数据逻辑变得不够清晰了。

点击查看代码
java
/**
 *
 * 用户信息查询仓储
 *
 * 
 */
@Repository
public class UserQueryRepositoryImpl implements UserQueryRepository {

    @Autowired
    private UserMapper userMapper;

    @Override
    public Page<UserPageDTO> userPage(KeywordQuery query){
        Page<UserPO> userPos = userMapper.userPage(query);
        return UserConverter.serializeUserPage(userPos);
    }

}

事件驱动模型实践

为了解耦业务流程中与主流程非强相关的外部领域逻辑调用,DDD引入了事件驱动模型来作为不同领域间的通信的一种方式。

与聚合核心逻辑有关的,走应用服务编排。

与核心逻辑无关的,走事件驱动模型,采用独立事务模式。

至于数据一致性,就根据你相关的业务来决定。

什么是事件驱动模型

假设我们现在有一个比较庞大的单体服务的订单系统,有下面一个业务需求:创建订单后,需要下发优惠券,给用户增长积分。

在单体服务内的写法,这里假设订单、优惠券、积分均为独立service。

1

这样写法的缺点是业务需求在不断迭代的过程中,与当前业务非强相关的主流程业务,随时都有可能被替换或者升级。

双11大促,用户下单的同时需要给每个用户赠送几个小礼品,那你又要写一个函数了,拼接在主方法的后面。双11结束,这段要代码要被注释。又一年大促,赠送的东西改变,代码又要加回来。来来回回的,订单逻辑变得又臭又长,注释的代码逻辑很多还不好阅读与理解。

Details
java
//在orderService内部定义一个放下
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command){
  //创建订单
  Long orderId = this.doCreate(command);
  //发送优惠券
  couponService.sendCoupon(command,orderId);
  //增长积分
  integralService.increase(command.getUserId,orderId);
}

如果用了事件驱动模型,那么当第一步创建订单成功之后,发布一个创建订单成功的领域事件。

优惠券服务、积分服务、赠送礼品等等监听这个事件,对监听到的事件做出相应的处理。

1

Details
java
//在orderService内部定义一个放下
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command){
  //创建订单
  Long orderId = this.doCreate(command);
  publish(orderCreateEvent);
}

//各个需要监听的服务
public void handlerEvent(OrderCreateEvent event){
//逻辑处理
}

如何实现事件驱动

Spring在4.2之后提供了@EventListener注解,让我们更便捷地使用事件监听机制。

了解过Spring启动流程的同学都知道,Spring容器刷新的时候会发布ContextRefreshedEvent事件,如果我们需要监听此事件,直接写个监听类即可。

Details
java
@Slf4j
@Component
public class ApplicationRefreshedEventListener implements   ApplicationListener<ContextRefreshedEvent> {

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        //解析这个事件,做你想做的事,嘿嘿
    }
}

1. 定义领域事件

第一步,定义领域事件基类。它是所有领域事件的父类,定义了领域事件的通用字段,代码如下所示:

Details
java
/**
 * 领域事件基类
 *
 * 
 */
@Getter
@Setter
@NoArgsConstructor
public abstract class BaseDomainEvent<T> implements Serializable {

    private static final long serialVersionUID = 1465328245048581896L;

    /**
     * 领域事件id
     */
    private String domainId;

    /**
     * 发生时间
     */
    private LocalDateTime occurredOn;

    /**
     * 领域事件数据
     */
    private T data;

    public BaseDomainEvent(String domainId, T data) {
        this.domainId = domainId;
        this.data = data;
        this.occurredOn = LocalDateTime.now();
    }

}

第二步,定义具体的业务领域事件,比如用户新增事件,便于特定的事件类型被特定事件处理类监听到并处理。代码如下:

Details
java
/**
 * 用户新增领域事件
 *
 * 
 */
public class UserCreateEvent extends BaseDomainEvent<User> {

    public UserCreateEvent(User user) {
        super(//仅做演示,领域事件id为防止重复建议自定义雪花id
                UUID.fastUUID().toString(),
                user
        );
    }

}

2. 定义统一的业务总线发送事件

可以定义一个统一的业务事件发送入口,方便我们对于事件发送前后做一些日志记录等额外操作。

接口代码如下:

Details
java
/**
 * 领域事件发布接口
 *
 * 
 */
public interface DomainEventPublisher {

    /**
     * 发布事件
     *
     * @param event event
     */
    <EVENT extends BaseDomainEvent> void publish(EVENT event);
}

接口的实现类在发送领域事件前记录日志,代码如下:

这里可以自己根据业务需求自行扩展领域事件发送前后的业务逻辑

Details
java
/**
 * 领域事件发布实现类
 *
 * 
 */
@Component
@Slf4j
public class DomainEventPublisherImpl implements DomainEventPublisher {

    @Autowired
    private ApplicationEventPublisher applicationEventPublisher;

    @Override
    public <EVENT extends BaseDomainEvent> void publish(EVENT event) {
        log.info("发布事件,evnt:{}", GsonUtil.gsonToString(event));
        applicationEventPublisher.publishEvent(event);
    }


}

3. 应用服务发送领域事件

用户应用服务在处理完当前业务后,通过事件发送总线DomainEventPublisher发送用户新增领域事件通知其他领域模型。

Details
java
@Slf4j
@Service
public class UserApplicationServiceImpl implements UserApplicationService {

    @Autowired
    DomainEventPublisher domainEventPublisher;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void create(CreateUserCommand command){
       
       //省略不必要的业务逻辑代码。。。
        
        //发布用户新建的领域事件
        domainEventPublisher.publish(new UserCreateEvent(user));
    }
}

4. 业务监听类处理事件

对应的业务处理类监听用户新增时间,进行后续处理。

Details
java
@Component
@Slf4j
public class UserEventHandler {

    @EventListener
    public void handleEvent(UserCreateEvent event) {
       //用户删除后,后续执行强相关的链式调用逻辑
    }

}

事件驱动之事务管理

如果使用了事件之后,那事件处理逻辑的成功与否会影响到应用服务内的主逻辑呢?事务还是一体的吗?

INFO

下面举一个在完成某些业务逻辑处理后,记录操作记录的业务场景例子。

使用事件驱动模型的话就变成了我们在应用服务编排完业务逻辑之后,发送一个当前业务的领域事件,后续操作记录监听类将事件内容存储至ES。

但是有的时候可能因为网络波动原因,ES集群响应超时了,操作记录入库失败,即监听类抛出异常。

这时从业务逻辑上来看,操作记录的入库失败,不应该影响到主流程的逻辑执行,需要事务独立。

亦或是,如果主流程执行出错了,那么我们需要触发一个事件,发送钉钉消息到群里进行线上业务监控,需要在主方法逻辑中抛出异常再调用此事件。

而此时如果我们使用的是@EventListener,它的事务逻辑是随着事件发送方的。

也就是说如果主流程报错,后续的告警通知将无法接受,如果事件监听处理报错,主流程数据将入库失败。

为了解决上述问题,Spring为我们提供了两种方式:

  • @TransactionalEventListener注解。
  • 事务同步管理器TransactionSynchronizationManager。

@TransactionalEventListener

Spring在4.2版本之后支持了@TransactionalEventListener,它能够实现在控制事务的同时,完成对事件的处理。我们可以从命名上直接看出,它就是个EventListener。

Spring的事件监听机制在默认情况下并不是解耦的,而是同步地来将代码进行解耦。

而@TransactionEventListener在这种方式的基础之上,加入了回调的方式,这样就能够在事务进行 Commited、Rollback 等时候才去进行Event的处理,来达到事务同步的目的。

Details
java
//被@EventListener标注,表示它能够监听事件
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EventListener
public @interface TransactionalEventListener {

  //表示当前事件跟随消息发送方事务的出发时机,默认为消息发送方事务提交之后才进行处理。
   TransactionPhase phase() default TransactionPhase.AFTER_COMMIT;

   //true时不论发送方是否存在事务均出发当前事件处理逻辑
   boolean fallbackExecution() default false;

   //监听的事件具体类型,还是建议指定一下,避免监听到子类的一些情况出现
   @AliasFor(annotation = EventListener.class, attribute = "classes")
   Class<?>[] value() default {};

   //指向@EventListener对应的值
   @AliasFor(annotation = EventListener.class, attribute = "classes")
   Class<?>[] classes() default {};

   //指向@EventListener对应的值
   String condition() default "";

}
Details
java
public enum TransactionPhase {
   // 指定目标方法在事务commit之前执行
   BEFORE_COMMIT,

   // 指定目标方法在事务commit之后执行
   AFTER_COMMIT,

   // 指定目标方法在事务rollback之后执行
   AFTER_ROLLBACK,

   // 指定目标方法在事务完成时执行,这里的完成是指无论事务是成功提交还是事务回滚了
   AFTER_COMPLETION
}

实践及踩坑

实际应用对比@TransactionEventListener与@EventListener的区别

假设有这样一个业务需求:新增用户,关联角色,增加关联角色赋权操作记录。

针对这个业务需求,我想要实现如下两种事务管理方式。

  • 统一事务:上述三个操作事务一体,无论哪个发生异常,数据统一回滚。
  • 独立事务:上述三个操作事务独立,事件一旦发布,后续发生任意异常均不影响。

1. 统一事务

@EventListener标注的方法是被加入在主流程执行逻辑的事务中的,与主流程事务一体。

因此以下三段逻辑即处于同一事务中,任意方法内抛出异常,所有数据的插入逻辑都会回滚。

(1)用户新增

用户应用服务编排完逻辑后发送用户新增事件。

Details
java
@Service
@Slf4j
public class UserApplicationServiceImpl implements UserApplicationService {

    @Autowired
    DomainEventPublisher domainEventPublisher;

    @Transactional(rollbackFor = Exception.class)
    public void createUser(){
        //省略非关键代码
        save(user);
        domainEventPublisher.publish(new UserCreateEvent(save));
    }
}
(2)用户新增事件处理

标注了@EventListener的用户事件监听类拿到数据进行逻辑处理,然后再次发送一个新的用户角色关联新增事件。

Details
java
@Component
@Slf4j
public class UserEventHandler {

    @Autowired
    DomainEventPublisher domainEventPublisher;

    @Autowired
    UserRoleApplicationService userRoleApplicationService;

    @EventListener
    public void handleEvent(UserEvent event) {
        log.info("接受到用户新增事件:"+event.toString());
        //省略部分数据组装与解析逻辑
        userRoleApplicationService.save(userRole);
        domainEventPublisher.publishEvent(userRoleEvent);
    }

}
(3)用户角色事件处理

最后用户角色事件监听类拿到事件后,进行操作记录入库。

Details
java
@Component
@Slf4j
public class UserRoleEventHandler {

    @Autowired
    UserRoleRecordApplicationService userRoleRecordApplicationService;

    @EventListener
    public void handleEvent(UserRoleEvent event) {
        log.info("接受到userRole事件:"+event.toString());
        //省略部分数据组装与解析逻辑
        userRoleRecordApplicationService.save(record);
    }

}
坑点1

严格意义上来说这里不算是把主逻辑从业务中拆分出来了,还是在同步的事务中。只是为了代码简洁性与函数级逻辑清晰可以这么做。

用户与赋权显然不是强一致性的操作,赋权失败,不应该影响我新增用户,所以这个场景下做DDD改造,不建议使用统一事务。

坑点2

Listener里面的执行逻辑可能比较耗时,需要做异步化处理,在UserEventHandler方法上标注@Async,那么这里与主逻辑的方法事务就隔离开了,监听器内的事务开始独立,将不会影响到userService内的事务。

例如其他代码不变的情况下用户角色服务修改如下,用户新增了,用户角色关联关系新增了,但是操作记录没有新增。

第一个结果好理解,第二个结果就奇怪了,事件监听里面抛了异常,但是居然数据保存成功了。

这里其实是因为UserEventHandler的handleEvent方法外层为嵌套 @Transactional,userRoleService.save操作结束,事务就提交了,后续的抛异常也不影响。为了保持事务一致,在方法上加一个 @Transactional 即可。

Details
java
@Component
@Slf4j
public class UserEventHandler {

    @Autowired
    DomainEventPublisher domainEventPublisher;

    @Autowired
    UserRoleService userRoleService;

    @EventListener
    @Async
    public void handleEvent(UserEvent event) {
        log.info("接受到用户新增事件:"+event.toString());
        //省略部分数据组装与解析逻辑
        userRoleService.save(userRole);
        domainEventPublisher.publishEvent(userRoleEvent);
        throw new RuntimeException("制造一下异常");
    }

}

2. 独立事务

@EventListener作为驱动加载业务、分散代码管理还是比较好用的。

但是在DDD层面,事务数据被杂糅在一起,出了问题一层层找也麻烦,而且数据捆绑较多,建议还是使用@TransactionalEventListener。

我们来看看使用@TransactionalEventListener如何实现上述需求,使用过程中又会有什么样的坑。

与统一事务一样的业务代码,只是把注解从@EventListener更换为@TransactionalEventListener。

但是逻辑执行之后发现了一个神奇的问题,用户角色操作记录数据没有入库。

比较一下两个注解的区别。 @TransactionalEventListener事务独立,且默认注解phase参数值为TransactionPhase.AFTER_COMMIT,即为主逻辑方法事务提交后再执行。

(1)用户 新增
Details
java
@Service
@Slf4j
public class UserServiceImpl implements UserService {

    @Autowired
    DomainEventPublisher domainEventPublisher;

    @Transactional(rollbackFor = Exception.class)
    public void createUser(){
        //省略非关键代码
        save(user);
        domainEventPublisher.publishEvent(userEvent);
    }
}
(2)用户角色关联
Details
java
@Component
@Slf4j
public class UserEventHandler {

    @Autowired
    DomainEventPublisher domainEventPublisher;

    @Autowired
    UserRoleApplicationService userRoleApplicationService;

    @TransactionalEventListener
    public void handleEvent(UserEvent event) {
        log.info("接受到用户新增事件:"+event.toString());
        //省略部分数据组装与解析逻辑
        userRoleApplicationService.save(userRole);
        domainEventPublisher.publishEvent(userRoleEvent);
    }

}
(3)用户角色操作记录
Details
java
@Component
@Slf4j
public class UserRoleEventHandler {

    @Autowired
    UserRoleRecordApplicationService userRoleRecordApplicationService;

    @TransactionalEventListener
    public void handleEvent(UserRoleEvent event) {
        log.info("接受到userRole事件:"+event.toString());
        //省略部分数据组装与解析逻辑
        userRoleRecordApplicationService.save(record);
    }

}

我们知道Spring中事务的提交关键代码在AbstractPlatformTransactionManager.commitTransactionAfterReturning。

在下面代码的地方打上断点,再次执行逻辑。

Details
java
protected void commitTransactionAfterReturning(@Nullable TransactionInfo txInfo) {
   if (txInfo != null && txInfo.getTransactionStatus() != null) {
      if (logger.isTraceEnabled()) {
         logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "]");
      }
      //断点处
      txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
   }
}

配置文件中添加以下配置:

Details
java
logging:
  level:
    org:
      mybatis: debug

第一次userApplicationService保存数据进入此断点,然后进入到userRoleApplicationService.save逻辑,此处不进入断点,后续的操作记录的事件处理方法也没有进入。

看一下日志:

Details
java
- 19:54:38.166, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
- 19:54:38.166, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 19:54:38.167, DEBUG, [,,], [http-nio-8088-exec-6], o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@1832a0d9] will be managed by Spring
- 19:54:38.184, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 19:54:51.423, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Transaction synchronization committing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 19:54:51.423, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 19:54:51.423, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Transaction synchronization closing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@77a74846]
- 19:54:51.430,  INFO, [,,], [http-nio-8088-exec-6], com.examp.event.demo.UserEventHandler - 接受到用户新增事件:com.examp.event.demo.UserEvent@385db2f9
- 19:54:53.602, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
- 19:54:53.602, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9af2818] was not registered for synchronization because synchronization is not active
- 19:54:53.603, DEBUG, [,,], [http-nio-8088-exec-6], o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@1832a0d9] will be managed by Spring
- 19:54:53.622, DEBUG, [,,], [http-nio-8088-exec-6], org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9af2818]

接受到用户新增事件之后的日志

SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@9af2818] was not registered for synchronization because synchronization is not active 说明当前事件是无事务执行的逻辑。

再回过头去看一下@TransactionalEventListener,默认配置是在事务提交后才进行事件执行的,但是这里事务都没有,自然也就不会触发事件了。

总结一下上述 Bug:

1

  • 创建用户后,用户应用服务提交事务,触发userRole事件逻辑处理。
  • 此时userRole事件逻辑处理在默认事件传播机制下,加入事务失败,以非事务方式运行
  • 由于userRole是以非事务方式运行的,则不提交事务,则无法触发操作记录事件监听

解决上面的问题有两种方式

  • 可以对监听此事件的逻辑无脑标注@TransactionalEventListener(fallbackExecution = true),无论事件发送方是否有事务都会触发事件。

  • 在第二个发布事件的上面标注一个@Transactional(propagation = Propagation.REQUIRES_NEW),切记不可直接标注@Transactional。这样因为userApplicationService上事务已经提交,而@Transactional默认事务传播机制为Propagation.REQUIRED,如果当前没有事务,就新建一个事务,如果已经存在一个事务,加入到这个事务中。

事件驱动模型在 DDD 的应用注意点

聚合的命令性操作理论上都应该发布领域事件并存储,便于逻辑扩展与后续事件溯源。

统一事务的处理方式不是DDD的初衷,如果遇到了事件处理逻辑与主流程逻辑事务一体的场景,需要重新思考应用服务的编排逻辑是否合理或者领域模型划分是否正确。

如何落地CQRS

增删改这种指令性操作走DDD的领域建模,而查询就直接走CQRS模型。

CQRS 是什么

CQRS 英文全称 Command Query Responsibility Segregation,翻译过来就是命令查询职责分离。

MVC分层下,一个前端请求从进入后端到处理完成返回前端的过程如下图所示:

1

接受请求:

  • 后端的Controller接口接受来自于前端传递的Request-DTO(Data Transfer Object)实体请求参数
  • Controller将Request-DTO传递给Service层做业务处理
  • Service层解析Request-DTO拆分成DO(Data Object)转给Dao层
  • Dao层与DB使用DO进行交互

返回请求:

  • Dao层从DB取到的数据使用DO进行包装
  • 再返回给Service层进行业务逻辑处理
  • Service层将处理后的DO包装的Return-DTO返回给Controller
  • 最后返回给请求方

INFO

做后端管理系统的时候,报表展示的字段往往跟报表新增的字段是非常类似的。为了偷懒常常会将Request-DTO与Return-DTO定义成同一个DTO。如果说它们之间有字段差异了,就缝缝补补加字段上去,但这样做会有以下三个“致命伤”:

  • Request-DTO与Return-DTO由于字段被混合定义,如果要修改一个字段,找到这个字段的影响点往往特别费劲。

  • 从把DO拷贝给时,经常做的操作就是BeanUtils.copyProperties,特别容易在接口层面返回过多的字段给前端,造成数据泄漏。

  • DTO本身直接是数据传输,承担的是固定的业务数据装载。现在因为职责的模糊化,在业务代码中我们无法直接根据DTO知道业务数据的具体作用是什么,需要结合代码的上下文来理解,增加了系统的复杂度。

本质原因就是:DTO 太万能了,应该把这两者区分开来:

1

这张图分为上、下两个部分。

增删改会修改DB数据的命令我们称为指令性查询,通过Command类型命令参数传递进来,层层处理并入库。

查询是幂等操作,我们通过Query类型的查询参数一层层传递进去,在DB接受请求后返回DO数据,然后再Service层包装成Return-DTO返回。

这样,增删改查的入参都有了各自对应的参数类型,返回的数据结果由DTO来装载。

不仅从名称上一眼就能看出来的作用,在使用上也不会互相混淆字段,将字段修改对业务的影响从设计层面降到最低。

事件溯源

已经把查询与命令的职责分离了,但是为了追求更高的性能,经常会通过分库的形式将数据库分为读库与写库。这种分库操作比如MySQL其实已经有现成的方案做了支撑。

但是还有一种情况,比如需要对数据进行溯源,保证数据的高性能的同时,还要能够各种维度查看数据。

比如支付宝与微信,它就是比较典型的可溯源的系统。每一笔出账,入账都会被记录在账单中,最终账户的余额就是根据这些账单溯源计算得到,也能通过账单查看某一个节点的数据状态。

这种事件溯源的分库方式是怎么样的呢?

在DDD中的应用