dcddc

西米大人的博客

0%

系统学习DDD基础篇

前言

软件开发当前的问题现状:

  • 交付速度不够快
  • 测试覆盖度不够,上线后容易出故障,质量难以保证

原因:
随着软件的持续演进,系统复杂度成倍增加,造成技术债务越来越多,阻塞了开发效率也降低了交付质量

重构能解决吗?
重构有边界,边界内的重构无法影响到边界外的代码设计,且重构后的代码也容易被逐渐侵蚀。所以很难。

微服务、中台化能解决吗?
在不良的业务分析、架构设计基础上,推动中台化或微服务,往往会使矛盾凸显

云原生时代,软件性能、可靠性等已被底层接管。作为业务研发,实现软件高质量、快速交付的关键因素应该是:

  • 需求分析能力
    • 该阶段需要做到:业务引领的领域建模
  • 架构设计和实现能力
    • 该阶段需要做到:设计领域驱动的微服务架构 & 契约导向的软件实现

领域模型

领域模型的重要性

领域模型是软件开发的核心载体
好的领域模型沉淀下来的是领域资产,能快速满足后续业务发展。领域资产越来越多,技术债务就会越来越少
领域模型反应的是对行业的认知,是一家企业的根本能力和竞争力。

领域模型的基本概念

领域模型是概念模型,描述的是现实世界的实体和他们之间的关系,与软件实现无关,存在于问题空间,在需求分析阶段产生领域模型。
领域模型建立后,会贯穿软件开发始终,直接指导架构设计和实现

领域模型的表示

类图是一种较好的领域模型表示方法。
每个类代表一种概念。
普通箭头表示一种可见关系。
空心菱形箭头表示一种包含关系,但生命周期不同。
实心菱形箭头表示一种包含关系,但生命周期相同。
三角箭头表示一种抽象与继承关系。

领域模型的作用

好的领域模型应该具备以下三个作用。

  • 建立共识
    • 建立与业务人员对于需求、产品的共识。避免需求失焦而产生返工,甚至做错。
    • 建立共识需要拉齐业务与开发,在协作空间(白板)里产生共识。
    • 建立共识的目的就是统一语言。
    • 建立共识衡量的方法:任何在需求中出现的概念,都必须出现在领域模型中
  • 产生洞察
    • 产生洞察要相信业务人员的直觉
    • 产生洞察要有分解和抽象的能力
    • 产生洞察的领域模型才是企业真正的领域资产
  • 持续演进
    • 没有绝对正确的领域模型。随着认知的深入,领域模型需要持续演进
    • 持续演进对领域模型的修改,也要相应修改代码和架构

从需求分析到领域建模

任何业务需求都可以拆分为三个组成部分,逐层展开分别是目标、操作流程、规则。
需求分析有很多方法论。

  • 用例分析:结构化的分析繁杂的业务场景。用例组成元素有执行者、交互、边界、前置后置条件、例外等。
  • 事件风暴:事件+风暴。以领域事件(业务视角的重要事件)为主体构建业务场景的流程,用头脑风暴探索和发现可能的异常情况而衍生出的其他场景。

事件风暴是一种很适合用来分析需求的方法论。
事件风暴中,需要我们先划分业务场景,然后针对一次完整的业务场景去定义其中产生的所有领域事件。
领域事件是由执行者执行了一些命令而产生的。因此一个领域事件必然会携带执行者和命令。

事件风暴的过程中,领域模型同步产出。
验证领域模型的方法:

  • 用例场景中的任何实体都应该出现在领域模型中
  • 任何领域模型都应该有对应的用例场景

从领域模型到代码实现

好的代码设计应该具备以下三点:

  • 易于理解
  • 易于演进
  • 低开发成本

实现域的核心概念

实体

领域模型构建之后,首先要找出其中最重要的概念模型。一般来说,重要的概念模型有两个特定:

  • 与其他模型建立的关系更多
  • 需要使用唯一标识符进行跟踪
  • 随着业务进展,概念模型的状态和属性也会改变

