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