深刻理解领域驱动设计中的聚合

简介:聚合模式是 DDD 的模式结构中较为难于理解的一个,也是 DDD 学习曲线中的一个关键障碍。合理地设计聚合,能清晰地表述业务一致性,也更容易带来清晰的实现,设计不合理的聚合,甚至在设计中没有聚合的概念,则相反。

image.png

做者 | 嵩华
来源 | 阿里技术公众号程序员

聚合模式是 DDD 的模式结构中较为难于理解的一个,也是 DDD 学习曲线中的一个关键障碍。合理地设计聚合,能清晰地表述业务一致性,也更容易带来清晰的实现,设计不合理的聚合,甚至在设计中没有聚合的概念,则相反。数据库

聚合的概念并不复杂。本文但愿能回到聚合的本质,对聚合的定义和实操给出一些有价值的建议。架构

一 聚合解决的核心问题是什么

咱们先来看一下在 DDD Reference 中关于聚合的定义。并发

将实体和值对象划分为聚合并围绕着聚合定义边界。选择一个实体做为每一个聚合的根,并仅容许外部对象持有对聚合根的引用。做为一个总体来定义聚合的属性和不变量,并把其执行责任赋予聚合根或指定的框架机制。

这是典型的“模式语言”,说明了聚合是什么,聚合根(aggregation root)是什么,以及如何使用聚合。可是,模式语言的问题在于过分精炼,若是读者已经熟悉了这种模式,很容易看懂,可是最须要看懂的、那些尚不够熟悉这些概念的人,却容易感到不知所云。为了能深刻理解一个模式的本质,咱们仍是要回到它试图解决的核心问题上来。框架

在软件架构领域有一句名言:数据库设计

“架构并不禁系统的功能决定,而是由系统的非功能属性决定”。

这句话直白的解释就是:假如不考虑性能、健壮性、可移植性、可修改性、开发成本、时间约束等因素,用任何的架构、任何的方法,系统的功能老是能够实现的,项目老是能开发完成的,只是开发时间、之后的维护成本、功能扩展的容易程度不一样罢了。分布式

固然现实绝非如此。咱们老是但愿系统在可理解、可维护、可扩展等方面表现良好,从而多快好省的达成系统背后的业务目标。可是,在现实中,不合理的设计方法有可能增长系统的复杂性。咱们先来看一个例子:微服务

假设问题领域是一个企业内部的办公用品采购系统。性能

  • 企业的员工能够经过该系统提交一个采购请求,一个请求包含了若干数量、若干类型的办公用品(称为采购项)。(1)
  • 主管负责对采购申请进行审批。(2)
  • 审批经过后,系统会根据提供商不一样,生成若干订单。(3)

对同一个问题,存在若干种不一样的设计思路,例如以数据库为中心的设计、面向对象的设计和“正确的 OO”的 DDD 的设计。学习

若是采用以数据库为中心的建模方式,首先会进行数据库设计——我确实看到还有许多团队仍然在采起这种方法,花费大量的时间进行数据库结构的讨论。为了不图表过大,咱们仅仅给出了和采购申请相关的表格。结构以下图所示:

image.png

图1 数据库视角下的设计

若是直接在数据库这么低的设计层次上考虑问题,除了数据库的设计繁琐易错,更重要的是会面临一些比较复杂的业务规则和数据一致性保证的问题。例如:

  • 若是采购请求被删除,则相应的和该采购请求相关的采购项以及它们之间的关联都须要被删除——在数据库设计中,这种约束能够经过数据库外键来保证。
  • 若是多个用户在对具备相关关系的数据进行并发处理,则可能涉及到复杂的锁定机制。例如,若是审批者正在对采购请求进行审批,而采购提交者正在对采购项进行修改,则就有可能致使审核的数据是过时数据,或者致使采购项更新的失败。
  • 若是同时更新某些相关联的数据,也可能面临部分更新成功致使的问题——在数据库设计中,这类约束则须要经过 transaction 来保证。

确实,每一个问题都是有解决方案的,可是,第一,对于模型的讨论过早地进入了实现领域,和业务概念脱开了联系,不便于持续地和业务人员协做;第二,技术细节和业务规则的细节纠缠在一块儿,很容易顾此失彼。有没有一种方案,可让咱们更多的聚焦于问题领域,而不是深陷到这种技术细节中?

面向对象技术和 ORM(对象-关系映射)有助于咱们提升问题的抽象层级。在面向对象的世界中,咱们看到的结构是这样的:

image.png

图2 传统OO视角下的设计

