dcddc

西米大人的博客

0%

系统学习DDD实践篇

事务脚本

将所有业务功能堆砌在一个大接口里,这样的代码也称为事务脚本,且存在以下问题:

× 可维护性(依赖变化时要改多少代码)

  • 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 接口做编排调用实现业务接口
  • 最后实现基础设施层