《实现领域驱动设计》-聚合
将
实体
和
值对象
在一致性边界之内组成聚合乍看起来是一件轻松的任务,但在DDD众多的战术性指导中,该模式却是最不容易理解的。
让我们首先来看看一些常见的问题。聚合只是将一些共享父类、密切相关联的对象聚集成一个对象树吗?如果是这样,对于存在于这个树中的对象有没有一个实用的数目限制?既然一个聚合可以引用另一个聚合,我们是否可以深度地递归遍历下去,并且在此过程中修改对象呢?聚合的不变条件和一致性边界究竟是什么意思?最后一个问题的答案将在很大程度上影响我们对前面所有问题的解答。
有很多途径都将导致我们建立不正确的聚合模型。一方面,我们可能为了对象组合上的方便而将聚合设计得很大。另一方面,我们设计的聚合又可能过于贫瘠而丧失了保护真正不变条件的目的。我们应该同时避免这两个极端,转而将注意力集中在业务规则上。
原则:在一致性边界之内建模真正的不变条件
要从
限界上下文
中发现聚合,我们需要了解模型中真正的不变条件。只有这样,我们才能决定什么样的对象可以放在一个聚合中。
这里的不变条件表示一个业务规则,该规则应该总是保持一致的。存在多种类型的一致性,其中之一便是事务一致性,事务一致性要求立即性和原子性。同时,还存在最终一致性。
对于一个典型的持久化机制来说,我们通常使用单事务来管理一致性。在提交事务时,边界之内的所有内容都必须保持一致。对于一个设计良好的聚合来说,无论由于何种业务需求而发生改变,在单个事务中,聚合中的所有不变条件都是一致的。而对于一个设计良好的限界上下文来说,无论在哪种情况下,它都能保证在一个事务中只修改一个聚合实例。此外,在设计聚合时,我们必须将事务分析也考虑在内。
在一个事务中只修改一个聚合实例,这听起来可能过于严格。但是,这却是设计聚合的重要经验原则,也是我们为什么使用聚合的原因。
前面我们提到,在设计聚合时,我们需要慎重地考虑一致性,这意味着每次客户请求应该只在一个聚合实例上执行一个方法。如果客户所请求的业务过多,那么有可能出现一次请求修改多个聚合实例的情况。
因此,在设计聚合时,我们主要关注的是聚合的一致性边界,而不是创建一个对象树。现实世界中的有些不变条件可能比这更加复杂。但是即便如此,通常情况下的不变条件所需要的建模代价并不大,所以,要设计出小的聚合是可能的。
原则:设计小聚合
现在,我们可以全面地回答前面的问题了:要维护一个庞大的聚合还存在哪些额外的成本?对于大聚合,即便我们可以保证事务的成功执行,它依然有可能限制系统的性能和可伸缩性。
考虑一下系统性能和可伸缩性,假定一个存在了一年多的敏捷项目,其中已经包含了数以千计的待定项,如果一个租户的某个用户只是需要将一个待定项添加到产品中,会发生什么情况?假设我们使用了延迟加载的持久化机制,我们几乎不用同时加载待定项、发布和冲刺。但是,为了添加一个待定项,我们依然需要先将所有的待定项集合元素加载到内存里,而这个数目是巨大的。对于那些不支持延迟加载的持久化机制来说,问题就更糟了。即便我们将内存考虑在内,有时我们仍然需要加载多个集合。
如果我们要设计小的聚合,那么,这里的“小”是什么意思呢?最极端的情况是,一个聚合只拥有全局标识和单个属性,当然,这并不是我们所推荐的做法(除非这正是需求所在)。好的做法是,使用根实体来表示聚合,其中只包含最小数量的属性或值类型属性。这里的“最小数量”表示所需的最小属性集合,不多也不少。
哪些属性是所需的?简单的答案是:那些必须与其他属性保持一致的属性——虽然这不是领域专家所指定的原则。
小聚合不仅有性能和可伸缩性上的好处,它还有助于事务的成功执行,即它可以减少事务提交冲突。这样一来,系统的可用性也得到了增强。在你的领域中,迫使你设计大聚合的不变条件约束并不多。当你遇到这样的情况时,可以考虑添加实体或是集合,但无论如何,我们都应该将聚合设计得尽量减少。
不要相信每一个用例
在交付用例规范时,业务分析人员扮演者非常重要的角色。他们将大量的精力放在那些大而细的规范上,而这将在很大程度上影响我们的设计。此时,我们应该知道,以这种方式产生的用例并没有表达出领域专家的意图。对于每一个用例,我们依然需要用当前模型来进行验证,其中便包含聚合。此时容易出现的一个问题是,某个用例需要修改多个聚合实例。在这种情况下,我们需要搞清楚的是,对用户需求的实现是否分散在多个事务中,还是单个事务?无论写得多好,这样的用例都不能准确地反映出模型中真正的聚合。
假设你的聚合边界与真实的业务约束是一致的,如果业务分析人员给了你如下图的用例需求,问题也将随之而来。考虑不同的提交顺序,你会发现在有些情况下,三次请求中的两次都会失败。对于你的设计来说,这能说明什么呢?这个问题的答案将引导你更深层次地去理解自己的领域。试图保持多个聚合实例间的一致性通常意味着我们缺少了某些聚合不变条件。为了满足新的业务规则,你可能会将多个聚合组合在一起而创建一个新的概念(当然,有可能只是将原有聚合中的某些部分提取出来,然后创建一个新的聚合)。
因此,新的用例可能引导我们重新对聚合进行建模,但是此时你依然需要谨慎行事。从多个聚合中创建一个新的聚合可能会引出一个全新的概念,该概念拥有全新的名字。但是,如果对这个新的概念建模导致了一个大的聚合,这样显然是不好的。那么,此时我们还可以采取什么方法呢?
一个用例可能要求在单个事务中维持聚合的一致性,但是,这并不意味着我们就必须这么做。通常来说,在这种情况下,业务目标都可以通过聚合间的最终一致性来实现的。因此,我们需要带着批判性的态度来审查用例,并在必要的时候敢于挑战自己的假设。
原则:通过唯一标识引用其他聚合
在设计聚合时,我们可能希望使用对象组合,因为这样我们可以对聚合中的对象树进行深度遍历。但是,这并不是使用聚合模式的动机。[Evans]写道,一个聚合可以引用另一个聚合的根聚合。然而,我们需要注意的是,此时被引用的聚合不应该放在引用聚合的一致性边界之内。同时,这种引用方式也并非创建了一个整体性的聚合。 让我们看看下图中的例子:
public classBacklogItem extends ConcurrencySafeEntity{
...privateProduct product;
...
}
在上例中,一个BacklogItem直接关联了一个Product。
结合前文已经讨论的和接下来即将讨论的,以上实现方式隐含着以下几点:
- 引用聚合(BacklogItem)和被引用聚合(Product)不可以放在同一个事务中进行修改。
- 如果你试图在单个事务中修改多个聚合,这往往意味着此时的一致性边界是错误的。发生这样的情况通常是因为我们遗漏了某些建模点,或者尚未发现通用语言中的某个概念。
- 如果你试图采用第2点,但却遇到了先前所讲的有关大聚合的种种麻烦,那么此时你可能需要使用最终一致性,而不是原子一致性。
在不持有对象引用的情况下,我们是不能修改其他聚合的,因此我们可以避免在同一个事务中修改多个聚合。但是,这种方式的缺点在于限制性太强,因为在领域模型中我们总需要对象之间的关联关系来完成一些任务。那么,此时我们应该怎么办呢?
通过标识引用使多个聚合协同工作
我们应该优先考虑通过全局唯一标识来引用外部聚合,而不是通过直接的对象引用,如图所示。
public classBacklogItem extends ConcurrencySafeEntity{
...privateProductId productId;
...
}
自然地,通过这种方式创建的聚合也会变得更小,因为此时所关联的聚合是不会即时加载的。模型的性能也将随之变好,因为它需要更少的加载时间和更小的内存。更小的内存使用量不止在内存分配上有好处,对于垃圾回收也是有好处的。
建模对象导航性
通过标识引用并不意味着我们完全丧失了对象导航性。有些人习惯在聚合中使用
资源库
来定位其他聚合。这种技术称为
失联领域模型
,而事实上这只是延迟加载的一种形式。此外,我们还推荐另一种方法:在调用聚合行为方法之前,使用资源库或
领域服务
来获取所需要的对象。在客户端中,应用服务可以对此做出控制,然后分发给聚合:
通过应用服务来处理依赖关系可以避免在聚合中使用资源库或领域服务。然而,如果要处理特定于领域的复杂依赖关系,在聚合的命令方法中使用领域服务却是最好的方法。这里再重申一次,不管使用哪种方式在一个聚合中引用另外的聚合,我们都不能在同一个事务中修改多个聚合实例。
在模型中只使用唯一标识来引用对象的缺点在于:在客户端的用户界面层,要组装多个聚合并予以显示将变得非常困难,我们不得不使用多个资源库。此时,如果对聚合的查询导致了性能问题,那么我们可以考虑theta联合查询或者CQRS。如果heta联合查询和CQRS都不能满足我们的需求,那么就需要在标识引用和直接引用之间折中考虑了。
如果以上所有的建议有损模型的使用方便性,那么我们可以转而考虑它们的其他好处——一个小聚合可以增强模型的性能和伸缩性,另外它还有助于创建分布式系统。
可伸缩性和分布式
当一个核心域中,通常存在多个限界上下文,使用标识引用使得我们可以将分布式的领域模型关联起来。在使用事件驱动架构时,基于消息的领域事件包含了聚合标识,这样的领域事件将在整个企业范围内传播。外部限界上下文中的消息订阅方将使用聚合标识在他们自己的领域模型中展开操作。标识引用形成了一种远程关联或者合作者关系。分布式操作通过双方活动进行管理,但是在
发布-订阅
或者
观察者模式
中,却是多方的。分布式系统中的事务并不是原子性的,各个系统中的聚合通过事件达成一致性。
原则:在边界之外使用最终一致性
在[Evans]对聚合模式的定义中,有一条经常被忽略。如果单次用户请求需要修改多个聚合实例,而此时我们