面向对象的方式提升了抽象层级,忽略了没必要要的技术细节,例如已经不须要关心外键、关联表这些技术细节了。咱们须要关心的模型元素的数量减小了,复杂性也相应减小了。只是,业务规则如何保证,在传统的面向对象方法中并无严格的实现约束。例如:

从业务角度来看,若是采购申请的审批已经经过,对采购申请的采购项进行再次更新应该是非法的。可是,在面向对象的世界中,你却无法阻止程序员写出这样的代码:

...
PurchaseRequest purchaseRequest = getPurchaseRequest(requestId);
PurchaseItem item = purchaseRequest.getItem(itemId);
item.setQuantity(1000);
savePurchaseItem(item);

语句 1 取得了一个采购申请的实例;语句 2 取得了该申请中的一个条目。语句 3 和 4 修改了采购申请条目并保存。假如采购申请已经审批经过,这种修改岂不是能够轻易突破采购申请的预算?

固然,程序员能够在代码中加入逻辑检查来保证一致性:在修改或保存申请条目前老是检查 purchaseRequest 的状态,若是状态不为草稿就禁止修改。可是,考虑到 PurchaseItem 对象能够在代码的任何位置被取出来,且可能在不一样的方法间传递,若是 OO 设计不当,就可能致使该业务逻辑分散到各处。没有设计约束,这种检查的实现并非一件容易的事情。

让咱们回到本质思考:采购项若是脱离采购请求,它自身的单独存在有价值吗?——没有价值。若是没有价值:名义上看起来对采购项的修改,本质上是对采购项的修改吗?仍是本质上实际上是对采购请求的修改?

若是咱们承认“修改采购项也是修改采购请求”这个结论,那么咱们就不该该分开来研究采购项和采购请求,而是应该以下图所示:

image.png

图3 用聚合封装对象

咱们把“采购请求”和“采购项”组织到一块儿,看作一个更大的总体,称为“聚合”。这个聚合内部的业务逻辑,例如“采购申请审核经过后,不得对采购申请条目进行更改”,应內建于聚合内部。为了实现这一目标,咱们约定:对采购项的一切操做(增长、删除、修改等),都是对采购请求对象的操做。

也就是说:在 DDD 的世界中,历来就不该该存在 savePurchaseItem() 这种方法,而应以 purchaseRequest.modifyPurchaseItem() 和 purchaseRequestRepository.save(purchaseRequest) 取代之。

在新的对象关系中,采购申请负责“把守关隘”(即“聚合根”),采购条目成为了聚合的内部数据。因为聚合如今已是一个总体,与其相关的操做只能经过采购申请对象进行,业务一致性就能够获得保证。这事实上也是关于对象之间关系的更精确的描述:虽然采购申请和采购项都被建模为对象,可是它们的地位是不对等的。采购项是从属于采购申请的对象,它们只有是一个总体才有意义。

聚合的本质就是创建了一个比对象粒度更大的边界,汇集那些紧密关联的对象,造成了一个业务上的对象总体。使用聚合根做为对外的交互入口,从而保证了多个互相关联的对象的一致性。合理使用聚合,能够更容易地保证业务规则的一致性,减小了对象之间可能的耦合,提高设计的可理解性,下降出问题的可能性。

因此,经过把对象组织为聚合,在基本的对象层次之上构造了一层新的封装。封装简化了概念,隐藏了细节,在外部须要关心的模型元素数量进一步减小,复杂性降低。可是,封装边界的引入也引起了一个新的问题,例如:商品信息也是采购项的有效部分,应不该该把商品也放入“采购请求”这个聚合呢?提交人和审批人是否是也该放入聚合呢?若是要便利地得到业务规则的一致性,那岂不是把一切存在业务关联的对象都应该放在一块儿更好?若是有些对象应该放入聚合,有些不该该放入聚合,那么是否存在一个清晰的指导原则?本文在下一节回答这个问题。

二 聚合划分的原则

聚合做为 DDD 的对象体系中的一层,也一样应该遵循高内聚、低耦合的原则。本文认为,聚合边界内的对象应知足以下的启发式规则:

  • 生命周期一致性
  • 问题域一致性
  • 场景频率一致性
  • 聚合内的元素尽量少

1 生命周期一致性

生命周期一致性是指聚合边界内的对象,和聚合根之间存在“人身依附”关系。即:若是聚合根消失,聚合内的其余元素都应该同时消失。例如,在前述例子中,若是聚合根(采购请求)不存在了,那么采购项固然也就失去了存在的意义。而商品、做为申请人的用户等对象,和采购请求之间则不存在此关系。

