聚合
1.介绍
实体和值对象是对业务知识的建模,而聚合和聚合根则是对领域模型一致性的建模
采用面向对象的方式对领域知识进行建模之后,得到了实体和值对象这两类领域模型。这些领域模型的对象不会孤立地存在,往往存在引用关系,形成一颗对象树。这颗对象树就是一个聚合
聚合根指的是这颗树的根,聚合根必定是一个实体
聚合表达的是真实世界中整体与部分的关系,不是整体与部分的关系是不能设计成聚合的。当整体不存在时,部分就变得没有了意义
聚合的本质就是建立了比对象粒度更大的边界,聚合了那些紧密联系的对象,形成了一个业务上的整体。使用聚合根作为对外交互的入口,从而保证了多个互相关联的对象的一致性
2.官方定义
将实体和值对象聚集到聚合中。每个聚合定义了一个边界。为每个聚合选择一个实体作为其根,并通过根来控制所有对边界内对象的访问。外部对象只能持有根的引用;对内部元素的临时引用只能在单个操作中使用。由于根控制了访问,因此我们无法绕过它去修改内部元素。这种安排使得我们可以保证在任何状态变化中,聚合本身的不变量,以及聚合中对象的不变量都可以被满足
3.原版定义导致对于建模的疑惑
原版聚合定义是从不变量(强一致性)入手的,但是这对于建模来说没有太大帮助
4.聚合作用
总览
- 简化复杂性:聚合通过组织相关的对象,提供了一个清晰的业务逻辑模型,有助于业务规则的实施和维护,降低对象之间的交互关系
- 保证一致性:聚合确保内部状态的一致性,通过定义清晰的边界和规则,聚合可以在内部强制执行业务规则,从而保证数据的一致性
- 事务边界:聚合也定义了事务的边界。在聚合内部,所有的变更操作应该是原子的,即它们要么全部成功,要么全部失败,以此来保证数据的一致性
简化复杂性
在DDD中,聚合可以说是最核心的一种领域模型对象。聚合概念的提出与软件复杂度有直接关联。通常,一个系统中的对象之间都会存在比较复杂的交互关系,下图展示了系统具有8个对象时各对象之间的交互示意图:
从图中可以看出,原则上这8个对象之间的交互方式最多可以达到2的8次方-1种。为了降低对象交互所带来的复杂度,DDD引入了聚合的概念。那么,聚合是如何降低复杂度的呢?
聚合的核心思想在于将领域对象的关联关系减至最少,这样就简化了对象之间的遍历过程,从而降低了系统的复杂度。聚合由两部分组成:聚合根,指聚合中的某一个特定实体;聚合边界,定义聚合内部包含的范围
聚合代表一组相关对象的组合,是数据修改的最小单元,也就意味着对领域模型对象的修改只能通过聚合根实现,而不能通过组合中的任何实体直接修改。换句话说,只有聚合根的实体暴露了对外操作的入口,其他对象必须通过聚合内部的遍历才能进行访问,而删除操作也必须一次性删除聚合之内的所有对象。通过这种固定的规则,我们确保聚合内部的数据操作具有严格的事务性
关于聚合,我们可以进一步看图所示的聚合示意图。在这张图中,根据聚合思想把原有的8个对象划分成3个边界,每个边界包含一个聚合。我们可以看到与外部边界直接关联的就是聚合根,只有根对象之间才能直接交互,其他对象只能与该聚合中的根对象直接交互
显然,以上图中的8个对象为例,通过聚合可以把对象之间最多2的8次方-1次直接交互减少为2的3次方-1次
保证一致性
介绍
下面的Role就是一个聚合,并且聚合根就是Role这个类,通过它可以访问到聚合内所有的状态。除非通过Role聚合根,否则外部无法访问该聚合的状态
/**
* 这一组紧密关联的领域对象形成了Role聚合
* Role类是这个聚合的聚合根
* 需要通过Role才能访问内部状态
*/
public class Role {
private RoleId roleId;
private String roleName;
/**
* 资源实体
*/
private Set<Resource> resources = new HashSet<>();
/**
* 更新角色名称
*/
public void modifyRoleName(String roleName) {
this.roleName = roleName;
}
/**
* 外部看不到Resource实体,为Role新增资源需要将资源的key和name传进来
*/
public void addResource(String resourceKey,String resourceName) {
// 创建Resource实例
Resource r = this.createResource(resourceKey,resourceName);
this.resources.add(r);
}
/**
* 通过key判断是否存在某个Resource
*/
public boolean hasResource(String resourceKey) {
Objects.requireNonNull(resourceKey,"resourceKey为空");
return resources.stream().anyMatch(e -> e.getResourceKey().equals(resourceKey));
}
}
聚合根避免外部对象访问内部属性
聚合根可以控制外部对聚合内状态的访问。外部对象只能引用聚合根,不能直接引用聚合根内的对象,避免了外部对象绕过聚合根来修改内部对象的状态,确保任何状态变化都符合聚合的固定规则
以Role这个聚合为例,当需要给角色添加资源时,我们必须通过聚合根Role提供的addResource方法进行操作,而不是某个方法返回resources,再通过resources添加
/**
* 以下是正例,通过聚合操作内部状态
*/
public class RoleApplicationService {
@Resource
private RoleRepository roleRepository;
public void addResource(String roleId,String resourceKey,String resourceName) {
Role role = roleRepository.load(new RoleId(roleId));
role.addResource(resourceKey,resourceName);
roleRepository.save(role);
}
}
以下这个案例是某个方法返回聚合内部的状态,直接操作聚合根状态,这是不推荐的
/**
* 以下是反例,直接绕可聚合根操作
*/
public class RoleApplicationService {
@Resource
private RoleRepository roleRepository;
public void addResource(String roleId,String resourceKey,String resourceName) {
Role role = roleRepository.load(new RoleId(roleId));
// 错误,对外暴露了聚合内部的状态
Set<Resource> resources = role.getResources();
// 错误,绕开聚合根操作聚合内部状态
resources.add(new Resource(resourceKey,resourceName));
roleRepository.save(role);
}
}
通过拷贝暴露聚合内部状态
外部对象如果需要获取聚合的内部状态,可以通过聚合根创建一个状态副本并返回这个副本,避免了外部私自修改聚合内部状态的风险
public class Role {
private Gson gson = new Gson();
private String roleName;
private Set<Resource> resources = new HashSet();
/**
* 当需要暴露聚合内部的状态,不能直接返回内部状态的引用,而是应当返回一个副本
*/
public Set<Resource> getResources() {
String json = gson.toJson(this.resources);
Set<Resource> newSet = gson.fromJson(json,new TypeToken<Set<Resource>>(){}.getType());
return new Set;
}
}
事务管理
只让聚合根持有Repository,因为聚合的状态是通过聚合根进行维护的,要避免绕开聚合根进行状态修改,因此,只有聚合根拥有Repository,非聚合根的实体没有Repository的
聚合根的Repository只有两个方法:load(根据聚合根id加载聚合根),save(保存聚合根),当然要注意事务控制
5.怎么划分聚合
6.案例理解聚合(一)
数据库设计的问题
以订单为例子,在真实世界中订单与订单明细本来是同一个事物,订单明细是订单中的一个属性,但是由于在关系型数据库中没有办法在一个字段表达一对多的关系,因此必须将订单明细设计成另外一张
聚合的代码
尽管如此在DDD的领域模型的设计中,我们又将其还原到了真实世界中,以聚合的形式进行设计
效果
将订单明细封装在订单对象里面去使用!
7.案例理解聚合(二)
8.案例理解聚合(三)
解决的问题
我们先从一个问题域开始,拿大家都能理解的企业采购系统来举例:
- 提交人通过采购系统提交一个采购申请,采购申请中包含了本次要采购的若干办公用品(称为采购项)和对应的数量
- 主管对采购申请进行审批
- 审批通过后,生成订单发送到供应商出货
面向数据库设计解决方案
类图
面向数据库设计的问题
为了保证业务规则的正确性和数据一致性,在上面的采购系统中,我们需要考虑如下几个问题:
- 如果采购申请单被删除,则和该采购请求相关的采购项是否也应该都被删除
- 如果你的主管正在对采购申请进行审批,而你又同时在修改采购申请中的采购项,那该如何进行并发处理呢?如果设计不当,要么你主管审批的就是过期的数据,要么你更新采购项会失败
面向数据库设计问题的解决方案
虽然上面的问题都有对应的解决办法,但是会过早的陷入技术细节的讨论中,这样业务和技术就混在一起了,会让我们错失和业务专家充分讨论的机会,而很多业务隐含的概念是在和业务专家协作过程中显现的,同时技术复杂性和业务复杂性混合在一起,让我们顾此失彼
总之,在简单的场景下,采用面向数据库的设计简单直接,能快速实现需求。但是在较复杂的业务场景下,如果一上来就在数据库这么低的层次上考虑问题,我们会花大量的时间在表结构的设计上,而没有重视对重要的业务规则的梳理。随着业务的快速发展,由于我们最初设计考虑不当,我们会疲于应付不断出现的新需求和bug,我们会陷入沉重的泥潭,最后系统只能推倒重来
那我们有没有一种方法能够让我们聚焦于问题领域,而不是过早地陷入到技术细节中呢?答案就是: 面向对象设计
面向对象设计解决方案
图示
好处
面向对象的设计方法提高了抽象层级,忽略一些不必要的技术细节(例如不用再关心表的外键、表的关联关系等技术细节了),让我们能够更加专注地聚焦到问题领域,同时业务人员也能够看懂,技术和业务专家也能够基于统一语言进行持续的交流协作
问题
但是,业务规则如何保证? 在传统的面向对象的设计中,并没有很好的方法能够对业务规则进行约束。例如: 从业务规则上来看,当采购电请审批通过了,就不允许电请者再对采购申请中的采购项进行修改。但是在面向对象的设计中,你没法阻止程序员写出如下的代码:
语句1取得了采购申请的实例,语句2获取了该采购申请中的一个采购项,语句3,4对采购项的数量进行修改并保存。如果该采购申请已经审批通过了,那这种修改就违背了业务规则
可能你会说在修改之前,我先对purchaseRequest的状态进行校验,如果状态是已审批通过,就不允许修改。加上校验的代码如下:
但是PurchaseItem在任何地方都能够被提取出来,并且PurchaseItem对象可以在方法间进行传递
要满足上述的业务规则,你需要在每个对PurchaseItem修改的地方加上上面这段校验代码。如果设计不当,那这段校验逻辑就会散落在各个地方,未来要修改这段校验逻辑,你需要找出散落的每个地方进行修改,这成本可想而知
没有设计上的约束,那要保证业务规则的正确性并不是一件很容易的事
面向DDD设计的解决方案
让我们回到本质问题:采购项脱离了采购申请有单独存在的价值吗?
答案显然是没有什么卵用。既然采购项没有单独存在的价值,那对采购项的修改本质上是不是对采购申请的修改?
如果我们认同:‘对采购项的修改就是对采购申请的修改’这个结论,那我们就不应该将采购项和采购申请分开来看待,而应该如下图所示:
我们把“采购申请”和“采购项”看做是一个整体,这个比对象更大粒度的整体就称为“聚合”
这个聚合内部的业务逻辑,例如“采购申请审批通过后,不得对采购项进行修改”,应该内建于聚合内部。为了实现这一目标,我们约定:一切对采购项的操作(增删改查),都是对采购请求对象的操作
也就是说,在代码中从来就不应该出现savePurchaseItem()这种方法,应该用purchaseRequest.modifyPurchaseItem()和purchaseRequest.savePurchaseItem()方法代替
现在对purchaseItem的访问必须通过purchaseRequest对象,purchaseRequest对象作为访问聚合的入口,称为“聚合根”(又是一个重要的概念)。由于聚合是一个整体,对聚合的任何操作只能通过聚合根来进行,从而业务规则在聚合内部得到了保证
9.聚合实现手段
- 定义聚合根:选择合适的聚合根是实现聚合的第一步。聚合根应该是能够代表整个聚合的实体,并且拥有唯一标识
- 限制访问路径:只能通过聚合根来修改聚合内的对象,不允许直接修改聚合内部对象的状态,以此来维护边界和一致性
- 设计事务策略:在聚合内部实现事务一致性,确保操作要么全部完成,要么全部回滚。对于聚合之间的交互,可以采用领域事件或其他机制来实现最终一致性
- 封装业务规则:在聚合内部实现业务规则和逻辑,确保所有的业务操作都遵循这些规则
- 持久化:聚合根通常与数据持久化层交互,以保存聚合的状态。这通常涉及到对象-关系映射(ORM)或其他数据映射技术
10.聚合的原则
介绍
Vaughn Vernon在其《实现领域驱动设计》一书中,列举了一些聚合的原则,当然我这里还说了一些除了书中以外的原则
单一聚合根与聚合负责维护自身内部一致性
外部必须通过聚合根才能操作聚合内部的状态,这样做的好处是所有对聚合的操作都经过聚合根的校验和协调从而确保聚合内部的一致性
通过唯一标识引用其他聚合根
介绍
在同一个聚合内部由于实体和值对象之间关系过于紧密,生命周期通常是一致的,而且需要共同维护它的一致性,所以它们之间通过对象引用即可
聚合代表着一致性的边界,直接在一个聚合内引用其他聚合根会破坏这个边界,而通过唯一标识引用其他聚合根,则可以保证聚合内部的一致性
例子
假如在一个聚合根内部直接通过对象引用了其他的聚合根,此时就会遇到如何确保这两个聚合根一致性的问题。在聚合根内直接引用其他聚合如图所示:
在图中A和E都是聚合根,它们是操作聚合状态的入口。由于在聚合范围内需要保证状态的强一致,聚合的状态必须通过聚合根来维护。然而,由于A和E都是聚合根,所以很难选择是通过A还是E来修改聚合根的时机;如果所有的聚合状态都通过A聚合根来维护,那么E就不应该是聚合根;如果E确实是聚合根,那么A就不应该维护E,否则E的状态就不独立,这就破坏了聚合所代表的一致性边界
因此,在聚合根内部直接通过对象引用其他聚合根是不可行的,所以需要通过聚合根的唯一标识来引用其他聚合
通过唯一标识引用其他聚合还可以将系统分解成更小,更独立的部分,从而降低了聚合之间的耦合度,使它们之间的状态更容易被维护
设计小而全的聚合
过大聚合的问题
若聚合设计的过大,则在聚合状态变更时,维护聚合内一致性的成本会很高,举个例子:
比如某个较大的聚合具有100个属性,拆分成2个聚合之后,可能一个有40个属性,另一个有60个属性,100个属性的聚合比40或者60个属性的聚合被更新的概率更高
用极端的思维去考虑,如果将系统建模成只有一个聚合,那么系统就变成串行的了,因为每个事务只能更新一个聚合,所有的操作必须逐一执行
过小聚合的问题
若聚合设计的国小,则没有办法完整地表达领域概念,这也是需要避免的,这里也举个极端的例子:
将某个聚合的所有属性一一单独拆分为聚合,那么领域的业务概念无法被正确地表达出来
总结
- 大聚合会降低性能:聚合中的每个成员会增加数据的量,当这些数据需要从数据库中进行加载的时候,大聚合会增加额外的查询,导致性能降低
- 大聚合更容易受到并发冲突的影响:大聚合可能包含了很多职责,这意味着它要参与多个业务用例。随之而来的就是,有很大可能出现多个用户对单个聚合进行变更的情况,从而导致了严重的并发冲突,影响了程序的可用性和用户体验
- 大聚合扩展性差:大聚合意味着与更多的模型产生依赖关系,这会导致重构和扩展的难度增加
- 过小的聚合:无法完整表示领域概念
因此,推荐将聚合设计的尽可能小,小到刚好包含某个完整的领域概念,在最理想的情况下,当然是一个实体作为一个聚合,但达不到的时候也不必苛求,只需要满足小而全即可
此外,不要苛求一次性将聚合设计得很完美,导致研发流程卡在建模阶段迟迟得不到推进,这是不可取的。领域建模得到领域模型后,可以先应用于实际开发,在实践中不断调整,由于业务概念已经被建模为值对象或者实体,业务逻辑高度内聚在领域对象内,调整聚合的大小是轻而易举的事情
一个事务只更新单个聚合
介绍
聚合是一组相关的领域对象的集合,它们共同构成了一个有边界的整体。聚合内部的对象之间具有非常强的业务关联,它们的状态必须保持一致。如果一个事务同时更新了多个聚合,就会破坏聚合的一致性边界
好处
一个事务只更新单个聚合会使聚合的概念更清晰,并且一个事务只更新单个聚合可以使并发控制变得简单:
在分库分表成为标配的微服务时代,同时更新多个库,多张表意外这分布式事务。要实现这种强一致性的分布式事务,不仅实现复杂,而且可能对系统性能造成影响
多个事务同时对聚合进行更新时,如果每个事务只更新单个聚合,那么可以避免死锁等并发问题的发生。如果每个事务都要同时更新多个聚合,为了确保数据一致性,就需要对待更新的所有聚合进行锁定,这会导致并发性能的下降
一个事务只更新单个聚合,还可以使代码更加清晰和易于维护。如果每个事务都要同时更新多个聚合,那就需要编写更加复杂的代码来处理这些操作。当业务逻辑变得越来越复杂时,这些代码也会变得越来越难以维护
跨聚合采用最终一致性
什么是最终一致性
最终一致性是指在分布式系统中,如果没有新的更新操作发生,那么最终所有的节点都会达到一致的状态。与强一致不同的是,最终一致性不会要求每个节点都立即获得最新的数据。这是因为在分布式系统中,各节点之间的网络延迟,故障等因素可能导致数据同步的不及时,因此最终一致性是一种更加灵活的数据一致性模型
为什么跨聚合采用最终一致性
首先,一个事务只更新单个聚合这个原则既然约束了一个事务只更新单个聚合,那么跨聚合就不能在一个事务里面更新了,也就只好退而求次采用最终一致性
其次,在分布式系统中,跨聚合的操作可能会涉及多个聚合之间的数据交互,这些聚合的数据模型可能存放在不同的数据库中,跨聚合的事务操作意味着分布式事务。如通过为了保证强一致性而采用分布式事务模型,则有可能会影响系统的性能
总结
在一次事务中,最多只能更改一个聚合的状态。若一次业务操作导致多个聚合状态的修改。可以采用领域事件异步修改相关的聚合
可以通过领域事件的方式实现跨聚合的最终一致性,某个聚合完成事务之后,对外发布领域事件,其他聚合通过订阅感兴趣的领域事件,完成自身的状态更新
关于跨聚合事物的处理
聚合间通过领域服务协调
聚合是一个相对独立的单元,当一个业务操作涉及到多个聚合的状态变化时,我们不能让一个聚合直接修改另外一个聚合的内部状态,这样会打破聚合的封装性,增加了耦合度
正确的做法是引入领域服务,来协调这些聚合之间的操作,领域服务没有状态,它负责协调多个聚合或者基础设施来完成一个比较复杂的业务流程
通过应用层实现跨聚合的服务调用:为实现微服务内聚合之间的解耦,以及未来以聚合为单位的微服务组合和拆分,应避免跨聚合的领域服务调用和跨聚合的数据库表关联
避免聚合之间双向关联
这会大大增加聚合之间的耦合度,让系统结构变得更为僵硬,更难维护和演化,当你修改一个聚合时,不得不考虑另一个聚合是否会受到影响,保持单向依赖的关系会更灵活
乐观并发控制
多用户或多进程并发访问的系统中,很有可能多个操作同时修改同一个聚合根的情况,如果我们不加以控制,这就会导致数据的不一致性或者丢失更新,我们在DDD中更多使用乐观锁来控制并发
生命周期一致性原则
生命周期一致性是指聚合内部的对象,应该和聚合根具有相同的生命周期,聚合根消失,则聚合内部的所有对象都应该一起消失
例如,在上面的例子中,聚合根采购请求被删除,那采购项也就没有存在的意义,但是申请人、审批人、产品和采购申请却不存在该关系
如果违反生命周期一致性原则,会带来比较严重的后果
问题域一致性原则
上面的生命周期一致性只是指导原则之一,有时如果只考虑生命周期一致性原则可能会引起问题
让我们考虑一个在线论坛这样的场景:一个在线论坛,用户可以对论坛上用户的文章发表评论。文章显然应该是一个聚合根。如果文章被删除,那么,用户的评论看起来也要同时消失。那么评论是否可以属于文章这个聚合?
例如,用户可以对用户的文章发表评论,同时也可以对该论坛的电子图书发表评论。如果只是因为文章和评论之间存在逻辑上的关联,就让文章聚合持有评论对象,那么显然就约束了评论的适用范围。所以,我们得到了一个新的、凌驾于生命周期一致性原则的原则——不属于同一个问题域的对象,不应该出现在同一个聚合中
在上图中评论这聚合根可以持有其他聚合根的id(可评价对象id), 同时聚合之间的一致性通过最终一致性来保证(文章删除发送领域事件通知删除对应的评论)
场景一致性原则
通过生命周期一致性原则与问题域一致性原则,我们基本能够划分清楚一个聚合的边界,但是仍然会存在一些复杂的情况。这时我们可以根据第三个原则来判断:场景一致性原则
什么是场景一致性呢?场景一致性就是场景操作频率的一致性
在很多业务场景中,我们会对领域对象进行查看、修改等各种操作。 经常被同时操作的对象,应该属于同一个聚合,而那些极少被同时关注的对象,即使上面两个原则都满足也不应该划为一个聚合
不在同一个场景下操作的对象,放入同一个聚合意味着每次操作一个对象,就需要把其他对象的所有信息抓取到,这是非常没有意义的。这在日常开发中我也是深有体会
从实现层次,如果不紧密相关的对象出现在同一个聚合中,会导致它们经常在不同的场景中被并发修改,也增加了这些对象之间冲突的可能性
所以:大多数时候的操作场景都不一致的对象,应该把它们分到不同的聚合中
11.聚合的拆分
介绍
咱们以RBAC权限模型的Role和Resource为例进行讲解。以下是Role的初始版本Role0,接下来对Role的聚合范围进行探讨
public class Role0 {
private RoleId roleId;
private String roleName;
private Set<Resource> resources;
}
对于聚合的拆分,目前没有统一的标准,这里只是经验之谈
第一次拆分
首先根据聚合的定义,将一些外部聚合根移除聚合,通过聚合根ID进行引用
如何判断一个实体是不是外部聚合呢?一般来说,如果某个实体被多个聚合根引用,那么这个实体就可以被提升为聚合根。例如上面的Resource,一般会被多个Role引用,因此可以判断Resource可以被提升为聚合根
以下是第一次拆分后的Role1聚合根:
public class Role1 {
private RoleId roleId;
private String roleName;
private Set<ResourceId> resourceIds;
}
第二次拆分
在Role1中将Resource移除了聚合,但是留下了对Resource的引用。考虑到不仅需要根据Role查询到其关联的Resource,有时候还需要清楚某个Resource被那些Role引用。如果按照Role1的设计,那么在Resource中,显示也需要持有对RoleId的引用,例如:
public class Resource {
private ResourceId resourceId;
private String resourceName;
private Set<RoleId> roleId;
}
这会造成循环依赖问题。假设Role1中需要移除某个ResourceId,那么不仅需要在Role1中操作,还需要到Resource中将对应的RoleId移除。这明显是不合理的
因此,我们开始第二次拆分。将resourceIds也移除Role聚合,并将Role与Resource的关联关系提升为一个聚合,将其命名为RoleResourceBinding。其业务意义是为某个Role分配的Resource
/**
* 角色聚合
*/
public class Role2 {
private RoleId roleId;
private String roleName;
}
/**
* 角色与资源绑定聚合根
*/
public class RoleResourceBinding {
private BindingId bindingId;
private RoleId roleId;
private ResourceId resourceId;
private Integer active;
private Date bindTime;
}
总结
通过两次拆分,可以总结处以下经验:
- 重点关注容易被多个聚合根持有的公共实体,这些实体可能被提升为聚合根,并被移除聚合
- 重点关注1:N的集合属性,这些属性有可能因为业务需要,需要被提升为聚合根
12.注意
聚合根的设计很多场景都是有效的,但是也不是所有场景都有效
比如在管理订单时对订单进行增删改聚合是有效的,如果要统计销量分析销售趋势等,需要对大量的订单明细进行汇总和统计,如果对订单明细的汇总和统计都必须经过聚合根(订单的查询),必然使得汇总统计的性能极低而无法使用
因此领域驱动设计通常适用于增删改的业务,但不适用于分析统计的业务
我们后面会使用CQRS来弥补这部分问题