什么是领域模型
在领域驱动设计(Domain-Driven Design,DDD)中,领域对象分为实体(Entity)和值对象(Value Object)。实体指的是能够通过唯一标识符标识出来的对象,有生命周期管理,而值对象仅仅表示一个值。实体的属性是可以变的,只要标识符不变,它就还是那个实体。但值对象的属性却不能变,一旦变了,它就不再是那个对象,所以,我们会把值对象设置成一个不变的对象。在 DDD 中我们为什么要将领域对象分为实体和值对象?其实主要是为了分出值对象,也就是把变的对象和不变的对象区分开。
对于领域对象,它的生命周期管理包括:
- 使用工厂(Factory)模式来创建和销毁领域对象;
- 使用聚合(Aggregate)模式来封装领域对象;
- 使用仓储(Repository)来查找和持久化领域对象。
工厂和仓储理解起来一点都不难,我们重点看一下聚合。
聚合就是多个实体或值对象的组合,它们共同构成了一个业务边界。聚合里可以包含很多个对象,每个对象里还可以继续包含其它的对象,就像一棵大树一层层展开。但重点是,这是一棵树,所以,它只能有一个树根,这个根就是聚合根(Aggregate Root)。聚合根必须是一个实体,是从外部访问这个聚合的起点。可见,最简单的聚合仅包含一个实体。
有了聚合模式后,我们所说的领域对象大多数情况下特指的是聚合,也有时指的是聚合内部的实体或值对象,这个可以通过所在的上下文来判断。
领域模型是关于统一语言的软件模型,存在于限界上下文(Bounded Context,BC)这个显式的边界之内,是 DDD 战术设计的目标。领域模型通过领域建模得到。领域建模简单来说就是识别领域对象,领域对象之间的关系,以及领域对象的关键属性。
领域模型是 DDD 的核心,主要作用有两个:
- 将领域知识可视化,准确、深刻的反映领域知识,并且在业务和技术人员之间达成一致;
- 指导系统的设计和编码。
团队成员不管是头脑里想的、交流中用的,还是文档中写的、UML图中画的、代码里表达的都是对领域模型的直接映射,所以我们说领域模型是团队所有角色在脑海里对业务知识构建的一致画面。
如何画领域模型
领域模型用领域模型图来呈现,通常用UML类图和包图来画。
领域模型的表达包括领域对象关系的表达和领域对象的表达,其中领域对象的表达又包括实体的表达、值对象的表达和聚合的表达。
领域对象关系的表达
领域对象的关系主要有两种:关联和泛化。
靶子和靶标是两个实体,你问我:“它们之间是否有关系?”,我回答说:“靶标是靶子的答案,肯定是有关系的。”于是你在它们两个之间画了一条线,表示它们之间有关系。
你又问我:“一个靶子最多可以有几个靶标?”我回答说:“一个靶子只能有一个靶标”。于是你在靶标那一端写了一个“1”来表示。你接着反问:“一个靶标最多可以属于几个靶子?”我回答说:“一个靶标最多属于一个靶子。”于是你在另一边也写上“1”。
我们可以说,靶子和靶标具有一对一的关系。这里的两个 “1” ,在 UML 中称为多重性(multiplicity)。那么,这种关系整体上呢,在 UML 的术语里叫做“关联”(association)。后面我们都用这种严格的说法,说成一对一关联。
同理就有一对多关联和多对多关联,其中多用 “*”来表达。
一个语言规范可以对应多个靶子,一个靶子只能归属一个规范:
一个靶场可以包含多个靶子,一个靶子可以属于多个靶场:
解释完关联的含义后,我们再来看泛化的概念:如果 A 类和 B 类可以统称为 C 类的话,C 类和 A、B 两个类就具有泛化关系,其中 C 是父类,A 和 B 是子类。泛化关系用一个空心箭头表示,由子类指向父类。
除了“统称”以外,泛化关系转换成自然语言,还可以有另外三种说法,我们以教练为例进行说明:
- 对于教练来说,可以分成两类:一类是管理教练,另一类是技术教练。也就是说,泛化表示的是一种“分类”关系。
- 管理教练是教练,技术教练也是教练。也就是所谓“是一个”(is-a)的关系。
-
管理教练和技术教练具有共性,那就是教导和实操能力,我们把这个共性的概念提取出来,称为“教练”。另一方面,管理教练和技术教练又具有“个性”,也就是两者有差别。
“统称”、“分类”、“是一个”以及“共性 / 个性”这四种说法,虽然从表面上看不同,背后的含义却是完全一样的。在领域模型里,不论哪种说法,都可以用泛化来表达。总的来说,泛化是一种强大的抽象机制,能够同时表现出不同对象间的共性和个性。
领域对象的表达
领域对象分为实体和值对象,其中值对象用类图来表达,通过<<value>>衍型(stereotype)来标识。比如时间段是一个值对象,它的类图如下所示:
需不需要用<<entity>>衍型来标识实体?这样做当然也没有错,但一般来说必有性不大,因为对于领域对象,除过值对象都是实体。
聚合使用包图来表达,内部有一个实体为聚合根,通过<<aggregate root>>衍型来标识。聚合是对一组实体和值对象的封装,表示整体和部分的关系,可以使用空心菱形(原书中 Eric Evans 用错了,故将错就错)表示,也可以使用实心菱形(更符合UML,但命名有混淆)表示,但团队内需保持一致。笔者更倾向使用空心菱形来表示整体部分关系,后续的例子都采用这种方式。整体部分关系是关联关系的一种特例,原来聚合这一端的 “1” 被删掉了,因为对于这种整体部分关系而言,这一端必然是 “1”,出于简洁的原因,所以就可以不写了。
员工是一个聚合,其中一个员工实体作为聚合根代表整体,另外两个实体技能和工作经验作为整体的部分,与员工关联,一个员工可以有多种技能和多段工作经验,如下图所示:
使用drawIO画领域模型图
假设我们已经完成了靶场管理上下文的领域建模,成果如下:
- 共有 3 个聚合,包括靶子、规范和靶场;
- 聚合根靶子聚合了值对象源文件和值对象靶标,其中靶标又由值对象靶标项组成;
- 聚合根规范聚合了实体版本规范,版本规范由值对象语言规范组成,同时语言规范又由值对象语言规范项组成;
- 聚合根靶场可以泛化为定标靶场、练习靶场和比赛靶场;
- 聚合根靶子与聚合根规范是多对一关联,聚合根靶子和聚合根靶场是多对多关联。
我们使用 drawIO 来画靶场管理上下文的领域模型,如下图所示:
使用 plantUML 画领域模型图
在 AI 2.0 时代,使用 drawIO 画的领域模型图不太方便作为业务上下文与大语言模型(Large Language Model,LLM)交流,于是我们考虑使用 plantUML 来重画领域模型图。
plantUML 使用简单的描述性语言来定义图表,这使得用户能够通过编写文本来生成图形表示,而无需使用复杂的图形编辑工具。
我们使用 plantUML 来描述靶场管理上下文的领域模型,如下所示:
@startuml
hide methods
hide circle
package "靶子" {
class 靶子 <<aggregate root>> {
工作空间
版本
语言
是否共享
靶标模式
状态
可见用户组
}
class 源文件 <<value>>{
}
class 靶标 <<value>>{
}
class 靶标项 <<value>>{
文件名
起始行号
结束行号
缺陷编码
缺陷大类
缺陷小类
缺陷细项
}
靶子 o-- "*" 源文件
靶子 o-- "*" 靶标
靶标 “1” -- "*" 靶标项
}
package "靶场" {
class 靶场 <<aggregate root>> {
语言
组织
成绩
记录
}
class 定标靶场 {
靶标负责人
靶标专家组
}
class 练习靶场 {
靶标脱敏时间
}
class 比赛靶场 {
开始时间
结束时间
靶标脱敏时间
}
靶场 <|-- 定标靶场
靶场 <|-- 练习靶场
靶场 <|-- 比赛靶场
}
package "规范" {
class 规范 <<aggregate root>> {
工作空间
已启用版本列表
}
class 版本规范 {
版本
}
class 语言规范 <<value>>{
语言
}
class 语言规范项 <<value>>{
缺陷编码
缺陷大类
缺陷小类
缺陷细项
}
规范 o-- "*" 版本规范
版本规范 “1” -- "*" 语言规范
语言规范 “1” -- "*" 语言规范项
}
靶子.靶子 "*" -left- "*" 靶场.靶场
靶子.靶子 "*" -right- “1” 规范.规范
@enduml
在 VSCode 中使用 plantUML 插件生成领域模型图如下所示:
说明:在有的系统中,使用 plantUML 表达聚合根的关联关系时,聚合根的格式必须为类名,而不是本文中的包名.类名,否则生成的领域模型图将与上图不同。
LLM 辅助画领域模型图
既然已经可以使用 plantUML 画领域模型图了,我们考虑后续降低画其他领域模型图的成本:沉淀画领域模型图的 Prompt 模版,注入目标领域模型逻辑,让 LLM 生成 plantUML 文本描述,然后在 VSCode 中使用 plantUML 插件生成领域模型图。
我们直接给出画领域模型图的 Prompt 模版,如下所示:
# 目标领域模型逻辑
%question%
# 输出要求
- 使用 plantUML 文本描述;
- 使用类图和包图来表达领域模型;
- 属性仅保留中文描述;
- 一对多关联用 plantUML 语法表达就是在线的一端写 “1”另一端写"*" ,多对一关联就是在线的一端写 “*”另一端写"1" ,多对多关联就是在线的两端都写 “*” ;
- 当表达聚合根之间的关联关系时,聚合根格式必须为**包名.类名**,比如对于聚合根靶子来说,类名和包名均为靶子,则描述的格式为**靶子.靶子** ;
- 排版紧凑整齐。
# 示例
```plantuml
@startuml
hide methods
hide circle
package "靶子" {
class 靶子 <<aggregate root>> {
工作空间
版本
语言
是否共享
靶标模式
状态
可见用户组
}
class 源文件 <<value>>{
}
class 靶标 <<value>>{
}
class 靶标项 <<value>>{
文件名
起始行号
结束行号
缺陷编码
缺陷大类
缺陷小类
缺陷细项
}
靶子 o-- "*" 源文件
靶子 o-- "*" 靶标
靶标 “1” -- "*" 靶标项
}
package "靶场" {
class 靶场 <<aggregate root>> {
语言
组织
成绩
记录
}
class 定标靶场 {
靶标负责人
靶标专家组
}
class 练习靶场 {
靶标脱敏时间
}
class 比赛靶场 {
开始时间
结束时间
靶标脱敏时间
}
靶场 <|-- 定标靶场
靶场 <|-- 练习靶场
靶场 <|-- 比赛靶场
}
package "规范" {
class 规范 <<aggregate root>> {
工作空间
已启用版本列表
}
class 版本规范 {
版本
}
class 语言规范 <<value>>{
语言
}
class 语言规范项 <<value>>{
缺陷编码
缺陷大类
缺陷小类
缺陷细项
}
规范 o-- "*" 版本规范
版本规范 “1” -- "*" 语言规范
语言规范 “1” -- "*" 语言规范项
}
靶子.靶子 "*" -left- "*" 靶场.靶场
靶子.靶子 "*" -right- “1” 规范.规范
@enduml
# 任务描述
假如你是一名 DDD 专家,请参考示例,根据领域模型逻辑来画领域模型图。
两点说明:
- Prompt 模版中的
%question%
变量就是待用户注入的目标领域模型逻辑; - Prompt 模版中的输出要求可根据需要灵活扩充。
假设我们已经完成了日常评审上下文的领域建模,其领域模型逻辑如下所示:
- 共有 4 个聚合,包括评审组、缺陷扩展、工程配置和评审;
- 聚合根评审组有 2 个关键属性,即名称和成员列表,没有聚合其他实体和值对象;
- 聚合根缺陷扩展有 2 个关键属性,即名称和自定义标签, 没有聚合其他实体和值对象;
- 聚合根工程配置有 3 个关键属性,即路径、工作空间和规范版本;
- 聚合根评审有 1 个关键属性,即评审人员,同时泛化了个人评审和集体评审两个子类,并且聚合了一个值对象工程(有 2 个关键属性,即具体路径和CommitId),聚合根评审与值对象工程是一对一关联;
- 聚合根工程配置与聚合根评审组是多对一关联,聚合根工程配置与聚合根缺陷扩展是多对一关联,聚合根工程配置与聚合根评审是一对多关联。
当我们要画日常评审上下文的领域模型图时,仅需将该上下文的领域模型逻辑注入到 Prompt 模版中的变量 %question%
即可,这个过程叫模版实例化。
我们将实例化后的 Prompt 模版发送给 LLM(比如 ChatGPT):
LLM 生成的 plantUML 文本格式的领域模型图如下所示:
@startuml
hide methods
hide circle
package "评审组" {
class 评审组 <<aggregate root>> {
名称
成员列表
}
}
package "缺陷扩展" {
class 缺陷扩展 <<aggregate root>> {
名称
自定义标签
}
}
package "工程配置" {
class 工程配置 <<aggregate root>> {
路径
工作空间
规范版本
}
}
package "评审" {
class 评审 <<aggregate root>> {
评审人员
}
class 个人评审 {
}
class 集体评审 {
}
class 工程 <<value>> {
具体路径
CommitId
}
评审 <|-- 个人评审
评审 <|-- 集体评审
评审 o-- "1" 工程
}
' 关联关系
工程配置.工程配置 "1" -down- "*" 评审.评审
工程配置.工程配置 "*" -left- "1" 评审组.评审组
工程配置.工程配置 "*" -right- "1" 缺陷扩展.缺陷扩展
@enduml
在 VSCode 中使用 plantUML 插件生成目标领域模型如下图所示:
小结
领域模型是 DDD 的核心,修改模型就是修改代码,修改代码就是修改模型。软件研发的核心难度在于处理隐藏在业务知识中的复杂度,那么模型就是对这种复杂度的简化与精炼。
本文详细阐述了领域模型的概念和表达方法,同时沉淀了一个画领域模型的 Prompt 模版。当给 LLM 注入目标领域模型逻辑后,可以直接生成 plantUML 文本格式的目标领域模型图。LLM 辅助画领域模型图的实践,不仅降低了我们画领域模型图的成本(节省了时间),而且提高了我们向 LLM 注入业务知识的效率(LLM 容易理解 plantUML 文本格式的领域模型图),希望对读者有一定的收益!
参考资料
- 极客时间专栏,《手把手教你落地 DDD》,钟敬