DDD实践反思

某大型互联网公司于2019年开始在XX中台财务域进行DDD实践。事后回顾,整体并没有达到预期的效果,个人也做了很多的反思和总结,形成此文。

1. 背景

为什么当时要实践DDD?其中的缘由比较复杂,可以从外部和内部两个视角来看。

首先,从外部也即整个BU的视角来看,最先开始实践DDD的是A域,并在该域诞生了一套在公司现有RPC框架之上的业务SPI框架(以下简称为【N框架】)。相较于dubbo这样纯技术的服务框架,它有以下特点:

  • 标榜serverless,自身提供了代码托管和运行容器,可以直接进行服务的开发、部署及运维,也可以将现有docker应用的服务以SPI的形式注册到这个框架上并发布,实现“服务市场”的效果
  • 可以进行服务的热插拔,不停机升级回滚不同版本的服务
  • 业务SPI管控,可以将SPI按照业务域划分到不同的路径下,方便管理和业务发现、服务编排

在BU内部推行N框架时,对于现有的应用,需要将核心服务用SPI的方式注册上去;对于新接入的业务,则推荐使用serverless形式的bundle提供服务。对于哪些是“核心服务”,这些“核心服务”是否足够标准化,亟待一轮梳理甚至重构。

其次,从本域来看,在组织架构调整后,线上同时运行的多套系统已经日渐成为瓶颈。由于历史原因,XX中台财务域对接多个业务域时,往往会新搭一套系统,造成了当时一共有五套财务系统运行在线上,系统间相互依赖,系统内又有大量的if…else硬编码来处理定制化的需求。在这种情况下,无论对于新业务、新需求的支持,还是对于现有业务的运维,都需要付出巨大的人力成本,并且存在着很多隐患,多版本归一化迫在眉睫。

因为供应商发票在整个财务域的最下游,相对比较独立,并且对于现有的系统业务方也有开票流程线上化的诉求(原先的流程存在大量的人工步骤:是将对账单导出为excel,导入到开票系统,然后再将开票结果数据导回系统),所以选定了供应商发票这个子域作为第一个试点,目标是新建一套销项发票系统,发布可扩展的SPI,并将已有的业务线流量逐步切过来,降低运维成本,提高研发效率。

2. 实践

2.1 布道

对于DDD,其中有很多的概念和方法,需要所有开发认知达成一致,这里就不重复那些经典的概念了。

这里想稍微聊一下”六边形架构“。为什么是六边形,不是正方形、五边形、八边形?我思考了很久,得出的结论是:六边形比较好看,而且也好画。具体这六个边除了表示边界,并没有什么实际的含义,比如六大金刚什么的。使用”六边形架构“,一是为了和传统的分层结构进行区分,二是为了表示这个系统不是孤立的,而是在一个大网格中的一个节点。

对于实体,最初我的理解是现实中存在的对象,在DDD里则被定义为”有标识符来区分的对象“。从哲学的角度,则是”是客观存在并可相互区别的事物“。这样理解思路就开阔多了, ”订单“是实体,”抽奖活动“也可以抽象为实体[1]

需要注意的是,一定要遵守现有的或形成一套统一的命名规范。比如DO,一般指的是DataObject,但是DomainObject也能缩写成DO,是不是很神奇?

2.2 建模

首先使用用例分析法,分析现有系统+短期未来中需求的用例。“发票”这一实体在现实世界中相对固定,所以建模相对比较简单——实际上并不完全正确。在子域划分中可以看到,不仅仅只有一个发票实体。

但是仅通过用例分析,又显得比较单薄——甚至很多业务场景都能套进这个模板中,因此需要在后续的环节(分析流程中缺失的实体)中丰富一些细节进来。

至于当时为什么没有考虑四色分析法和事件风暴法,事后回想这个问题,可能原因是:老系统的代码和场景比较复杂,新系统可以按业务线维度做进行迁移的时候再进行扩展,第一版不需要考虑大而全的所有场景,因此只需要梳理好SOP即可。而四色分析法,事件风暴法对于老系统的分析成本过高,即使将所有开发集中到一起也难以将所有事件罗列出来。

建模的结果简单表示如下:

各域职责如下:

  • 费用域:对接上游不同形式的单据,统一转化成可以用来开票的账款单
  • 开票申请域:抽象开票的过程信息,包括发票待填充字段和单据拆分规则
  • 开票执行域:对接不同的开票ISV,提供统一的开票能力。

把发票域再划分成更细的子域,是出于以下考虑:

  • 限界上下文更清晰,一个聚合根就是一个子域
  • 将高内聚低耦合、单一职责原则极致化,外部应用可以自由选择调用哪个子域的服务,从而决定自己的应用边界
  • 后续团队的扩张,更加专人专职,在需求迭代时降低并发度,避免十几个人同时修改一个系统的局面

