【问题标题】:Enforce invariants spanning multiple aggregates (set validation) in Domain-driven Design在领域驱动设计中强制执行跨越多个聚合(集合验证)的不变量
【发布时间】:2017-11-01 04:45:33
【问题描述】:

为了说明问题,我们使用一个简单的案例:有两个聚合 - LampSocket。必须始终执行以下业务规则:LampSocket 都不能同时连接多次。为了提供适当的命令,我们设想了一个 Connector-service 和 Connect(Lamp, Socket)- 方法来插入它们。

因为我们要遵守一个事务应该只涉及一个聚合的规则,所以不建议在Connect-transaction 中为两个聚合设置关联。所以我们需要一个代表Connection 本身的中间聚合。所以Connect-transaction 只会用给定的组件创建一个新的Connection。不幸的是,此时麻烦开始了。我们如何保证连接状态的一致性?可能会发生许多同时用户想要在完全相同的时间插入相同的组件,因此我们的“一致性检查”不会拒绝该请求。新的Connection-aggregates 将被存储,因为我们只锁定在聚合级别。系统会在不知情的情况下不一致。

但是我们应该如何设置聚合的边界以确保我们的业务规则?我们可以设想一个Connections-aggregate,它收集所有活动连接(如Connection-entity),从而启用我们的锁定算法,该算法将正确拒绝重复的Connect-requests。另一方面,这种方法效率低下且无法扩展,而且在领域语言方面也违反直觉。

你知道我错过了什么吗?

编辑:总结问题,想象一个聚合User。由于聚合的定义是基于事务的单元,我们可以通过锁定每个事务的单元来强制执行不变量。一切都很好。但是现在出现了一个业务规则:用户名必须是唯一的。因此,我们必须以某种方式使我们的总边界与这一新要求相协调。假设有数百万用户同时注册,这将成为一个问题。我们试图在非锁定状态下确保此不变量,因为多个用户意味着多个聚合。

根据 Eric Evans 的“领域驱动设计”一书,只要单个事务中涉及多个聚合,就应该应用最终一致性。但这里真的是这样吗?这样做有意义吗?

在这里应用最终一致性需要注册User,然后使用用户名检查不变量。如果两个Users 实际设置了相同的用户名,系统将撤消第二次注册并通知User。想到这种情况让我感到不安,因为它扰乱了整个注册过程。例如,发送确认电子邮件必须延迟等等。

我想我只是忘记了一般情况,但我不知道是什么。在我看来,我需要Repository-level 上的不变量之类的东西。

【问题讨论】:

  • 你有没有试过看看现实生活中的事情是如何发生的? DDD 是关于域的,在这个问题中,域是我们生活的现实。
  • 我认为你应该重命名这个问题,因为“双向关联”在 DDD 中指的是不同的东西——即相互引用的实体。在这里,我们讨论的是跨越多个聚合(可能不仅仅是两个)的事务和不变量,无论对象是否具有双向引用。

标签: domain-driven-design aggregate bidirectional boundary


【解决方案1】:

您描述的问题称为set validation。 Greg Young 提出了一个很好的观点,关键问题是成本/收益分析是否证明在代码中强制执行此约束是合理的。

但是让我们假设它确实......

我发现从 RDBMS 的角度考虑集合验证是最有用的。如果我们用表格做事,我们将如何处理这个问题?一个可能的候选者是我们将拥有某种连接表,其中包含 Lamp 和 Socket 的外键。然后我们将定义约束条件,即这些外键中的每一个在表中都必须是唯一的。

那些外键约束跨越整个表;这是数据库告诉我们整个表代表一个单个聚合的方式。

因此,如果您要将这些约束提升到您的域模型中,您可以通过聚合所有连接来实现,以便域模型可以立即决定是否应允许给定 Lamp-Socket 连接.

现在,这里有一个重要的警告——我们假设域模型是灯和插座之间连接的权威。如果我们在现实世界中对连接到现实世界中的插座的灯进行建模,那么重要的是要认识到现实世界是权威,而不是模型。

换句话说,如果域模型得到关于现实世界的冲突信息(据报道,两个灯连接到同一个插座),模型只知道它关于世界的信息是不正确的——也许第一盏灯被插上在,也许是第二个,也许缺少关于灯被拔掉的消息。因此,在这种情况下,您通常希望允许冲突,并升级为人类解决。

用户名必须是唯一的

这是集合验证问题的最常见变体。

基本的补救措施是相同的:您现在拥有一个带有标识符的用户配置文件聚合和一个单独的用户名目录聚合,它确保每个名称都与配置文件唯一关联。

如果您不担心个人资料最多链接一个用户名,那么您可以采取另一种方法,即为每个用户名引入一个聚合,其中包含 profileId 作为成员。因此,每个聚合都可以强制执行只有在先前的分配终止时才能分配名称的约束。

我想我只是忘记了一些一般的东西,但我不知道是什么。

只有约束不是凭空而来的——它们应该有商业动机;并且某人(领域专家)应该能够记录未能维护提议的集合约束的业务成本。

例如,如果您已经在收集电子邮件地址,您真的需要一个唯一的用户名吗?通过在模型中包含用户名,您创造了多少附加价值?让它独一无二……还有多少?

如果我们计划一个在线游戏,例如,有数百万用户不断请求游戏,这是一个真正的问题。

是的,是的;但这可能表明游戏设计是错误的。回顾 Udi Dahan 对high contention domains 的讨论,以及他的文章Race Conditions Don't Exist

