如何有效的建模聚合(一)

时间:2022-08-31 13:54:05
你们最尊敬的翻译官:

当然在此声明,由于翻译的这篇文章,已经被作者收录进IDDD的第十章:聚合篇,所以有书的同学还是看书比较好,这部分翻译一是纠正我在中文版中的一些不理解,二是通过翻译加深对聚合与建模的理解,三呢也是对DDD思想的宣传吧,希望更多的开发可以意识到自己的狭隘思想,作为一个引路人,希望这篇聚合,可以让你信服。




聚合是战术建模中更具挑战性的方面之一。开发人员经常会有大量的对象,这些对象没有提供良好的性能和可伸缩性。在这篇由三部分组成的系列文章中,沃恩·弗农(Vaughn Vernon)通过一些常见的设计陷阱进行了讨论,讨论了各种聚合模型选择的优缺点,并提供了经验规则,以指导聚合的建模。他不再强调诸如通过对象图的导航,而是专注于挖掘业务领域中的实际一致性约束,并使用提示来帮助团队筛选通常的相互竞争的用例。
沃恩的具体规则阐明了DDD领导者目前的共识观点,即帮助发展建立在更坚实基础上的聚合的风格。下面我会分部分进行翻译(你们可敬的翻译官~~)

1.考虑聚合的建模

将实体和值对象聚集进具有一个精心设计的一致性边界的聚合中,这在开始时候似乎可以很快速的完成,但其实在所有的[DDD]战术指导中,这种模式是最不容易理解的。
首先,可以考虑一些常见的问题来帮助我们理解。聚合只是一种将密切相关的对象的图形聚在一个公同的父节点之下吗??如果是这样的话,那么对于在这个图中所有的对象的数量,有什么实际的约束吗??既然一个聚合实例可以引用其他聚合实例,那么这些关联可以深入地进行导航,并在此过程中修改各种对象吗?这个不变量和一致性边界的概念是什么呢?最后一个问题的答案,它极大地影响了其他问题的回答。
有多种方法可以错误地建模聚合(其实都不用学,天生的)。我们可能会陷入为了组装方便的设计的陷阱,并最终让聚合变得越来越大(通俗而言,就是业务中加字段,加字段,这样的改造组装方便,可以快速完成功能,但是导致聚合变得越来越大)。另一种做法则是,我们可以剥光所有的聚合物,结果是无法保护真正的不变量。正如我们将要看到的,我们必须避免两个极端,而应该关注业务规则。

1.1 设计一个Scrum管理应用程序

解释聚合的最好方法就是举个例子。我们的虚拟公司正在开发一个应用程序来支持基于scrum的项目---ProjectOvation。它遵循传统的Scrum项目管理模型,包括产品(product)、产品负责人(producet owner)、团队(team)、待定项(backlog items)、计划发布(planned releases)和冲刺(sprint)。这为我们大多数人提供了一个熟悉的领域。Scrum术语形成了通用语言的起点。它是一个基于订阅的应用程序,使用该软件作为服务(SaaS)模型。每个订阅组织都注册为租户,这是我们通用语言的另一个术语。
该公司已经召集了一批有才华的Scrum专家和Java开发人员。然而,他们在DDD的经验是有限的。这意味着当他们在艰难的学习曲线上爬时,他们将会犯一些错误。他们会成长,我们也会成长。他们的斗争史可能帮助我们认识和改变我们在自己的软件中创建的类似的不利情况。
这个领域的概念,以及它的性能和可伸缩性需求,比以前任何一个都要复杂得多。为了解决这些问题,他们将使用的DDD战术工具之一----聚合。
团队应该如何选择最好的对象集群?聚合模式讨论组合,并暗指信息隐藏,它们理解如何实现。它还讨论了一致性边界和事务,但是他们并没有过度关注这个问题。他们选择的持久化机制将有助于管理数据的原子提交。然而,这是对模式指导的一个关键误解,导致他们回归。这里是发生了的事。该研究小组在通用语言中考虑了以下几项声明:
>Products have backlog items, releases, and sprints.
产品(product)有待办事项(backlog items)、发布(releases)和冲刺(sprint)
>New product backlog items are planned.
>New product releases are scheduled.
>New product sprints are scheduled.
>A planned backlog item may be scheduled for release.
>A scheduled backlog item may be committed to a sprint.
通过这些,他们设想了一个模型,并第一次尝试设计。让我们看看它是怎么做的。

