Domain Driven Design 简单入门
Domain Driven Design 译为领域驱动设计,通常缩写为 DDD。
MVC 和分层都是很好的,成型的框架,新项目可以快速的以此搭建架构。
而 DDD 则是一个设计思想,而不是具体的框架。
其核心思想体现在要从领域问题出发,设计领域模型,解决领域问题,再设计具体的软件来实现领域模型,从而得到可以解决领域问题的软件系统。
DDD 的领域,本质上指的是业务领域,即软件系统所需要解决的问题的范围。这使得开发人员需要首先关注具体业务,而不是具体的技术。
如果是长期维护的项目,推荐使用 DDD,因为在长期维护中,业务会越来越复杂。到这时已经没人敢用 DDD 重构了,即使有也不一定会成功。
领域驱动设计概念
统一语言
简单来说就是建立对于业务的统一认识,形成统一的概念(即建模),避免 A 说 A,B 说 B 的情况,是在使用中确立的。
以用户订阅专栏系统为例:
- User,是指所有在系统中注册的人员。(领域模型概念)
- 订阅的专栏,指用户付费过的专栏。(领域模型概念)
- 用户可以订阅多个专栏(领域模型逻辑)
- 订阅(限界上下文)
通过定义和解释,使得词语在上下文中没有歧义,就可以通过这些术语来描述业务行为和规则。
有了统一语言后,就可以方便的描述需求和测试用例:
User 可以查阅自己 订阅的专栏 和其中内容
当 User 访问已经 购买的专栏,就不需要重新付费
战略设计和战术设计
战略设计是指对于整个领域的分析和规划,确定领域概念,业务规则等内容。涵盖了领域,子域,限界上下文概念。
战术设计则是在战略设计的基础上,对于领域中的具体问题做具体的解决处理。
领域
领域就是业务范围。例如,技术服务支持系统的领域就是技术服务领域。
子域
子域是指在大领域中,可进一步划分的子领域。例如网络订餐系统,可以划分为订餐域,餐馆域,配送域等。
核心域
决定公司和产品竞争核心的子域就是核心域,是业务成功的主要因素,直接对业务产生价值。
例如网络订餐系统中的订餐域,就是核心域。
通用域
没有太多个性化诉求,同时被多个子域使用的就是通用域,通用域是间接对业务产生价值的。
例如网络订餐系统中的餐馆域就是通用域。
支撑域
支持其他领域业务,但不具备通用性,支撑域间接对业务产生价值。
例如网络订餐系统中的配送域就是支撑域。
限界上下文
就是对于业务边界的划分,这个边界可以是一个或多个子域的集合。
通过保证一个限界上下文必须支持一个完整的业务流程并保证这个业务流程所涉及的领域都在一个限界上下文中,可以有效的划分限界上下文。
例如网络订餐系统中,订餐上下文,就包含订餐域和餐馆域。
领域模型
实体
实体是对领域模型里的一种特殊对象的分类。
是具备唯一标识和状态,具有生命周期的业务对象。
与数据库实体不同,一个领域实体可能不一定存储到数据库,或不一定只存一张表,或可能和别的对象共用一张表。
实体通常分为:
- 失血模型:只有数据定义和 getter/setter。
- 贫血模型:在失血模型基础上扩展了一些业务逻辑,但不包含依赖持久层的逻辑。
- 充血模型:包含所有业务逻辑。
- 胀血模型:包含了业务逻辑和非业务逻辑的其他应用逻辑。
值对象
简单来说就是自定义的数据模型,如果定义不好值对象,会使得开发领域模型成本过大,降低可读性可维护性。
类似字符串对象类型,能够有自己的围绕数值的函数。
定义值对象可以通过类型显示表达业务含义,提升可读性。也可以围绕这该类型做检查,确保其他类型拿到实例时,一定是合格的数据。
值对象应该是不可变的,只在构造时设置值,而不提供修改内部属性函数,就是不可变的。
如果值对象满足数据传输要求,可以承担为 DTO,但不应该为了承担 DTO 而修改值对象格式。
聚合
是一种更大范围的封装,将一组相同生命周期的实体,值对象放在一起考虑。看起来是一组数据对象的集合。本质上是业务规则的控制单元。
聚合包含了自己的数据和逻辑,只有聚合根可以对外暴露引用,一切内部操作都需要通过聚合根。
Order,OrderItem,Payment,Coupon 如果从数据关系上看,都有关联。看起来放在一起就是一个聚合。
但在 DDD 中,要看哪些对象必须在同一个事务内保持业务一致。
例如 Order + OrderItem,下单要求订单金额为 item 金额之和,删除 item 会同步更新总价,已支付订单不可修改 item,规则要求两者始终保持一致。
所以 Order,OrderItem 是一个聚合,Order 是聚合根,OrderItem 是聚合对象。
聚合定义了三个边界:
- 一致性边界,聚合内部规则必须立即一致,例如订单取消,order 状态标记为 CANCELLED,items 释放预占库存等。
- 事务边界,一个事务通常只修改一个聚合,如果领域命令会同时修改多个聚合,则可能是划分问题,或需要领域事件。
- 访问边界,外部只能通过聚合根访问和操作聚合,不允许直接操作聚合内部的其他对象。
好比公司财务部,有会计,出纳;是一个集合,但财务部的关键不是会计和出纳聚在一起,而是财务操作都需要经过财务部。
工厂
不完全是 OOP 的工厂模式,它强调的是封装聚合从无到有的创建过程。关注的是业务上的聚合的从无到有,而不是技术上的对象实例化。
工厂是只针对聚合的。而聚合内的对象由聚合根创建。
仓储
对应的是聚合持久化入口,只对应聚合根,聚合根内部的聚合对象默认隐式归聚合根仓储处理。
即,Order { OrderItems },由 OrderRepository 管理,OrderRepository 管理 orders 和 order_items 两张表。
领域服务
领域模型中无状态的对象,称为领域服务,通常封装了一个算法,或交互过程。
Factory,Repository,CommandHandler,Policy 都是领域服务。
领域事件
也是值对象。
即使不使用 CQRS,事件溯源,也应该建模领域事件。
领域事件是很好的扩展机制,后续回头再补领域事件成本会相较最初高很多。
通常会有:
- ID,标识事件,做幂等
- 事件类型
- 发出事件的聚合类型
- 发出事件的聚合 ID
- 事件内容,业务场景数据
- 事件发生时间
领域事件是聚合执行功能的结果体现,在聚合对象中发布最好。
否则就破坏了聚合封装的目的,使得用户使用聚合时,还需要根据返回值发出不同的领域事件。一旦忘记,就容易出问题。
对于 XX 创建这样的领域事件,因聚合本身还不存在,可以在工厂内直接发送。
如果限于代码或框架无法发送,那么只能妥协。
建议持久化领域事件,可以用来排查业务问题,将来回溯用。
领域事件的语义是广播的,具体实现按具体技术走。
领域命令
也是值对象,表达外部对领域模块的一个期望。不存在返回值说法。
执行领域模型可以有执行结果,除了通过“已创建订单”这样的领域事件外,还可以通过返回订单 ID 表现。
不涉及领域对象的变更的,都是查询,否则就是命令。
领域命令不允许部分成功,只能成功或失败。执行结果应该是强一致的。
领域模型建模
在进行领域驱动设计时,第一步就是需要进行领域模型建模。
建模过程,就是对于领域问题的分析,然后基于分析设计出领域模型作为领域问题的解决方案。
在建模过程中,开发人员会频繁与领域专家互动,获取领域知识。领域专家就是了解领域的角色,也就是了解业务的人员。
领域专家是必需的,因为领域驱动设计是需要从问题域出发的,必须要有需求。
提供需求的人员就是领域专家,因此客户,行业专家,产品经理或开发者自身都可以是领域专家。
事件风暴
在领域建模过程中,事件风暴是一个很有效的办法。
事件风暴就是一种头脑风暴形式的需求分析和建模活动。
简单来说,就是首先提出某个事件,然后基于该事件发散,逐步扩展其他事件。接着梳理业务规则,列举业务行动,引出热点,最后将这些串联起来。
以用户订阅系统为例,它允许用户订阅专栏,作家创建专栏,订阅专栏会有分成给作家。
- 提出某个事件,基于该事件发散。
- 提出用户订阅专栏事件。
- 订阅专栏首先要有专栏存在,需要有创建专栏事件,创建专栏需要有作家,又需要先有创建作家事件...
- 订阅专栏后,会生成订单让用户支付,用户支付后获得专栏相关权利...
- 梳理业务规则。
- 订单支付是否必定成功,不成功可能是因为用户取消了订单,余额不足,账户被冻结,网络问题...
- 订单支付如果成功,是否会引发其他事件...
- 列举业务行动。谁触犯了事件?谁执行了事件?行动前要做什么?之后呢?
- 谁触发了订单支付事件?是用户触发的。
- 谁执行了创建专栏事件?是作家执行的。
- 在订单支付事件前需要获取哪些信息?需要首先获取专栏信息,订阅金额等。
- 引出热点,可能的业务痛点,瓶颈,模糊问题,但不用一次性立即解决。
- 订阅分成如何划分?
- 如果多个用户同时订阅同个专栏会怎么样?
- 订阅分成是按什么规则打款?
- 串联以上内容。
- 作家创建专栏 -> 用户看见专栏 -> 用户订阅专栏 -> 用户支付订单 -> 作家获得分成...
- 由架构师产出架构。
基于事件风暴,以对事件的响应为主要维度寻找事件关联,是一种简单明快的办法。
但它也有不足,需要多名不同角色成员参与,涉及人员过多,流程太长。此外,事件风暴的成功要保证逻辑可以收敛。
四色建模
四色建模法源自 Peter Coad 在研究对象模式时发明的四原型法。
后来,作为一种补充策略,Peter Coad 开始在模型中引入色彩,将模型的动态交互展示在静态的模型中,形成了彩色建模法。
而后,徐昊将事件建模与彩色建模法结合形成了四色建模法,用以获得更具业务含义的模型,以便直接使用模型作为统一语言。
四色建模基于三个关键逻辑寻找领域事件:
- 有现金收入,承担义务,拿钱办事,需要证明履约
- 有现金支出,拥有权利,花钱消灾,需要检查履约
- 无现金往来,通过目标,制定计划,追踪实践结果
建模流程:
- 寻找资金往来,建立凭证,罗列关键数据,如时间点,额度
- 针对关键数据,寻找来源,寻找前一凭证
- 针对现金凭证,思考权责义务,需要哪些凭证证明,寻找后续凭证
- 不论何种凭证,都需要列举关键数据,并保证获取顺畅
- 若与现金往来不大,则寻找目标,并构建凭证验收,重复 1~4
- 围绕相互关联的凭证流每个凭证,寻找参与其中的角色
- 寻找参与方可能扮演的角色
- 描述对象,补充模型说明
花费金额购买 <- 购买金额由订单产生 <- 订单基于定价生成 <- 定价源自运营输入
花费金额购买 -> 拥有购买内容权利
四色建模对最终的模型构造有着明确的要求,凭证需要围绕现金往来或 KPI,凭证必须通过关键数据项明确关联关系。
领域扩展
CQRS
命令和查询职责分离 (Command and Query Responsibility Segregation)。
将业务需求分为不会引起业务变更的查询类和会引发业务变更的命令类。
在采用 CQRS 的 DDD 实践中,查询类需求,尤其是复杂查询通常交由外部读模型单独实现,领域模型通常只实现命令类的业务需求。
但遇到需要内部查询时,可以直接读取,没必要经过外部读模型,因为内部的聚合信息本就不应该依赖外部,除非是特殊场景才有必要依赖外部查询读模型。
这是因为领域模型的本质是维护业务不变量,而不是描述数据。例如已订单不可取消,库存不足不可下单,优惠券不可重复使用等。
执行命令类时,本质上是在请求改变系统状态,而系统状态改变则需要通过业务规则校验。
如果将命令放到领域模型外部,则规则会散落各处,可能还会绕过业务规则,DDD 就没意义了。
而查询则不一样,查询只会观察当前状态,不破坏业务不变量。
例如查询订单列表,罗列用户信息,看错了是展示问题,而改错了则是系统事故。
且查询通常是跨聚合,复杂多变的,例如,“查询某用户最近30天购买且退款失败的订单,并关联物流状态”。
同时,业务规则通常是稳定的,例如订单支付了就不会取消。但查询可能会天天变化,新增筛选,排序等,如果交由领域模型实现,则每次报表改动都需要动核心。
简单的查询直接复用查询聚合可以满足的,复用没问题,但不应该因为不满足查询需求就改动查询聚合。
CQRS 不是数据库架构中的读写分离,而是从架构角度来看的。而实际上,领域模型和查询模块,两者都可能进行读写操作。
六边形架构
也称为端口和适配器架构。
分层架构,分为严格分层架构和松散分层架构。
- 严格分层架构:某层只能与直接位于其下方的层发生耦合。
- 松散分层架构:允许任意上方层与任意下发层发生耦合。
即 UI -> App -> Domain -> Infra。
不论哪种,都遵循每层只能与其下方的层发生耦合这一原则。
但在 DDD 项目中,这一原则会引发问题。
DDD 中 Repository 抽象属于 Domain,实现属于 Infra,导致依赖方向冲突。
因此引入 DIP,依赖倒置 让 Infra 依赖 Domain 抽象,使得依赖关系变为。
而在引入 DIP 后,不论是高层还是低层,实际上依赖的都是抽象,而不是具体的某层,分层概念实际上被改变了,由上下顺序分层,变为了围绕业务核心依赖方向而收缩的一个圆。
Adapter --> Port <-- Core。
而为了避免传统上下分层图带来的视觉暗示,同时便于标注多个离散端口,通常采用六边形进行示意,每条边都可以作为端口挂载适配器。
实际上几边形不重要,N 边形都行,重点在于端口和适配器概念。通过端口隔离业务核心,通过适配器对接外部世界。
端口是核心定义的交互约定,分为输入和输出端口。
- 输入端口,是由外部输入的,提供给外部输入的驱动能力入口,例如 PaymentUseCases。
- 输出端口,是输出给外部的,允许外部驱动进行调动的输出口,例如 Repository,EventPublisher,Gateway 等。
适配器则是端口的具体实现,例如 REST Controller,DatabaseRepository 等。