WangShuXian6 / blog

FE-BLOG
https://wangshuxian6.github.io/blog/
MIT License
46 stars 10 forks source link

领域驱动设计 Domain-Driven Design DDD #138

Open WangShuXian6 opened 2 years ago

WangShuXian6 commented 2 years ago

领域驱动设计

(Domain-Driven Design,简称DDD)

WangShuXian6 commented 2 years ago

MVC vs DDD

MVC等传统开发方式:以数据为起点去开发整个系统

先动手建立表结构,再针对表数据进行 CRUD 组装出功能点。

业务逻辑极其混乱,为了完成一个功能点,代码就是往上堆

数据库表又是一个很敏感的东西,不会动不动就大改表结构。那为了能够满足日益复杂的需求,你只能够加表、加字段、加 if/else 的逻辑嵌套。

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

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

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

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

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

生命周期

image

用户需求会被生命周期每层参与人理解转化。 用户提出的 A 需求到了产品那里可能被理解成了 A1; 需求评审结束后,进行编码开发需求之前,往往最先做的一步就是设计表结构,此时需求可能被理解成了 A2; 等表结构设计完成,再“自底向上”设计 DAO、Service、Controller,最终产出了 A3。

DDD:从业务角度为起点去开发整个系统

从业务出发自顶向下地去思考一个系统里面各个功能模块的职责与能力

宗旨:内聚与解耦

从业务模型与业务边界出发去设计代码层级结构,将散落的、重复的逻辑内聚到业务模型中。

生命周期

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

电商订单场景

一个电商下单的需求,这会涉及到用户选定商品、下订单、支付订单、订单发货等步骤。

MVC 架构

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

DDD 架构。

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

业务的交互方式

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

贫血模型

MVC 分层下,不论是系统内交互还是系统与外部交互,逻辑都是按照功能点被杂糅在一起。 Service 层利用 DO、DTO、VO 等业务 POJO 作为数据载体,完成了所有模型之间的逻辑处理、数据转换等跟业务有关或者无关的事情。 Service 层臃肿且条理不清晰。就像是吃大乱炖,什么都往里面加,反正最后能吃就行。 而这些 POJO 除了字段属性,内部没有任何的业务逻辑,这就是典型的贫血模型。

充血模型

DDD 核心思想是解耦与内聚 建立领域模型形成聚合根,将原先散落在 Service 层的业务逻辑收拢到领域模型内部,变成充血模型,聚合即为业务。 业务不是像炒大锅饭一样混在一起,而是一道道工序复杂的美食,都有它们自己独立的做法。

DDD 是如何处理这两种交互方式

系统内部交互

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

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

系统与外部交互

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

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

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

DDD 的优势

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

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

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

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

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

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

WangShuXian6 commented 2 years ago

DDD 设计

DDD 不是 MVC 那种业务通用性的分层架构(任何业务都能按照固定的技术形态进行套用)。 它不是一个固定的技术工具形态,针对不同的业务它有不同的呈现方式。

DDD 的核心战略目标是解耦与内聚,为了完成这个战略目标,它定义了领域、子域、限界上下文、通用语言、上下文映射图和架构风格的概念。

领域与子域

领域

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

一种专门活动或事业的范围、部类或部门。 在 DDD 中,领域本身并不是一个学术性很强的概念,任何边界明确的业务都能被称为领域。 比如,一个电商平台中,订单、物流、支付等都是这个平台的领域。

子域

针对一个领域做二次划分它就是子域了。 领域和子域都是相对的概念。 如果把电商平台看成一个大的电商领域,那么订单、物流这些就是它的子域。 但如果把订单看成一个领域,那么商品、订单明细等就是它的子域。

电商系统

可以把电商系统看作领域,然后进行这样的划分: image

把电商系统看成一个大领域,根据功能职责划分为订单子域、物流子域等。 分布式系统中,往往我们把这种细粒度划分出来的子域看成微服务。 把微服务看成一个大的领域范畴,微服务内部的小模块就是我们的子子域。 按照这种方式我们可以建立起一个领域树。

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

核心域

核心域是业务系统的核心,它是业务系统核心价值的体现。 核心域的划分标准是根据系统的定位而决定的。 比如,把桃子树看成一个系统,如果它存在果园中,那么桃子是它的核心域;如果它存在于花园中,那么桃花是它的核心域。

支撑域