1.2 第一次尝试:大型集群聚合

该团队对第一份声明中的“products havve”这一词表示了极大的重视。它听起来像是组合,对象需要像一个对象图那样相互连接起来。把这些对象生命周期保持在一起被认为是非常重要的。因此,开发人员在规范中加入了以下的一致性规则:
>如果一个待办事项列表被提交到一个sprint,我们就不能让它从系统中删除。
>如果sprint上有已经提交了的待办事项列表,我们就不能让它从系统中删除。
>如果一个发布版本已经安排了待定项,我们就不能让它从系统中删除。
>如果一个待办事项列表被安排发布,我们就不能让它从系统中删除。
因此,Producet第一次被建模为一个非常大的聚合。聚合根对象Producet,并持有对所有的BacklogItem(待办事项),所有的Release(发布)和所有的Sprint(冲刺)实例的关联。接口设计保护了所有的部件,避免了意外的客户端移除。这个设计如下面的代码所示,并且作为图1中的UML图:

如何有效的建模聚合(一)

这个大的聚合看起来很有吸引力,但是它并不是真的具有实用性。一旦应用程序在其预期的多用户环境中运行,它就开始经常出现事务故障。让我们更仔细地研究一些客户端使用模式,以及它们如何与我们的技术解决方案模型交互。我们的聚合实例使用乐观的并发性来保护持久对象不受不同客户机同时重叠的修改的影响,从而避免使用数据库锁。对象携带一个版本号,当进行更改并在保存到数据库之前进行检查时,会增加一个版本号。如果持久化的对象上的版本比客户端副本上的版本要大,那么客户端就会被认为是过时的,更新也会被拒绝。
考虑一个常见的同时多客户端使用场景:
>两个用户,Bill和Joe,查看相同的Producet,此时的版本是1,现在开始在它上面操作
>Bill计划(注意,原文用的plan)了一个新的BacklogItem毛病提交啦,此时Product的版本增加到了2
>Joe调度(注意,原文用的schedules)了一个Realease,并打算保存了它。但是在提交的时候失败了, 因为它的版本号是1,而此时最新的版本号是2

持久化机制通常用于处理并发性(作者注:例如,Hibernate以这种方式提供了乐观的并发性。键值存储也可能是这样的,因为整个聚合通常被序列化为一个值,除非设计为单独保存组成部分)。如果您认为可以更改默认的并发配置,可以把你的结论保留一段时间。这种方法在并发更改下保护聚合的不变量是很重要的。
这些一致性问题只是由仅仅两个用户带来的。如果再增加一些用户,那么这个问题会编程一个真正的大问题。在Scrum中,多个用户经常会做出这样的重叠修改。所有的请求都失败了,但是这些请求中的一个却整在执行,这是完全不能容忍的。

计划一个新的待办事项没有任何理由可以逻辑的妨碍调度一个新的发布!为什么Joe的提交会失败?在这个问题的核心,这个大的集群聚合被设计为错误的不变量,而不是真正的业务规则。这些错误的不变量是由开发人员强加的人为约束。团队有其他方法可以防止不适当的移除,同时不受这种无端的限制。除了造成事务性问题外,该设计还具有性能和可伸缩性方面的缺陷。

1.3 第二次尝试:多个聚合

现在考虑一个如图2所示的替代模型,其中有4个不同的聚合。每个依赖项都使用公共的ProductId推断为相关联的,ProductId是产品的标识,它被认为是其他三种产品的父类。

如何有效的建模聚合(一)

将一个大的聚合分解为4个聚合,这将会改变Product的一些方法契约。在大型集群聚合设计中,方法签名如下:

