领域驱动设计的实践


前言

领域驱动设计断断续续看过几本书,但是都没有系统性的学习完成过,因此这篇文章从三个阶段来完成的记录一下,分别是基础概念核心知识高级扩展;

基础概念

领域驱动设计的开发流程

领域驱动设计的开发流程

模型的建立:获取行为需求 -> 领域建模。
模型的实现:架构设计 -> 数据库设计 -> 代码实现。
DDD 的基本开发过程:获取行为需求 -> 领域建模 -> 架构设计 -> 数据库设计 -> 代码实现

获取行为需求

获取行为需求指的是,我们首先要分析系统具有哪些功能,这些功能由什么人操作,会产生什么效果。这个过程传统上叫做“捕获行为需求”
常用的获取行为需求的方式有两种,第一种是用例(use case),第二种是事件风暴

2.1 事件风暴
!()[]
事件风暴在实践过程中会遇到来回返工的情况,出现这两种情况一般是两个原因,第一个原因会议偏离主题、第二个原因会议无结论记录,因此在事件风暴这个环节,可以落地的方法是由一个人负责,完成梳理之后进行评审的方式;

事件风暴过程中需要注意的点:

  1. 对于领域事件的命名,采用完成时+被动语态,例如添加完成订单,在事件风暴中的动作是’订单已添加’、签订好合同,在事件风暴中的动作是’合同已签订’;
  2. 在DDD的命名中,如果有约定俗成的业务术语,优先使用业务术语;
  3. 不要把技术事件当作领域事件,例如事务提交、缓存已命中等;
  4. 查询功能不算领域事件;

可以使用https://boardmix.cn/来做事件风暴

2.2 识别命令

命令(command),就是引发领域事件的操作,我们可以通过分析领域事件得到。除了识别出命令本身以外,我们通常还要识别出谁执行的命令,以及为了执行命令我们要查询出什么数据;

!(命令图列)[]

2.3. 识别领域名词

领域名词,是从领域事件、命令、执行者、查询数据里找到的名词性概念。例如,对于签订合同这个命令而言,受到影响的名词性概念是“合同”;

  • 注意实现
  1. 在事件风暴里只列出主要的、足以用于表达和交流领域知识的步骤
  2. 事件风暴的粒度原则上宜粗不宜细,有一个大体的轮廓即可

小结:事件风暴就是先确定要做什么事情(领域事件),然后由于什么动作出发(命令),然后找到动作由谁(执行者)进行触发,最后早到出发这个动作需要查询什么数据(查询数据)的过程:

领域建模

目的:

  • 将业务知识可视化,准确、深刻地反映为领域知识,并且在业务和技术人员之间达成一致;
  • 指导系统的设计和编码,也就是说,领域模型应该能够比较容易地转化成数据库模式和代码实现;

!(模型之间的联系)[]

领域对象表示的是领域事件的作用端,也就是领域名词,例如提交订单这个领域事件对应的领域对象就是订单;

领域对象由三部分组成:

  1. 领域对象
  2. 领域对象之间的关系
  3. 领域对象的关键属性
  • 领域对象又可以划分为:实体、值对象
    实体对应到代码中就是类对象,实体与实体之间的关系就是类图,类有关键属性;

建立完成领域模型后,需要完善业务规则建立词汇表

  • 模型驱动设计
  1. 领域模型要和业务需求保持一致
  2. 系统实现要和领域模型保持一致

领域模型是堆业务进行模拟和提炼,形成浓缩的知识

  • 统一语言

统一语言包含了两个层面的含义:一是业务和技术人员之间的语言是统一的,二是开发团队内部各角色之间的语言是统一的。最终结果就是每一行代码都能对应到统一语言,从而与业务保持一致

架构设计

分层架构

分层架构的核心思想就是将代码分成若干层,每一层负责不同的关注点;将不稳定的部分依赖与稳定的部分,常用的分层架构有六边形架构,外层依赖内层,但是内层不能依赖外层;
实践上就是采用分模块和分package的方式进行分层

数据库设计

