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


75

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

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

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

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

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

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

有任何想法吗?非常感谢!

[更新]

一个更复杂的示例:

需求:

下订单时,系统应检查客户的订购历史记录,如果他是有价值的客户(如果客户在去年每月至少下达10个订单,那么他是有价值的),我们将为您减价10%。

实现方式:

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

Answers:


37

如果您在发送命令之前使用读取模型验证用户名,那么我们所谈论的是一个竞争状态窗口,可能会发生几百毫秒的竞争状态,而在我的系统中这是无法处理的。与处理它的成本相比,它不太可能发生。

但是,如果您出于某种原因必须处理它,或者只是想知道如何处理这种情况,可以采用以下一种方法:

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

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

如您可能看到的那样,不可能以同步请求-响应方式进行此操作。为了解决这个问题,只要有什么要推送到客户端的东西(如果它们仍处于连接状态),我们就会使用SignalR更新UI。我们要做的是让Web客户端预订包含事件的事件,这些事件对于客户端立即查看非常有用。

更新资料

对于更复杂的情况:

我要说的是,下订单不那么复杂,因为在发送命令之前,您可以使用读取模型来确定客户端是否有价值。实际上,您可以在加载订单时进行查询,因为您可能想向客户表明,他们在下订单之前将获得10%的折扣。只需给折扣添加一个折价PlaceOrderCommand,也许是一个折扣的原因,这样您就可以追踪为什么要减少利润。

但是再说一次,如果由于某种原因您确实需要在下订单后计算折扣,请再次使用可以监听的域服务,OrderPlacedEvent在这种情况下,“ compensating”命令可能是aDiscountOrderCommand或诸如此类。该命令将影响Order Aggregate根,并且该信息可能会传播到您的读取模型。

对于重复的用户名情况:

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

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

当涉及SignalR时,我使用SignalR集线器,当用户加载某种形式时,它会与用户连接。我使用SignalR Group功能,该功能允许我创建一个组,该组命名为我在命令中发送的Guid的值。在您的情况下,这可能是userGuid。然后,我有Eventhandler订阅了可能对客户端有用的事件,当事件到达时,我可以在SignalR组中的所有客户端上调用javascript函数(在这种情况下,只有一个客户端在您的帐户中创建重复的用户名)案件)。我知道这听起来很复杂,但实际上并非如此。我已经在一个下午完成了所有工作。SignalR Github页面上有很棒的文档和示例。


当发现用户名重复时,应该在补偿命令中做什么?发布SignalR事件以通知客户端用户名不可用?(我还没有使用SignalR,我想可能会有某种“事件?”)
Mouhong Lin 2012年

1
我认为我们在DDD中将其称为“应用程序服务”,但我可能会误会。而且,域服务在DDDD / CQRS社区中是一个有争议的术语。但是,除了您可能不需要状态或状态机之外,您需要的东西与他们所谓的Saga相似。您只需要可以做出反应并产生事件,执行数据查找和调度命令的设备即可。我称它们为域服务。简而言之,您订阅事件并发送命令。在聚合根之间进行通信时也很有用。
MikaelÖstberg,2012年

1
我还应该提到,我的域服务处于完全不同的过程中,例如与读取模型分开的过程。这使得与消息相关的内容更易于处理,例如订阅等。
MikaelÖstberg,2012年

1
这是一个很好的答案。但是,我看到了很多这样的评论:“使用事件源时,您不应从命令处理程序或域访问读取模型”。有人可以解释为什么在命令/域内使用读取模型是一个坏主意吗?这是命令/查询分离的重点吗?
Scott Coates 2012年

1
域状态和命令的组合必须足以用于决策。如果您觉得在处理命令时需要读取数据,请将该数据带入命令中或以域状态存储。又为什么呢 -读取存储最终是一致的,它可能没有真相。域状态是事实,命令完成它。-如果您使用的是ES,则可以将命令与事件一起存储。通过这种方式,您可以准确地看到您正在处理的信息。-如果您事先阅读过,则可以执行验证并增加命令成功的可能性。
MikaelÖstberg,2012年

24

我认为您尚未将心态转变为最终的一致性和事件来源的性质。我有同样的问题。具体来说,我拒绝接受您应该信任来自客户端的命令,在您的示例中,说“以10%的折扣下单”而不使域验证折扣应继续进行。乌迪本人对我说的话对我来说真的很重要(检查已接受答案的评论)。

