阿里云 | 深入理解领域驱动设计中的聚合
作者 | 嵩华 阿里云云栖号
聚合模式是 DDD 的模式结构中较为难于理解的一个,也是 DDD 学习曲线中的一个关键障碍。合理地设计聚合,能清晰地表述业务一致性,也更容易带来清晰的实现,设计不合理的聚合,甚至在设计中没有聚合的概念,则相反。
聚合的概念并不复杂。本文希望能回到聚合的本质,对聚合的定义和实操给出一些有价值的建议。
一 聚合解决的核心问题是什么
我们先来看一下在 DDD Reference 中关于聚合的定义。
将实体和值对象划分为聚合并围绕着聚合定义边界。选择一个实体作为每个聚合的根,并仅允许外部对象持有对聚合根的引用。作为一个整体来定义聚合的属性和不变量,并把其执行责任赋予聚合根或指定的框架机制。
这是典型的“模式语言”,说明了聚合是什么,聚合根(aggregation root)是什么,以及如何使用聚合。但是,模式语言的问题在于过度精炼,如果读者已经熟悉了这种模式,很容易看懂,但是最需要看懂的、那些尚不够熟悉这些概念的人,却容易感到不知所云。为了能深入理解一个模式的本质,我们还是要回到它试图解决的核心问题上来。
在软件架构领域有一句名言:
“架构并不由系统的功能决定,而是由系统的非功能属性决定”。
这句话直白的解释就是:假如不考虑性能、健壮性、可移植性、可修改性、开发成本、时间约束等因素,用任何的架构、任何的方法,系统的功能总是可以实现的,项目总是能开发完成的,只是开发时间、以后的维护成本、功能扩展的容易程度不同罢了。
当然现实绝非如此。我们总是希望系统在可理解、可维护、可扩展等方面表现良好,从而多快好省的达成系统背后的业务目标。但是,在现实中,不合理的设计方法有可能增加系统的复杂性。我们先来看一个例子:
假设问题领域是一个企业内部的办公用品采购系统。
- 企业的员工可以通过该系统提交一个采购请求,一个请求包含了若干数量、若干类型的办公用品(称为采购项)。(1)
- 主管负责对采购申请进行审批。(2)
- 审批通过后,系统会根据提供商不同,生成若干订单。(3)
对同一个问题,存在若干种不同的设计思路,例如以数据库为中心的设计、面向对象的设计和“正确的 OO”的 DDD 的设计。
如果采用以数据库为中心的建模方式,首先会进行数据库设计——我确实看到还有许多团队仍然在采取这种方法,花费大量的时间进行数据库结构的讨论。为了避免图表过大,我们仅仅给出了和采购申请相关的表格。结构如下图所示:
图1 数据库视角下的设计
如果直接在数据库这么低的设计层次上考虑问题,除了数据库的设计繁琐易错,更重要的是会面临一些比较复杂的业务规则和数据一致性保证的问题。例如:
- 如果采购请求被删除,则相应的和该采购请求相关的采购项以及它们之间的关联都需要被删除——在数据库设计中,这种约束可以通过数据库外键来保证。
- 如果多个用户在对具有相关关系的数据进行并发处理,则可能涉及到复杂的锁定机制。例如,如果审批者正在对采购请求进行审批,而采购提交者正在对采购项进行修改,则就有可能导致审核的数据是过期数据,或者导致采购项更新的失败。
- 如果同时更新某些相关联的数据,也可能面临部分更新成功导致的问题——在数据库设计中,这类约束则需要通过 transaction 来保证。
确实,每个问题都是有解决方案的,但是,第一,对于模型的讨论过早地进入了实现领域,和业务概念脱开了联系,不便于持续地和业务人员协作;第二,技术细节和业务规则的细节纠缠在一起,很容易顾此失彼。有没有一种方案,可以让我们更多的聚焦于问题领域,而不是深陷到这种技术细节中?
面向对象技术和 ORM(对象-关系映射)有助于我们提高问题的抽象层级。在面向对象的世界中,我们看到的结构是这样的:
图2 传统OO视角下的设计
面向对象的方式提高了抽象层级,忽略了不必要的技术细节,例如已经不需要关心外键、关联表这些技术细节了。我们需要关心的模型元素的数量减少了,复杂性也相应减少了。只是,业务规则如何保证,在传统的面向对象方法中并没有严格的实现约束。例如:
从业务角度来看,如果采购申请的审批已经通过,对采购申请的采购项进行再次更新应该是非法的。但是,在面向对象的世界中,你却没法阻止程序员写出这样的代码:
...
PurchaseRequest purchaseRequest = getPurchaseRequest(requestId);
PurchaseItem item = purchaseRequest.getItem(itemId);
item.setQuantity(1000);
savePurchaseItem(item);
语句 1 取得了一个采购申请的实例;语句 2 取得了该申请中的一个条目。语句 3 和 4 修改了采购申请条目并保存。假如采购申请已经审批通过,这种修改岂不是可以轻易突破采购申请的预算?
当然,程序员可以在代码中加入逻辑检查来保证一致性:在修改或保存申请条目前总是检查 purchaseRequest 的状态,如果状态不为草稿就禁止修改。但是,考虑到 PurchaseItem 对象可以在代码的任何位置被取出来,且可能在不同的方法间传递,如果 OO 设计不当,就可能导致该业务逻辑分散到各处。没有设计约束,这种检查的实现并不是一件容易的事情。
让我们回到本质思考:采购项如果脱离采购请求,它自身的单独存在有价值吗?——没有价值。如果没有价值:名义上看起来对采购项的修改,本质上是对采购项的修改吗?还是本质上其实是对采购请求的修改?
如果我们认可“修改采购项也是修改采购请求”这个结论,那么我们就不应该分开来研究采购项和采购请求,而是应该如下图所示:
图3 用聚合封装对象
我们把“采购请求”和“采购项”组织到一起,看做一个更大的整体,称为“聚合”。这个聚合内部的业务逻辑,例如“采购申请审核通过后,不得对采购申请条目进行更改”,应內建于聚合内部。为了实现这一目标,我们约定:对采购项的一切操作(增加、删除、修改等),都是对采购请求对象的操作。
也就是说:在 DDD 的世界中,从来就不应该存在 savePurchaseItem() 这种方法,而应以 purchaseRequest.modifyPurchaseItem() 和 purchaseRequestRepository.save(purchaseRequest) 取代之。
在新的对象关系中,采购申请负责“把守关隘”(即“聚合根”),采购条目成为了聚合的内部数据。由于聚合现在已经是一个整体,与其相关的操作只能通过采购申请对象进行,业务一致性就可以得到保证。这事实上也是关于对象之间关系的更精确的描述:虽然采购申请和采购项都被建模为对象,但是它们的地位是不对等的。采购项是从属于采购申请的对象,它们只有是一个整体才有意义。
聚合的本质就是建立了一个比对象粒度更大的边界,聚集那些紧密关联的对象,形成了一个业务上的对象整体。使用聚合根作为对外的交互入口,从而保证了多个互相关联的对象的一致性。合理使用聚合,可以更容易地保证业务规则的一致性,减少了对象之间可能的耦合,提升设计的可理解性,降低出问题的可能性。
所以,通过把对象组织为聚合,在基本的对象层次之上构造了一层新的封装。封装简化了概念,隐藏了细节,在外部需要关心的模型元素数量进一步减少,复杂性下降。但是,封装边界的引入也引发了一个新的问题,例如:商品信息也是采购项的有效部分,应不应该把商品也放入“采购请求”这个聚合呢?提交人和审批人是不是也该放入聚合呢?如果要便利地获得业务规则的一致性,那岂不是把一切存在业务关联的对象都应该放在一起更好?如果有些对象应该放入聚合,有些不应该放入聚合,那么是否存在一个清晰的指导原则?本文在下一节回答这个问题。
二 聚合划分的原则
聚合作为 DDD 的对象体系中的一层,也同样应该遵循高内聚、低耦合的原则。本文认为,聚合边界内的对象应满足如下的启发式规则:
- 生命周期一致性
- 问题域一致性
- 场景频率一致性
- 聚合内的元素尽可能少
1 生命周期一致性
生命周期一致性是指聚合边界内的对象,和聚合根之间存在“人身依附”关系。即:如果聚合根消失,聚合内的其他元素都应该同时消失。例如,在前述例子中,如果聚合根(采购请求)不存在了,那么采购项当然也就失去了存在的意义。而商品、作为申请人的用户等对象,和采购请求之间则不存在此关系。
可以用反证法来证明生命周期一致性:如果一个对象在聚合根消失之后仍然有意义,那么说明在系统中必然需要存在其他方法访问该对象。这和聚合的定义相矛盾。所以聚合根内的其他元素必然在聚合根消失后失效。违反生命周期一致性,也会同时带来实现上的严重问题。让我们一起看一个例子:
其中 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 中对应于限界上下文这一战略模式。限于文章篇幅,我们在此不过多展开。
图4 问题域一致性
由于聚合根无法保证聚合之外的一致性,所以我们需要依赖”最终一致性“来实现聚合之间的一致性。例如,在文章删除的时候,发送一个文章删除的消息。评论系统接收到文章删除消息之后,删除文章对应的评论。
3 场景频率一致性
依赖于前述两个原则已经能够区分出大多数聚合。但是,仍然会存在一些比较复杂的情况。例如,考虑软件开发中的“产品”和“版本”以及“功能”的关系。“产品”和“版本”算不算是同一个问题域?——这几个概念之间的关系可能就不如“文章”和“评论”那么清晰。不过不要紧,我们仍然有一个启发式规则来规避这种模糊性。这就是“场景频率一致性”原则。
场景(scenario)是业务用例的具体化描述,反应了用户使用系统达成业务目标的方式。我们可以观察这些场景中涉及的领域对象操作,如对领域对象的查看、修改等。场景操作频率的一致性是同一聚合内部对象的一个关键表征。经常被同时操作的对象,它们往往属于同一个聚合。而那些极少被同时关注的对象,一般不应该划为一个聚合。
以下图所示的“产品”、“版本”和“功能”这三个概念为例来说明。产品确实包含了很多功能,这些功能通过一系列的版本发布。但是,在产品层面的操作,例如查看所有的产品列表,却并不需要关心特定功能的详细信息,也不需要了解特定的某个版本信息。我们做版本规划的时候,确实会用到功能列表,但是大多数时候我们并不会去查看功能详情,更加不可能在做版本规划的时候修改功能描述。
图5 不合适的聚合
根据这一原则,我们划分出了如下的三个聚合:
图6 更合理的聚合
基于场景一致性划分聚合,对于实现也有很大好处。不在同一个场景下操作的对象,放入同一个聚合意味着每次操作一个对象,就需要把其他对象的所有信息抓取到,这是非常没有意义的。从实现层次,如果不紧密相关的对象出现在同一个聚合中,会导致它们经常在不同的场景中被并发修改,也增加了这些对象之间冲突的可能性。所以:操作场景不一致的对象,或者说如果一个对象在不同场景下都会被使用,应该考虑把它们分到不同的聚合中。
4 尽量小的聚合
聚合出现的本质是解决一致性问题带来的复杂性。因此,那么凡是不破坏以上三个一致性的情况,都没有必要把它们放到同一个聚合中。仅仅由一个业务概念(即领域模型中的类名及属性以及后面马上提到的 Id 对象)构成的聚合在面向对象的世界中是大多数。
根据上述分析,在采购申请的例子中,采购申请、采购申请的一些属性(如状态、提交时间等)以及采购项属于一个聚合。但是,商品、用户这些不能属于采购申请这个聚合。这些聚合之间如何关联起来呢?我们引入一种新的值对象来解决这个问题,如下图所示。图中也顺便标记了各对象是值对象还是实体对象。
图7 精化后的聚合封装
在采购请求这个聚合中,除了采购请求聚合根是实体对象外,其他对象,包括作为对外引用的 Id 对象都是值对象。
对应的代码如下:
Id 值对象的引入是一个值得讨论的问题。
首先,Id 值对象的引入能断开聚合,能加快查询的速度,但是它不可避免的会导致某些场景下,需要对信息进行第二次查询,而且无法利用 ORM 的 EagerFetch/LazyFetch 加载机制的遍历。这是一种损失吗?简单地回答是:不是损失。不要贪图不属于一个聚合的对象层次嵌套带来的所谓便利——它引起的麻烦要远远多于带来的益处。这类问题应该由外部服务,例如应用层服务来完成。
其次,为了断开聚合而额外引入的 Id 值对象,还能算是领域模型或者是 “统一语言” 的一部分吗?我对这一问题的解释是:这是 DDD 的实现机制的一部分,它属于领域模型,但是请把可见性控制在开发团队。
没有必要和业务人员沟通这些概念。仅仅使用问题域识别出的实体、值对象、领域服务和领域事件和业务人员进行沟通。Id 值对象、资源库和工厂以及聚合、聚合根这些概念留给实现人员自己理解和在实现中使用就可以了。它们仍然是领域模型的一部分,它们的存在也仍然是统一语言的一部分,但是正如视图可以有选择地忽略部分信息一样,这些概念应该在和业务人员的沟通以及业务描述时忽略。
第三,请注意这个 Id 对象引用的只能是其他聚合根的 Id。由于只有聚合根才可能会被外部引用,所以聚合根的 ID 应该做到全局唯一。聚合内部的对象,无论是实体对象还是值对象,都只需要保证内部的 ID 唯一即可。
三 实现方面的考虑
1 资源库、工厂面向聚合定义
工厂(Factory)模式、资源库(Repository)模式都是 DDD 在实现维度的模式。尽管在 DDD Reference 给出的模式关系图中,工厂、资源库除了与聚合之间有连接之外,与实体之间也有连接,甚至工厂和值对象之间也有连接,但是,本文认为,这些连接的强度是不同的,价值也是不同的。
工厂模式的存在显然是为了分离对象的构造与使用,但是在 DDD 的上下文中,它包含了更深层面的意义。聚合内部的对象直接的关系可能是复杂的,业务一致性是需要保证的,那么使用工厂来构造聚合对象是一种更好的对复杂性的封装。诚然,工厂模式对于非聚合跟的复杂的体对象和值对象的构造也有价值,但这只是设计或者实现层面的事情,和业务模型扯不上什么关系。
尽管聚合的工厂和一般对象的工厂都是以工厂模式同名,但是 DDD 以聚合为基本单位设计的 Factory 对于简化系统的复杂性具有更重要的意义。从设计约束上,在聚合以外,只应该有一个工厂对外可见,那就是聚合的工厂。(领域事件的 Factory 也是有意义的,领域事件离本文的话题稍远,暂且不做讨论)。
资源库模式也绝非只是意味着持久化,更不是数据库访问层,所以不要误解。资源库更重要的意义是:资源库是聚合的仓储机制,外部世界通过资源库,而且只能通过资源库来完成对聚合的访问。资源库以聚合的整体管理对象。因此,从设计约束上,一个聚合只能有一个资源库对象,那就是以聚合根命名的资源库。除此之外的其他对象,都不应该提供资源库对象。
图8 聚合和资源库
2 代码结构与聚合保持一致
细心的读者肯定已经发现了,在上图中包的组织方式也是和聚合一致的,并且使用了聚合根的名字作为包名。这是我本人组织代码时的惯用方式,把聚合作为代码的一个层级(之上当然存在其他层级,例如限界上下文、模块等),把所有属于该聚合的实体(包含聚合根)对象、值对象、资源库、工厂等都放入到同一个代码包中。代码结构和领域模型的结构高度一致,可以降低表示差距,更好的管理对象世界的复杂性。
3 聚合不可跨越部署的边界
部署的边界是一个复杂的话题,本文仅就和聚合有关的内容进行讨论。首先,如果系统采用了微服务架构,应该保持部署边界和限界上下文边界的一致——不要让部署的粒度大于限界上下文的粒度,这样可以带来更好的业务灵活性和可伸缩性。其次,从服务的最小边界上,不可让最小边界小于聚合的粒度,否则会带来大量的数据的一致性问题——因为微服务之间的一致性一般需要通过最终一致性来保证,如果聚合跨越了部署边界将会是一致性的灾难。曾经在某些书上看到一些关于关于微服务划分的不甚合理的建议,例如把对每一个对象的增删改查都做成一个服务。这种建议在我看来是错误的。
4 聚合改进了系统性能和可伸缩性
很多人会为 ORM 机制中低效的查询所困扰。为什么会这样?看一下前面的例子就明白了。我们为前述的不正确的聚合的例子加上 Spring JPA 的 Annotation:
由于缺乏聚合的概念,或者不正确的做了一个超大的聚合,那么每次对 PurchaseRequest 的查询,都需要从系统抓取大量的对象,耗费了大量的计算资源——也许 User 自己也是一个超大的对象呢?“拔出萝卜带出泥”,性能自然不可能好。
也许有读者会说,我不用 Eager Fetch,我可以用 Lazy Fetch 啊。是的,这确实对性能上更好一些,但是不幸的是,数据访问的上下文将不得不一直保留,系统出错的概率大大增加,也给分布式设计带来了不便。
小的聚合就完全没有这个问题了——在这种情形下,每个涉及访问的对象(事实上就是聚合)不可能很大,而所需的数据又恰如其分的都在,数据完整性和业务完整性就有了保障,还可以方便地进行水平扩展,性能和可伸缩性也就同时得到了满足。
四 总结
建模是我们理解现实世界,简化问题复杂性的方法之一。聚合作为领域建模的一个层次,通过恰如其分的边界,实现了信息隐藏、提高了抽象层级,封装了紧密关联的业务逻辑,保证了系统数据的一致性,改进了系统的性能。
本文讨论了聚合的定义和价值,概括的说:
- 聚合是面向对象的世界中建模的一个层次。它隐藏了细粒度对象,约束了对象之间的耦合。
- 聚合是一致性的边界,是对具有紧密关联关系的对象的封装。聚合封装了实体对象和值对象,并且采用其中最重要的一个实体对象作为聚合根。聚合根作为聚合的唯一外部入口,保证了业务规则和数据的一致性。
本文也探讨了关于聚合识别的四条启发式规则,具体是:
- 生命周期一致性
- 问题域一致性
- 场景频率一致性
- 聚合内的元素尽可能少
从实现角度,资源库、工厂的粒度应该和聚合的粒度一致,代码结构和部署结构也可以和聚合对齐。实现和领域模型保持一致,这也是领域驱动设计作为正确的 OO 的目标和价值所在。