当前位置: 移动技术网 > IT编程>开发语言>JavaScript > DDD聚合设计原则

DDD聚合设计原则

2020年09月29日  | 移动技术网IT编程  | 我要评论
“话说天下大势,分久必合,合久必分”                  ——《三国演义》那到底什么时候该分?什么时候该合呢?前言领域模型的推演是领域专家和技术同学就对业务的理解和抽象进行讨论和碰撞的过程,通常情况下,对一个具有一定复杂度的业务进行建模,模型之间的关系会非常复杂。在包含复杂关联的模型中,要保证对象修改的一致性是很困难的,我们必须保证紧密关联的对象组也能保证不变性,而不仅仅只保证各个离散的对象。这个问题实际上是源于模型之中缺乏明确的边界,而聚合的出现就是在模型层面来为这个问题寻找解决.

“话说天下大势,分久必合,合久必分”
                  ——《三国演义》

那到底什么时候该分?什么时候该合呢?

前言

领域模型的推演是领域专家和技术同学就对业务的理解和抽象进行讨论和碰撞的过程,通常情况下,对一个具有一定复杂度的业务进行建模,模型之间的关系会非常复杂。在包含复杂关联的模型中,要保证对象修改的一致性是很困难的,我们必须保证紧密关联的对象组也能保证不变性,而不仅仅只保证各个离散的对象。这个问题实际上是源于模型之中缺乏明确的边界,而聚合的出现就是在模型层面来为这个问题寻找解决方案。在DDD的众多战术设计中,聚合是最不容易理解的一个,这也直接导致了聚合设计的难度偏高,本文我们来理一下,DDD聚合设计都有哪些原则。

一致性原则

在DDD中,聚合是由在一致性边界内的实体和值对象组成的,创建一个聚合最基本的原则是领域对象的群集必须基于领域不变条件。领域不变条件是指无论何时数据发生变化都必须满足的一致性原则,这个一致性原则一定要满足真正的业务规则,不能是开发同学自己想当然的约束条件。在讨论到一致性时,我们知道存在多种类型的一致性,其中之一便是事务一致性,同时还存在最终一致性。这里我们讨论领域不变条件时,讨论的是事务一致性。例如业务规则中有以下不变条件:x + y = z,那么当x = 1、y = 2时,z必定等于3,根据这条规则,如果z不为3,那么我们便违背了领域的不变条件,为了保证z的一致性,我们应该为这些属性设计一个边界:

Aggregate {
  int x;
  int y;
  int z;
  methods ...
}

这样,边界之内的所有内容组成了一套不变的业务规则,任何操作都不能违背这个规则。一致性原则是设计聚合时要遵守的最基本的原则。

假设我们的问题域是一个支付系统,要求系统可以支持消费者进行多批次支付,类似于双11预售,消费者可以先付一部分定金,到双11当天再进行尾款支付。这样我们的领域模型不但要有支付单,还要有针对每个支付批次的批次单,同一个支付单的各个批次单的支付金额之和要等于支付单的总支付金额,这就是一个领域不变条件,显然我们需要将支付单和批次单圈在一个聚合范围内,这就是一个简单的聚合。
image.png

选择聚合根

要让聚合保持一致性,其组成部分就不应该在整个领域模型中共享或者可以访问服务层。这样我们就可以避免应用程序的其他部分将聚合置为不一致状态。但是聚合肯定是需要提供行为方法的,这时我们可以通过为聚合选择一个实体作为聚合根,之后使用一个聚合的所有内容都应该仅通过其根来产生。

聚合根是一个被选中作为进行聚合的入口的实体,它负责协调对聚合的所有变更,确保调用方不会将聚合置为不一致的状态。它通过委托聚合中的其他实体和值对象来支持聚合满足领域不变条件。其他领域对象仅作为聚合的一部分存在,它们是概念化整体的一部分。调用方不允许绕过聚合根直接访问聚合的内部结构以及直接与聚合的成员实体进行交互,这样可能会导致聚合处于不一致状态。所以在设计聚合根时我们同样要遵守一定的原则:
1)公开行为接口:在使用实体和其他领域对象时,公开聚合行为是非常可取的,这样你的模型就能显示地传达领域概念。对于一个聚合来说这意味着在根上公开表述性方法以供其他聚合与之交互。聚合根介于聚合的其他成员之间,因而它也是所有外部通信的入口点。

