4

系统学习DDD实践篇

 1 year ago
source link: https://dcbupt.github.io/2023/03/31/blog_article/%E7%B3%BB%E7%BB%9F%E5%AD%A6%E4%B9%A0%E7%B3%BB%E5%88%97/%E7%B3%BB%E7%BB%9F%E5%AD%A6%E4%B9%A0DDD%E5%AE%9E%E8%B7%B5%E7%AF%87/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

系统学习DDD实践篇

2023-03-312023-06-10系统学习系列

Counter not initialized! More info at console err msg.℃ 4.5k 7 分钟

系统学习DDD实践篇

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

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

  • DB 数据结构经常变
  • 依赖框架、中间件升级或替换
    • 不满足依赖倒置,业务层依赖的还是具体实现
  • 三方服务不确定性,例如要接口签名变更、替换或直接断流了
    • 不满足依赖倒置,业务层依赖的还是具体实现

× 可扩展性(新需求要改多少代码)

  • 数据格式不兼容:接新的服务,新的数据结构,导致老代码逻辑不能复用
  • 大量分支逻辑:大量的 if-else 语句带来多分支逻辑,造成分析代码非常困难,容易错过边界情况造成 bug

× 可测性(新需求要增加的测试用例数乘以跑每个用例的时间)

  • 测试环境搭建困难(数据库、中间件、三方依赖)
  • 运行时间长(启动 Spring、IO 密集型调用)
  • 逻辑高度耦合,导致用例数目呈现指数级增长

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:

  • 接口层入参,对应读写服务
  • 接口层出参
  • 转换器:VoAssembler
    • 实现 DTO 到 VO 的转换
  • 应用层入参,接口层调用应用层系统服务时转成 DP
  • Spi 接口出入参
  • 领域层领域服务出入参
  • ACL 接口的出入参
  • Entity 的成员变量
  • 命名:Xxx
  • 不需要序列化,内存对象
  • 应用层出参
  • 转化器:DtoAssembler
    • 实现 Entity 到 Dto 的转换,推荐使用 MapStruct 作为实现

Entity:

  • Spi 接口出入参
  • 领域层领域服务出入参
  • ACL 接口的出入参
  • 命名:XxxEntity
  • 不需要序列化,内存对象
  • 基础设施层
  • 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 接口,依赖 Domain 层(DP、Entity)
    • 定义 Entity、DP,实现 DomainService
  • 基础设施层

    • 防腐层的 ACL 接口实现,依赖应用层,适配业务系统依赖的所有外部服务
    • 依赖三方服务、中间件服务、dao 层 ORM 框架
      • dao 层实现推荐 CQRS 架构

DDD 的一般开发模式:

  • 先实现领域层和 ACL 防腐层,定义好域对象 Entity 和 DP,定义好 ACL 接口,通过 DomainService 实现领域知识,即业务计算逻辑
  • 再实现应用层,对领域服务和适配外部服务的 ACL 接口做编排调用实现业务接口
  • 最后实现基础设施层

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK