CQRS事件采购:验证UserName唯一性

时间:2012-02-29 08:47:36

标签: domain-driven-design cqrs event-sourcing unique

我们来看一个简单的“帐户注册”示例,这是流程:

  • 用户访问网站
  • 点击“注册”按钮并填写表格,点击“保存”按钮
  • MVC Controller:通过读取ReadModel
  • 验证UserName的唯一性
  • RegisterCommand:再次验证UserName唯一性(这是问题)

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

如果我们不使用Event Sourcing,我们可以查询域模型,这样就没问题了。但是如果我们使用Event Sourcing,我们无法查询域模型,那么我们如何验证RegisterCommand中的UserName唯一性?

注意:用户类具有Id属性,UserName不是User类的关键属性。我们只能在使用事件源时通过Id获取域对象。

BTW:在要求中,如果已经输入了UserName,则网站应向访问者显示错误消息“抱歉,用户名XXX不可用”。我们不能接受这样的信息,例如“我们正在创建您的帐户,请稍后,我们会通过电子邮件将注册结果发送给您”给访问者。

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

[UPDATE]

一个更复杂的例子:

要求:

下订单时,系统应该检查客户的订购历史,如果他是一个有价值的客户(如果客户在去年每月至少订购10个订单,他是有价值的),我们可以10%折扣到订单。

实施

我们创建PlaceOrderCommand,在命令中,我们需要查询订购历史记录以查看客户端是否有价值。但是我们怎么能这样做呢?我们不应该在命令中访问ReadModel!作为Mikael said,我们可以在帐户注册示例中使用补偿命令,但如果我们在此订购示例中也使用补偿命令,那么它将太复杂,并且代码可能难以维护。

8 个答案:

答案 0 :(得分:36)

如果在发送命令之前使用读取模型验证用户名,我们正在谈论一个几百毫秒的竞争条件窗口,其中可能发生真正的竞争条件,这在我的系统中未被处理。与处理它的成本相比,它不太可能发生。

但是,如果你觉得你必须出于某种原因处理它,或者如果你只是想知道如何掌握这种情况,那么这是一种方法:

使用事件源时,不应从命令处理程序或域访问读取模型。但是,您可以使用的域服务将侦听再次访问读取模型的UserRegistered事件,并检查用户名是否仍然不重复。当然,您需要在此处使用UserGuid,并且您的读取模型可能已经与您刚刚创建的用户进行了更新。如果找到重复,您有机会发送补偿命令,例如更改用户名并通知用户用户名已被删除。

这是解决问题的一种方法。

正如您可能看到的那样,无法以同步请求 - 响应方式执行此操作。为了解决这个问题,我们正在使用SignalR在我们想要推送到客户端时更新UI(如果它们仍然连接,那就是)。我们所做的是让Web客户端订阅包含对客户端立即查看有用的信息的事件。

更新

对于更复杂的案例:

我会说订单放置不太复杂,因为在发送命令之前,您可以使用读取模型来确定客户端是否有价值。实际上,您可以在加载订单时查询,因为您可能希望向客户显示在下订单之前他们将获得10%的折扣。只需为PlaceOrderCommand添加折扣,也许还有折扣的原因,这样您就可以跟踪削减利润的原因。

但话又说回来,如果你因为某种原因确实需要在订单出现之后计算折扣,那么再次使用一个可以监听OrderPlacedEvent的域服务,在这种情况下“补偿”命令可能是一个DiscountOrderCommand或者什么。该命令会影响Order Aggregate root,并且信息可以传播到您的读取模型。

对于重复的用户名案例:

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

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

说到SignalR,我使用了一个SignalR Hub,用户在加载某个表单时会连接到它。我使用SignalR Group功能,它允许我创建一个组,我命名我在命令中发送的Guid的值。在您的情况下,这可能是userGuid。然后我有Eventhandler订阅可能对客户端有用的事件,当事件到达时我可以在SignalR组中的所有客户端上调用javascript函数(在这种情况下,只有一个客户端在您的客户端创建重复的用户名)案件)。我知道这听起来很复杂,但事实并非如此。我把这一切都安排在一个下午。 SignalR Github页面上有很棒的文档和示例。

答案 1 :(得分:22)

我认为你尚未将心态转向eventual consistency以及事件采购的本质。我有同样的问题。具体而言,我拒绝接受您应该信任来自客户的命令,使用您的示例,在没有域确认折扣应该继续的情况下说“以10%折扣放置此订单”。真正让我回家的一件事是something that Udi himself said to me(查看已接受答案的评论)。

基本上我意识到没有理由不相信客户;读取端的所有内容都是从域模型生成的,因此没有理由不接受命令。无论阅读方面是什么,说客户都有资格享受折扣,这一点都归域所有。

  

BTW:在要求中,如果已经输入了UserName,则网站应向访问者显示错误消息“抱歉,用户名XXX不可用”。我们不能接受这样的信息,例如“我们正在创建您的帐户,请稍后,我们会通过电子邮件将注册结果发送给您”给访问者。

如果您打算采用事件采购和最终的一致性,您需要接受有时在提交命令后无法立即显示错误消息。使用唯一的用户名示例,这种情况发生的可能性非常小(假设您在发送命令之前检查读取方面),不值得担心太多,但是需要为此方案发送后续通知,或者可能要求他们下次登录时会使用不同的用户名。这些场景的好处在于它可以让您思考商业价值和业务价值。什么是非常重要的。

更新日期:2015年10月

