CQRS事件溯源:验证用户名唯一性

83

让我们以一个简单的“帐户注册”示例为例,以下是流程:

  • 用户访问网站
  • 点击“注册”按钮并填写表单,然后点击“保存”按钮
  • MVC控制器:通过从ReadModel读取内容验证用户名唯一性
  • RegisterCommand:再次验证用户名唯一性(这里是问题)

当然,我们可以通过在MVC控制器中从ReadModel中读取来验证用户名的唯一性,以提高性能和用户体验。但是,我们仍然需要在RegisterCommand中再次验证唯一性,并且显然不应该在Commands中访问ReadModel。

如果我们不使用事件溯源,我们可以查询领域模型,所以这不是一个问题。但是如果我们正在使用事件溯源,则无法查询领域模型,因此如何在RegisterCommand中验证用户名的唯一性?

注意: User类有一个Id属性,而UserName不是User类的关键属性。当使用事件溯源时,我们只能通过Id获取领域对象。

顺便说一句: 在要求中,如果输入的用户名已经被占用,网站应向访问者显示错误消息“抱歉,用户名XXX不可用”。向访问者显示“我们正在创建您的帐户,请等待,我们将通过电子邮件向您发送注册结果”的消息是不可接受的。

有什么想法吗?非常感谢!

[更新]

一个更复杂的例子:

要求:

下订单时,系统应检查客户的订购历史记录。如果客户是有价值的客户(如果客户在过去一年中每月至少下了10个订单,则他是有价值的),我们会给予订单10%的折扣。

实现:

我们创建了PlaceOrderCommand,在该命令中,我们需要查询订购历史记录以查看客户是否有价值。但是我们该怎么做呢?我们不应该在命令中访问ReadModel!正如Mikael所说的,我们可以在账户注册示例中使用补偿命令,但如果我们在此订购示例中也使用它,那将会过于复杂,并且代码可能会难以维护。
8个回答

39
如果在发送命令前使用读取模型验证用户名,则存在几百毫秒的竞争条件窗口,其中真正的竞争条件可能发生,在我的系统中未予处理。与应对它的成本相比,这种情况发生的可能性太小了。
然而,如果你认为有某些原因必须处理它,或者只是想知道如何掌握这种情况,下面是一种方法:
在使用事件溯源时,不应从命令处理程序或域访问读取模型。但是,您可以使用一个域服务来监听UserRegistered事件,在该事件中再次访问读取模型并检查用户名是否仍未重复。当然,你需要使用UserGuid,因为你刚刚创建的用户可能已更新你的读取模型。如果找到重复项,则可以发送补偿命令,例如更改用户名并通知用户用户名已被占用。
那是解决问题的一种方法。
正如你可能已经看到的,这不能以同步请求-响应方式完成。为了解决这个问题,我们使用SignalR在想要推送给客户端的内容(如果他们仍然连接)时更新UI。我们让Web客户端订阅包含对客户端立即显示有用信息的事件。
对于更复杂的情况:
我会说下订单的下单较不复杂,因为您可以使用读取模型找出客户端是否有价值,然后再发送命令。实际上,您可以在加载订单表单时查询它,因为您可能想在客户下订单之前向他们展示10%的折扣。只需将折扣添加到PlaceOrderCommand中,可能还需要添加一个折扣原因,以便您可以跟踪为什么要削减利润。

但如果你真的需要在订单下单后计算折扣,可以使用一个领域服务来监听OrderPlacedEvent,在这种情况下,“补偿”命令可能会是DiscountOrderCommand或其他类似的命令。该命令将影响订单聚合根,并且信息可以传播到读取模型。

对于重复用户名的情况:

您可以从领域服务发送ChangeUsernameCommand作为补偿命令。甚至可以使用更具体的内容描述用户名更改的原因,这也可能导致创建一个事件,Web客户端可以订阅该事件,以便您可以让用户看到用户名是重复的。

在领域服务上下文中,我认为您还可以使用其他方法来通知用户,例如发送电子邮件,这可能非常有用,因为您无法知道用户是否仍然连接。也许可以通过与Web客户端订阅的同一事件启动通知功能。