能够用反证法来证实生命周期一致性:若是一个对象在聚合根消失以后仍然有意义,那么说明在系统中必然须要存在其余方法访问该对象。这和聚合的定义相矛盾。因此聚合根内的其余元素必然在聚合根消失后失效。违反生命周期一致性,也会同时带来实现上的严重问题。让咱们一块儿看一个例子:

image.png

其中 User 对象的生命周期和采购申请不一致。如今假若有两段程序代码并行执行:

代码 1(例如采购申请的修改)得到了某个采购申请的对象,对该对象进行了修改,进行保存。注意因为 User 对象嵌入到了 PurchaseRequest 中,User 对象也会被同时保存。

r = purchaseRequestRepository.findOne(id);
//...一些修改
purchaseRequestRepository.save(r);

代码 2(例如是用户管理),得到了该对象对应的审批人的信息,也进行了修改。

User user = userRepo.findOne(r.getSubmitter().getId());
//...一些修改
userRepo.save(user);

这将会致使一种彻底不可接受的后果:对于 User 对象的修改不肯定性!所以,对于那些说不清楚是否应该划入同一个聚合的对象,不妨问一下:这个对象若是离开本聚合的上下文,是否还有单独存在的价值?若是答案是确定的,该对象就不该该划到本聚合中:

  • Submitter/Approver 对应的 User 对象脱离了 PurchaseRequest,仍然有单独存在的理由。
  • Product 对象脱离了 PurchaseRequest,是能够单独存在的。

因此以上两个对象都不属于采购申请这个聚合。

2 问题域一致性

第二个原则是问题域一致性。事实上问题域一致是限界上下文(Bounded Context)的约束。聚合做为一种战术模式,所表示的模型必定会位于同一个限界上下文以内。

虽然原则一说明了对象的生命周期一致性可做为聚合划分的依据,可是什么是”一个对象脱离另一个对象是否有存在的意义“,有时候可能会存在争议。例如:若是采购申请被删除,那么根据此采购申请生成的订单是否有价值?(因为订单这个例子可能会陷入另一种争论,它能够从业务流程上规避:只要订单存在,采购申请就不能删除),让咱们换一个很是近似的例子:

一个在线论坛,用户能够对论坛上用户的文章发表评论。文章显然应该是一个聚合根。若是文章被删除,那么,用户的评论看起来也要同时消失。那么评论是否能够属于文章这个聚合?

如今让咱们来考虑评论是否还可能有其余的用途。例如,一个图书网站,用户能够对图书发表评论。若是只是由于文章删除和评论删除之间存在逻辑上的关联,就让文章聚合持有评论对象,那么显然就约束了评论的适用范围。一目了然的事实是,评论这一个概念,在本质上和文章这个概念相去甚远。因此,咱们获得了一个新的、凌驾于原则 1 之上的原则——不属于同一个问题域的对象,不该该出如今同一个聚合中。对 DDD 熟悉的朋友可能知道,这在 DDD 中对应于限界上下文这一战略模式。限于文章篇幅,咱们在此不过多展开。

image.png

图4 问题域一致性

因为聚合根没法保证聚合以外的一致性,因此咱们须要依赖”最终一致性“来实现聚合之间的一致性。例如,在文章删除的时候,发送一个文章删除的消息。评论系统接收到文章删除消息以后,删除文章对应的评论。

3 场景频率一致性

依赖于前述两个原则已经可以区分出大多数聚合。可是,仍然会存在一些比较复杂的状况。例如,考虑软件开发中的“产品”和“版本”以及“功能”的关系。“产品”和“版本”算不算是同一个问题域?——这几个概念之间的关系可能就不如“文章”和“评论”那么清晰。不过没关系,咱们仍然有一个启发式规则来规避这种模糊性。这就是“场景频率一致性”原则。

场景(scenario)是业务用例的具体化描述,反应了用户使用系统达成业务目标的方式。咱们能够观察这些场景中涉及的领域对象操做,如对领域对象的查看、修改等。场景操做频率的一致性是同一聚合内部对象的一个关键表征。常常被同时操做的对象,它们每每属于同一个聚合。而那些极少被同时关注的对象,通常不该该划为一个聚合。

如下图所示的“产品”、“版本”和“功能”这三个概念为例来讲明。产品确实包含了不少功能,这些功能经过一系列的版本发布。可是,在产品层面的操做,例如查看全部的产品列表,却并不须要关心特定功能的详细信息,也不须要了解特定的某个版本信息。咱们作版本规划的时候,确实会用到功能列表,可是大多数时候咱们并不会去查看功能详情,更加不可能在作版本规划的时候修改功能描述。