在传统的软件开发过程中,对于数据库的设计是采用的’主观理解法’俗称拍脑袋法进行设计的,在了解到需求后,通过思考需求得到数据库模型,在根据数据库模型进行实现,这种方式设计的数据库模型很难精确的反映业务领域模型;
采用DDD的方式通过模块到实体,在到数据库模型的方式进行设计的方法,得到的数据库模型更贴近业务领域模型;

代码实现

在对象的定义上有两种模式:面向过程的方式和面向对象的方式,面向过程的方式就是常说的贫血模型,面向对象的方式就是充血模型或者说是富领域模式;在实践过程中并不是面向过程模式就要比面向对象模式低级,在实际研发过程中两者的关系如下所示:

面向过程与面向对象之间的关系

面向对象和面向过程之间有一个广阔的"灰色地带",这里面的变化非常多,难以穷尽,这两个极端都不是我们要追求的,我们需要做的是找到其中的一个平衡点;

原则:

  1. 依赖层问题
  2. 依赖倒置原则

提供领域对象的封装性

  1. 限制getter和setter的数量
  2. 用有业务含义的接口替代简单的setter和getter

编程风格

  1. 领域对象不能访问数据库
  2. 领域服务只能读数据库,领域服务需要读数据库。而写库的功能通常可以由应用服务来做,从而减轻领域层的负担
  3. 应用服务可以读写数据库
  4. 用ID表示对象之间的关联
  5. 领域对象有自己的领域服务

小结

捕获行为需求和事件风暴的关系如下图所示,通过事件风暴的形式捕获到系统的行为需求,从而形成统一语言模型驱动设计

事件风暴的关系

执行者查询读数据,然后发出命令。命令触发领域事件。
可以从命令、领域事件、执行者、读数据中识别出领域名词。

模型的建立

这张图是领域驱动设计的精华,最核心的是领域模型,领域模型上面衍生出领域对象,下面衍生出业务规则;

业务规则

聚合

聚合指的是实体与实体之间的一种关系,这种关系有两种特点:第一具有整体和部分的关系,第二具有不变规则,而且这种不变规则的并发的时候可能被破坏;
这种关系中心的实体被称为聚合根,聚合根需要有一个全局唯一标识;\

聚合的作用是为一组具有整体部分关系的对象维护不变规则;

