首页 文章

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

提问于
浏览
57

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

  • 用户访问网站

  • 单击"Register"按钮并填写表格,单击"Save"按钮

  • MVC控制器:通过从ReadModel读取来验证UserName的唯一性

  • RegisterCommand: Validate UserName uniqueness again (here is the question)

当然,我们可以通过读取MVC控制器中的ReadModel来验证UserName的唯一性,以提高性能和用户体验 . 但是, we still need to validate the uniqueness again in RegisterCommand ,显然,我们不应该在命令中访问ReadModel .

如果我们不使用Event Sourcing,我们可以查询域模型,以便's no a problem. But if we'重新使用Event Sourcing,我们无法查询域模型,所以 how can we validate UserName uniqueness in RegisterCommand?

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

BTW: 在要求中,如果已经输入了UserName,则网站应向访问者显示错误消息"Sorry, the user name XXX is not available" . 向访问者显示消息,例如"We are creating your account, please wait, we will send the registration result to you via Email later"是不可接受的 .

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

[UPDATE]

一个更复杂的例子:

Requirement:

在下订单时,系统应该检查客户的订购历史,如果他是一个有 Value 的客户(如果客户在去年每月至少订购10个订单,他是有 Value 的),我们会在订单上减价10% .

Implementation:

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

7 回答

  • 20

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

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

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

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

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

    Update

    For the more complex case:

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

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

    For the duplicate username case:

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

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

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

  • 11

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

    基本上我意识到没有理由不相信客户;读取端的所有内容都是从域模型生成的,因此没有理由不接受这些命令 . 无论读取方面是什么,表示客户有资格享受折扣,都由域名提供 .

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

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

    UPDATE : Oct 2015

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

  • 5

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

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

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

  • 1

    我认为对于这种情况,我们可以使用像“过期咨询锁”这样的机制 .

    样品执行:

    • 在最终一致的读取模型中检查用户名是否存在

    • 如果不存在;通过使用像keyvalue存储或缓存的redis-couchbase;尝试将用户名作为关键字段推送一些过期 .

    • 如果成功;然后引发userRegisteredEvent .

    • 如果读取模型或缓存存储中存在任一用户名,请通知访问者用户名已经使用 .

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

  • 36

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

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

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

    这样的事情也可以起作用订单折扣问题 .

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

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

  • 1

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

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

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

  • 5

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

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

    • 在“UserRegistrationStarted”上,异步消息将被发送到无状态服务“UsernamesRegistry” . 就像“RegisterName” .

    • 服务将尝试更新(没有查询,“告诉不要问”)表,其中包含唯一约束 .

    • 如果成功,服务将使用另一条消息(异步)回复,并使用一种授权“UsernameRegistration”,声明用户名已成功注册 . 你可以包含一些requestId来跟踪并发能力(不太可能) .

    • 上述消息的颁发者现在拥有该名称已自行注册的授权,因此现在可以安全地将UserRegistration聚合标记为成功 . 否则,标记为丢弃 .

    包起来:

    • 这种方法不涉及任何疑问 .

    • 将始终创建用户注册而不进行验证 .

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

    • 最后,一个异步命令确认User是有效的 .

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

相关问题