这种子域它本身没有核心域对于业务价值那么突出,但是业务系统根据核心域开展业务时又需要依赖它。 比如,安全气囊对于车辆而言,它不会成为车辆这个系统的核心卖点,但是它如果没有,一定会影响到车辆的价值。 并且不同的车型,安全气囊的规格(比如大小)也是不一样的, 这就是支撑域的业务定制性,强业务相关,但又非核心。

通用域

通用域的核心诉求是稳定与高兼容性,它能够被移动至其他的领域下。 比如,在订单领域中,用户与权限就是它的通用域。 同样的,这个子域能够在几乎不修改核心逻辑的情况下被应用至物流领域中。

限界上下文与通用语言

限界上下文意味着特定的、具有明确边界的语义环境,定义了领域的业务边界。 在同一个限界上下文中,我们对于领域内所有内容的认知应该都是一致的。 相信大家在需求开发过程中遇到过跟产品、业务人员、测试“扯皮”的头疼时刻,为了解决这个问题,我们需要有一套通用语言来消除项目相关的人员对领域内的业务逻辑、流程处理规则、专业术语的信息差。

通用语言表示着对领域内的一切动词、名词、形容词达到了一致的认知。 比如,我们在果园的限界上下文里认为桃树是用来生产桃子的,而不是用来开桃花的。

造成子域划分差异的原因 具体语义环境就是上下文

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

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

上下文映射图

电商领域中下有订单领域、物流领域等子域。 商品这个属性在订单上下文与物流上下文中都是存在的,只是在不同的上下文中地位不同而已。 但是,商品的信息会随着业务的进行从订单上下文流转到物流上下文。 这种上下文之间的协作模式可以用上下文映射图表示。

上下文映射图分为以下几种方式。

合作关系

image

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

共享内核

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

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

客户方-供应方开发

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

追随者

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

防腐层

image

上下文 A 与上下文 B 之间不直接进行交互,而是通过定义一个防腐接口进行交互。 上下文 A 至依赖接口的标准,而上下文 B 给接口所提供的逻辑上下文 A 无法直接感知。

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

开放主机服务

image

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

发布语言

image

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

另谋他路

image

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

大泥球

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

架构风格

前面我们从业务领域划分、领域边界确认、上下文协作的角度理清了 DDD 在应对大型应用系统时,如何一步步地拆分业务,关联业务的解决方案。

DDD 的六边形架构

image

在这个架构下,领域模型是逻辑处理的出入口,应用服务层是业务功能点的出入口,是整个系统对外的门面。 一切外部输入均需要通过应用服务层来处理,再通过应用服务层返回。 这种方式下,我们平等地看待例如 Web、RPC、MQ 等外部服务,认为它们都属于用户接口层。 所有的外部服务通过应用服务提供的接口来访问领域模型。

WangShuXian6 commented 2 years ago

DDD 的战略设计

DDD 的战略设计包含了:领域、子域、限界上下文、通用语言、上下文映射图和架构风格。 战术设计为了匹配战略设计主要包括以下概念:聚合、聚合根、实体、值对象、应用服务、领域服务、仓储、事件模型等。

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

领域/子域是 DDD 战略设计中最核心的业务体现。 那么对应到代码层面,领域/子域的概念的呈现方式是什么呢?答案是:聚合。 为了描述聚合内部的属性,DDD 定义了实体与值对象的概念。 最后,领域的逻辑呈现要在一个限界上下文中才有意义,必然要有一个概念来包括下领域的逻辑与定义业务的边界,这个就是聚合根。

实体

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

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

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

值对象

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

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

比如,双十一我们在淘宝购买商品的订单,订单中会包含地址,地址就是典型的值对象。 只要省、市、区与详细地址一致,就判断它是同一个地址,并且这个地址一旦确认下来之后就不会产生属性的变更。

等等,好像不对,我下的订单明明可以修改地址啊,这不是可变的吗?

这里很容易进入一个误区,我们认为修改地址是在原有的地址上进行的修改,但实际我们是给了一个新的地址直接去替换的原来的地址。如果需要对值对象作出修改,那就整体替换。

聚合

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

比如下图,我定义了一个用户领域,那么我会划分出一个用户的聚合包,把专属于用户领域的内容放在 com.baiyan.ddd.domain.aggregate.user 这个包下。 image

聚合根

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

如果说聚合是领域的抽象体现,那么聚合根就是领域的具象体现,它是一种特殊的实体。 聚合根内部定义了当前领域需要的业务属性(实体与值对象),并且包含了该领域内所有的业务逻辑定义。

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

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