减号(-)表示这是一个私有(private)属性、加号(+)表示公有(public)权限、井号(#)表示保护(protected)权限、波浪号(~)表示包级私有(package private)权限

领域模型

实现模型

实现对象关联的方法分为两种,一种是对象关联、一种是ID关联,使用对象关联切换的成本较高,比较倾向与使用ID进行关联;
1
聚合对象进行持久化的时候,是针对整个聚合下的多个对象进行的持久化


Repository.java

public void save(Emp emp) {
    // 持久化聚合对象
    empRepository.save(emp);
    // 插入skill对象
    emp.getSkills().forEach(skill -> {
        skill.setEmpId(emp.getId()); // 设置关联的ID
        skillRepository.save(skill);
    });

    //插入work对象
    emp.getWorks().forEach(work -> {
        work.setEmpId(emp.getId()); // 设置关联的ID
        workRepository.save(work);
    });
}

Repository表示的是对整个聚合进行持久化操作,而DAO表示的是对单个对象进行持久化操作,因此在DDD中Repository是对聚合的操作,而DAO是对单个对象的操作;

对于不变规则的实现,有两个需要注意的点:

  1. 如果规则的验证不需要访问数据库,那么首先应该考虑在领域对象里实现,而不是在领域服务里实现;
  2. 对于聚合根的内部对象,对于它们的验证必须是从整个聚合层面才能进行验证的,无法单独进行验证;

聚合在达到一定规模之后也要进行拆分,避免一个大的聚合导致的性能问题;

用事务保证固定规则

由于数据库事务无法完成避免并发修改聚合根的问题,会存在数据库事务的粒度与业务需求不匹配、丢失更新的问题,因此需要使用乐观锁的方式来保证聚合根的并发修改问题;

乐观锁的实现方式:

  1. 在聚合根中添加版本号字段
  2. 在更新聚合根时,使用版本号进行校验
  3. 如果版本号不匹配,则说明有其他事务已经修改了聚合根,需要重新加载聚合根并进行处理

悲观锁的实现方式:

  1. 在聚合根中添加锁字段
  2. 在更新聚合根时,使用锁字段进行校验
  3. 如果锁字段被其他事务占用,则等待或抛出异常
    类似于独占锁的方式,悲观锁会导致性能问题,因此在DDD中不推荐使用悲观锁的方式;

没有关联对象的聚合根,被称为单实体聚合,现在我们面向对象的项目对于PO的定义就是单实体聚合;

泛化

泛化是指在领域模型中,将具有相同属性和行为的对象抽象为一个父类,从而减少代码的重复和复杂度;
泛化的实现方式是通过继承的方式,将相同的属性和行为;

直接“借用”系统中已经存在的机制,在短期内虽然达到了目的,但长期来看会导致概念混乱,这种做法是很多开发团队常见的错误。而错误的根源,就在于我们没有掌握一种优雅的方法,来处理不同概念的共性和个性;

可以泛化,不代表必须进行泛化,有三个原则:
第一:如果只有特征值不相同,那么用特征值为对象进行分类即可,不必要进行泛化;
第二:如果特性种类不同,那么很可能需要进行泛化
第三:如果在业务规则操作接口操作实现上有共性和个性的化,优先考虑在实现上

限界上下文

限界上下文确实和划分模块、划分子系统一样,是一种分而治之的手段,可以起到分离关注点的作用。但限界上下文增加了一个要点,就是,它的目的还在于维护概念一致性。这里的概念一致性,不在是全局一致性,而是局部一致性;

限界上下文的划分是和组织结构相关的,由于全局一致性已经超过了团队的认知负载了,所有限界上下文不在追求全局一致性;

如何践行领域驱动设计

实施DDD的项目要满足以下几个关键因素:有价值\有痛点\有意愿\有时间;

  • 有价值,是指站在企业的角度,这个系统对达成企业的战略目标有较大的意义;或者从业务角度,这个系统能够为公司带来比较大的收益。包括 DDD 在内的任何技术改进过程,都需要一定的成本。只有应用到价值较大系统,才能带来足够的回报

  • 有痛点,指的是公司管理层或者开发团队确实遇到了难以解决的困难,需求寻求方法学的帮助。如果目前的开发方法挺顺利的,没有感受到明显的问题,只是“为了引入而引入 DDD”,那么往往会动力不足;

  • 有意愿,指的是开发团队确实愿意学习新技能。其中,项目经理、开发组长、技术骨干等角色往往起着决定性的作用;

  • 有时间,也很重要。引入任何新技术,总会有些成本。包括学习成本、试错成本等等。关键是看产出是否大于成本;

实施DDD的场景有三种实际场景:

  1. 新的项目
  2. 改造现有项目
  3. 改进现有的研发流程

新的项目

领导希望保证质量,降低风险,觉得需要方法学的支持,因此要引入 DDD

在新的项目中引入DDD要避免的是瀑布型思维,避免在一开始就想要把模型建立的完美;

改造现有项目

改造现有项目主要分为四个步骤:反推领域模型 -> 建立目标领域模型 -> 设计演进路线 -> 迭代实施,在这个过程中,一定不要陷入第一个步骤和第二个步骤中,在这两个步骤中只会有模型产出,没有实际落地的代码,也看不到任何实际的效果,会导致人们失去耐心,最终DDD无疾而终;

建议的做法是,首先选择系统中一个相对独立的小模块,然后按照前面的 4 步,尽快落地到代码并上线,建立最小闭环。通过这个过程,初步掌握 DDD 落地技能并取得实际效果。
同时,这么做也能培养人才,积累经验,建立必要的开发流程。完成之后,再选择下一个切片,逐步扩大范围,并深化 DDD 的技能。

改进现有的研发流程


  TOC