关于SignalR,我使用一个SignalR Hub,用户在加载特定表单时连接到该Hub。我使用SignalR Group功能,允许我创建一个组,将其命名为我在命令中发送的Guid值。在您的情况下,可以是userGuid。然后我有事件处理程序,订阅对客户端有用的事件,当事件到达时,我可以在SignalR Group中的所有客户端上调用Javascript函数(在这种情况下,只有创建重复用户名的那个客户端)。我知道听起来很复杂,但实际上并不是。我一个下午就把它全部设置好了。 SignalR Github页面上有很好的文档和示例。


1
我认为在DDD中我们称之为应用服务,但我可能错了。此外,在DDD/CQRS社区中,“领域服务”是一个有争议的术语。然而,你需要的是类似于他们所谓的Saga的东西,只是你可能不需要状态或状态机。你只需要一些可以对事件做出反应并进行数据查找和分派命令的东西。我称它们为领域服务。简而言之,你订阅事件并发送命令。这在Aggregate Roots之间通信时非常有用。 - Mikael Östberg
1
我还应该提到,我的域服务完全独立于读模型等其他服务进程。这使得处理消息相关的事情更加简单,例如订阅等。 - Mikael Östberg
1
这是一个很好的回答。然而,我经常看到这样的评论:“使用事件溯源时,您不应从命令处理程序或域中访问读取模型”。有人能解释一下为什么从命令/域方面使用读取模型是如此糟糕的主意吗?这是命令/查询分离的重点吗? - Scott Coates
1
域状态和命令的组合必须足以做出决策。如果在处理命令时需要读取数据,请将该数据随命令一起带来或存储在域状态中。为什么呢?
  • 读取存储是最终一致的,可能不是真实的。域状态才是真相,而命令则完成了它。
  • 如果您正在使用ES,则可以将命令与事件一起存储。这样,您就可以准确地看到您所操作的信息。
  • 如果您事先进行读取,可以执行验证并增加命令成功的概率。
- Mikael Östberg
与处理它的成本相比,这种情况发生的可能性太小了。如果可能的话,它就会发生(例如,由于网络问题或一次注册过多,事件总线存在巨大延迟,因此用户再次尝试)。想象一下,在您的系统中有一个用户注册了两次,一天他/她使用帐户1生成内容,另一天使用帐户2生成内容。过一段时间后,他们会要求您合并两个帐户及其内容。祝你好运修复事件存储,并尝试向客户收费 :) - huysentruitw
显示剩余3条评论

26
我认为你还没有意识到最终一致性和事件溯源的本质。我曾经也有同样的问题。具体来说,我拒绝接受客户端命令的信任,就像你的例子中所说的“使用10%折扣下单”,而领域未验证该折扣是否适用。对我真正有启示的一件事是Udi亲自对我说的话(请查看已接受答案的评论)。
基本上,我意识到没有理由不信任客户端;读取侧的所有内容都是从领域模型产生的,因此没有理由不接受命令。任何在读取侧指出客户有资格获得折扣的内容都是领域放置的。
如果输入的用户名已被使用,网站应向访问者显示错误信息“抱歉,用户名XXX不可用”,而不是显示一条消息,例如“我们正在创建您的帐户,请等待,稍后我们将通过电子邮件向您发送注册结果”。如果您要采用事件溯源和最终一致性,您需要接受有时在提交命令后无法立即显示错误消息的事实。对于唯一用户名示例,发生这种情况的机会非常小(假设您在发送命令之前检查了读取端),因此不值得过多担心,但是此场景需要发送后续通知,或者要求他们在下次登录时使用其他用户名。这些场景的好处在于它让您思考业务价值和真正重要的事情。

更新:2015年10月

我想补充一下,在公开的网站中,提示电子邮件已被使用实际上是违反安全最佳实践的。相反,注册应该显示已成功完成,并告知用户已发送验证电子邮件,但如果用户名已存在,则应通知他们并提示登录或重置密码。尽管这仅适用于将电子邮件地址用作用户名的情况,但基于此原因,我认为这是可取的。