如何有效的建模聚合(一)
如何有效的建模聚合(一)

所有的这些方法都是[COS]命令.也就是说,它们通过将新元素添加到集合中来修改Product的状态,因此它们有一个void返回类型。但是现在有了多个聚合的设计,我们有:

如何有效的建模聚合(一)

这些重新设计的方法有一个[CQS]查询契约,并充当factories(工厂)。也就是说,它们分别创建一个新的聚合实例并返回一个对于它的引用。现在,当客户端想要计划一个待办事项列表项时,事务应用程序服务必须像如下这样做:

如何有效的建模聚合(一)

因此,我们通过对其分别进行建模来解决事务失败的问题。现在可同时的用户请求可以安全地创建任何数量的BacklogItem(待定项)、Release(发布)和Sprint(冲刺)实例。这是非常简单的。
然而,即使有明显的事务性的优势,从客户消费的角度来看,这四个较小的聚合也不太方便。也许我们可以通过调优之前的大型聚合来消除并发性问题。通过将Hibernate映射的optimistic-lock选项设置为false,事务失败的时候,多米诺效应也会消失不见。创建的BacklogItem、Release或Sprint实例的总数上没有不变量,所以为什么不允许这些集合无限制地增长,忽略这些对Product的特定修改呢?保持大型集群聚合会有哪些额外的成本?问题是,它实际上可能会失控。在深入研究原因之前,让我们考虑一下团队所需要的最重要的建模技巧。

1.4 规则:在一致性边界内建模真正的不变量

当尝试在一个限界上下文中发现聚合时候,我们必须了解模型的真正不变量。只有通过这些知识,我们才能确定应该将哪些对象聚集到一个给定的聚合中。
不变量是一种必须始终保持一致的业务规则。有不同种类的一致性。一个是事务性的,它被认为是立即的和原子的。还有一个则是最终一致性。当我们讨论不变量的时候,我们指的是事务一致性。我们可能有这么一个不变量
c = a + b
因此,当a是2,b是3的时候。那么c必须是5.根据这个规则和条件,如果c不等于5,系统不变量就会被破坏。为了确保c是一致的,我们对模型的特定属性建模了一个边界
AggregateType1 {
int a; int b; int c;
operations...
}
一致性边界在逻辑上断言:无论执行什么操作,内部的所有内容都遵循一组特定的业务不变量规则。这一边界外的所有事物的一致性都与聚合无关。因此,聚合是事务一致性边界的同义词(在这个有限的例子中,AggregateType1有三个类型为Int的属性,但是任何给定的聚合都可以持有不同类型的属性。)。当使用一个典型的持久化机制时,我们使用单个事务(作者注:事务可以由一个unit of work,即工作单元来处理。)来管理一致性。当事务提交的时候,边界内的任何东西都必须保持一致性。一个设计得当的聚合体是可以在业务需要的任何方式上进行修改,其不变量在单个事务中完全一致。在所有情况下,一个设计得当的限界上下文中的每个事务只修改了一个聚合实例。更有甚者,我们在没有应用事务分析的情况下不能正确再聚合设计上推理。
限制每个事务的只能修改一个聚合实例可能听起来过于严格。然而,这是一个经验法则,在大多数情况下应该是目标。它解决了使用聚合的原因。
由于聚合必须以一致的焦点来设计,因此它意味着用户界面(原文是user interface)应该集中于每个请求,以便在一个聚合实例上执行单个命令。如果用户请求尝试完成太多的话,它将迫使应用程序一次性修改多个实例。

因此,聚合主要是关于一致性边界的,而不是由设计对象图所驱动的。一些真实的不变量将会比这个更加复杂。即便如此,典型的不变量对我们的建模工作要求并不高,这使得设计小的聚合成为可能。

1.5 规则:设计小的聚合