但是,需要注意的是,如果您确实有一个聚合,您可以独立于系统的其余部分对其进行扩展。一个怪物盒专门用于管理集合聚合而没有别的(类比:专用于管理单个表的 RDBMS)。

更有可能的选择是按领域/实例/whatzit 进行分片;在这种情况下,您将为每个领域实例设置一个较小的集合。

【讨论】:

  • 非常感谢您的回答。你提到要比较它在现实世界中的情况。特别是关于 Lamp-Socket 的例子,但让我们忘记这一点,因为我将构建一个不反映真实单词组合的系统。如果我们计划一个在线游戏,例如,有数百万用户不断请求游戏,这是一个真正的问题。对于此类问题,您会建议允许不一致并随后解决与业务专家讨论的问题,还是创建集合聚合?
【解决方案2】:

我们可以设想一个连接聚合,它收集所有活跃的 连接(作为连接实体),从而使我们的 可以正确拒绝重复的锁定算法 连接请求。另一方面,这种方法效率低下, 不能扩展,而且在领域方面是违反直觉的 语言

相反,我认为您采用这种方法是正确的。这似乎令人费解,因为您使用的示例没有任何意义 - 没有现实生活中的系统可以检查灯是否连接到多个插座或插座是否连接到多个灯。

但是将这种方法应用于第二个示例会导致您问自己在这种情况下“连接”聚合是什么,即在哪个范围内用户名是唯一的。在Company?对于给定的TenantCustomer?对于整个<whatever-subdomain-youre-in>System?找到作用域的名称就可以了——Aggregate to enforce the unique name invariant。仔细选择名称,如果它在通用语言中尚不存在,请在领域专家的帮助下发明一个新概念。 DDD 不仅尊重现有的领域术语,还允许您在实现突破时引入新的术语。

但有时,您会发现对该聚合的并发访问过于密集,并且会产生有问题的争用。借助领域专家的同意,您可以在发生冲突时通过补偿操作引入最终一致性 - 例如,将后缀附加到昵称并通知用户。或者你可以split the "hot" aggregate 变成更小、更智能、更高效的。

【讨论】:

  • 好的,这很好。 Lamps and Sockets 的例子当然不是很有用。但是您会建议定义它们所在的范围并从中派生一个新的聚合,例如托管连接的 LightSystem(可能作为另一个聚合)?
  • 当然。就像您在 Q 中所说的那样 - 我们可以设想一个 Connections-aggregate [...] 它必须是一个新的 Aggregate,因为它将强制执行在以前没有被其他人考虑的范围内的不变量聚合体。这是一个新的交易空间。
  • 我们必须评估交易的频率和聚合的数量。如果 Connect 和 Disconnect 命令会被非常频繁地调用和/或组件的数量会超过某个限制,我们应该重新考虑这种方法,并可能寻求最终一致性的解决方案。但是,如果两者都不涉及,则这种方法最合适。如果我们想要最终一致性,会发生什么变化?我们是否会消除 Connections-aggregate 并定义一个 Connection-aggregate(具有多个实例)来处理之后的争用(例如基于事件)?
  • 是的,最终一致性通常采用带外过程的形式,这意味着您可能可以摆脱以前的聚合形式。命名了不变量(或我们最终补偿的规则)成立的边界仍然具有价值。如果您找到了一个好的名称,您可能希望将其作为带外进程名称的一部分重用。
  • 但请稍等。使用 UserDirectory-aggregate 的事件我们违反了一项仅涉及聚合的事务的规则,或者不是吗?
【解决方案3】:

除了已经提出的建议之外,请考虑其中一些问题与数据库并发问题非常相似。假设您有一个联系人,一个用户更改了姓名,而另一个用户更改了此联系人的电话号码。如果您编写的命令将整个联系人更新为修改后的状态,那么除非采取措施,否则两者中的一个将用旧值覆盖另一个的更改。

但是,如果您编写了“ChangeEmailForContact”命令,那么您将只更改该字段并且不会与名称更改发生冲突,这与“Name”或“RenameContact”命令类似。

现在,如果两个人在另一个人之后不久更改了电子邮件地址怎么办?一种真正有效的方法是将原始值(原始电子邮件地址)与命令中的新值一起传递。现在您可以在更新电子邮件地址时检查原始电子邮件地址是否与当前电子邮件地址相同(因此这是一个有效的起点),或者新电子邮件地址是否与当前电子邮件地址相同(无需做任何事情)。如果不是,那么,只有这样,你才处于冲突的境地。

现在,将其应用于您的“设置操作”。第一次将灯泡移动到“连接”(也许我会称之为灯具)时,它会从未分配移动到连接1。然后,当灯泡移动时,它必须从连接 1 移动到连接 2,比如说。现在,您可以验证该灯泡是否已分配,是否已分配给 connection1,或者在此期间是否发生了某些变化。

它当然不能解决所有问题,但是对于剩下的小案例,即两个初始分配足够接近地发生的那个小时刻,你要么必须去说一个分配的用户名的 redis 缓存来验证,要么给管理员是解决这个非常罕见的实例的简单工具。例如,您可以制作一个偶尔报告此类情况的预测,并确保重命名不会太痛苦。

【讨论】:

    猜你喜欢
    • 2014-12-31
    • 1970-01-01
    • 2010-11-01
    • 2013-09-16
    • 1970-01-01
    • 1970-01-01
    • 2020-06-08
    相关资源
    最近更新 更多