Skip to content

DDD分享

为什么要做DDD

现在的MVC开发模式有什么问题?

现有的业务代码里经常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。这样的代码样式叫做事务脚本Transaction Script。 事务脚本的写法在实现功能上没什么问题,长久来看主要存在以下几个问题:

可维护性差

一个应用最大的成本一般都不是来自于开发阶段,而是应用整个生命周期的总维护成本。有个公式:

可维护性 = 当依赖变化时,有多少代码需要随之改变

  • 数据结构的不稳定性。目前的数据结构是和表字段映射的(例如OrderInfo),而数据库的表结构和设计是应用的外部依赖,长远来看都有可能会改变,如果修改或者删除一个字段的可能要走查所有相关联的地方并做出修好。非常耗时且容易出bug
  • 依赖升级。例如订单的mapper是依赖mybatis的,如果要升级mybatis版本,可能会有一些用法不同,或者以后彻底换一个ORM来做,都是巨大的改动量。且升级版本可能会有一些隐性问题,这是不可预估的
  • 第三方依赖不稳定。服务不稳定,接口入参和返回参数变化,这类都是可能会变的
  • 中间件更换,从RabbitMQ换到Kafka之类的

如果以上问题频繁出现,那么时间基本上都会被各种库升级、依赖服务升级、中间件升级、jar包冲突等占满,最终这个应用变成了一个不敢升级、不敢部署、不敢写新功能、并且随时会爆发的炸弹

可扩展性差

可扩展性 = 做新需求或改逻辑时,需要新增/修改多少代码

  • 逻辑和数据存储的相互依赖。当业务逻辑增加变得越来越复杂时,新加入的逻辑很有可能需要对数据库schema或消息格式做变更。而变更了数据格式后会导致原有的其他逻辑需要一起跟着动。
  • 业务逻辑无法复用。有的方法为了能复用、兼容多个场景,会添加入参、在里面增加if/else,一旦这种分支逻辑多且参数多就会导致分析代码困难,容易错过边界情况,造成bug。

在事务脚本式的架构下,一般做第一个需求都非常的快,但是做第N个需求时需要的时间很有可能是呈指数级上升的,绝大部分时间花费在老功能的重构和兼容上,最终你的创新速度会跌为0,促使老应用被推翻重构。

可测试性差

除了部分工具类、框架类和中间件类的代码有比较高的测试覆盖之外,我们在日常工作中很难看到业务代码有比较好的测试覆盖,而绝大部分的上线前的测试属于人肉的“集成测试”。

可测试性 = 运行每个测试用例所花费的时间 * 每个需求所需要增加的测试用例数量

可测试性差的原因有:

  1. 设施搭建困难。特别是代码中依赖了数据库、第三方服务、中间件等,想跑成一个测试用例,成本非常大。另外也有可能由于依赖的某个节点问题会导致整体的测试时间延长
  2. 运行耗时长。大多数的外部依赖调用都是I/O密集型,如跨网络调用、磁盘调用等,而这种I/O调用在测试时需要耗时很久。另一个经常依赖的是笨重的框架如Spring,启动Spring容器通常需要很久。当一个测试用例需要花超过10秒钟才能跑通时,绝大部分开发都不会很频繁的测试。
  3. 业务迭代后期场景复杂度高。假如一段脚本中有A、B、C三个子步骤,而每个步骤有N个可能的状态,当多个子步骤耦合度高时,为了完整覆盖所有用例,最多需要有N * N * N个测试用例。当耦合的子步骤越多时,需要的测试用例呈指数级增长。一般的情况是可能只测试自己改动的那一小部分。容易导致边界bug

代码中的很多地方违背SRP单一职责原则(例如在Controller层写业务代码),代码复杂度增加、逻辑分支越来越多,最终造成bug或者没人敢重构

总结

基于以上问题,采用DDD应用架构来重构订单业务

订单中台DDD应用架构介绍

应用架构图

starter层

Start模块是SpringBoot的启动类