包含关系

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

生命周期关系

这个从包含角度其实就很明显,聚合根里面包含了实体与值对象。 也就是说实体的生命周期是捆绑着聚合根的,由聚合根来维护。 而值对象不存在生命周期,只能被整体替换。

标识关系

聚合根本身就是实体,它的 ID 就是它的唯一标识。 但是实体的唯一标识是仅针对当前聚合根而言的,就像商品实体能够被订单聚合关联,也能被物流聚合关联。 值对象在聚合内部的唯一性通过属性相等判断实现。

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

在领域建模过程中怎么来划分实体、值对象与聚合根?

以新建用户,新建过程中需要给赋予角色这个需求为例 角色能不能独立开展业务,是否有独立的生命周期 分别根据角色的不同定位来划分一下这里的关联关系。

角色非独立维护

整个系统中的角色不是独立开展的业务,比如我们定义了一个角色的枚举类, 系统的用户只能关联这个枚举类对应的角色。 这个时候,角色在用户聚合根内就是值对象,因为此时角色满足了不变性与属性判断相等这两个条件。

示例代码


/**
* 用户聚合根
*
* @author baiyan
*/
public class User implements AggregateRoot {
​
/**
* 用户id
*/
private Long id;
​
//省略非关键属性
​
/**
* 角色值对象
*/
private List<Role> roles;
​
//省略业务逻辑方法

​ } ​ /**

角色独立维护

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

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

示例代码


/**
* 用户聚合根
*
* @author baiyan
*/
public class User implements AggregateRoot {
​
/**
* 用户id
*/
private Long id;
​
//省略非关键属性
​
/**
* 角色实体,这里也可以直接是
* private List<Long> roleIds;
* 包装成POJO,业务语义更强,表示这是实体,区分于本身领域内部的基础业务字段
*/
private List<Role> roles;
​
//省略业务逻辑方法

​ } ​ /**

应用服务与领域服务

领域的具象体现——聚合根 此时,原子化的业务逻辑都被定义在了聚合根内部,这也是 DDD 所推崇的解耦与内聚思想 一个聚合根只代表了一个领域的业务, 而我们系统的功能体现往往是多个领域聚合协作的,对应了战略设计里面的上下文协作。 为了完成这种协作逻辑,战术设计中定义了应用服务层与领域服务层。

应用服务

应用服务可以看作是一个流程编排引擎,它本身不承担任何业务逻辑处理。 应用服务可以理解为功能用例层, 比如新建用户,这个功能就应该定义在应用服务层。 但是新建用户是一个比较繁琐的流程,比如涉及到关联角色等业务逻辑处理。 这些业务逻辑处理应该被定义在用户聚合根内部,而应用服务只负责调用定义在聚合根内部的方法就好了,屏蔽的业务逻辑的具体实现。

应用服务表象定位与 MVC 中的 Service 比较像, 但是 Service 内部充满了功能点的逻辑处理,而应用服务相对来说是比较薄的一层,它只做逻辑编排。 参数校验、聚合根方法调用、外部服务调用、持久化聚合根等与业务流程走向相关,业务逻辑无关的代码均可定义在此处。

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

领域服务

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

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

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

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

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

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

仓储

为了内聚业务逻辑,应用服务层编排的都是聚合根的业务逻辑,也就是说我们一直在应用服务内操作的都是领域模型。 但是领域模型是针对于业务层面的,而领域模型处理完业务之后需要通过数据层存储。 数据层对应的是数据模型,为了桥接数据模型与领域模型,DDD 在战术设计中提出了仓储的概念。

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

事件模型

虽然按照上述的方式我们已经可以在战术上切合战略设计,但是貌似应用服务为了完成一个功能要做一些都不是这个功能点的事情。

比如下订单后,给用户增长积分与赠送优惠券的需求。如果在应用服务内实现,用户逻辑处理完,数据入库成功后,再依次调用用户增长积分的外部服务接口与赠送优惠券的外部服务接口。

到这里是不是很奇怪?我一个订单领域,已经把下订单这个事情做完了,但是却还要调用其他的三方服务的接口通知它们订单生成这个事情。如果后续通知的接口越来越多,对于应用服务简直就是灾难。

为了解决这个耦合严重的鸡肋点,DDD 的战术设计中提出了事件模型。下单完成后,发布一个下单完成的领域事件,让需要感知这个事件的服务自行监听并处理,忽略不相关的领域活动。

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

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

WangShuXian6 commented 2 years ago

DDD 事件风暴

DDD 如何消除需求分析与同步过程中的信息不对称

从工程角度来看,DDD 很多专有名词与结构划分都是基于解耦模式下的套路。 从落地 DDD 的过程来看,有两个问题是最困难且最重要的:一个是界定出一个系统中有多少个聚合,即划分多少个业务模型;另一个是界定出每个聚合之间的限界上下文,即划分清楚领域的业务边界。

为了解决以上两个痛点问题,一种被叫作事件风暴的轻量型系统分析方法被提出。

事件风暴的概念及流程

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

事件风暴的分析过程就像在讲述一个个的用户故事。 通过一个个的用户故事来统一开发人员、业务人员、UX、测试等项目参与者对业务流程的认知,这包括关键的流程、核心的业务规则、系统不同模块的使用。 其次是帮助开发人员梳理清楚领域模型与业务边界。

用户故事的分析

事件风暴对于用户故事的分析的最简核心流程: image 事件风暴的核心流程就是:用户执行了命令,从而产生了事件。

事件:

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

当然上面的只是一个理想化的最简业务流程。 但事实上,一个业务系统的业务逻辑绝不是这么简单的。 比如,“合同已被签署”事件发生后,需要通知财务系统触发“发起扣款”动作; 根据签署合同的类别产生“发送优惠券”的动作; 以上两种动作都会导致产生新的事件。

命令/动作:

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

角色/执行者:

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

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

image

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

策略/业务规则:

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

数据/读模型:

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

外部系统:

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

分析业务系统

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

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

事件风暴的开展事项

事件风暴主要包括参与人员、准备工作和建模讨论这三个大的事项。

第一个事项,参与人员。

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

第二个事项,准备工作。

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

第三个事项,建模讨论。

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

电商分期购车订单业务场景

事件风暴的分析流程 image

产品从用户或者需求分析师处调研得到第一版需求后,需要先整理出第一个版本的事件列表,并且根据时间的先后顺序进行排列。

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

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

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

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

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

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

按照上面的思考与探索方式,根据约定的事件进行反推与逻辑归并,最后将得到业务领域聚合。比如,上图的分期购车逻辑将会反推得到订单领域,再根据订单所承载的业务逻辑得到订单的业务属性(从上可得知订单合同签署状态与关联的合同信息是订单的业务属性)。订单的业务属性被敲定后,即可自顶向下将业务模型开始转化为数据模型。

总结

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

通过事件风暴,我们能够得到业务参与人总结出的业务聚合与聚合所承载的逻辑功能,进而分析得到业务聚合所包含的业务属性。完成业务建模后,就可以根据业务模型去设计数据模型了。这种自顶而下的分析模式从业务角度出发,让数据模型更加适配业务模型,而不是常规 MVC 设计下的数据模型去套用业务模型。

WangShuXian6 commented 2 years ago

DDD 项目架构

通过三层架构对比演进的方式介绍常用的两种 DDD 架构。

六边形架构演进之路

MVC 的分层架构

image

从上往下依次对应了用户接口层、业务逻辑层与数据服务层。 它的显著优势就是:结构足够简单,不管业务简单还是业务复杂的系统都能往上套。 因为本质上它的分层思想是工程化分包思想,而不是业务化分包思想。

六边形架构

为了将纯工程化思想转化为业务驱动架构思想,DDD 提出了六边形架构来解决日益庞大的系统维护困难的问题。 DDD 的架构分层模型不止六边形架构这一种。 DDD 的架构在市面上被说到比较多的就是四层架构、五层架构与六边形架构。

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

MVC 直接映射 DDD 分层

还记得战略设计中 MVC 直接映射 DDD 分层的这张图吗? image 它的分层思想依赖关系即符合了 DDD 的在战术设计上的分层,又跟 MVC 的分层极为类似。 从上往下, 用户接口层对应了 MVC 中的 Controller 层, MVC 中的 Service 层被拆分成了应用层(用于编排逻辑)和领域层(实际业务逻辑编写), 最底层的基础设施层对应了 MVC 中的 Dao 层。

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

为了解决这个问题,世界级编程大师 Robert C. Martin 提出了改进四层架构的思想:依赖倒置。他认为: 高层模块不应该依赖于底层模块,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。

在四层架构的世界中,上级需要做什么事情都是需要下级实际拥有这个能力,上级直接调用才能完成。 而依赖倒置之后,只要下级定义了能力的接口,上级就可以通过依赖注入的方式来直接注入接口,调用接口方法即可。 而下级对应的接口逻辑实现,被放置在基础设施层,提到了最上层,如下图所示: image

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

传统DDD分层架构

即在 Spring 的项目中, Maven 的模块依赖 Infrastructure Module依赖Application Module, Application Module依赖了Domain Module。 项目结构如下图所示 image

Infrastructure(基础设施层):

提供系统运转的非业务逻辑的基础能力,支撑系统运转。

User Interface(用户接口层):

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

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

Application(应用服务层):

编排领域层业务逻辑、参数校验、事务控制等。

Domain(领域层):

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

灰度分层架构

传统的分层架构在大多数业务场景下是没有问题的。 但在实际业务中,领域层的领域服务为了完成原子化的业务逻辑难免会依赖应用服务。 为了保证领域层足够纯粹,增加了 Interface(灰度层) 这一层,在里面定义了领域层需要调用依赖的接口,在基础设施中去实现调用应用服务。

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

代码分层如下: image

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

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

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

能力分层架构

传统六边形架构在应对复杂业务场景时可能会出现的逻辑混乱问题:

Interface 层是为了解决领域服务在处理原子化逻辑的时候,可能出现依赖其他应用服务或者领域服务能力的情况,但是它又可能导致代码混乱的问题,看上去好像发生死锁了。

解决原子化逻辑定义的位置

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

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

私有方法 [违背了应用服务层的对外定义]

在编写 MVC 架构下 Service 的代码时,为了包装一个显著的逻辑,需要定义一个私有的方法

那在 DDD 里面是不是也是可以这么做呢?

显然是可以的,我们可以定义一个私有方法去包装这个原子化逻辑,主方法的逻辑就很清晰。

那么问题来了,这段逻辑如果其他的应用服务也需要使用呢?有人会说把私有方法变成公有方法开放出去。

应用服务是什么?整个系统对外提供的功能点出入口,你的这个逻辑只能被系统内部所使用,外部根本用不到。违背了应用服务层的对外定义。

能力层

所以,为了防止这段逻辑,我们定义了一个中间层——能力层。它介于领域层与应用层之间,用于表达原子化的领域逻辑,它的编码规范与应用服务一致,即只能编排逻辑。 image

比如,我现在要实现新建用户这个需求。可以有两种方式: 一种是直接在应用服务内编排完你的新建用户逻辑; 另外一种就是定义一个新建用户的能力层,A 应用服务可以调用这个能力层完成用户新建,B 应用服务也可以调用能力层完成用户新建。

能力层的调用与被调关系 image

每层的调用关系为:

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

总结

六边形架构通过依赖倒置来纯粹化领域层依赖 传统分层架构、灰度分层架构与能力分层架构在不同业务场景与实际应用场景下优缺点。

能力分层架构不是最好的

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

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

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

WangShuXian6 commented 2 years ago

应用服务与领域服务

应用服务

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

在 MVC 的分层逻辑里面去实现一个新增用户的需求

@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 的应用服务来解决上面的问题

@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));
    }

新建用户的流程就变成了如下

1 通过固定的转换方法或者工厂类新建出我们的领域模型; 2 调用领域模型内部方法去处理类似加解密逻辑等与用户领域模型强相关的业务逻辑; 3 调用仓储直接存储领域模型,屏蔽底层的数据模型; 4 发送用户新增领域事件,让需要感知到的其他领域自行监听事件,解耦用户新增与其他不管的领域处理逻辑。

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

判断 业务逻辑 编排逻辑

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

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

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

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

领域服务

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

领域服务不是 DDD 落地代码中必须要存在的一层,应用服务也能直接实现这个需求

在应用服务中一大串的编排其实只是为了完成一个原子化的逻辑。需要抽离该原子逻辑形成领域服务

例子:现在有用户与角色两个聚合

新增用户时,需要关联角色,需要根据角色的类别设置用户的类型标签。

这段逻辑直接在应用服务实现的伪代码

//工厂或者转换方法获取用户聚合User
​
Role role = roleRepository.ById(user.getRole().getId());
String roleTag = role.createRoleTag();
user.bindTag(roleTag);
​
//省略后续处理

这个逻辑的处理过程为:

1 调用角色仓储拿到角色聚合根; 2 角色聚合根根据属性创建标签; 3 用户聚合根绑定标签。

上面的三个步骤是一个原子化的逻辑

这段逻辑能被放到用户的聚合根中吗?

显然不能,因为这里关联到了角色的聚合根,而用户聚合根本身应该是纯粹的,不能突破它本身的限界上下文。 为了凸显出这个逻辑,我们在聚合根与应用服务之间插入一个中间方——领域服务来完成这个事情。

有了领域层之后应用服务层的代码

应用服务中此段逻辑的语义非常清晰。


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);
  }
​
}

用户聚合根与角色聚合根协作完成了用户领域下强业务的原子化逻辑处理。

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

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

Interface(灰度层)

能力层

WangShuXian6 commented 2 years ago

仓储层(Repository)

DDD 战术设计中的数据防腐层

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

仓储

为每种需要全局访问的对象类型创建一个对象,这个对象就相当于该类型的所有对象在内存中的一个集合的“替身”。通过一个众所周知的接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。提供根据具体标准来挑选对象的方法,并返回属性值满足查询标准的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。只为那些确实需要直接访问的 Aggregate 提供 Repository。让客户始终聚焦于型,而将所有对象存储和访问操作交给 Repository 来完成。

上文通俗来讲就是:当领域模型一旦建立之后,你不应该关心领域模型的存取方式;仓储就相当于一个功能强大的仓库,你告诉它唯一标识,例如用户ID,它就能把你想要的数据组装成领域模型一口气返回给你。 存储时也一样,你把整个用户领域模型给它,至于它怎么拆分,放到什么存储介质(DB、Redis、ES 等),这都不是你业务应该关心的事。你完全信任仓储能帮助你完成数据管理工作。

为什么要用仓储

贫血模型的缺点

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

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

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

代码逻辑重复。

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

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

代码的健壮性差。

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

强依赖底层实现。

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

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

领域模型与数据模型

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

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

为什么系统大多是基于贫血模型开发的

数据库思维。

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

贫血模型“简单”。

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

脚本思维。

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

解决方案

出现以上情况的根本原因就是我们混淆了两个概念:领域模型与数据模型

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

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

仓储在 DDD 中应用落地的流程与规范。

落地流程

前端参数->领域模型->数据模型转换流程 image

入参指令化。

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

Command 转聚合。

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

Converter 衔接模型。

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

一个指令转化为存储介质数据的流程

首先,参数或者 Command 通过转换方法或者工厂类初始化领域模型;

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

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

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

仓储规范

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

第一,统一接口方法,无底层逻辑。

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

第二,出入参仅为领域模型与唯一ID。

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

第三,避免一个仓储走天下。

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

第四,仓储只做模型的转换,不处理业务逻辑。

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

image

第五,仓储内尽量不控制事务。

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

CQRS 命令查询职责分离

Command Query Responsibility Segregation 它在 DDD 中的理论体现可总结为如下流程图: image

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

从数据角度来看,增删改数据非幂等操作,任何一个动作都能对数据进行改动,称为危险行为。 而查询,不会因为你查询次数的改变,而去修改到数据,称为安全行为。 而往往功能迭代过程中,数据修改的逻辑还是复杂的,因此建模也都是针对于增删改数据而言的。

查询数据原则

原则一:构建独立查询仓储。

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

原则二:不要越权。

不要在查询仓储内中做太多的 SQL 逻辑,查询逻辑应该在功能点的 queryApplicationService 中体现。

原则三:利用好 Assembler。

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

当然我还看到过这样的文章,如果查询结果足够简单,甚至可以直接在 Controller 层调用 Mapper 查询结果返回。 除非是一个固定的字典服务或者规则表,否则哪怕业务再简单,你的业务也会迭代,后续查询模型变化了,DAO 层里面的查询逻辑就外溢到用户交互层,显然得不偿失。

ORM 框架选型

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

可以将领域模型的定义与 ORM 框架的应用分离,单独定义 Converter 去实现领域模型与数据模型之间的转换

如果是新系统或者迁移时间足够多,使用JPA

MyBatis

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

JPA

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

Demo

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

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

1 新增用户; 2 修改用户; 3 删除用户; 4 用户数据在列表页分页展示。

领域模型

用户的领域模型在应用服务中通过业务逻辑编排处理后得到,然后通过仓储保存到 DB。


@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);
}

}

louyanfeng25 commented 2 years ago

收费内容,请删除