2)保护内部状态:通过前面表达的内容,我们不难看出,封装对于领域结构很重要,所以我们要非常小心的使用我们平时经常使用的getter&setter方法,因为它们很可能会公开聚合的内部,进而就很可能会导致聚合处于不一致状态。而且会增加应用程序的其他部分对于领域模型的耦合度,从而阻碍我们对领域模型进行重构。

3)只允许根具有全局标识:我们可能听说过全局标识和局部标识之分,在设计聚合时,只允许聚合根拥有全局标识,因为可以从聚合外部访问它,而聚合的其他成员只有局部标识,因为他们位于聚合内部。

针对我们前面提到的支付系统模型,我们可以选择支付单作为聚合根,针对批次单的操作我们可以通过暴露支付单的行为方法来完成,例如我们可以编辑支付单的批次,使其从一个批次裂化为两个批次,外部对象不会直接持有批次单的引用,所有对批次单的操作都要经过支付单,这样我们就可以在聚合内部维护我们的领域一致性原则,确保领域一致性原则不会被破坏。
image.png

小聚合

前面我们提到,在设计聚合时应该遵守一致性原则,我们可以为需要满足领域不变条件的实体和值对象设计一个边界来满足一致性原则,那是不是说我们可以把限界上下文内的模型都圈到一个大聚合中进行控制呢?这样既简单粗暴又能满足领域不变条件岂不是很完美?答案当然是否定的,这样聚合就失去它存在的意义了,聚合应该设计的尽量小,大聚合存在很多缺陷。

1)大聚合会降低性能:聚合中的每个成员会增加数据的量,当这些数据需要从数据库中进行加载的时候,大聚合会增加额外的查询,导致性能降低。大聚合同样意味着一次性加载到内存的数据量会更多,这个问题即使使用延迟加载也很难解决,如果我们需要执行复杂的查询,或者需要聚合多个数据表的内容返回给调用方,我们可以考虑使用CQRS模式而不是大聚合。

2)大聚合会时常导致事务失败:大聚合可能包含了很多职责,这意味着它要参与多个业务用例。通常我们会使用OCC(乐观并发控制)来进行数据库的并发变更控制,这样当多个用户对单个聚合进行变更时,出现并发冲突的几率会变大,从而导致事务失败的几率变大。

3)大聚合扩展性差:大聚合意味着与更多的模型产生依赖关系,这会导致重构和扩展的难度增加。

支付系统在支付完成后需要将支付完成的状态通知到订单系统,告知这笔订单已经支付完成,这样订单才可以继续向下流转到发货履约流程。那是不是说我们一定要将支付单和订单放在一个聚合中,然后将两者的状态在一个数据库事务中更新到支付成功状态呢?当然不需要,支付单和订单都有各自相对独立的生命周期,虽然订单会依赖支付单状态的推进,但是我们完全没有必要将它们放在同一个聚合中,将支付单和订单放在同一个聚合中会导致聚合的数据量变大从而导致加载聚合的成本变高,这样直接的耦合也会阻碍系统的扩展,我们可以使用最终一致性来保证双方支付状态的一致性,避免大聚合。
image.png

一个事务只修改一个聚合

前面我们提到,大聚合会导致事务失败的几率变大,在一个事务中修改多个聚合会存在同样的问题,我们基于领域不变条件来设计聚合,每次请求应该尽量只在一个聚合实例上执行一个命令方法,如果我们发现需要在一个事务中修改多个聚合,我们可以尝试与领域专家探讨用例寻求新的见解,另外采用最终一致性(可以接受的前提下)是一个很好的解决方案。

这个规则同样适用于上面的例子,消费者支付成功后,对支付单状态的修改在一个事务中,我们采用最终一致性方案后,对订单状态的修改会在另外一个事务中,互不影响。

