目录
引言
之前我在《DDD系列 - 第4讲 从架构师的角度看待DDD - 一个关于拆解、微服务、面向对象的故事(二)》中提到过,一个聚合
在代码组织形式上对应到一个包Package
,通过包Package
将不同的类进行分堆,使得包内的类(实体、值对象、仓库、领域事件等)
高度相关,即包(聚合)内高内聚,同时为了避免包与包之间的对象错综复杂的交互与耦合,每个包Package
内选出一个代表(实体
)作为访问入口类(聚合根)
,所有对该包内的访问都要通过这个入口类(聚合根)
发起,即包(聚合)间低耦合。
上述的这个认知的过程是从底层代码不断重构后抽象出的结论,但是在实际的业务分析与架构设计阶段,我们是从上层进行分析与设计,理想的情况是需要先根据业务、原型等识别出聚合,然后再通过聚合指导程序的实现。通过之前的分析可以确定:聚合只是起到模型边界的作用(高内聚低耦合的边界),同时聚合中一定包含一个聚合根,聚合根又一定是一个实体,所以最先对我们的建模起到影响的应该是实体,然后再在实体的基础上决定是否需要合并不同实体到同一个聚合中,所以聚合的识别可以优先从识别实体入手。
一、实体
1.1 数据库实体
说起实体,通常我们最先想到的就是关系数据库中的ER中的实体,我们在平时的事务脚本的程序设计中都会优先识别出不同的实体表、关系表,实体表中包含主键(唯一标识)、列(属性)等,而我们可以针对实体表中的某一条记录(行)进行增删改查,如我们会在最开始先生成实体主键、创建实体记录,然后根据实体的主键去查询出实体记录,并对指定实体主键的实体记录进行修改,最后在实体记录失去作用时会根据实体主键删除相应的实体记录。
1.2 数据库实体 vs. DDD实体
其实数据库中实体表
本质上就是DDD中实体
的一部分,数据库中的实体表承载了DDD中实体的数据
部分,我们可以从数据
维度将实体表(即主键、列)
映射为实体对象(身份标识ID、属性)
,再引入一些面向对象与DDD思想,将实体对象相关的操作(领域行为)
、实体对象间的关联关系(其他聚合下实体的身份标识)
都一并封装到实体中,如此即可进化为DDD中的实体。也就是说,即便我们并未采用过DDD的建模方法,我们也可以从过往的数据库、事务脚本的设计经验中提取出最初的实体设计,然后再结合面向对象、DDD的建模思想逐步完善实体的设计。
1.3 DDD实体的本质及其识别规则
接下来我们看看所谓实体的本质,首先实体是对领域逻辑中某个特征的映射,在关系型数据库维度,即存在主键且支持CRUD的表记录,在面向对象层面即存在唯一身份标识且支持创建、加载、持久化、删除的对象,综合起来就可以理解为:实体是具备唯一身份标识
且具有生命周期
的领域概念的对象映射
。
所谓生命周期,可以参照人的一生:生老病死,金榜题名,娶妻生子,事业有成,周而复始
:
阶段 | 人生 | 实体的生命周期 |
---|---|---|
生 | 出生, 来到这个世界, 有了名字,有了身份证号 | 被创建, 持久化到仓库, 被分配了唯一的身份标识 |
金榜题名 | 努力学习, 不断充实自己, 如愿成为高考状元 | 持续的发生变化, 自身状态在不断被改变 |
娶妻生子 | 身边有了更多的牵挂, 和更多人有了联系 | 持续的发生变化, 和其他实体有了关联 |
事业有成 | 与事业伙伴合作, 影响他人或被他人影响, 最终事业上有了一点成绩 | 持续的发生变化, 依赖其他实体或者被其他实体依赖 |
老 病 | 从孩啼到叛逆, 从成熟到迟暮, 难免头疼脑热,磕磕碰碰, 但求无病无灾 | 持续的发生变化, 自身状态在不断被改变 |
死 | 死亡, 离开这个世界 | 被删除, 从仓库中被移除 |
周而复始 | 旧人去, 新人来 | 旧的实体被删除, 新的实体被创建 |
一个实体通常会被创建、删除,并且在实体存在的整个生命周期中,通常会会持续的发生变化,与其他实体产生关系,但无论怎么变化,实体的唯一身份都是不会变的,在对实体进行改变时,会通过实体的唯一身份标识区分不同的实体(例如通过实体的唯一身份标识查询出相应的实体后再对其进行改变)。
综上,我认为我们可以从实体的 生命周期(创建、持续变化、删除) 、唯一身份标识 两个维度识别出业务中的实体。
例如以
我在优快云写博文
为例,每当我学习了新知识时,我想把这些新心得记录下来并分享给粉丝的时候,我都会创建一篇新的博文,我给博文起了个标题,在正文中先列出博文的大纲和一些想法,起初先保存为草稿,之后再经过多次反复修改和完善后才决定最终发布。在我决定将博文发布后,博客平台会对我的博文内容进行审核,只有审核通过后大家才能真正看到我的博文,如果审核不通过我会按照审核要求对博文进行修改直至通过。后续如果我发现某篇博文的内容不太合适又或者可以合并到另一篇博文中,我也会其删除,避免我的博文列表中存留着大量无用的博文。
上面这段描述中,显然写博文这件事一直围绕着博文进行,例如博文会被创建、持续变化(会被保存为草稿、会被修改、会被发布、会被审核)、会被删除
等。其中还隐藏的逻辑就是每一遍博文都有一个唯一的ID
,之所以不能用博文标题做唯一标识,就是因为不同作者的博文内容可能都围绕同一个主题,那博文标题很有可能会出现重复,所以需要给每篇博文一个专门生成的ID,博客平台通过博文ID来唯一区分不同的博文,当我们查看、修改、删除博文时,会通过博文ID来告诉博客平台我们操作的是哪一篇博文。显然博文
这个概念满足生命周期
、唯一身份标识
这两个维度,所以博文
可以作为实体
。
让我们再换个角度来看这件事,针对博文
这个概念,有如下动作:
- 保存草稿
- 发布文章
- 审核博文
- 删除博文
同一个概念存在
多个动作
,则可能意味着该概念存在生命周期,即该概念可对应为实体。
而针对这些动作,又会衍生出不同的博文状态(例如xx已yy
),如下图:
同一个概念存在
多个状态
,则可能意味着该概念存在生命周期,即该概念可对应为实体。
将上图建模过程反过来:先识别领域事件(状态),再识别动作,也即为事件风暴的建模思想,通过领域事件的生命周期线即可识别出相应的实体。
在我看来,无论是通过动作
还是通过领域事件(状态)
,其本质都是从实体的生命周期维度进行实体的识别,实际使用时可将两者融会贯通,简单的业务直接从动作出发即可,复杂的业务及状态变迁可结合事件风暴进行更细粒度的梳理,亦有益于加深对业务和流程的认知。
1.4 代码中如何定义实体
讲完了实体的识别,我们在来看看一个实体对象中具体组成元素,通常一个典型的实体
应该具备三个要素:
元素 | 说明 |
---|---|
唯一身份标识 | 1)通用类型 - 自增Long、雪花ID、UUID等 2)领域类型 - 身份证号、订单号 |
属性 | 1)基本属性(比如String、Integer、Double等) 2)自定义属性类( 值对象 )3)同聚合内的其他实体 - 同聚合内的实体可直接通过对象引用进行关联 4)关联的其他聚合根实体ID - 不同聚合通过聚合根实体ID进行关联 |
领域行为(方法) | 1)变更状态的领域行为 - 修改自身属性状态 2)自给自足的领域行为 - 如根据自身做聚合、派生(计算)属性等 3)互为协作的领域行为 - 其他实体(聚合根)作为方法参数进行相互调用 |
以Java语言为例,给出实体
的类图组成如下:
上图中需要注意如下元素的使用场景:
元素 | 说明 |
---|---|
~NoArgsConstuctor 包内的无参构造函数 | 不允许被公开调用, 仅用作反序列化框架的对象初始创建 |
~AllArgsContructor 包内的全参构造函数 | 不允许被公开调用, 仅当聚合包内的工厂Factory创建聚合实体时调用 |
自我验证 | 自我验证并不是单独的方法, 而是强调自我验证贯穿于每个改变实体自身状态的方法中(构造函数、变更方法等) |
Getter | 可存在公共的getter方法, 用于获取实体对应的属性 |
不存在任何setter方法, 推荐使用具有领域意义的方法进行替代, 如修改商品状态setGoodsStatus可替换为上架商品shelve、下架商品unshelve等 |
二 、值对象
说起值对象,我在《DDD系列 - 第4讲 从架构师的角度看待DDD - 一个关于拆解、微服务、面向对象的故事(二)》中直接将自定义属性类
映射为DDD中的值对象
。
2.1 值对象 vs. 附属属性
值对象通常作为实体的附属属性
,何为附属属性?实体、值对象都是对领域概念的映射,比如之前提到的博客
、博客标题
、博客内容
这三个概念,之前我们已经从生命周期、唯一身份标识的维度梳理出博客
是一个实体,同样我们以同样的维度去看待博客标题、博客内容
,虽然标题
和内容
也都可以被创建、修改、删除,但其创建、删除都是伴随着博客
一起被创建和删除,修改时也无法直接去修改某一个标题
或内容
,而是需要先去检索出对应的博客
后再去修改该博客中的标题
和内容
,所以我们没有必要为某一个博客标题
或内容
分配单独的唯一标识,而是通过博客实体
去定位和操作相关的附属属性
博客标题
和内容
,所以我们认为博客标题
和博客内容
为博客实体
的附属属性。附属属性又分为基本属性(比如String、Integer、Double等)和自定义属性类,自定义属性类则统称为值对象
。
2.2 值对象 vs. 实体
相较于实体,值对象一个最大的显著区别就是值对象不需要唯一的身份标识(ID),两个实体的身份标识相等即可认为两个实体相等(实体比较身份标识),而两个值对象仅在值对象内的所有属性都相等才认为两个值对象相等(值对象比较属性值)。
2.3 代码中如何定义值对象
结合之前的分析,可以推出一个典型的值对象应该具备如下两个要素:
元素 | 说明 |
---|---|
属性 | 1)基本属性(比如String、Integer、Double等) 2)其他值对象 |
领域行为(方法) | 1)自我组合领域行为 - 例如Price.add(Price)返回一个新的Price,类似String.concat(str) 2)自我运算的领域行为 - 例如Location.distanceOf(Location)返回两个位置间的距离, 又例如IdCardNo.getBirthday()返回身份证号中的出生日期 |
同样以Java语言为例,给出值对象
的类图组成如下:
上图中需要注意如下元素的使用场景:
元素 | 说明 |
---|---|
~NoArgsConstuctor 包内的无参构造函数 | 不允许被公开调用, 仅用作反序列化框架的对象初始创建 |
~AllArgsContructor 包内的全参构造函数 | 不允许被公开调用, 仅当聚合包内的工厂Factory创建聚合实体中的值对象时调用 |
自我验证 | 自我验证并不是单独的方法, 而是强调自我验证贯穿于每个改变值对象自身状态的方法中(构造函数、变更方法等) |
Getter | 可存在公共的getter方法, 用于获取实体对应的属性 |
不存在任何setter方法, 推荐使用具有领域意义的方法进行替代 |
需要注意的是值对象不允许直接修改其内部状态,若需要改变其自身状态则需要生成一个包含新状态的新值对象,即不可变性。究其原因我们可以参照下面这个例子,例如想要访问用户实体
的身份证号值对象
关联的出生日期,我们需要:
//从实体对象开始
User user = userRepository.load(userId);
//遍历关联的值对象
LocalDate birthDate = user.getIdCardNo().getBirthDate() ;
通过如上代码我们可以发现,我们仍然可以通过User类访问到其内部的属性类IdCardNo:
IdCardNo idCardNo = user.getIdCardNo();
如果该IdCardNo对象被其他代码引用,其他代码就可以直接操作IdCardNo对象,如果其他代码修改了IdCardNo对象的内部状态(如通过setter),若此时产生此IdCardNo的User对象可能已经被持久化,则User对象已无法感知IdCardNo修改后的状态,User无法对修改后的IdCardNo进行持久化,而开发人员却以为已在其他代码处对IdCardNo进行了修改,很容易引起二者间状态的不一致,进而引发问题。为了避免这种自定义值对象如IdCardNo被任意修改的情况,我们可以参考Java原生对象如String的定义方式,即我们无法修改String对象的字符串内容(不存在setText等方法),就算迫不得已对String进行了修改(如String.concat、String.substring)也不会影响原String对象,而是将修改作用于一个新生成的String对象。遵照此原则,即便在外部代码对值对象进行了修改,也会迫使我们去思考如何同步属性状态回到实体中,既然这么麻烦,渐渐地就会迫使我们养成直接通过实体对象去操作其内部的所有状态,尽可能少的暴露内部引用,避免不必要的同步等复杂性,也有助于我们实现高内聚的实体建模。
2.4 何时使用值对象
那么相较于基本属性(String、Integer等),我们在什么情况下需要定义单独的值对象呢?当出现如下场景可考虑创建值对象:
场景 | 示例 |
---|---|
属性存在特定的约束规则 | 如属性需要验证自身的长度、正则规则等 |
多个属性为组合因子 | 如长度值对象包含length、unit两个属性,这两个属性加在一起才能体现出真正的长度 |
属性有属于自己的领域行为 | 如身份证号属性提供获取所在的省市区、出生日期等方法 |
通过枚举更好的表达领域概念 | 枚举也可以是值对象,通过枚举可以更好的表达领域概念及约束, 如商品状态枚举、性别枚举等 |
值对象相较于基本属性,有其特殊的优势:
值对象优势 | 说明 |
---|---|
更好的展示领域概念 | 例如使用单独定义UserName值对象类来表示用户名称这个领域概念,而这是直接使用String类型所无法表达的 |
更好的封装领域逻辑及验证行为 | 将属性及其相关操作均封装到同一个值对象中,高内聚,避免实体类过于膨胀 |
支持强类型校验 | 例如UserName只能传递UserName类型,而不会和String类型的accoutName混淆 |
凡事有利有弊,说完了值对象的优势,我们再来看看值对象的问题:
值对象劣势 | 说明 |
---|---|
复杂的对象关系 | 本来一个实体加基本属性就能搞定的事,又额外定义了更多的值对象, 实体与值对象间存在嵌套,增加了维护成本 |
复杂的转换成本 | 应用层DTO <==> 领域层实体、值对象 <==>基础设施层DTO、DataObject 间转换复杂,DTO、DataObject中都是基本属性,需解决基本属性和值对象间转换的各种嵌套关系, 基本告别Bean拷贝工具,需手写复杂的转换逻辑(代码 或 JPA注解@Embedded、@Embeddable等) |
我的观点是:如果你的团队整体技术水平很高,能hold住复杂的对象关系、转换成本,那么你可以按照DDD推荐的优先使用自定义值对象
进行建模。但是通常我都会推荐尽量少使用值对象
,不要给自己找麻烦,如果同样都能解决问题,那我们理应使用更简单的方式。
奥卡姆剃刀定律
如无必要,勿增实体(此处的实体不是DDD中实体,而是一个统称),即简单有效原理
那么如果不使用值对象,仅通过基本属性,我们应该怎么做?结合值对象的使用场景,可参考如下的替代方案:
值对象使用场景 | 基本属性替代方案 |
---|---|
属性存在特定的约束规则 | 实体中使用基本属性代替,可结合Java Validation通过验证注解完成基本属性的验证(非空、长度、正则等),后续我会在Always Valid Domain Model中单独介绍此模式 |
多个属性为组合因子 | 实体中使用多个基本属性代替 |
属性有属于自己的领域行为 | 将行为统一放到实体中,若属性相关的领域行为数量较多,亦可将其提取到单独的领域服务中 |
通过枚举更好的表达领域概念 | 保留枚举,但在实体中使用基本属性代替,可结合Java Validation通过自定义验证注解(如@EnumValueRange(YourEnum.class) )完成基本属性的验证(取值范围),后续我会在Always Valid Domain Model中单独介绍此模式 |