3
优秀的输入。在系统发生改变之前,必须先改变心态(我没有打算模仿Yoda的语气)。 - Mikael Östberg
6
我会尽力做到最好的翻译:+1 只是在这里非常严谨...ES和EC是两个完全不同的东西,使用一个不应意味着使用另一个(虽然在大多数情况下这是有道理的)。使用ES而没有最终一致性模型是完全有效的,反之亦然。 - James
基本上我意识到没有理由不信任客户端 - 是的,我认为这是一个公正的评论。但是如何处理可能产生命令的外部访问呢?显然,我们不希望允许自动应用折扣的PlaceOrderCommand; 折扣的应用是领域逻辑,不是我们可以“信任”别人告诉我们要应用的东西。 - Stephen Drew
3
在这个上下文中,“Client”指的是生成命令的任何代码单元。你可能(并且可能应该)在命令总线之前添加一层。如果你正在创建一个外部网络服务,那么MVC控制器首先会执行查询,然后提交命令。这里的客户端就是你的控制器。 - ryeguy
5
如果您把这个回复当作重要的话,那么意味着所有关于“不变性”、“业务规则”、“高度封装”的理论都是绝对的无意义。有太多原因不信任用户界面(UI)。而且毕竟UI并不是必需的组成部分......如果没有UI会怎样呢? - Cristian E.
@David Masters,如果我需要检查产品编号是否存在,该怎么办? - minhhungit

23

创建一些立即一致的读模型(例如不使用分布式网络),并在与命令相同的事务中进行更新是没有问题的。

使读模型在分布式网络上最终是一致的,有助于支持大量读取系统的读模型扩展。但这并不意味着您不能拥有特定于域的立即一致的读模型。

立即一致的读模型仅用于在发出命令之前检查数据,您永远不应该将其用于直接向用户显示读取数据(例如来自GET Web请求或类似请求)。为此,请使用最终一致、可扩展的读模型。


1
好主意 :) 谢谢 - Sergii Shevchyk
1
在阅读了所有关于唯一性问题的帖子后,这是我喜欢的唯一解决方案。拥有内联投影确实是一个很好的想法,可以在领域处理程序中查询以验证命令。 - AlmostDev

12
关于唯一性,我实现了以下内容:
  • 首先执行"StartUserRegistration"命令。无论用户是否唯一,都将创建UserAggregate,但状态为RegistrationRequested。

  • 在"UserRegistrationStarted"时,会向一个无状态服务"UsernamesRegistry"发送异步消息,类似于"RegisterName"。

  • 服务将尝试更新表(不查询,"tell don't ask"),其中包括唯一约束。

  • 如果成功,服务将回复另一条消息(异步),带有某种授权"UsernameRegistration",表示用户名已成功注册。您可以包含一些requestId以在并发竞争的情况下进行跟踪(不太可能)。

  • 上述消息的发出者现在拥有一个授权,即该名称是由其自己注册的,因此现在可以安全地将UserRegistration聚合标记为成功。否则,将其标记为丢弃。

总结:

  • 这种方法不涉及任何查询。

  • 用户注册将始终创建,没有验证。

  • 确认流程需要两个异步消息和一个数据库插入。该表不是读模型的一部分,而是一个服务的一部分。

  • 最后,一个异步命令来确认用户是否有效。

  • 此时,一个去规范化程序可以对UserRegistrationConfirmed事件做出反应,并为用户创建读模型。


2
我做了类似的事情。在我的事件溯源系统中,我有一个UserName聚合。它的AggregateID是我想要注册的UserName。我发出一个命令来注册它。如果它已经被注册了,我们会得到一个事件。如果它可用,那么它会立即注册并且我们会得到一个事件。我尽量避免使用“服务”,因为它们有时会感觉领域建模存在缺陷。通过将UserName作为一流聚合,我们在领域中对约束进行建模。 - CPerson