通过为唯一标识引用

在设计聚合时,我们可能希望使用对象组合,因为这样我们可以对聚合中的对象树进行深度遍历,一个聚合可以引用另一个聚合的根,但是被引用的聚合不应该放在引用聚合的一致性边界之内,因为领域对象之间的关系应该仅为行为需要而存在。不支持行为的关系会增加领域模型的复杂性。所以聚合之间的对象引用是不必要的。

在不持有对象引用的情况下,我们是不能修改其他聚合的,因此我们可以避免在同一个事物中修改多个聚合。但是,在领域模型中我们总需要对象之间的关联关系来完成一些任务,这时我们应该怎么办呢?我们应该优先考虑通过全局唯一标识来引用外部聚合。通过这种方式创建的聚合会变得更小,关联的聚合是不会即时加载的。模型的性能就会随之变好,因为它需要更少的加载时间和更小的内存,更小的使用内存不止在内存分配上有好处,对于垃圾回收也是有好处的。

有些人倾向在聚合中使用资源库(repository)来定位其他聚合。这种技术称为失联领域模型(Disconnected Domain Model),这种方式实际上就是延迟加载的一种形式。延迟加载的问题我们前面也提到过了,比较推荐的做法还是在调用聚合行为方法之前,通过资源库、工厂或域服务等方式来获取所需要的对象,这部分内容可以在应用层做控制,然后分发给聚合进行行为方法的执行。

我们没有把支付单和订单放在一个聚合中,但是在支付单支付成功推进订单状态的这个过程中,支付单需要知道具体要推进哪一笔订单,所以我们可以在支付单中维护订单的唯一标识,以此来建立支付单和订单的关联关系。
image.png

在边界外使用最终一致性

有的时候,我们需要在单次请求中修改多个聚合,我们同时也需要保证模型的一致性,这时我们可以使用最终一致性。这可能会带来一定的延迟,大部分情况下,这种延迟是可以接受的,我们可以和领域专家去讨论,你会发现,只要你的理由是合理的,他们甚至会接受更高的延迟,秒级、分钟级、小时级甚至天级都是可以的。

实现最终一致性的技术手段有很多,一般我们会将领域事件通过消息队列进行发送,在事件消费的流程中处理对其他聚合的变更。这样我既可以满足一个事务只修改一个聚合这一原则,又可以借助消息队列中间件提供的失败重试机制,在事件处理失败时帮助我们进行失败重试,完成最终一致性。

关于一致性我们可能会有一个疑问,到底什么时候该使用事务一致性什么时候该使用最终一致性呢?这里有一个简单且实用的指导原则,对于一个用例,问问是否应该由执行该用例的用户来保证最终一致性。如果是,请使用事务一致性,当然此时依然要遵守其他聚合原则。如果需要其他用户或者系统来保证数据一致性,那么请使用最终一致性。以上原则不仅有助于我们做出决定,还能帮助我们更深入地了解自己的领域。它向我们展示了真正的系统不变条件:那些必须使用事务一致性的不变条件。通过领域来理解问题比纯粹的技术学习更有价值。

前面我们多次提到通用最终一致性来解决支付单支付成功后推进订单状态的问题,这里我们可以将“支付完成”定义为一个领域事件,在支付单支付完成后,发送“支付完成”领域事件,事件会携带订单号,订单接受到这个领域事件后,将自己的状态推进到支付完成。借助消息中间件,我们可以将事件处理的流程异步化,甚至完全在不同的系统中,最终达成一致性。
image.png

总结

如果我们遵循聚合的设计原则,那么我们便可以获得很好的一致性,并且创建出高性能高伸缩性的系统,同时还可以捕获到业务领域中的通用语言,更好的理解业务。领域的建设任重而道远,我辈仍需努力呀~

本文地址:https://blog.csdn.net/heroqiang/article/details/108863252

如您对本文有疑问或者有任何想说的,请点击进行留言回复,万千网友为您解惑!

相关文章:

验证码:
移动技术网