interface层

  • 职责:主要负责承接网络协议的转化、Session管理、限流、前置缓存、日志等
  • 接口数量:避免所谓的统一API,不必人为限制接口类的数量,每个/每类业务对应一套接口即可,接口参数应该符合业务需求,避免大而全的入参

    刻意去追求接口的统一通常会导致方法中的参数膨胀,或者导致方法的膨胀,最终会导致越来越难维护

  • 接口出参:统一返回ResponseBase
  • 异常处理:应该捕捉所有异常,避免异常信息的泄漏。可以通过AOP统一处理,避免代码里有大量重复代码。

入参转换器: DTO —> Command、Query、Event

规范1:interface层的HTTP和RPC接口,返回值为ResponseBase,捕捉所有异常

规范2:一个interface层的类应该是“小而美”的,应该是面向“一个单一的业务”或“一类同样需求的业务”,需要尽量避免用同一个类承接不同类型业务的需求

规则2符合了Single Responsibility Principle单一职责原则,也就是说一个接口类仅仅会因为一个(或一类)业务的变化而变化,而不会影响其他类

关于 规范2

因为在DDD分层架构里,接口类的核心作用仅仅是协议层,每类业务的协议可以是不同的,而真实的业务逻辑会沉淀到应用层。也就是说Interface和Application的关系是多对多的:

业务需求是快速变化的,所以接口层也要跟着快速变化,通过独立的接口层可以避免业务间相互影响。但是我们期望application是相对稳定的,所以没有和接口层一一对应

所以,回到订单的DDD应用架构图,也是类似的,starter、interface、application是为了符合SRP原则(module维度,非接口维度),后面的order-doman等是相对稳定的

application层

负责业务流程的编排,但本身不负责任何业务逻辑,即胶水代码,剥离了校验逻辑、领域计算、持久化等逻辑之后的剩余流程。

  • 入参:具像化Command、Query、Event对象作为ApplicationService的入参,唯一可以的例外是单ID查询的场景。
    • CQE是有明确的”意图“的,所以这个对象必须保证其”正确性“,通过命名和校验来实现
    • 规范:CQE对象的校验应该前置,避免在ApplicationService里做参数的校验。可以通过JSR303/380和Spring Validation来实现
  • 出参:统一返回DTO,而不是Entity或DO。DTO对象只是数据容器,只是为了和外部交互,所以本身不包含任何逻辑,是贫血对象。
    • ApplicationService应该永远返回DTO而不是Entity.因为:
      • ApplicationService的入参是CQE对象,出参是DTO,这些基本上都属于简单的POJO,来确保Application层的内外互相不影响。
      • 降低规则依赖:Entity里面通常会包含业务规则,如果ApplicationService返回Entity,则会导致调用方直接依赖业务规则。如果内部规则变更可能直接影响到外部。
      • 通过DTO组合降低成本:Entity是有限的,DTO可以是多个Entity、VO的自由组合,一次性封装成复杂DTO,或者有选择的抽取部分参数封装成DTO可以降低对外的成本。
  • CQE的语意化:CQE对象有语意,不同用例之间语意不同,即使参数一样也要避免复用,禁止类似继承已有的CQE对象的操作。
  • 异常处理:不统一捕捉异常,可以随意抛异常。

转换器: 领域模型转换为可以对外的DTO

规范:Application层的所有接口返回值为DTO,不负责处理异常。除非需要特殊处理,否则不需要刻意捕捉异常

如何判断application层是流程的编排,而不是业务逻辑

  1. 不要有if/else分支逻辑,让代码的Cyclomatic Complexity(循环复杂度)应该尽量等于1,不是一定等于1,因为某些场景可以包含if/else,例如中断条件,比如订单不存在,可以使用if/else,因为测试的终端属于Precondition
  2. 不要有任何计算
  3. 不要有具体的数据转换操作

domain层

依赖反转原则(Dependency Inversion Principle):依赖反转原则要求在代码中依赖抽象,而不是具体的实现。这个有一个架构,叫做六边形架构(或者端口和适配器架构)

UI层、DB层、和各种中间件层实际上是没有本质上区别的,都只是数据的输入和输出,而不是在传统架构中的最上层和最下层。

infrastructure层

基础设施层,负责抽象的具体实现

