关于ddd:深入理解领域驱动设计中的聚合

49次阅读

共计 8384 个字符,预计需要花费 21 分钟才能阅读完成。

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

作者 | 嵩华
起源 | 阿里技术公众号

聚合模式是 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 的指标和价值所在。

原文链接
本文为阿里云原创内容,未经容许不得转载。

正文完
 0