2.3 编码

  1. 按照应用边界划分以后,按子域分别建立应用、申请主机实例、DB资源等。
  2. 由于充血模型的repository注入会导致写测试用例比较麻烦,采用了贫血模型,发票本身也没有什么领域方法,再加上子域已经拆到最细了,跨子域操作都需要通过RPC来交互,当时只抽取了业务校验和计算金额的方法出来。

实践期间遇到的最大问题是,原先面向DO的CRUD过程式编程(当然其中也有一定的抽象和封装),要转到不关注存储结构、所有对象都在内存中的DDD,会很不适应。并且,你会发现如何处理底层存储又是绕不开的,诸如模型转换和事务处理,不可能完全照搬DDD,也不能重回CRUD的老路上去,非常的别扭,只能做一定程度的折中。

3. 问题

  1. 在建立领域模型时,产品侧很少参与,业务方则基本没有直接参与。整个用例分析和建模的过程技术团队反复的开会讨论,耗费了很多时间,但是对于使用者来说并没有相应的体感。实际上这违反了DDD的一大前提:需要技术团队和业务团队__共同__建立一套领域语言,才能减少后续的交流沟通成本。这导致了后续的需求沟通仍停留在原先的水平上,没有提升什么效率。更严重的是,有的需求上线了,但是业务发现并不好用(甚至不如老系统好用)。举一个例子:业务方想知道哪些账单开了哪些发票,开票状态是什么。老系统直接就能展示(当然这也和老系统实际开票链路是人工的,系统层面很简单有关);新系统却要先找到当时的申请单,申请单上找到对应的发票。可是业务方视角下完全是没有申请单的概念的,他们也不关心申请单ID,只要把发票开出来就行了。
  2. 拆分出子域过多,并没有带来其好处,反而带来了一些副作用:
    1. 项目联调成本比较高,特别是项目刚上线时问题比较多,和有新人加入需要上手。由于开票流程至少跨越了子域的三个系统,出问题时为了定位,可能要在这三个系统之间来回穿梭,查看日志、反复debug,效率很低
    2. 由于人员变动,原先规划的每个应用2人互为backup,在相当一段长的时间变成了1人同时需要负责5个系统的运维和需求,严重挤占了个人时间
    3. 分布式系统的一致性问题被放大,核心的三个域既要各自进行的幂等,又要做跨系统核对来保证最终一致性。
    4. 额外的RPC开销使得业务失败几率变大,在单据数目较多的情况下提交开票很容易超时,只能进行限制。
  3. 上游系统的域也在做DDD实践,他们的模型变了好几版,导致发票域的待开票单据模型很多字段都废弃了。因为是大而全的模型,字段不够用倒是未出现。
  4. 不同子域使用了不同的分库分表规则,增加了问题的排查难度。
  5. 由于不同子域牵头的开发不一样,对于技术选型在架构组也没有给出结论,自由发挥的空间很大,有的子域用了一些事后发现有坑的技术,举几个例子:
    • JPA。之前全组惯用的是mybatis,JPA”看上去“更适合直接绑定领域模型,但是其门槛比较高,需要花很多的时间去学习。同时,在分库分表的环境下,想写定时任务处理,只能用entityManager来写原生SQL拼接分表名
    • Guava EventBus(实际是上游域用的),场景是用于发送领域事件。Guava EventBus实现了观察者模式,可以将流程解耦,同步或异步地进行后续处理,看上去很适合做领域事件的载体。但是它是单机JVM范围内的,如果发生宕机或重启,未处理的事件直接丢失。【思考:如果仍然想用Guava EventBus来发送领域事件,如何防止这个问题?答案是先将领域事件持久化。】
  6. 照搬现实世界的实体,带来了一些意想不到的问题。如上图的发票模型,并没有保存business_parnter_id,毕竟现实中是不存在的。如果需要查询,直接从申请单的invoice_id去查即可。但是当需要做鉴权时,也即需要判断这张发票是否属于该用户时,无法直接从发票实体上判断,不得不绕道到发票申请域。
  7. 预想的用来方便扩展的设计,实际上并没有用到。比如开票执行域,预期能够对接不同的开票ISV,但是该系统上线近两年都只接入了一家ISV,并且在可预见的一年以内,都没有接入其他ISV的需求。又如CQRS,除了OpenSearch和MySql这两个数据源,没有其他数据源,并且所使用的MySql实例的主从结构目前对于开发是透明的,所谓CQRS只不过是代码层面写操作继续走repository,读操作绕过repository直接查询DAO而已。
  8. 另外一个预想的让业务接入方/行业研发来编写防腐层、直接调用下层服务,则基本没有影子,毕竟业务方开发资源也有限,平台还没有发展到那么强大的程度。
  9. 与现有技术框架是否能相容。这个看上去不像是DDD的问题,因为DDD更关注业务,实践中遇到的问题是:两个实体有循环引用(就像Father和Son两个类相互持有对方一样),在通过自定义日志拦截器打日志时,由于使用的是fastjson做序列化,循环引用直接stackoverflow了。【思考:除了去除循环引用外,如何解决这个问题?答:可以关闭fastjson的循环引用功能,更好的实践是手写实体的toString()方法,选择要打印的信息】