我们现在可以彻底地解决这个问题:即在保持大型集群聚合的情况下,还有什么额外的成本的问题?即使我们保证每个事务都能成功,我们仍然会限制性能和可伸缩性。随着我们公司的发展,它将带来很多租户。随着每个租户对ProjectOvation的深入承诺,他们将会托管越来越多的项目以及伴随这些项目的管理构件。这将导致大量的product(产品)、backlog items(待办事项)、releases(发布)、sprint(冲刺)和其他的产品。性能和可伸缩性是不可忽视的非功能性需求。
考虑到性能和可伸缩性,当一个租户下的的一个用户想要向一个已经有数年之久并且有数以千计的待定项的product(产品)中添加一个单一的backlog item(待办事项)时,会发生什么呢??假设一个持久性机制具有能够延迟加载(Hibernate)的能力。我们几乎从来没有一次加载所有的backlog items(待办事项)、releases(发布)和sprint(冲刺)。尽管如此,成千上万的backlog items(待办事项)将被加载到内存中,只是为了在已经非常大的集合中添加一个新元素。如果持久性机制不支持延迟加载,则更糟糕。即使是内存方面的问题,有时我们也不得不加载多个集合,比如为release(发布)调度一个backlog items(待定项),或者将一个backlog items(待定项)提交到sprint(冲刺);所有的backlog items(待定项),和所有的release(发布)或者所有的sprint(冲刺),都将被加载。

为了清楚地看到这一点,请看图3中的图,其中包含了放大的组合。不要让0..*欺骗你;关联的数量几乎从不为零,而且会随着时间的推移而不断增长。我们可能需要一次性将成千上万的对象加载到内存中,只是为了执行一个相对基本的操作。这这仅仅是针对单个产品的单个租户的单个团队成员。我们必须记住,这种情况可能同时发生,我们有成百上千的租户,每个租户都有多个团队和许多产品。随着时间的推移,情况只会变得更糟。

如何有效的建模聚合(一)

