怎样深入理解领域驱动设计中的聚合?
发布时间:2021-06-16浏览次数:76
支付宝聚合模式是 DDD 的模式结构中较为难于理解的一个,也是 DDD 学习曲线中的一个关键障碍。合理地设计聚合,能清晰地表述业务一致性,也更容易带来清晰的实现,设计不合理的聚合,甚至在设计中没有聚合的概念,则相反。
聚合的概念并不复杂。本文希望能回到聚合的本质,对聚合的定义和实操给出一些有价值的建议。
我们先来看一下在 DDD Reference 中关于聚合的定义。
将实体和值对象划分为聚合并围绕着聚合定义边界。选择一个实体作为每个聚合的根,并仅允许外部对象持有对聚合根的引用。作为一个整体来定义聚合的属性和不变量,并把其执行责任赋予聚合根或指定的框架机制。
这是典型的“模式语言”,说明了聚合是什么,聚合根(aggregation root)是什么,以及如何使用聚合。但是,模式语言的问题在于过度精炼,如果读者已经熟悉了这种模式,很容易看懂,但是最需要看懂的、那些尚不够熟悉这些概念的人,却容易感到不知所云。为了能深入理解一个模式的本质,我们还是要回到它试图解决的核心问题上来。
“架构并不由系统的功能决定,而是由系统的非功能属性决定”。
这句话直白的解释就是:假如不考虑性能、健壮性、可移植性、可修改性、开发成本、时间约束等因素,用任何的架构、任何的方法,系统的功能总是可以实现的,项目总是能开发完成的,只是开发时间、以后的维护成本、功能扩展的容易程度不同罢了。当然现实绝非如此。我们总是希望系统在可理解、可维护、可扩展等方面表现良好,从而多快好省的达成系统背后的业务目标。但是,在现实中,不合理的设计方法有可能增加系统的复杂性。我们先来看一个例子:假设问题领域是一个企业内部的办公用品采购系统。
1.企业的员工可以通过该系统提交一个采购请求,一个请求包含了若干数量、若干类型的办公用品(称为采购项)。
2.主管负责对采购申请进行审批。
3.审批通过后,系统会根据提供商不同,生成若干订单。
对同一个问题,存在若干种不同的设计思路,例如以数据库为中心的设计、面向对象的设计和“正确的 OO”的 DDD 的设计。如果采用以数据库为中心的建模方式,首先会进行数据库设计——我确实看到还有许多团队仍然在采取这种方法,花费大量的时间进行数据库结构的讨论。为了避免图表过大,我们仅仅给出了和采购申请相关的表格。结构如下图所示:如果直接在数据库这么低的设计层次上考虑问题,除了数据库的设计繁琐易错,更重要的是会面临一些比较复杂的业务规则和数据一致性保证的问题。
例如:
1.如果采购请求被删除,则相应的和该采购请求相关的采购项以及它们之间的关联都需要被删除——在数据库设计中,这种约束可以通过数据库外键来保证。
2.如果多个用户在对具有相关关系的数据进行并发处理,则可能涉及到复杂的锁定机制。例如,如果审批者正在对采购请求进行审批,而采购提交者正在对采购项进行修改,则就有可能导致审核的数据是过期数据,或者导致采购项更新的失败。
3.如果同时更新某些相关联的数据,也可能面临部分更新成功导致的问题——在数据库设计中,这类约束则需要通过 transaction 来保证。
确实,每个问题都是有解决方案的,但是,第一,对于模型的讨论过早地进入了实现领域,和业务概念脱开了联系,不便于持续地和业务人员协作;第二,技术细节和业务规则的细节纠缠在一起,很容易顾此失彼。有没有一种方案,可以让我们更多的聚焦于问题领域,而不是深陷到这种技术细节中?
面向对象技术和 orM(对象-关系映射)有助于我们提高问题的抽象层级。在面向对象的世界中,我们看到的结构是这样的:
面向对象的方式提高了抽象层级,忽略了不必要的技术细节,例如已经不需要关心外键、关联表这些技术细节了。我们需要关心的模型元素的数量减少了,复杂性也相应减少了。只是,业务规则如何保证,在传统的面向对象方法中并没有严格的实现约束。
例如:
从业务角度来看,如果采购申请的审批已经通过,对采购申请的采购项进行再次更新应该是非法的。但是,在面向对象的世界中,你却没法阻止程序员写出这样的代码:
语句 1 取得了一个采购申请的实例;语句 2 取得了该申请中的一个条目。语句 3 和 4 修改了采购申请条目并保存。假如采购申请已经审批通过,这种修改岂不是可以轻易突破采购申请的预算?
当然,程序员可以在代码中加入逻辑检查来保证一致性:在修改或保存申请条目前总是检查 purchaseRequest 的状态,如果状态不为草稿就禁止修改。但是,考虑到 PurchaseItem 对象可以在代码的任何位置被取出来,且可能在不同的方法间传递,如果 OO 设计不当,就可能导致该业务逻辑分散到各处。没有设计约束,这种检查的实现并不是一件容易的事情。
让我们回到本质思考:采购项如果脱离采购请求,它自身的单独存在有价值吗?——没有价值。如果没有价值:名义上看起来对采购项的修改,本质上是对采购项的修改吗?还是本质上其实是对采购请求的修改?
如果我们认可“修改采购项也是修改采购请求”这个结论,那么我们就不应该分开来研究采购项和采购请求,而是应该如下图所示:我们把“采购请求”和“采购项”组织到一起,看做一个更大的整体,称为“聚合”。这个聚合内部的业务逻辑,例如“采购申请审核通过后,不得对采购申请条目进行更改”,应內建于聚合内部。为了实现这一目标,我们约定:对采购项的一切操作(增加、删除、修改等),都是对采购请求对象的操作。也就是说:在 DDD 的世界中,从来就不应该存在 savePurchaseItem() 这种方法,而应以 purchaseRequest.modifyPurchaseItem() 和 purchaseRequestRepository.save(purchaseRequest) 取代之。在新的对象关系中,采购申请负责“把守关隘”(即“聚合根”),采购条目成为了聚合的内部数据。由于聚合现在已经是一个整体,与其相关的操作只能通过采购申请对象进行,业务一致性就可以得到保证。这事实上也是关于对象之间关系的更精确的描述:虽然采购申请和采购项都被建模为对象,但是它们的地位是不对等的。采购项是从属于采购申请的对象,它们只有是一个整体才有意义。聚合的本质就是建立了一个比对象粒度更大的边界,聚集那些紧密关联的对象,形成了一个业务上的对象整体。使用聚合根作为对外的交互入口,从而保证了多个互相关联的对象的一致性。合理使用聚合,可以更容易地保证业务规则的一致性,减少了对象之间可能的耦合,提升设计的可理解性,降低出问题的可能性。所以,通过把对象组织为聚合,在基本的对象层次之上构造了一层新的封装。封装简化了概念,隐藏了细节,在外部需要关心的模型元素数量进一步减少,复杂性下降。但是,封装边界的引入也引发了一个新的问题,例如:商品信息也是采购项的有效部分,应不应该把商品也放入“采购请求”这个聚合呢?提交人和审批人是不是也该放入聚合呢?如果要便利地获得业务规则的一致性,那岂不是把一切存在业务关联的对象都应该放在一起更好?如果有些对象应该放入聚合,有些不应该放入聚合,那么是否存在一个清晰的指导原则?本文在下一节回答这个问题。
管理员
该内容暂无评论