基本上,我意识到没有理由不信任客户。读取端的所有内容都是由域模型生成的,因此没有理由不接受命令。无论阅读方面说客户有资格获得折扣的任何内容,都已由域放在那里。

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

如果要采用事件源和最终一致性,则需要接受的是,有时在提交命令后有时将无法立即显示错误消息。对于唯一的用户名示例,发生这种情况的可能性非常小(考虑到您在发送命令之前检查了读取端),不必担心太多,但是在这种情况下,需要发送后续通知,或者询问他们在下次登录时使用其他用户名。这些方案的妙处在于,它使您思考业务价值以及真正重要的事情。

更新:2015年10月

只是想补充一下,实际上,涉及面向公众的网站-表示已经收到一封电子邮件实际上违反了安全性最佳做法。取而代之的是,注册应该似乎已经成功地通知用户已发送验证电子邮件,但是在存在用户名的情况下,电子邮件应将其告知用户并提示他们登录或重置密码。尽管这仅在使用电子邮件地址作为用户名时有效,但出于这个原因,我认为这是明智的选择。


3
出色的输入。头脑必须在系统能够改变之前改变(我原本不想在那里听起来像Yoda)。
MikaelÖstberg,2012年

5
+1只是在这里真的很古怪... ES和EC是2种完全不同的事物,使用其中一项并不意味着要使用另一种事物(尽管在大多数情况下,这是很有意义的)。在没有最终一致的模型的情况下使用ES是完全有效的,反之亦然。
詹姆斯

“基本上我开始意识到没有理由不信任客户”-是的,我认为这是一个公平的评论。但是如何处理可能产生命令的外部访问?显然,我们不想让PlaceOrderCommand带有自动应用的折扣。折扣的应用是领域逻辑,而不是我们可以“信任”某人告诉我们应用的东西。
斯蒂芬·德鲁

3
@StephenDrew-在这种情况下,客户端仅表示正在生成命令的任何代码单元。您可能(也许应该)在命令总线之前有一层。如果要进行外部Web服务,则下订单的mvc控制器将首先执行查询,然后提交命令。客户是您的控制器。
ryeguy 2014年

5
贴近您的回应,将意味着围绕“不变式”,“业务规则”,“高度封装”的所有理论都是绝对的废话。不信任UI的原因太多。毕竟,UI不是必不可少的部分...如果没有UI,该怎么办?
Cristian E.

18

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

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

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


9

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

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

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

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

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

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

包起来:

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

  • 用户注册将始终在没有验证的情况下创建。

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

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

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


2
我做类似的事情。在事件源系统中,我有一个UserName集合。它的AggregateID是我要注册的用户名。我发出命令进行注册。如果已经注册,我们将收到一个事件。如果可用,则会立即进行注册,我们会收到一个事件。我尝试避免使用“服务”,因为有时它们会感觉到域中存在建模缺陷。通过将UserName设为第一类Aggregate,我们在域中对约束进行建模。
CPerson

7

我认为在这种情况下,我们可以使用“带有到期通知的锁定”之类的机制。

示例执行:

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

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


7

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

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

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

这样的事情也可以解决订单折扣问题。

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

不利的一面是,您需要为每个唯一性约束进行额外的查询,并且正在实施额外的复杂性。


2

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

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

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


2

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

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

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


这正是您必须考虑聚合的方式。聚合的目的是防止并发/不一致(您必须通过某种机制保证这一点才能使其成为聚合)。当您以这种方式考虑它们时,您还将意识到保护不变式的代价。在高度争议的系统中,在最坏的情况下,所有发送到集合的消息都必须由单个进程序列化和处理。这是否与您使用的秤相冲突?如果是这样,则应重新考虑不变值。
安德鲁·拉尔森

2
对于这种带有用户名的特定方案,您仍然可以在水平扩展的同时获得唯一性。您可以沿用户名的前N个字符对用户名注册表进行分区。例如,如果您必须处理数千个并发注册,则请沿用户名的前3个字母进行分区。因此,要注册用户名“ johnwilger123”,您可以将消息发送到ID为“ joh”的Aggregate实例,它可以检查其所有“ joh”用户名的集合的唯一性。
安德鲁·拉尔森
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.