深入实践 DDD——以 DSL 驱动复杂软件开发

Posted by 皮皮潘 on 09-19,2023

前言

在做研究生毕设的时候,本来想针对低代码平台抽象出一套基于 DDD 的 DSL 用于低代码的开发,鉴于毕业设计的学术价值大于工程价值,最终还是没有精力真正实现出来,不过还是把一些设计过程中阅读 DDD 相关书籍中记录的概念总结下来,以供参考。如果大家对于领域驱动设计感兴趣,建议可以读一下《解构领域驱动设计》一书,个人感觉写的蛮不错的,相较于开山之作《领域驱动设计》而言,后者上手直接阅读过于深奥难懂了。

这篇文章先简单地介绍一些 DDD 的概念与 DSL 的大致思路。

DDD 核心概念

DDD 领域模型本质上是基于 OO 模型的,它在 OO 模型的基础上,通过一系列的设计模式,成为了状态管理的艺术,其内部各个核心概念之间的关系如下:

领域驱动设计元模型.png

DDD 的 DSL 是什么

DSL 是面向特定的应用领域专门化的计算机语言,它与通用语言(GPL)相对应,后者广泛适用于不同的领域

DDDML 的核心是一个文档对象模型(DOM 树状建模),它可以使用 YAML、JSON 或其他基于开放标准的标记语言来编写,最好使用 YAML 进行编写,因为 YAML 相较于 JSON 具有更好的可读性

通过 DDDML 可以严谨地、原汁原味地将领域中的概念模型表述出来,然后基于这些领域模型的表述,我们应该能将模型忠实、快速地映射到代码中;并且在修改、扩展这些代码的时候,我们应该为开发人员提供足够的约束,尽可能地不让他们破坏代码与模型之间地映射关系

我们可以设计一门 DDD 的 DSL,然后使用 DSL 描述领域分析和建模的结果 —— 领域模型,并使用软件工具从 DSL 文档生成与领域模型存在映射关系的代码

DDDML 需要能够:

  1. 基于 DDD 原生,描述 DDD 风格的领域模型
  2. 在复杂和简单中平衡
  3. 准确地描述对于需求分析和建模的结果

DDDML Schema

前置说明

在具体实现的时候,对于实体、值对象、属性、限界上下文以及方法等对象,为了方便引用以及减少冗余,这些对象的名称都不单独地设置为 name 属性,而是在一个 Map 类型的 DOM 结点中定义,这个 Map 中元素 的 Key 就是对象的名称

与此同时,为了避免名称与 DDDML Schema 中的关键字同时作为 Map 的 Key 而导致二义性的问题,我们对于那些需要在特定位置出现,具有特定含义的关键字采用 camelCase 风格进行命名,而对于普通对象的名称则采用 PascalCase 风格进行命名

为了避免在属性中单数与复数而导致的二义性的问题,我们采用了 type 与 itemType 两种关键字进行区分

限界上下文

限界上下文定义了每个模型的应用范围,且每个限界上下文对应一个微服务边界,在实践中,我们一般会用一个目录来存放一个限界上下文中所有模型的 DDDML 文件,然后 DDDML 的工具会读取这个项目目录中的所有 DDDML 文件,把它们在逻辑上合并为一个 DDDML 文档,这个文档描述的内容被当作同一个限界上下文中的模型来处理

每个限界上下文下面包括以下核心结点:

  1. aggregates 结点
  2. entities 结点
  3. valueObjects 结点(可以认为枚举对象和基本类型对象都是特殊的值对象)
  4. domainServices 结点
  5. domainEvents 结点

聚合

聚合是 DDD 在战术层面最重要的概念,它定义了一系列在概念上比较内聚的实体与值对象,另外一个聚合只能指定一个实体作为聚合根,且该实体作为整个聚合的入口

每个聚合下面具有下面两个核心结点:

  1. root 结点:root 结点指定了当前聚合下的聚合根实体,通过该聚合根实体所关联的所有非聚合根的实体与值对象都算在当前聚合下,被关联的同为聚合根的实体在具体实现时采用 Id 的方式进行关联
  2. methods 结点:methods 结点定义了聚合上面的方法,也即聚合根实体的方法,一般实体和值对象不存在方法,因为一个非聚合根对象的状态应该被视为某个聚合根实例的状态的一部分

实体

实体是针对具有生命周期或者唯一标识符的领域概念的模型的建模

每个实体对象具有下面两个核心结点:

  1. aggregate 结点:实体所处的聚合
  2. properties 结点:properties 结点通过 Map 类型的 Key 指定了值对象有什么属性,再通过 type 或者 itemType 关键字指定了每个属性的具体类型,值对象下的 property 可以是实体、值对象或者基本类型。实体与实体之间只有一种基本关系,这种关系从外层实体指向和它直接关联的内层实体,也就是说,从聚合根指向它直接关联的聚合内部实体,或者从聚合内部的非聚合根实体指向它直接关联的与其在同一个聚合内的另一个非聚合根实体,对于跨聚合的实体,我们采用 id 的方式进行

值对象

值对象是针对没有生命周期或者唯一标识符的领域概念的模型的建模,枚举对象以及基本类型对象都可以视作特殊的值对象

每个值对象下面包括了两个核心结点:

  1. aggregate 结点
  2. properties 结点,但是区别于实体的地方在于,值对象下的 property 只能也是值对象或者基本类型,不然值对象就会由于包含实体而导致其也拥有生命周期

方法

方法分为聚合方法和领域服务:

  1. 聚合方法定义在 aggregates 结点中,实际上是作用在聚合根实体上的实例方法,其核心是用来修改整个聚合实例的状态从而保证单个聚合状态修改的强一致性
  2. 领域服务定义在限界上下文结点中,需要在 DDDML 文档中描述的领域方法往往是命令方法,由于严格遵循 CQRS 的职责分离原则,因此命令方法没有任何返回值,但是会产生对应的领域事件,又返回值的应该是查询方法,但是由于用户对于查询的需求往往多变,因此大部分查询方法都不在 DDDML 中进行建模。在领域方法中一般会隐式地存在命令 Id,从而用于实现方法地幂等性,一般可以考虑使用“聚合根 ID + 命令 ID”来检测对应地命令是否重复,命令 ID 的产生方式可以是客户端生成 UUID 作为具体的值