领域驱动设计 DDD 实践


背景

DDD 领域驱动设计,想必大家都已经耳熟能详了,经常能听到『事件风暴』、『聚合根』、『限界上下文』等等名词,对其概念一知半解,又或者知道一些概念,又不知道如何落地实践,怎么将设计转换成代码实现,这篇文章或许可以帮到你。

ps: 有些遗漏的概念直接看末尾的参考目录的文章就好了,已经写的很详细了,这里就不再赘述,本文着重于实践方式的说明。

DDD干的事儿:提供一套方法/工具、标准套路,对复杂领域进行分析建模,让参与设计的人,不论是研发还是客户都能达成认知的一致。

DDD不干的事儿DDD不能降低系统的复杂度,也不能帮你减少编码,还会增加初期的设计开发时间

DDD不是银弹,它更适用于成熟稳定的业务线,适用于一些具有业务不变性的领域。很多面向C端客户时时刻刻变化的需求就不太适合DDD;公司早期也不适合使用DDD,会导致开发复杂度开发时间大幅增加(业务一变,可能导致从底层开始每一层都需要推倒重来),对公司一些逐渐稳定的业务可以尝试使用DDD进行重构(稳定的东西大概也没人去碰了吧


实践

DDD的名词概念不清楚?别人家的DDD领域图画的很炫酷?不用慌,跟着我一起走,我们撸代码画图都是一把梭。

大部分老铁都应该接触过CRM系统,接下来我将以CRM系统中的部分内容进行距离讲解。

  • 画图工具

所以示例均基于该工具绘图:miro.com

妈妈再也不用担心我画不好图了!

image.png

事件风暴

首先抓几位幸运的研发小伙伴和产品(通常作为研发没办法直面一线客户,也没有相应的领域专家,所以产品就是最了解业务本身的人了),一起来开始事件风暴吧。

啪!

先放一张事件风暴的基础元素在这里,每个颜色的贴纸代表什么意思现在先不用关心,后面回来查看就行了。

image.png
  • 事件梳理

CRM系统(全称客户关系管理系统),通常包含从:商机获客 -> 线索 -> 销售机会 -> 客户 -> 客户公海 等等功能模块,大同小异。这里就以销售机会进行举例。

首先和产品同学一起梳理出『销售机会』业务中发生的所有事件,事件使用橙色标签,使用动词的过去式进行描述,比如:销售机会已创建

image.png

有异议的地方可以标记为热点问题(超过5分钟未达成一致),后续讨论,不影响事件风暴主流程:

image.png

ps:

  1. 区分事实和方案:DDD分析建议以现实中真实发生的事件为主,系统已有的功能多是软件设计中的取舍,并非现实生活中发生的事实,只是一个实现方案而已,事件风暴分析过程中尽量以还原业务本身为主(比如销售机会导出,只是一个方案而非事件)。
  2. 状态已变更:这个阶段很容易错误把状态变更当做事件,状态变更是一个典型的方案而非事件,事件触发最终导致了状态的变更。
  3. 多个业务同时事件风暴:可以按业务组划分,一组小伙伴一起画一份,最后一起分析,方便了解其他小伙伴负责的业务。

接下来你有半小时和小伙伴们梳理出所有事件(不用管对错都先放出来),按事件发生的顺序,从左向右,从上到下进行排列。

image.png
  • 移除不属于当前业务的事件

这里我们认为销售汇报不属于销售机会业务的事件,我们画一条竖线,将所有人达成一致的放右边,不属于当前业务的事件放左边。

image.png

命令风暴

事件都梳理好了,那事件总得有人触发吧,事件的触发可能还得依赖某些规则,外部系统等等。这就是命令风暴:梳理清楚业务中的事件是如何发生的,搞清楚这些事件的因果关系。

命令风暴阶段主要涉及到以下标签:

image.png
  • 事件的触发和依赖关系

接下来我们给事件加一点点细节!(30分钟过去了)

ps:

  1. 事件触发:通常为内部用户或外部系统
  2. 策略:如果是做整体战略设计,这个阶段就不用将依赖的规则列的很细致;如果是做业务设计,建议列的尽可能详尽,后续编码可以直接作为参考。
image.png
  • 事件的因果关联

这一步比较简单,找到事件的上下游关系,依赖的外部系统,关联的策略都可以给连起来了。这个阶段需要研发和产品同学对事件触发的原因等达成一致。(又30分钟过去了,battle不过产品先在自己身上找原因.jpg)

ps:

  1. 虚线:事件并非100%触发,允许异步触发或者需要根据策略进行判断。
  2. 实现:强一致性,上游的事件触发,必定会触发下游事件。
image.png

寻找聚合

寻找聚合,主要是找到业务的边界,建立统一的业务模型。

  • 建立聚合
image.png

操作步骤:

  1. 我们先需要先贴一个销售机会的聚合。
  2. 然后将相关的贴纸拖到聚合旁边,围城一圈按贴纸颜色进行摆放即可,没有固定顺序。
  3. 相同的贴纸可以只保留一个了,比如:事件的触发角色只用保留一个员工。
  4. 已有的关联连线保留不要删除。

ps:

  1. 边界划分:比如销售机会阶段的变更都归属于销售机会的整个生命周期内,可以划分到销售机会内。而关联订单的变更,来源于外部系统,可能销售机会已经完成了还会有订单的绑定或者解绑到销售机会,最好划分到外部。
  2. 聚合的大小:一个聚合通常对应的我们的一个业务实体,聚合不要太大,也不要太小。太大的聚合通常是没有梳理清楚聚合的边界,可以继续拆分;太小的聚合单独维护麻烦。
image.png

子域划分

子域的划分主要是找到核心域、支撑域、公共域。子域的划分没有标准答案,这一步建议每个人单独做,然后轮流分享自己的想法,最终达成一致即可。

  1. 核心域:领域最主要解决的问题,需要投入最优先的人力物力,直接决定了产品的竞争力。如:对销售机会阶段的管理。
  2. 支持域:非核心领域,但又不可或缺,决定了用户体验的好坏。如:销售机会分类的管理,拥有多套销售机会的切换,对大体量客户使用体验更佳,无需多个业务组混用一套销售机会。
  3. 公共域:提供一些通用能力,通常和业务无强关联。如:用户登录、消息通知等
image.png

划分上下文

终于到领域设计的最后一步了(前面已经和产品同学battle了2小时,不要慌最后半小时了,battle完就可以去干饭了)

其实到上一部领域划分完成,DDD的主体设计就算完成了。划分限界上下文,更多的是从系统架构层面,确定业务的解决方案,最常见的就是微服务架构,天生可以一个服务对应一个域(考虑到维护方便,也可以一个服务对应多个域,没有固定规则)

  • 如何确定上下文关系
  1. 遵从上下游关系:如,各个渠道的线索经过筛选合并后,转换为销售机会,分配给具体的销售人员进行跟进,那么线索领域就是销售机会领域的直接上游。这种就是比较好划分的上下文,可以独立迭代。
  2. 合作关系:两个领域具有紧密的依赖关系,通常需要同时进行开发维护,通常划分为一个上下文。如,对客户资料的管理和对客户跟进关系的管理,通常需要一起维护,拆分为多个上下文会增加维护成本,这就是基于系统架构层面的划分考虑。
  3. 混合关系:多业务混杂在一起,业务边界模糊。如,常见的公海管理,刚开始只有线索公海,后面加入客户公海,再加入销售机会公海等等,那么这些公海是按业务拆分为独立的子域,多个上下文,还是使用一个上下文统一维护,具体实现只有团队内部达成一致就行了。
  4. 技术复杂度:当一个领域内存在技术复杂度较高的部分时,也可以考虑单独拆分为子域来进行维护。比如:全局搜索,敏感词过滤等等
image.png

DDD设计没有标准答案,通常我们采用:事件风暴 -> 命令风暴 -> 寻找聚合 -> 子域划分 -> 上下文划分,这样的流程去完成整个设计,最终的目的是要参与设计的每个小伙伴,用统一的语言,达成对业务一致的认知,研发、产品、领域专家 对齐即可。


DDD项目总览

DDD领域设计,这里主要结合洋葱架构进行使用

  • 洋葱架构

整洁架构最主要的原则是依赖原则,它定义了各层的依赖关系,越往里依赖越低,代码级别越高,越是核心能力。外圆代码依赖只能指向内圆,内圆不需要知道外圆的任何情况。

image.png
  • golang 洋葱架构项目分层
application 
|- service     // 应用服务
cmd         
|- server      // 服务启动入口,main函数
common
|- dto         // 数据传输模型:应用服务、领域服务的入参定义
|- vo          // 请求响应模型:应用服务、领域服务的返回值定义
|- errors      // 业务错误的预定义
|- utils       // 基础设施无关的工具类
domain
|- entity      // 领域模型(聚合根、实体、值对象)、防腐层模型、读模型
|- factory     // 工厂方法,用于创建模型实例
|- event       // 领域事件
|- interfaces  // 对基础实施依赖的接口约定
|- repository  // 对领域模型持久化的接口约定
|- service     // 领域服务(与应用服务的区别是领域服务具有业务不变性,如果识别不了可以都写到应用服务中)
infrastructure 
|- config      // 配置的数据结构与加载
|- controller  // 请求入口
    |- http       // HTTP请求入口,以及路由与Controller的绑定(北向网关)
    |- grpc       // GRPC请求入口,以及路由与Controller的绑定(北向网关)
|- driver      // 各种基础设施客户端的初始化
|- pubsub      // 事件订阅入口,以及事件与订阅函数的绑定(北向网关)
|- persistence // 领域模型持久化的实现,实现了领域模型到存储模型的转换与落库(南向网关)
|- serviceimpl // 对领域层的定义接口的实现,比如RPC请求
    |- httpclient // 接口实现方式为HTTP调用
    |- grpcclient // 接口实现方式为gRPC调用

  • 一个请求的执行流程
image.png

可以看到,使用洋葱架构+领域模型的方式,通过依赖倒置,实现了各层级的解耦,领域服务和基础设施的领域持久化实现也是通过接口访问,互不影响。不会像传统的MVC,一改需求从内到外每个层级都需要改动。

优点:

  1. 层级解耦:应用服务、领域服务、基础设施,三层互不影响;洋葱的的外层可以依赖内层,内层不能感知外层。
  2. 业务的不变性:可以将领域中具有不变性的部分抽离到domain service中,功能迭代时减小影响范围。
  3. 可测试:核心的领域服务不依赖其他部分,可以方便的进行单元测试,保证应用的稳定性。

缺点:

  1. 模型转换:为了层级间的解耦,会存在大量的模型转换过程,比较耗时力(go 可以使用 copier 之类的包来完成不同结构体,相同字段的映射,减轻转换工作量)。
  2. 繁重:一个简单的CRUD,需要写很多业务无关的代码,限制了洋葱架构的使用范围。

大系统或者业务复制的系统,每一个应用节点都可以使用这样的代码分层方式;一些简单节点,可以选择去掉领域层,也可以直接使用传统的MVC,或者函数式编程,没有必要将问题复杂化。领域设计不是万能的,还是需要结合具体的业务选择合适的架构模型。

一份简单的DDD示例代码:transfer-money-go


参考:
一文带你落地DDD
「DDD 战略设计」实践手册

你可能感兴趣的:(领域驱动设计 DDD 实践)