对于这种最重要的概念模型,在解空间(实现域)我们称它为实体,用唯一 id 来标识。

值对象

领域模型中那些用来描述实体模型的概念,称为值对象。
值对象用来描述实体的特征,我们只关心值对象的属性,不关心它有没有唯一标识符。值对象的值相同,就认为相同,值不同,就认为不同。
引入值对象的原因是,在解空间需要将其和实体区分开,减少系统的复杂度

有时可以使用 id 作为一种特殊的值对象,代替实体的全部信息,例如用户 id。需要的时候再通过 id 查出实体信息

领域服务

领域模型中不包含任何数据的无状态的概念模型,例如一些业务处理过程和业务策略。这类概念模型称为领域服务。

领域事件

通过事件风暴进行需求分析,可以很容易得到领域事件。
领域事件表达了系统中发生了什么,它们源自于业务活动的结果
引入领域事件可以解耦业务间的复杂关系
领域事件里需要记录事件上下文,例如一些实体 id 等

聚合

引入聚合有两个好处

  • 保证业务逻辑的完整性,减少出错的概率
  • 明确划分业务边界

聚合的概念:

  • 将实体对象和值对象划分为聚合,形成聚合根,并围绕聚合根定义边界
  • 每个聚合根需要用实体来描述
    • 聚合根往往就是领域模型里的实体,围绕实体划分边界,保障对实体操作的业务完整性
  • 聚合根使用充血模式,执行边界内的业务行为,保障边界内的业务逻辑完整性
  • 外部只能通过持有对聚合根的引用来执行聚合根边界内的业务行为,不能越过聚合根,直接持有聚合根内部的属性

划分聚合的原则

生命周期一致性原则:

  • 聚合边界内的对象,和聚合根之间存在人身依附关系。即,聚合根消失,聚合根内的元素应该同时消失
  • 如果聚合根需要依赖其他的实体对象,引用实体 id 作为值对象,不要直接依赖实体对象,因为实体对象间的生命周期是不同的
    • 聚合根只持有其他聚合根(实体)的 id,这样聚合根只能在充血模式下操作和自己生命周期一致的值对象保证业务完整性,无法操作其他聚合根。保证了业务边界清晰,也符合高内聚低耦合的设计原则

小聚合原则:

  • 在不破坏业务逻辑完整性的基础上,小聚合带来更大的灵活性
  • 小聚合的聚合根可能并不是实体的概念,但也包含了一套完整的业务逻辑。将小聚合根作为值对象提供给更大边界的实体,相比于直接在实体里保证小聚合根内部的业务逻辑完整性更加灵活

工厂

使用工厂模式来构建聚合根,保障聚合根构造的业务完整性。也可以使用 Builder 模式

资源库

资源库是聚合根的仓储机制,外界只能通过资源库完成对聚合根的访问,也只有聚合根需要提供资源库访问方式。

代码结构

使用聚合根名称作为包名。包下是围绕这个聚合定义的所有 JAVA 类,例如一些描述值对象的类

分层架构

基于领域驱动的软件开发,系统架构一般由上到下分为这几层:接口层、应用层、领域层、基础设施层。
接口层:

  • 基于特性通信协议封装对外提供的接口服务。通信协议例如 Dubbo\HSF\Restful
    应用层:
  • 实现满足应用场景的外层服务,这部分的业务逻辑是经常变动的
    领域层:
  • 基于领域模型得到的实例、值对象、领域服务、领域事件集合。负责处理稳定的业务相关逻辑,应用层的接口需要依赖领域层的能力
    基础设施层:
  • 数据库、消息、缓存等中间件
  • 通过防腐层模式包装的外部依赖服务

建立高效的软件开发,应该以领域层为核心进行开发。领域层和其他层之间通过接口建立联系。因为领域层是最稳定也是最核心的业务抽象和实现。

意图导向编程

在开发阶段,编程的顺序应该是由外而内,即意图导向的编程。理由是:

  • 外层功能比底层实现方案具有更高的确定性
  • 延迟决策到最后时刻,关键信息经常会自然显现
  • 由外而内编程允许暂时忽略不重要的细节