这个大型集群聚合将永远不会具有比较好的执行或扩展。它更有可能成为一个只会导致失败的噩梦。它从一开始就有缺陷,因为错误的不变量和对组合便利的渴望驱动了设计,从而损害了事务的成功、性能和可伸缩性。
如果我们要设计小的聚合,那么这个“小”意味着什么?极端的情况是,聚合只有全局惟一的标识和一个额外的属性,这不是所推荐的(除非这确实是一个特定的聚合所需要的)。相反,将聚合限制为根实体,以及最小数量的属性 and/or(不知道为什么外国人非要写两个,和就是和,或就是或嘛)值类型(作者注:值类型属性是保存对值对象的引用的属性。我将它与一个简单的属性区分开来,例如字符串或数字类型,Ward Cunningham在描述整个值时也是如此;性情参考:http://fit.c2.com/wiki.cgi?WholeValue)的属性。正确的最小值是必要的,而不是更多。
哪些是必要的?简单的答案是:那些必须与其他保持一致的即是必要的,即使领域专家没有将其指定为规则。比方说,Product有name和description属性。我们无法想象name和description不一致的情况,并分别在不同的聚合中建模。当你改变name的时候,你可能也想改变description。如果你改变了一个,而另一个不变,那很可能是因为你在修改一个拼写错误或者使description更适合这个name。尽管领域专家可能不会认为这是一个明确的业务规则,但它是一个隐式的业务规则.
如果您认为应该将一个所包含的部分作为一个实体进行建模,该怎么办?首先要问的是,这个部分是否必须随着时间的推移而改变,或者它是否可以在必要的变化时被完全替换掉。如果实例可以被完全的替换掉,它指出了使用一个值对象而不是一个实体来建模。有时,实体部分是必要的。然而,如果我们在逐个案例的基础上进行这个练习,那么许多被建模为实体的概念可以被重构为值对象。将值类型作为聚合部分,并不意味着聚合是不可变的,因为当它的一个值类型属性被替换时,根实体它本身就会发生变化。
将内部部件限制为值的话,是有一些重要的优点的。根据您的持久性机制,您可以将值与根实体一起序列化,而如果采用实体的话,则通常需要单独跟踪存储(意思就是需要用新的表来存储,毕竟实体是有主键的)。因此使用实体部件的开销更高,例如,当需要使用Hibernate读取它们时,需要使用SQL表连接。然而读取单个数据库表的一行则要快得多。因此值对象具有更小和使用更安全的优点(bug少)。由于不可变性,单元测试更容易证明它们的正确性。
在使用Qi4j的金融衍生品领域的一个项目中,Niclas Hedhman报告说,他的团队成功的设计出大约70%的聚合,其根实体仅仅包含几个值类型属性。剩下的30%只有2到3个实体。这并不是说所有的领域模型都有70/30的分隔。它表明,高比例的聚合都是可以被限制为只有一个单一的实体----即聚合根。
DDD关于聚合的讨论给出了一个例子,其中多个实体是有意义的。一个采购订单被分配一个最大允许的总数,并且所有的行目的总和不能超过这个最大允许数。当多个用户同时添加行目时,规则变得难以执行。任何一个添加都不允许超过这个限制,但是多个用户的并发添加可以这样做。我不会在这里重复这个解决方案,但是我想强调的是,大多数情况下,业务模型的不变量比这个例子更容易管理。认识到这一点有助于我们用尽可能少的属性来建模聚合。
较小的聚集不仅在执行或扩展上表现的很好,而且还偏向于事务的成功,这意味着阻止提交的冲突是罕见的。这使得系统更加可用。您的领域将不会经常有真正的不变量约束,迫使您进入大型组合设计场景。因此,限制聚合的规模是非常明智的。当您偶尔遇到一个真正的一致性规则时,在必要时添加一些实体,或者可能是一个聚合,但是继续督促自己保持尽可能小的整体尺寸。

1.6 不要相信每个用例

业务分析师在交付用例规范方面扮演着重要角色。由于大量的工作涉及到一个庞大而详细的规范,它将影响我们的许多设计决策。但是,我们不能忘记用这种方式派生的用例并不包含我们紧密联系的建模团队的领域专家和开发人员的观点。我们仍然必须将每个用例与当前的模型和设计协调起来,包括我们对聚合的决策。出现的一个常见问题是需要修改多个聚合实例的特定用例。在这种情况下,我们必须确定指定的大用户目标是跨多个持久性事务传播的,还是仅在一个持久性事务中发生。如果是后者,就值得怀疑。不管它写得多么好,这样的用例可能不能准确地反映我们模型的真实聚合。
假设您的聚合边界与实际的业务约束一致,那么如果业务分析人员指定您在图4中看到的内容,那么它将会导致问题。仔细考虑各种提交顺序排列,您会发现在这三种请求中有两种会失败(作者注:这并没有解决一些用例描述对跨事务的多个聚合的修改,这是可以的。用户的目标不应该被看作是事务的同义词。我们只关心用例,它实际上表示在一个事务中修改多个聚合实例)。对于你的设计,这说明了什么?这个问题的答案可能会导致对该领域的更深层次的理解。试图保持多个聚合实例的一致性可能会告诉您,您的团队已经遗漏了一个不变量。为了解决新确认的业务规则,您可能最终将多个聚合体折叠为一个新的概念,并使用一个新名称(当然,被卷进新的聚合的可能只是旧的聚合的一部分)。

如何有效的建模聚合(一)

因此,一个新的用例可能会促使我们对聚合进行重新建模,但也要对此表示怀疑。从多个聚合组成一个聚合可能会用一个新的名称来驱动一个全新的概念,但是如果这个新概念的建模会导致您设计一个大型集群聚合,这最终可能会导致大聚合的所有坏问题。有什么不同的方法可以帮助我们吗?
仅仅因为您有一个用例,它要求在单个事务中保持一致性,这并不意味着您应该这样做。通常,在这种情况下,业务目标可以通过聚合之间的最终一致性来实现。团队应该对用例进行仔细的检查,并挑战他们的假设,特别是当他们遵循这些假设时,他们的设计会导致笨拙的设计。团队可能不得不重写用例(或者至少重新设想一下,如果他们面对的是一个不合作的业务分析师)。新的用例将指定最终的一致性和可接受的更新延迟。这是本文第二部分所讨论的问题之一。