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

Posted

技术标签:

【中文标题】CQRS 事件溯源:验证用户名的唯一性【英文标题】:CQRS Event Sourcing: Validate UserName uniqueness 【发布时间】:2012-03-18 18:05:31 【问题描述】:

我们以一个简单的“账号注册”为例,流程如下:

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

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

如果我们不使用事件溯源,我们可以查询领域模型,所以没问题。但是如果我们使用事件溯源,我们无法查询域模型,那么我们如何在 RegisterCommand 中验证 UserName 的唯一性?

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

顺便说一句:在需求中,如果输入的用户名已经被占用,网站应该向访问者显示错误消息“对不起,用户名XXX不可用”。不能向访问者显示“我们正在创建您的帐户,请稍候,我们稍后会通过电子邮件将注册结果发送给您”这样的消息。

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

[更新]

一个更复杂的例子:

要求:

下单时,系统会检查客户的订单历史,如果他是有价值的客户(如果客户在去年每月至少下过10个订单,他是有价值的),我们给予10%的折扣到顺序。

实施:

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

【问题讨论】:

【参考方案1】:

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

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

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

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

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

更新

对于更复杂的情况:

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

但话又说回来,如果您真的需要在订单下达后出于某种原因计算折扣,请再次使用会侦听OrderPlacedEvent 的域服务,在这种情况下,“补偿”命令可能是 @ 987654323@ 什么的。该命令会影响 Order Aggregate 根,并且信息可能会传播到您的读取模型。

对于重复用户名的情况:

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

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

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

【讨论】:

补偿命令中发现用户名重复怎么办?发布 SignalR 事件通知客户端用户名不可用? (我没用过SignalR,我猜可能有某种“事件?) 我认为我们在 DDD 中将其称为应用程序服务,但我可能弄错了。此外,域服务在 DDDD/CQRS 社区中是一个有争议的术语。但是,您需要的是类似于他们所谓的 Saga 的东西,只是您可能不需要状态或状态机。您只需要能够对事件做出反应和反馈、执行数据查找和调度命令的东西。我称它们为域服务。简而言之,您订阅事件并发送命令。这在聚合根之间进行通信时也很有用。 我还应该提到,我的域服务处于完全不同的过程中,例如与读取模型分开。这使得消息相关的东西更容易处理,例如订阅等。 这是一个很好的答案。但是,我经常看到这条评论“在使用事件源时,您不应该从命令处理程序或域访问读取模型”。有人可以解释为什么在命令/域端使用读取模型是个坏主意。这是命令/查询分离的重点吗? 域状态和命令的组合必须足以做出决定。如果您觉得在处理命令时需要读取数据,请将这些数据带入命令中或将其存储在域状态中。为什么? - 读取存储最终一致,它可能没有真相。域状态是事实,命令完成它。 - 如果您使用 ES,您可以将命令与事件一起存储。通过这种方式,您可以准确地看到您正在处理的信息。 - 如果您事先阅读,您可以执行验证并增加命令成功的概率。【参考方案2】:

我认为您尚未将思维方式转变为 eventual consistency 和事件溯源的性质。我有同样的问题。具体来说,我拒绝接受您应该信任来自客户端的命令,使用您的示例说“以 10% 的折扣下订单”,而域没有验证折扣应该继续进行。真正让我印象深刻的一件事是something that Udi himself said to me(检查已接受答案的 cmets)。

基本上我开始意识到没有理由不信任客户;读取端的所有内容都是从域模型生成的,因此没有理由不接受命令。阅读部分中表明客户有资格享受折扣的任何内容都已被域放在那里。

顺便说一句:在要求中,如果输入的用户名已经被占用,网站应该向访问者显示错误消息“对不起,用户名 XXX 不可用”。不能向访问者显示“我们正在创建您的帐户,请稍候,我们稍后会通过电子邮件将注册结果发送给您”这样的消息。

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

更新:2015 年 10 月

只是想补充一点,实际上,在涉及面向公众的网站的情况下 - 表明电子邮件已被接收实际上违反了安全最佳实践。相反,注册似乎已成功通知用户已发送验证电子邮件,但在用户名存在的情况下,电子邮件应通知他们并提示他们登录或重置密码。虽然这仅在使用电子邮件地址作为用户名时才有效,但我认为出于这个原因这是可取的。

【讨论】:

