




















某大型互联网公司于2019年开始在XX中台财务域进行DDD实践。事后回顾,整体并没有达到预期的效果,个人也做了很多的反思和总结,形成此文。
为什么当时要实践DDD?其中的缘由比较复杂,可以从外部和内部两个视角来看。
首先,从外部也即整个BU的视角来看,最先开始实践DDD的是A域,并在该域诞生了一套在公司现有RPC框架之上的业务SPI框架(以下简称为【N框架】)。相较于dubbo这样纯技术的服务框架,它有以下特点:
在BU内部推行N框架时,对于现有的应用,需要将核心服务用SPI的方式注册上去;对于新接入的业务,则推荐使用serverless形式的bundle提供服务。对于哪些是“核心服务”,这些“核心服务”是否足够标准化,亟待一轮梳理甚至重构。
其次,从本域来看,在组织架构调整后,线上同时运行的多套系统已经日渐成为瓶颈。由于历史原因,XX中台财务域对接多个业务域时,往往会新搭一套系统,造成了当时一共有五套财务系统运行在线上,系统间相互依赖,系统内又有大量的if...else硬编码来处理定制化的需求。在这种情况下,无论对于新业务、新需求的支持,还是对于现有业务的运维,都需要付出巨大的人力成本,并且存在着很多隐患,多版本归一化迫在眉睫。
因为供应商发票在整个财务域的最下游,相对比较独立,并且对于现有的系统业务方也有开票流程线上化的诉求(原先的流程存在大量的人工步骤:是将对账单导出为excel,导入到开票系统,然后再将开票结果数据导回系统),所以选定了供应商发票这个子域作为第一个试点,目标是新建一套销项发票系统,发布可扩展的SPI,并将已有的业务线流量逐步切过来,降低运维成本,提高研发效率。
对于DDD,其中有很多的概念和方法,需要所有开发认知达成一致,这里就不重复那些经典的概念了。

这里想稍微聊一下”六边形架构“。为什么是六边形,不是正方形、五边形、八边形?我思考了很久,得出的结论是:六边形比较好看,而且也好画。具体这六个边除了表示边界,并没有什么实际的含义,比如六大金刚什么的。使用”六边形架构“,一是为了和传统的分层结构进行区分,二是为了表示这个系统不是孤立的,而是在一个大网格中的一个节点。
对于实体,最初我的理解是现实中存在的对象,在DDD里则被定义为”有标识符来区分的对象“。从哲学的角度,则是”是客观存在并可相互区别的事物“。这样理解思路就开阔多了, ”订单“是实体,”抽奖活动“也可以抽象为实体[1]。
需要注意的是,一定要遵守现有的或形成一套统一的命名规范。比如DO,一般指的是DataObject,但是DomainObject也能缩写成DO,是不是很神奇?
首先使用用例分析法,分析现有系统+短期未来中需求的用例。“发票”这一实体在现实世界中相对固定,所以建模相对比较简单——实际上并不完全正确,并且这种思维定式为后续的建模和开发带来了隐患。在子域划分中可以看到,不仅仅只有一个发票实体。

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

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

各域职责如下:
把发票域再划分成更细的子域,是出于以下考虑:
实践期间遇到的最大问题是,原先面向DO的CRUD过程式编程(当然其中也有一定的抽象和封装),要转到不关注存储结构、所有对象都在内存中的DDD,会很不适应。并且,你会发现如何处理底层存储又是绕不开的,诸如模型转换和事务处理,不可能完全照搬DDD,也不能重回CRUD的老路上去,非常的别扭,只能做一定程度的折中。
项目在编码期间和上线后,都遇到了很多挫折,除了一些上游数据未标准化带来的日常运维和补漏以外,其中业务方吐槽不好用是最不愿看到的。
DDD最关键的一点是和产品、业务保持沟通,才能形成共识也即领域语言,不能仅仅是技术自high和闭门造车,这样是无意义和浪费的。此外,除了自己的域也要关注上下游,才能让模型高内聚低耦合。
DDD不能教条化,需要因地制宜。其实很早之前业界推行的分层架构本身已经有DDD的影子了,不要为了拆分出更多的应用而划分子域。同一个应用是可以包含多个子域的,是否需要进一步拆分要由业务现状和发展来确定。
在发票域这个具体的场景里,可以看出领域模型本身并不是易于变化的,经常变更的是规则和接入层。那么基于这个来扩展规则的配置和运转、以及接入层的校验和填充,会取得更好的结果。
DDD是一个持续的过程,不能一蹴而就,要随着业务的发展而持续迭代。
CQRS全称是Command Query Responsibility Segration,即命令与查询分离。一般架构图如下:

(图源: https://zhuanlan.zhihu.com/p/115685384)
最最简化的实现方式,是代码层面绕过Repository,直接查询DAO,然后转化成VO传给调用方。这样做初看并没有什么卵用,但是结合到具体的业务场景来看,就有用处了,举几个例子:
应用使用了多个数据源,如MySql+ES/opensearch。在分库分表场景下,如果需要查询”所有用户+状态为未开票 的 所有对账单“,直接走MySql是无法查询的,只能使用搜索引擎预先创建的索引,这是一种读写不分离也得分离的情况。
数据源的拓扑结构。MySql的读写分离是透明的,假如要自己造轮子,在应用层面指定写库和读库,并自己提供同步机制,那么此时就可以分别对写库和读库做操作,”强行“读写分离。
保持领域对象和Repository的纯洁性。有很多读操作,都是领域方法和Application层的业务逻辑用不到的,仅仅是提供给外部系统做查询。如果在Repository中加入了大量的查询方法,会增加维护成本。同时,领域对象之间操作时,加载的对象一般是完整的;但是对于外部查询,考虑到领域对象和DB表结构并不完全一致,需要进行定制化的简化和组合,如下图中如果只查商品基础信息,是没必要加载整个领域模型的

【思考】如果一个领域对象A需要操作另一个领域对象B,那么加载B是否走的读操作?
【个人见解】否,应该通过Repository加载。

不管是三层架构,还是六边形架构,其核心总是Application-Domain-Infrastructure。这并不意味着代码的组织形式必须完全照搬,我们仍可以使用更细的划分方法和层次结构,以下提供一种划分和命名方式作为参考:
User Interface,一般命名为client/facade,包括对外暴露的服务接口和DTO。虽然从分层的视角来看User Interface属于Application层,但是为了打二方包提供给外部使用,只能单独拆分出来
Application
Domain
Infrastructure
在逻辑上分层之后,实际编码中也会遇到一些问题,需要采取折中,比如:
首先也是最关键的一点:在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的操作显得很刺眼,单独再抽一层又十分的冗余。
DDD真的有最佳实践吗?就目前来看,没有。DDD不能只靠阅读就能充分理解,需要通过真正的实践,也会遇到挫折和怀疑,需要及时回顾和反复的学习。即使是聚合边界和聚合根的寻找,也是一件有难度的事情。
一种直觉性的实践方法是,看看代码是否有”坏味道“。举例几个例子:
个人认为DDD的最佳实践是,不断的重构。寻找模型中有问题或蹩脚的地方,如对象应从关联导航还是仓储获取?聚合设计是否正确?模型性能是否OK?然后停下来重构。
但是重构和满足业务当前需求在时间上是有冲突的,大规模重构会带来回归测试的工作量和一定的风险,如何平衡也是个问题。小心过度设计。
在编写本文的同时,也读了一些其他文章,补充了现有的认知。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。