事务脚本
将所有业务功能堆砌在一个大接口里,这样的代码也称为事务脚本
,且存在以下问题:
× 可维护性(依赖变化时要改多少代码)
- DB 数据结构经常变
- 依赖框架、中间件升级或替换
- 不满足依赖倒置,业务层依赖的还是具体实现
- 三方服务不确定性,例如要接口签名变更、替换或直接断流了
- 不满足依赖倒置,业务层依赖的还是具体实现
× 可扩展性(新需求要改多少代码)
- 数据格式不兼容:接新的服务,新的数据结构,导致老代码逻辑不能复用
- 大量分支逻辑:大量的 if-else 语句带来多分支逻辑,造成分析代码非常困难,容易错过边界情况造成 bug
× 可测性(新需求要增加的测试用例数乘以跑每个用例的时间)
- 测试环境搭建困难(数据库、中间件、三方依赖)
- 运行时间长(启动 Spring、IO 密集型调用)
- 逻辑高度耦合,导致用例数目呈现指数级增长
DDD
DDD 是一套服务于应用内部的架构设计思想,旨在为领域划分的微服务应用提供最大化的代码可读性、可维护性、可扩展性、可测试性
可读性 = 接口参数含义明确、接口实现只专注于最核心的业务逻辑编排
可维护性 = 当依赖变化时,有多少代码需要随之改变
可扩展性 = 做新需求或改逻辑时,需要新增/修改多少代码
可测试性 = 运行每个测试用例所花费的时间 * 每个需求所需要增加的测试用例数量
DDD 的指导思想
贫血模式 VS 充血模式
贫血模式的缺点:
- 由于接口直接访问和修改对象的任意业务属性,无法保证对象的数据一致性,容易出 bug
- 大量重复的对象属性校验、计算逻辑,代码可复用性低
为什么那么多贫血模式?
- 面向数据模型编程的思维,所有的业务逻辑都只是对数据库的 CRUD
- 门槛低,写业务类似写脚本,瀑布式代码将各种逻辑铺满即可
充血模式是怎么做的?
- 对象的业务属性不能随意访问和修改,只能通过对象提供的行为方法来操作,每个行为方法都代表了一个业务领域知识,是一系列属性计算逻辑的聚合
数据模型 VS 领域模型
数据模型指直接和数据库表结构映射的持久化 DO 对象,它的作用域只能在数据层
领域模型是能准确描述业务域核心概念的数据结构,在 DDD 中称为 Entity 实体,是业务逻辑的集中式体现
因此在业务逻辑层,我们应该避免直接使用贫血模式面向数据模型编程,而应该使用充血模式面向领域模型编程,这其实就是 DDD 领域驱动思想的最本质体现
面向领域模型编程的另一个好处是业务逻辑与数据存储的解耦,即使底层更换了数据库,表结构发生很大改变,也不会被业务逻辑层感知,当然中间还要有一层转化适配,这是防腐层和基础设施层做的事情
DDD 的实践
无处不在的 DP
DP 主要提升代码可读性、可测试性
什么是 DP(DomainPrimitive,原始领域对象)
- 是特定领域中,概念明确、职能清晰、无状态的“值对象”,是领域的最小组成部分
- 所谓无状态,指属性一旦赋值不会再发生变化,可以认为 DP 中的数据都是静态的
使用 DP 能解决什么问题?
- 避免接口中定义基本类型的出入参导致语义不清晰,使用 DP 类型取而代之
- 应用层的业务接口、领域层接口和防腐层接口都可以使用 DP 作为参数
- 避免业务接口的实现里耦合参数校验逻辑,转而在 DP 对象构造时完成参数校验
- 避免写出“胶水代码”,而是将其改造为 DP 对象内部的一个属性计算逻辑,通过 get 方法对外暴露
- 业务方法中调用外部服务时,需要从参数中提取一部分属性,实现参数提取的代码称为胶水代码
- 在复杂度很高的业务接口中,会充斥大量胶水代码,影响代码可读性、增加接口单测复杂度
其实这些问题的本质原因是我们习惯将业务接口实现为“事务脚本”,导致业务接口做了太多“非强相关”的事情。引入 DP 可以让业务接口只专注于最核心的业务逻辑编排,将其他的衍生逻辑提取到 DP 内部处理
如何编写 DP
- 深入思考 DP 在业务中的隐性属性,以成员变量的方式显性化定义在 DP 对象内部
- 构造函数里完成属性初始化和自检
- 使用充血模型实现无状态的业务行为
- 业务行为指收敛在这个 DP 对象内部的业务计算逻辑
- DP 对象也可以封装多个其他 DP 对象,实现更复杂的业务计算逻辑
常见的 DP 使用场景:
- 有格式限制的 String:比如 Name,PhoneNumber,OrderNumber,ZipCode,Address 等
- 有限制的 Integer:比如 OrderId,Percentage,Quantity 等
- 可枚举的 int:比如 Status(一般不用 Enum 因为反序列化问题)
- Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、等
- 复杂的数据结构:比如 Map<String, List
>等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为
领域的核心 - Entity
Entity 主要提升代码可扩展性、可测试性
什么是 Entity(实体)?
- 领域主体对象
- 带有 ID 属性,有可以相互映射的持久化 DO 对象
- 状态可变,或叫有状态
使用 Entity 能解决什么问题?
- 核心的领域知识(业务计算逻辑)内聚在 Entity 的行为接口内部,新需求只需要改造 Entity 的领域知识即可,扩展性强
- Entity 包含所有的业务逻辑,且它本身只是一个内存中的对象,易于对业务逻辑做完整的覆盖测试,提高可测性
如何编写 Entity?
- 构造函数里必须初始化所有必要属性并强校验,保证对 Entity 的所有操作(创建、业务行为)都能保证数据一致性
- 推荐使用 Builder 模式来实例化 Entity
- 成员变量和持久化 DO 对象没有必然联系,尽可能将 DO 对象的列属性转换成 DP
- 使用充血模型实现有状态的业务行为
- 业务行为指收敛在这个 Entity 对象内部的业务计算逻辑
- 只能通过业务行为改变 Entity 属性,不能直接用 set 方法修改属性,保证逻辑和数据一致性
- 不能直接将外部实体作为成员变量,否则外部实体的行为可能产生“副作用”,但可以将其他实体的 ID 作为成员变量
- 所谓副作用,指无法保证外部实体的行为变更后不会影响本实体,由此可能产生 bug。本质问题是这种依赖关系破坏了实体的职能边界
- 实体自身的业务行为如果依赖外部实体或外部 DP 等参数,可以将相关逻辑写到领域服务的方法实现里,然后将领域服务作为参数一并传入,在业务行为里调用领域服务
- 注意,如果涉及到多个实体的修改,必须通过领域服务实现,不能在任何一个实体的行为里实现
- 复杂业务里,会存在主实体和子实体,这时主实体就充当
聚合根
的作用,子实体的所有业务行为都只能通过主实体的业务行为暴露,且外部无法单独拿到子实体- 例如交易场景中的主子订单
防腐层 - ACL
防腐层主要提升代码可维护性、可测试性
什么是防腐层?
- 防腐层是业务系统和外部服务之间的隔离带,防腐层通过依赖倒置原则使得业务系统不直接依赖外部服务,而是依赖防腐层提供的抽象 ACL 接口
- 业务系统指应用层。应用层主要指业务接口
- 外部服务指 RPC 服务、中间件、ORM 框架提供的 dao 层服务等
- 由基础设施层实现防腐层接口,对外部服务做适配封装
使用防腐层能解决什么问题?
- 外部服务变化时,业务系统代码相对稳定,提高可维护性
- 防腐层接口易于 Mock,提高可测试性
- 防腐层接口的实现里可以对外部服务做统一的技术优化,例如缓存、降级等,真正做到技术实现和业务逻辑分离
如何实现防腐层?
- 在领域层的一个 package 里定义所依赖外部服务的抽象 ACL 接口
- 接口的入参、出参使用域对象 Entity 和 DP,保证业务系统内部只会操作域对象,使得业务逻辑高度内聚,提高可维护性
- 基础设施层实现 ACL 接口,适配(包装)所有的外部服务,包括外部对象到域对象(Entity、DP)的转换
领域服务 - DomainService
领域服务主要提升代码可维护性、可扩展性
什么是领域服务?
- 领域服务是比单个实体的业务行为更复杂的业务逻辑,通常用来编排多实体间的业务行为或单实体的多个业务行为,起到跨对象事务的作用,保证整体的逻辑和数据一致性
如何实现领域服务?
- 入参出参使用 DP、Entity
- 对 DP、Entity 暴露的业务行为做编排调用
使用领域服务解决什么问题?
- 业务接口的计算逻辑(或者说领域知识)完全不依赖任何外部服务,收敛在领域层内,提高可维护性、可扩展性
- 因此领域层其实没有任何外部依赖
业务接口(也称应用服务、系统服务)
在上面提到的 DP、Entity、ACL、DomainService 的基础上,业务接口只需要对领域服务和外部服务做编排调用即可
DDD 的应用架构
对象模型
Command/Query:
- 接口层入参,对应读写服务
VO:
- 接口层出参
- 转换器:VoAssembler
- 实现 DTO 到 VO 的转换
DP:
- 应用层入参,接口层调用应用层系统服务时转成 DP
- Spi 接口出入参
- 领域层领域服务出入参
- ACL 接口的出入参
- Entity 的成员变量
- 命名:Xxx
- 不需要序列化,内存对象
DTO:
- 应用层出参
- 转化器:DtoAssembler
- 实现 Entity 到 Dto 的转换,推荐使用 MapStruct 作为实现
Entity:
- Spi 接口出入参
- 领域层领域服务出入参
- ACL 接口的出入参
- 命名:XxxEntity
- 不需要序列化,内存对象
DO:
- 基础设施层
- ORM 框架操作的数据对象
- 转化器:DataConverter
- 实现 DO 和 Entity 之间的转换,推荐使用 MapStruct 作为实现
分层模型
业务系统的分层和依赖关系:
Client 层
- 放对外提供的应用服务接口定义
- 入参:Command/Query
- 出参:VO
接口层(网关层)
- 对各种通信协议框架(HTTP、RPC、消息队列、任务调度、socket 通信等)的包装
- session 管理、鉴权、日志、异常处理、前置缓存、限流
- 依赖应用层的系统服务,调用系统服务需要将 Command/Query 转为 DP 对象
- 转换器:VoAssembler,实现 应用层出参 DTO 到 VO 的转换,VO 做脱敏处理
应用层
- 实现领域对外提供的系统服务
- 依赖领域层(通过 Spi 层间接依赖)、Spi 层、Plugins(三方 Spi 实现类)
- 定义防腐层(ACL)接口,包括外部服务和仓储服务,不做具体实现,通过 Spring 依赖注入做依赖反转
- 只负责业务流程编排但不实现任何具体的业务计算逻辑
- 编排:调用领域服务和 ACL 代理接口,调用 spi 接口做业务隔离定制
- 入参 DP,出参 DTO
- 依赖 Spi 加载框架做业务扩展点注入,如 COLA-Extension
- 依赖 Spring 做依赖注入
Spi 层
- 定义为三方提供业务定制能力的 Spi 接口,依赖 Domain 层(DP、Entity)
领域层
- 定义 Entity、DP,实现 DomainService
基础设施层
- 防腐层的 ACL 接口实现,依赖应用层,适配业务系统依赖的所有外部服务
- 依赖三方服务、中间件服务、dao 层 ORM 框架
- dao 层实现推荐 CQRS 架构
DDD 的一般开发模式:
- 先实现领域层和 ACL 防腐层,定义好域对象 Entity 和 DP,定义好 ACL 接口,通过 DomainService 实现领域知识,即业务计算逻辑
- 再实现应用层,对领域服务和适配外部服务的 ACL 接口做编排调用实现业务接口
- 最后实现基础设施层