types层

Types模块是保存可以对外暴露的Domain Primitives的地方。Domain Primitives因为是无状态的逻辑,可以对外暴露,所以经常被包含在对外的API接口中,需要单独成为模块。Types模块不依赖任何类库,纯 POJO 。

案例:

不使用DP对象:

public class User {
// 定义user属性
Long userId;
String name;
String phone;
String address;
Long repId;
}
// 接口方法
User register(String name, String phone, String address)

使用DP对象后:

public class User {
// 使用DP
UserId userId;
Name name;
PhoneNumber phone;
Address address;
RepId repId;
}
// 接口方法
User register(
@NotNull Name name,
@NotNull PhoneNumber phone,
@NotNull Address address
)

DP是生成了一个 Type(数据类型)和一个 Class(类)(例如PhoneNumber):

  • Type 指我们在今后的代码里可以通过 PhoneNumber 去显性的标识电话号这个概念
  • Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里

DP的优点:

  • 接口非常可读
  • 校验逻辑内聚
  • 不可变,线程安全
  • 隐含概念显性化

参考: https://mp.weixin.qq.com/s/kpXklmidsidZEiHNw57QAQ

DDD应用架构有效的解决了传统架构中的问题

  1. 高可维护性:当外部依赖变更时,内部代码只用变更跟外部对接的模块,其他业务逻辑不变。
  2. 高可扩展性:做新功能时,绝大部分的代码都能复用,仅需要增加核心业务逻辑即可。DDD有严格的分层及依赖关系,会写出SRP的类,扩展性会越来越好
  3. 高可测试性:每个拆分出来的模块都符合单一性原则,绝大部分不依赖框架,可以快速的单元测试,做到100%覆盖。
    1. order-types,order-domain模块都是属于无直接外部依赖的纯POJO,基本上都可以100%的被单元测试覆盖。
    2. order-application模块的代码依赖的是外部抽象类,需要通过Mock掉所有的外部依赖,可以100%单元测试。
    3. order-infrastructure模块的代码相对独立,接口数量比较少,相对比较容易写单测。但是由于依赖了外部I/O,速度上不可能很快,但好在模块的变动不会很频繁,属于一劳永逸。
    4. order-interface模块的逻辑都后置到order-application中,Controller的逻辑变得极为简单,在测试时把Controller依赖的服务类都Mock掉,那么order-interface整体很容易100%覆盖。
    5. Start模块:通常应用的集成测试写在start里。当其他模块的单元测试都能100%覆盖后,集成测试用来验证整体链路的真实性。
  4. 代码结构清晰:通过POM module可以解决模块间的依赖关系, 所有外接模块都可以单独独立成Jar包被复用。形成规范后,可以快速的定位到相关代码。

DDD应用架构总结

  1. 独立于框架。不依赖某个外部的库或框架,不被框架的结构所束缚
  2. 独立于UI。底层架构不会随着前台展示的变化而变化
  3. 独立于底层数据源。不会因为不同的底层数据储存方式而产生巨大改变。因为核心领域在order-domain,且有基础设施层保证
  4. 独立于外部依赖。无论外部依赖如何变更、升级,业务的核心逻辑不会有大幅变化
  5. 可测试性。业务的逻辑应该都能够快速被验证正确性

基于DDD的思想,在承接业务时,我们可能会先写Domain层的业务逻辑,然后再写Application层的组件编排,最后才写每个外部依赖的具体实现。这种架构思路和代码组织结构就叫做Domain-Driven Design(领域驱动设计),和之前我们MVC的编码思考方式不同

DDD中核心模块的代码的演进

  • order-application层属于Use Case(业务用例)。业务用例一般都是描述比较大方向的需求,接口相对稳定,特别是对外的接口一般不会频繁变更。添加业务用例可以通过新增Application Service或者新增接口实现功能的扩展。
  • order-domain层属于核心业务逻辑,属于经常被修改的地方。
  • order-infrastructure层属于最低频变更的。一般这个层的模块只有在外部依赖变更了之后才会跟着升级,而外部依赖的变更频率一般远低于业务逻辑的变更频率。