结合上面介绍的分层架构,我们在编码阶段应该先从应用层入口,围绕应用场景定义接口职责。然后再将职责分配到领域层相关的实体、领域服务、领域事件等。

测试先行

后置的自动化单元测试的弊端:

  • 上下文丢失
    • 编码后再写测试,很难回忆起编码时考虑到的所有 case,丢失上下文也就很难写出完备的测试用例
  • 情绪和动机
    • 编码完成后再补测试,情绪和动机上都会受影响

自动化测试 Case 应该是表达业务意图,在定义好满足业务需求的应用层接口后,在实现内部逻辑前就应该补上测试 Case,来检验接口的业务功能是否满足。

测试用例的本质不是对被测代码的白盒化测试,而是对接口职责的测试。这样能保证在重构后,测试用例依旧可用。
测试用例其实描述的是软件开发中的契约(接口的入参格式、出参格式、出参值)。测试先行,就是为了在实现具体的功能细节(与接口职责无关)前,定义契约。

因此,测试先行的收益包括:

  • 聚焦于接口职责,从测试用例就能读懂接口意图
  • 在接口的内部功能实现前,就可以产出接口测试用例,避免丢失上下文
  • 支持重构
  • 持续建立对接口的信心

架构

子域和限界上下文

复杂的东西分而治之,各个击破,独立进化,易于复用。

领域划分需要围绕业务进行,而非技术实现!

核心域:提炼最有价值和最专业的概念到核心域,把核心变小,发现深层模型,打造核心竞争力
核心域需要能提炼出一句话来描述其职能,要足够内聚。

通用子域:简单理解为非核心域

拆分领域的方法论

  • 在业务流程中识别领域
  • 在领域模型中识别领域

限界上下文
比方:细胞膜隔离了细胞内的物质和细胞外的物质
限界上下文的边界和子域定义保持一致。在编码阶段维护住限界上下文是很有挑战的。最好的做法是用领域模型来守护限界上下文

如何识别限界上下文被破坏了?
一个子域的实现代码里,引入了其他域的领域服务。或者,一个领域模型里,引入了其他领域的概念模型

微服务很适合于保障限界上下文。即,通过进程来隔离,而非通过包来隔离(很容易被破坏)

划分领域需要保守一些,后续持续演进中可以再拆开细化。如果一开始就拆的太细,后续合并会很痛苦。

领域资产

好的领域资产一定是可以被集成、被扩展、被信赖
如何才能做出好的领域资产呢?必须满足以下三点:

  • 合理划分子域
  • 良好的契约描述资产能力和约束
  • 持续演进

处理遗留资产

对于遗留资产(前人写的并不符合领域驱动设计的代码),首先还是要把它当做资产看待,因为它还提供正常的功能。对待遗留资产,有这些方法:

  • 防腐层
    • 不直接调用遗留资产的方法,依然调用新的领域模型中定义的方法,在方法内部调用遗留资产的方法,增加防腐层(适配器)来适配遗留资产
  • 在遗留资产上包装并暴露 api
    • 从遗留资产出发,进行领域分析,提炼出领域方法,内部适配遗留资产的方法,对外只暴露领域方法,并做好自动化测试
    • 在这个基础上逐步重写遗留资产的方法,称为绞杀者模式

防腐层的设计

防腐层主要作用:

  • 在新的领域模型里兼容老的历史代码(遗留资产)
  • 封装其他领域提供的接口

1、使用门面模式,按新的领域模型需要,定义门面接口,实现上可能会调用多个遗留代码的接口来实现逻辑。

2、使用适配器模式,,提供适配器类,聚合门面类。它的作用是,对于调用门面类接口时,需要传入的一些遗留代码定义的参数,做一层新老模型的转义(适配)。新的领域模型里直接使用适配器类

  • 其实也可以直接把新老模型的转义适配放到门面类里,这样就只用一层门面类就能达到防腐层的作用