image.png

图5 不合适的聚合

根据这一原则,咱们划分出了以下的三个聚合:

image.png

图6 更合理的聚合

基于场景一致性划分聚合,对于实现也有很大好处。不在同一个场景下操做的对象,放入同一个聚合意味着每次操做一个对象,就须要把其余对象的全部信息抓取到,这是很是没有意义的。从实现层次,若是不紧密相关的对象出如今同一个聚合中,会致使它们常常在不一样的场景中被并发修改,也增长了这些对象之间冲突的可能性。因此:操做场景不一致的对象,或者说若是一个对象在不一样场景下都会被使用,应该考虑把它们分到不一样的聚合中。

4 尽可能小的聚合

聚合出现的本质是解决一致性问题带来的复杂性。所以,那么凡是不破坏以上三个一致性的状况,都没有必要把它们放到同一个聚合中。仅仅由一个业务概念(即领域模型中的类名及属性以及后面立刻提到的 Id 对象)构成的聚合在面向对象的世界中是大多数。

根据上述分析,在采购申请的例子中,采购申请、采购申请的一些属性(如状态、提交时间等)以及采购项属于一个聚合。可是,商品、用户这些不能属于采购申请这个聚合。这些聚合之间如何关联起来呢?咱们引入一种新的值对象来解决这个问题,以下图所示。图中也顺便标记了各对象是值对象仍是实体对象。

image.png

图7 精化后的聚合封装

在采购请求这个聚合中,除了采购请求聚合根是实体对象外,其余对象,包括做为对外引用的 Id 对象都是值对象。

对应的代码以下:

image.png

Id 值对象的引入是一个值得讨论的问题。

首先,Id 值对象的引入能断开聚合,能加快查询的速度,可是它不可避免的会致使某些场景下,须要对信息进行第二次查询,并且没法利用 ORM 的 EagerFetch/LazyFetch 加载机制的遍历。这是一种损失吗?简单地回答是:不是损失。不要贪图不属于一个聚合的对象层次嵌套带来的所谓便利——它引发的麻烦要远远多于带来的益处。这类问题应该由外部服务,例如应用层服务来完成。

其次,为了断开聚合而额外引入的 Id 值对象,还能算是领域模型或者是 “统一语言” 的一部分吗?我对这一问题的解释是:这是 DDD 的实现机制的一部分,它属于领域模型,可是请把可见性控制在开发团队。

没有必要和业务人员沟通这些概念。仅仅使用问题域识别出的实体、值对象、领域服务和领域事件和业务人员进行沟通。Id 值对象、资源库和工厂以及聚合、聚合根这些概念留给实现人员本身理解和在实现中使用就能够了。它们仍然是领域模型的一部分,它们的存在也仍然是统一语言的一部分,可是正如视图能够有选择地忽略部分信息同样,这些概念应该在和业务人员的沟通以及业务描述时忽略。

第三,请注意这个 Id 对象引用的只能是其余聚合根的 Id。因为只有聚合根才可能会被外部引用,因此聚合根的 ID 应该作到全局惟一。聚合内部的对象,不管是实体对象仍是值对象,都只须要保证内部的 ID 惟一便可。

三 实现方面的考虑

1 资源库、工厂面向聚合定义

工厂(Factory)模式、资源库(Repository)模式都是 DDD 在实现维度的模式。尽管在 DDD Reference 给出的模式关系图中,工厂、资源库除了与聚合之间有链接以外,与实体之间也有链接,甚至工厂和值对象之间也有链接,可是,本文认为,这些链接的强度是不一样的,价值也是不一样的。

工厂模式的存在显然是为了分离对象的构造与使用,可是在 DDD 的上下文中,它包含了更深层面的意义。聚合内部的对象直接的关系多是复杂的,业务一致性是须要保证的,那么使用工厂来构造聚合对象是一种更好的对复杂性的封装。诚然,工厂模式对于非聚合跟的复杂的体对象和值对象的构造也有价值,但这只是设计或者实现层面的事情,和业务模型扯不上什么关系。

尽管聚合的工厂和通常对象的工厂都是以工厂模式同名,可是 DDD 以聚合为基本单位设计的 Factory 对于简化系统的复杂性具备更重要的意义。从设计约束上,在聚合之外,只应该有一个工厂对外可见,那就是聚合的工厂。(领域事件的 Factory 也是有意义的,领域事件离本文的话题稍远,暂且不作讨论)。