8
当实现基于事件的系统时,我们和许多人一样遇到了独特性问题。
起初,我支持在发送命令之前让客户端访问查询侧以查找用户名是否唯一。但后来我发现,在后端没有验证唯一性的情况下,这是一个坏主意。如果可以发布会破坏系统的命令,那么为什么要强制执行任何事情呢?后端应该验证所有输入,否则你将面临不一致的数据。
我们所做的是在命令侧创建一个索引表。例如,在需要唯一性的简单情况下,只需创建一个包含需要唯一的字段的 user_name_index 表。现在,命令侧能够查询用户名的唯一性。在命令被执行后,将新用户名存储在索引中是安全的。
对于订单折扣问题,类似的方法也可能适用。
好处是你的命令后端能够正确地验证所有输入,因此无法存储任何不一致的数据。
缺点可能是每个唯一性约束都需要额外的查询,从而增加了额外的复杂性。

7
我认为在这种情况下,我们可以使用“带有过期时间的咨询锁”机制。
执行示例:
- 在最终一致性读模型中检查用户名是否存在。 - 如果不存在,则使用类似于Redis-Couchbase的键值存储或缓存尝试将用户名作为键字段推送,并设置一些过期时间。 - 如果成功,则触发userRegisteredEvent事件。 - 如果读模型或缓存存储中存在用户名,则通知访问者该用户名已被占用。
甚至您可以使用SQL数据库,将用户名作为某个锁表的主键插入,然后一个定期任务可以处理过期时间。

2
您考虑过使用“工作”缓存作为RSVP吗?这有点难以解释,因为它在一个循环中工作,但基本上,在新的用户名被“认领”(也就是,发出了创建该用户名的命令)时,您将该用户名放入带有短期限的缓存中(足够长,以便另一个请求通过队列并去规范化到读模型)。如果它是一个服务实例,则在内存中可能工作,否则请使用Redis或类似的方式将其集中起来。
然后,在下一个用户填写表单时(假设有前端),您异步地检查读模型以获取用户名的可用性,并在其已被占用时提醒用户。提交命令时,您检查缓存(而不是读模型)以验证请求是否有效,然后接受命令(在返回202之前);如果名称在缓存中,则不接受命令,如果名称不在缓存中,则将其添加到缓存中;如果添加失败(由于一些其他进程抢先使用导致的重复键),则假定该名称已被使用——然后适当地向客户端响应。这两件事之间,我认为不会有太多机会发生冲突。
如果没有前端,那么您可以跳过异步查找,或者至少让您的API提供查找它的端点。无论如何,您真的不应该让客户端直接与命令模型交互,将API放在其前面将允许您使API充当命令和读取主机之间的中介人。

2

我认为这里可能存在一些错误。

通常来说,如果你需要确保属于集合X的元素Y中的Z值是唯一的,那么就将X作为聚合根。毕竟,只有一个Z可以在X中存在,因此X才是不变量所在的地方。

换句话说,你的不变量是用户名只能在应用程序用户的范围内出现一次(或者可能是不同的范围,例如在组织内)。如果你有一个"ApplicationUsers"的聚合根,并将"RegisterUser"命令发送到该聚合根,则应该能够满足在存储"UserRegistered"事件之前验证命令是否有效所需的条件。(当然,你可以使用该事件来创建投影以执行其他操作,如对用户进行身份验证而无需加载整个"ApplicationUsers"聚合根。)

最初的回答


1
这正是你必须思考聚合的方式。聚合的目的是为了防止并发/不一致性(你必须通过某种机制来保证它成为一个聚合)。当你这样考虑时,你也会意识到保护不变量的代价。在高度争议的系统中,最坏的情况下,所有发送给聚合的消息都必须被序列化并由单个进程处理。这是否与你正在运作的规模相冲突?如果是,你应该重新考虑不变量的价值。 - Andrew Larsson
4
针对用户名的特定情境,您可以在水平扩展的同时实现唯一性。您可以沿着用户名的前N个字符对其进行分片。例如,如果您需要处理成千上万的并发注册,那么请沿着用户名的前3个字母进行分片。因此,要注册用户名“johnwilger123”,您将向ID为“joh”的聚合实例发送消息,并且它可以检查其所有“joh”用户名集合以确保唯一性。 - Andrew Larsson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接