只是想补充一点,实际上,面向公众的网站 - 表明已经采取的电子邮件实际上违反了安全最佳做法。相反,注册似乎已成功通知用户已发送验证电子邮件,但在用户名存在的情况下,电子邮件应通知他们并提示他们登录或重置其密码。虽然这只适用于使用电子邮件地址作为用户名,我认为这是可行的。

答案 2 :(得分:11)

创建一些在与命令相同的事务中更新的立即一致的读模型(例如,不通过分布式网络)没有任何问题。

读取模型最终在分布式网络上保持一致有助于支持重读取系统的读取模型的缩放。但没有什么可以说你不能拥有一个立即一致的特定领域的阅读模型。

立即一致的读取模型仅用于在发出命令之前检查和接收数据(实际上它是对命令的服务),您不应该使用它直接向用户显示读取数据(即从GET Web请求或类似)。最终使用合理的,可扩展的读取模型。

答案 3 :(得分:5)

我认为对于这种情况,我们可以使用类似"咨询锁定和过期的机制"。

示例执行:

  • 检查用户名是否存在于最终一致的阅读模型
  • 如果不存在;通过使用像keyvalue存储或缓存的redis-couchbase;尝试将用户名作为关键字段推送一段时间。
  • 如果成功;然后引发userRegisteredEvent。
  • 如果读取模型或缓存存储中存在任一用户名,请通知访问者该用户名已经使用。

即使你可以使用sql数据库;插入用户名作为某些锁定表的主键;然后预定的工作可以处理到期。

答案 4 :(得分:5)

与实施基于事件源的系统的许多其他人一样,我们遇到了唯一性问题。

起初,我是一名支持者,让客户端在发送命令之前访问查询端,以查明用户名是否唯一。但后来我发现有一个对唯一性没有验证的后端是一个坏主意。当可以发布破坏系统的命令时,为什么要强制执行任何操作?后端应验证其所有输入,否则您将对不一致的数据进行打开。

我们所做的是在命令端创建index。例如,在需要唯一的用户名的简单情况下,只需创建一个带有用户名字段的UserIndex。现在,命令端可以检查用户名是否已经在系统中。执行该命令后,可以安全地将新用户名存储在索引中。

这样的事情也适用于订单折扣问题。

好处是您的命令后端可以正确验证所有输入,因此不会存储任何不一致的数据。

缺点可能是您需要对每个唯一性约束进行额外查询,并且您要强制执行额外的复杂性。

答案 5 :(得分:2)

关于唯一性,我实现了以下内容:

  • 第一个命令,例如“ StartUserRegistration”。无论用户是否唯一,都将创建UserAggregate,并且状态为RegistrationRequested。

  • 在“ UserRegistrationStarted”上,异步消息将发送到无状态服务“ UsernamesRegistry”。类似于“ RegisterName”。

  • 服务将尝试更新(不包含查询,“不要问”)表,该表将包含唯一约束。

  • 如果成功,服务将通过另一条消息(异步)答复,并带有某种授权“ UsernameRegistration”,表明该用户名已成功注册。您可以包含一些requestId来跟踪并发能力(不太可能)。

  • 以上消息的发布者现在已授权该名称是自己注册的,因此现在可以安全地将UserRegistration聚合标记为成功。否则,标记为已丢弃。

总结:

  • 此方法不涉及任何查询。

  • 总是会在没有验证的情况下创建用户注册。

  • 用于确认的过程将涉及两个异步消息和一个数据库插入。该表不是读取模型的一部分,而是服务的一部分。

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

  • 这时,反规范化器可以对UserRegistrationConfirmed事件做出反应并为用户创建读取模型。

答案 6 :(得分:1)

您是否考虑过使用“工作”缓存作为RSVP?这很难解释,因为它在一个循环中工作,但基本上,当一个新的用户名被“声明”(即发出命令以创建它)时,你将用户名放在缓存中并且有一个短期到期(足够长的时间来解释通过队列的另一个请求并将其非规范化为读取模型)。如果它是一个服务实例,那么在内存中可能会起作用,否则将其集中在Redis或其他东西上。

然后当下一个用户填写表单时(假设有一个前端),您可以异步检查读取模型的用户名是否可用,并提醒用户是否已经使用。提交命令时,检查缓存(而不是读取模型)以便在接受命令之前验证请求(在返回202之前);如果名称在缓存中,则不接受该命令,如果不是,则将其添加到缓存中;如果添加它失败(重复键,因为其他一些进程击败你),然后假设名称被采取 - 然后适当地响应客户端。在这两件事之间,我认为碰撞的机会并不多。

如果没有前端,那么您可以跳过异步查找或者至少让您的API提供端点来查找它。您实际上不应该允许客户端直接与命令模型对话,并且在其前面放置API将允许您将API用作命令和读取主机之间的中介。

答案 7 :(得分:0)

在我看来,这里的汇总也许是错误的。

一般来说,如果需要确保属于Y的值Z在集合X中是唯一的,则可以使用X作为集合。毕竟,X是不变量真正存在的位置(X中只能有一个Z)。

换句话说,不变的是,用户名只能在您所有应用程序用户的范围内出现一次(或者可以是不同的范围,例如在组织内等)。如果您汇总了“ ApplicationUsers ”并向其发送“ RegisterUser”命令,那么在存储“ UserRegistered”事件之前,您应该能够拥有所需的命令,以确保该命令有效。 (当然,然后,您可以使用该事件创建所需的投影,以执行诸如验证用户身份之类的事情,而不必加载整个“ ApplicationUsers”集合。