资源库模式也绝非只是意味着持久化,更不是数据库访问层,因此不要误解。资源库更重要的意义是:资源库是聚合的仓储机制,外部世界经过资源库,并且只能经过资源库来完成对聚合的访问。资源库以聚合的总体管理对象。所以,从设计约束上,一个聚合只能有一个资源库对象,那就是以聚合根命名的资源库。除此以外的其余对象,都不该该提供资源库对象。

image.png
图8 聚合和资源库

2 代码结构与聚合保持一致

细心的读者确定已经发现了,在上图中包的组织方式也是和聚合一致的,而且使用了聚合根的名字做为包名。这是我本人组织代码时的惯用方式,把聚合做为代码的一个层级(之上固然存在其余层级,例如限界上下文、模块等),把全部属于该聚合的实体(包含聚合根)对象、值对象、资源库、工厂等都放入到同一个代码包中。代码结构和领域模型的结构高度一致,能够下降表示差距,更好的管理对象世界的复杂性。

3 聚合不可跨越部署的边界

部署的边界是一个复杂的话题,本文仅就和聚合有关的内容进行讨论。首先,若是系统采用了微服务架构,应该保持部署边界和限界上下文边界的一致——不要让部署的粒度大于限界上下文的粒度,这样能够带来更好的业务灵活性和可伸缩性。其次,从服务的最小边界上,不可以让最小边界小于聚合的粒度,不然会带来大量的数据的一致性问题——由于微服务之间的一致性通常须要经过最终一致性来保证,若是聚合跨越了部署边界将会是一致性的灾难。曾经在某些书上看到一些关于关于微服务划分的不甚合理的建议,例如把对每个对象的增删改查都作成一个服务。这种建议在我看来是错误的。

4 聚合改进了系统性能和可伸缩性

不少人会为 ORM 机制中低效的查询所困扰。为何会这样?看一下前面的例子就明白了。咱们为前述的不正确的聚合的例子加上 Spring JPA 的 Annotation:

image.png

因为缺少聚合的概念,或者不正确的作了一个超大的聚合,那么每次对 PurchaseRequest 的查询,都须要从系统抓取大量的对象,耗费了大量的计算资源——也许 User 本身也是一个超大的对象呢?“拔出萝卜带出泥”,性能天然不可能好。

也许有读者会说,我不用 Eager Fetch,我能够用 Lazy Fetch 啊。是的,这确实对性能上更好一些,可是不幸的是,数据访问的上下文将不得不一直保留,系统出错的几率大大增长,也给分布式设计带来了不便。

小的聚合就彻底没有这个问题了——在这种情形下,每一个涉及访问的对象(事实上就是聚合)不可能很大,而所需的数据又恰如其分的都在,数据完整性和业务完整性就有了保障,还能够方便地进行水平扩展,性能和可伸缩性也就同时获得了知足。

四 总结

建模是咱们理解现实世界,简化问题复杂性的方法之一。聚合做为领域建模的一个层次,经过恰如其分的边界,实现了信息隐藏、提升了抽象层级,封装了紧密关联的业务逻辑,保证了系统数据的一致性,改进了系统的性能。

本文讨论了聚合的定义和价值,归纳的说:

  • 聚合是面向对象的世界中建模的一个层次。它隐藏了细粒度对象,约束了对象之间的耦合。
  • 聚合是一致性的边界,是对具备紧密关联关系的对象的封装。聚合封装了实体对象和值对象,而且采用其中最重要的一个实体对象做为聚合根。聚合根做为聚合的惟一外部入口,保证了业务规则和数据的一致性。

本文也探讨了关于聚合识别的四条启发式规则,具体是:

  • 生命周期一致性
  • 问题域一致性
  • 场景频率一致性
  • 聚合内的元素尽量少

从实现角度,资源库、工厂的粒度应该和聚合的粒度一致,代码结构和部署结构也能够和聚合对齐。实现和领域模型保持一致,这也是领域驱动设计做为正确的 OO 的目标和价值所在。


【2021阿里巴巴研发效能峰会】开放报名

6月23日,阿里巴巴合伙人、IBM副合伙人、德勤云服务首席架构师、PMI业务副总裁等,近30位海内外大咖分享效能趋势和实践,云原生、低代码、智能化、将来架构、DevOps、数字化转型1200分钟精选干货汇聚,和你一块儿感知行业技术水位,洞悉将来发展态势。

点击这里,免费预定吧~

本文内容由阿里云实名注册用户自发贡献,版权归原做者全部,阿里云开发者社区不拥有其著做权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。若是您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将马上删除涉嫌侵权内容。