优秀的输入。在系统能够改变之前,思想必须改变(我不打算在那里听起来像尤达)。 +1 只是在这里真的迂腐...... ES 和 EC 是两个完全不同的东西,使用一个不应该意味着使用另一个(尽管在大多数情况下它完全有道理)。在没有最终一致的模型的情况下使用 ES 是完全有效的,反之亦然。 “基本上我开始意识到没有理由不信任客户”——是的,我认为这是一个公平的评论。但是如何处理可能产生命令的外部访问呢?显然,我们不想让 PlaceOrderCommand 自动应用折扣;折扣的应用是领域逻辑,而不是我们可以“信任”某人告诉我们应用的东西。 @StephenDrew - 在这种情况下,客户端仅表示生成命令的任何代码单元。您可能(也许应该)在命令总线之前有一层。如果您正在创建外部 Web 服务,则下订单的 mvc 控制器将首先执行查询,然后提交命令。这里的客户端就是你的控制器。 把你的回答放在心上,意味着所有关于“不变量”、“业务规则”、“高封装”的理论都是无稽之谈。不信任 UI 的原因有很多。毕竟 UI 不是必须的部分……如果没有 UI 怎么办?【参考方案3】:

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

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

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

【讨论】:

好主意 :) 谢谢【参考方案4】:

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

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

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

服务将尝试更新(无查询,“告诉不问”)包含唯一约束的表。

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

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

总结:

此方法不涉及查询。

将始终创建用户注册而无需验证。

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

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

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

【讨论】:

我做了类似的事情。在我的事件源系统中,我有一个 UserName 聚合。它的 AggregateID 是我想注册的用户名。我发出一个命令来注册它。如果它已经注册,我们会得到一个事件。如果它可用,那么它会立即注册并且我们会收到一个事件。我尽量避免使用“服务”,因为它们有时会觉得域中存在建模缺陷。通过将 UserName 设为第一类 Aggregate,我们对域中的约束进行建模。【参考方案5】:

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

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

我们所做的是在命令端创建一个index 表。例如,在用户名需要唯一的简单情况下,只需创建一个 user_name_index 表,其中包含需要唯一的字段。现在命令端能够查询用户名的唯一性。执行命令后,将新用户名存储在索引中是安全的。

类似的方法也可以解决订单折扣问题。

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

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

【讨论】:

【参考方案6】:

我认为对于这种情况,我们可以使用“有期限的咨询锁”之类的机制。

示例执行:

检查用户名是否存在于最终一致的读取模型中 如果不存在;通过使用像键值存储或缓存这样的 redis-couchbase;尝试将用户名推送为具有一定期限的关键字段。 如果成功;然后引发 userRegisteredEvent。 如果读取模型或缓存存储中存在用户名,请通知访问者用户名已被占用。

甚至可以使用 sql 数据库;插入用户名作为某个锁表的主键;然后计划的作业可以处理到期。

【讨论】:

【参考方案7】:

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

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

如果没有前端,那么您可以跳过异步查找,或者至少让您的 API 提供端点来查找它。无论如何,您确实不应该允许客户端直接与命令模型对话,而在其前面放置一个 API 可以让 API 充当命令和读取主机之间的中介。

【讨论】:

【参考方案8】:

在我看来,这里的聚合可能是错误的。

一般来说,如果你需要保证属于 Y 的值 Z 在集合 X 中是唯一的,那么使用 X 作为聚合。毕竟,X 是不变量真正存在的地方(X 中只能有一个 Z)。

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

【讨论】:

这正是您必须考虑聚合的方式。聚合的目的是防止并发/不一致(您必须通过某种机制来保证这一点才能使其成为聚合)。当您以这种方式考虑它们时,您也会意识到保护不变量的成本。在一个高度争议的系统中最坏的情况下,所有发送到聚合的消息都必须被序列化并由单个进程处理。这是否与您的运营规模相冲突?如果是这样,您应该重新考虑不变量的值。 对于这种使用用户名的特定场景,您仍然可以在水平扩展的同时实现唯一性。您可以按照用户名的前 N ​​个字符对您的用户名注册表聚合进行分区。例如,如果您必须处理数千个并发注册,则按照用户名的前 3 个字母进行分区。因此,要注册用户名“johnwilger123”,您可以将消息发送到 ID 为“joh”的聚合实例,它可以检查其所有“joh”用户名集的唯一性。

以上是关于CQRS 事件溯源:验证用户名的唯一性的主要内容,如果未能解决你的问题,请参考以下文章

使用事件溯源和 CQRS 的缺点是啥?

CQRS / 事件溯源 / 事件总线 / 时序

CQRS 和事件溯源指南

事件溯源/CQRS 读取模型 - 预测

用示例程序介绍CQRS和事件溯源机制

Akka.NET 中的事件溯源和 CQRS