4. 反思

  • DDD最关键的一点是和产品、业务保持沟通,才能形成共识也即领域语言,不能仅仅是技术自high和闭门造车,这样是无意义和浪费的。此外,除了自己的域也要关注上下游,才能让模型高内聚低耦合。

  • DDD不能教条化,需要因地制宜。其实很早之前业界推行的分层架构本身已经有DDD的影子了,不要为了拆分出更多的应用而划分子域。同一个应用是可以包含多个子域的,是否需要进一步拆分要由业务现状和发展来确定。

  • 在发票域这个具体的场景里,可以看出领域模型本身并不是易于变化的,经常变更的是规则和接入层。那么基于这个来扩展规则的配置和运转、以及接入层的校验和填充,会取得更好的结果。

  • DDD是一个持续的过程,不能一蹴而就,要随着业务的发展而持续迭代。

5. 再谈……

5.1 再谈CQRS

CQRS全称是Command Query Responsibility Segration,即命令与查询分离。一般架构图如下:

(图源: //zhuanlan.zhihu.com/p/115685384)

最最简化的实现方式,是代码层面绕过Repository,直接查询DAO,然后转化成VO传给调用方。这样做初看并没有什么卵用,但是结合到具体的业务场景来看,就有用处了,举几个例子:

  • 应用使用了多个数据源,如MySql+ES/opensearch。在分库分表场景下,如果需要查询”所有用户+状态为未开票 的 所有对账单“,直接走MySql是无法查询的,只能使用搜索引擎预先创建的索引,这是一种读写不分离也得分离的情况。

  • 数据源的拓扑结构。MySql的读写分离是透明的,假如要自己造轮子,在应用层面指定写库和读库,并自己提供同步机制,那么此时就可以分别对写库和读库做操作,”强行“读写分离。

  • 保持领域对象和Repository的纯洁性。有很多读操作,都是领域方法和Application层的业务逻辑用不到的,仅仅是提供给外部系统做查询。如果在Repository中加入了大量的查询方法,会增加维护成本。同时,领域对象之间操作时,加载的对象一般是完整的;但是对于外部查询,考虑到领域对象和DB表结构并不完全一致,需要进行定制化的简化和组合,如下图中如果只查商品基础信息,是没必要加载整个领域模型的

【思考】如果一个领域对象A需要操作另一个领域对象B,那么加载B是否走的读操作?

【个人见解】否,应该通过Repository加载。

5.2 再谈架构分层

img

不管是三层架构,还是六边形架构,其核心总是Application-Domain-Infrastructure。这并不意味着代码的组织形式必须完全照搬,我们仍可以使用更细的划分方法和层次结构,以下提供一种划分和命名方式作为参考:

  • User Interface,一般命名为client/facade,包括对外暴露的服务接口和DTO。虽然从分层的视角来看User Interface属于Application层,但是为了打二方包提供给外部使用,只能单独拆分出来

  • Application

    • assmbler,用于DTO和domain model的转换
    • service,应用服务,编排领域服务,可以提供事务等,不包含业务逻辑
  • Domain

    • core-service,领域服务,完成跨多个领域模型时的操作
    • core-model,领域模型,用来承载聚合根、实体。如果包含多个子域,可以用包路径来区分
  • Infrastructure

    • common,通用工具,包含一些常量、相对独立的工具(如PDF转换、MD5加解密等)、通用的类(自行封装的Exception)、通用配置等
    • message,对接消息中间件
    • dal,对接DB持久化层
    • 其他基础设施

在逻辑上分层之后,实际编码中也会遇到一些问题,需要采取折中,比如:

  • 首先也是最关键的一点:在Maven项目中,代码分层是通过pom的组织关系实现的。如果想按照更明显的依赖关系,如Application bundle包含assmbler和service两个bundle,会发现配置起来很麻烦也很容易出错,其他层bundle跨层依赖时也很难受。方便起见,可以仅仅通过命名(甚至是约定俗成)来体现哪个bundle属于哪一层,bundle之间的依赖通过pom解决。

  • DO和Domain Model的Convert放在哪里?假如Repository接口放在Domain层,实现放在Infrastructure层,会发现Repository操作的是Domain Model,强行让Infrastructure的dal层不得不依赖Domain Model层,破坏了上层依赖下层的关系。因此只能将Convert放在Domain层,直接对DO的操作显得很刺眼,单独再抽一层又十分的冗余。

6. DDD最佳实践?

DDD真的有最佳实践吗?就目前来看,没有。DDD不能只靠阅读就能充分理解,需要通过真正的实践,也会遇到挫折和怀疑,需要及时回顾和反复的学习。即使是聚合边界和聚合根的寻找,也是一件有难度的事情。

一种直觉性的实践方法是,看看代码是否有”坏味道“。举例几个例子:

  1. 一个实体持有了大量的其他的实体,比如School类中包含了一个的List<Student>,那么这个实体是不是会显得很笨重?即使用lazy-load来处理Student,仍然是反模式的。那么不如在领域模型层面将二者的引用关系解除掉。当需要操作这个School的所有Student时,在School类的领域方法中再进行处理。
  2. 如果DDD后的代码可读性变差了,那么这和DDD的初衷也是背离的。
  3. 对某类实体A批量操作(如果没有关联到另一个和A有关系的实体B上去)不得不在A中完成也是一种”坏味道“。Evans建议,当你在怀疑是否应该在一个类中放入”坏味道“的方法、其原因是你觉得它不属于这个类时,用一个ServiceFoo类来放这个方法。
  4. 贫血模型和充血模型,究竟选哪个。这两种模型的最大区别是Repository是否属于领域对象。充血模型需要走点弯路去注入Repository,并且测试起来会难一些。其实领域驱动在建模时更关注的是如何提取领域方法并和领域模型整合

个人认为DDD的最佳实践是,不断的重构。寻找模型中有问题或蹩脚的地方,如对象应从关联导航还是仓储获取?聚合设计是否正确?模型性能是否OK?然后停下来重构。

但是重构和满足业务当前需求在时间上是有冲突的,大规模重构会带来回归测试的工作量和一定的风险,如何平衡也是个问题。小心过度设计。

7. 新知

在编写本文的同时,也读了一些其他文章,补充了现有的认知。

  • 不应该给实体定义太多的属性或行为,而应该寻找关联,发现其他一些实体或值对象,将属性或行为转移到其他关联的实体或值对象上。
  • 跨实体的交互放在领域方法里。
  • 为什么Repository层不应该使用insert、update等作为方法命名?因为这些名称和SQL是强绑定的,而对于缓存如Redis,SET就是插入或更新。因此使用更加通用的命名如find、save、remove,再在实现里选择具体的DAO方法做处理。
  • “用户需求”不能等同于“用户”,捕捉“用户心中的模型”也不能等同于“以用户为核心设计领域模型”。 《老子》书中有个观点:有之以为利,无之以为用。在这里,有之利,即建立领域模型;无之用,即包容用户需求。举些例子,一个杯子要装满一杯水,我们在制作杯子时,制作的是空杯子,即要把水倒出来,之后才能装下水;再比如,一座房子要住人,我们在建造房子时,建造的房子是空的,唯有空的才能容纳人的居住。因此,建立领域模型时也要将用户置于模型之外,这样才能包容用户的需求。[2]
    • 我们设计领域模型时不能以用户为中心作为出发点去思考问题,不能老是想着用户会对系统做什么;而应该从一个客观的角度,根据用户需求挖掘出领域内的相关事物,思考这些事物的本质关联及其变化规律作为出发点去思考问题。
    • 领域模型是排除了人之外的客观世界模型,但是领域模型包含人所扮演的参与者角色,但是一般情况下不要让参与者角色在领域模型中占据主要位置,如果以人所扮演的参与者角色在领域模型中占据主要位置,那么各个系统的领域模型将变得没有差别,因为软件系统就是一个人机交互的系统,都是以人为主的活动记录或跟踪;比如:论坛中如果以人为主导,那么领域模型就是:人发帖,人回帖,人结贴,等等;DDD的例子中,如果是以人为中心的话,就变成了:托运人托运货物,收货人收货物,付款人付款,等等;因此,当我们谈及领域模型时,已经默认把人的因素排除开了,因为领域只有对人来说才有意义,人是在领域范围之外的,如果人也划入领域,领域模型将很难保持客观性。领域模型是与谁用和怎样用是无关的客观模型。归纳起来说就是,领域建模是建立虚拟模型让我们现实的人使用,而不是建立虚拟空间,去模仿现实。
  • 聚合根如何设计?关于领域驱动设计(DDD)中聚合设计的一些思考

其他参考文献


  1. 美团技术团队:领域驱动设计在互联网业务开发中的实践 ↩︎

  2. 领域驱动设计之领域模型
    阿里盒马领域驱动设计实践-InfoQ ↩︎