[翻译] 澄清CQRS

原文:Clarified CQRS by Udi Dahan - The Software Simplist on Wednesday, December 9th, 2009.

翻译没有获得任何授权,不得转载!!!


在听取了社区如何解释Command-Query Responsibility Segregation(CQRS,命令查询职责分离)后,我认为应该澄清一下CQRS。 有些人一直将CQRS与Event sourcing联系在一起。 其中大多数人已经在这种假设(将CQRS和Event sourcing作为一个整体)的基础上,用CQRS替换其先前的分层架构。 在这里,我希望可以讲解CQRS本身,并且阐述CQRS可以和其他模式的关联。

为什么选择CQRS

在描述CQRS的细节之前,我们需要了解使用CQRS的主要原因(或者说CQRS的作用):协作(collaboration)和旧数据(staleness)。

协作(collaboration)指的是多个参与者(actor)使用或修改同一组数据的情况,无论这些参与者的意图是否真的是彼此协作。通常会定义一些规则,用于规定哪个用户可以修改什么数据,这些修改可能在特定的情况下才能执行,或者在某些情况下是不能执行的。我们稍后会举一些相关的例子。这些参与者可以是用户,也可能是其他的程序或者进程之类的。

旧数据(staleness,或者翻译成过期数据? )是指在多个参与者在协作环境中,向用户展示的数据可能已经被其他的参与者修改了 - 这些数据是陈旧的。几乎所有使用缓存的系统都在提供旧数据 - 一般是出于性能原因。这意味着我们不能完全信任用户的动作(修改数据等),因为它们可能是基于过期的数据而做出的这些动作。

标准分层体系结构不会明确处理这些问题。虽然将所有数据放在同一个数据库中可以避免这种协作环境旧数据,但是在系统后续开发中,如果想使用缓存提高性能话,可能会加剧协作环境下旧数据的问题。

一张参考图片

我以前在讲解CQRS的时候使用这张图片来解释CQRS:

图中AC代表自治组件(Autonomous Components)。我们在讨论命令(command)时会讲到使它们自治的原因。 但在我们进入复杂的部分之前,让我们从查询(query)开始:

查询 Queries

如果无论如何我们展现给用户数据都是陈旧的,那么是否真的有必要从主数据库(master)中获取这些数据呢?如果我们只是想获取这些不包含任何业务规则的数据,那么为什么要将这些满足第三范式的数据转换为域对象(domain objects )?谁又规定了要将这些域对象(domain objects )转换为DTO?又为什么要将这些DTO转换成view model objects?

简而言之,我们其实做了很多不必要的事情。当然这是基于我们认为重用代码比单纯的解决问题更容易。让我们尝试一种不同的方法:

我们创建一个额外的数据库(data store),这个数据库可能会与主数据(master database)库不同步 - 我的意思是,既然我们向用户显示的数据无论如何都有可能是陈旧的,那么不如将这些陈旧的数据反映在数据库中。我们稍后会提出一种方法来保持这个数据库尽量同步主数据库。

现在,我们如何正确的保存这个数据库内的数据的结构呢?和view model一样的结构?为每个view(前端显示的view)都建一个table。然后客户端就可以简单地通过SELECT * FROM MyViewTable(或者在where传入一个ID),并将结果显示在屏幕上。存储的数据越简单越好。如果有需要的话可以做简单的封装,或者使用存储过程,或者使用 AutoMapper 将数据映射到view model。重点是view mode结构已经可以直接传输了,因此不需要将它们转换为其他任何东西。

甚至可以考虑将该数据库放入Web层。它和Web层中的内存缓存一样安全。Web服务器只有对这些表的SELECT权限就可以了。

查询数据存储

虽然可以使用常规数据库(译者注:作者指的是关系数据库)作为查询数据存储,但是也有其他的选择。记住查询用的schema和view model是相同的。在不同的view mode之间没有任何关系,所以也不应该在查询数据库的表之间建立任何关系。

那你真的需要一个关系数据库吗?

答案是否定的,但出于所有实际目的考虑和使用惯性,现阶段关系数据库可能是最好的选择(译者注:本文写于2009年)。

扩展(Scaling )查询数据库

因为现在查询数据从单独的数据库中获取数据,同时也不要求这些数据是最新的,所以可以很容易的添加更多查询数据库的实例,而不必担心它们的数据会不同。稍后会介绍使用同一个机制同时更新几个查询数据库的实例。

这可以更低成本的水平扩展查询数据库。此外,由于没有进行过多的数据转换,因此每个查询的延迟也会下降。 简单的代码就是是快速代码。

数据修改

由于用户根据旧数据做出相应的动作,所以需要更清楚地分辨出哪些动作是被允许的。下面通过一个具体的应用场景解释一下为什么:

假设我们有一个客服人员(用户)和客户打电话。这个客服人员正在查看客户的详细信息,并打算将客户标记成“优先”客户,同时修改客户的地址,将其称谓从Ms更改为Mrs,更改客户姓氏,并标记客户为已婚。但是客服人员不知道的是,在他取得用户详细资料之后,从账单部门发出一个事件(event),表明该客户没有支付账单 - 这个客户有欠款。此时,客服人员提交了的他的更改。

我们应该接受这些更改么?

好吧,其实我们应该接受其中一些,但不要将客户标记为“优先”客户,因为客户有欠款。但编写这样的校验是一件痛苦的事情 - 我们需要对数据进行对比,推断出这些更改请求的含义,哪些相互关联(更改名称,更改称谓)以及哪些是分开的,确定要检查的数据 - 不仅要与用户取得的数据进行比较,而且与数据库中的当前状态进行比较,然后拒绝或接受这些修改请求。

对于用户来说不幸的是,如果这些数据中的任何部分过期了,我们倾向于拒绝整个事情。此时,用户必须刷新屏幕以获取最新数据,并重新输入所有先前的更改,希望这次我们不会因为乐观锁的并发冲突而再次导致更新失败。

当我们获得具有更多字段、更大实体时,我们也会让更多的参与者使用这些相同的实体,并且对实体的属性修改越频繁,并发冲突的可能性越大。

是否有什么办法可以在用户修改数据的时候适当的划分粒度和意图?这就是命令的全部内容。

命令 Commands

CQRS的核心内容是重新思考用户界面的设计,使我们能够捕捉用户的意图,例如将用户标记“优先”和用户地址搬迁或用户已婚是不同的。如上所述,使用类似Excel进行数据更改并不会捕获意图。

我们甚至可以考虑允许用户在收到前一个命令确认之前提交新命令。我们可以在侧面显示一个小插件,向用户显示其处于挂起状态的命令,在我们从服务器收到确认时异步检查它们,或者如果它们失败则用X标记它们。然后,用户可以双击该失败的任务以查找有关的信息。

请注意,客户端将命令 发送(send) 到服务器 - 而不是 发布(publish)。发布用于事件 - 发生了某些事情,并且发布者不关心该事件的接收者对其做了什么。

命令和校验

在思考可能导致命令失败的原因时,其中之一是校验(命令数据)。 校验与业务规则的不同之处在于它声明一个上下文无关的规则。 另外,业务规则取决于上下文。

在我们之前看到的示例中,我们的客服人员提交的数据是有效的,这个命令失败的原因是因为账单部门的事件更早的到达了,要求拒绝该命令。 如果这个账单事件还没有到达,则数据将被接受。

即使命令可能有效,仍有理由拒绝它。

因此,可以在客户端上执行验证,检查该命令所需的所有字段是否存在,数字和日期范围是否正常等。服务器仍将验证所有到达的命令,而不会信任客户端进行验证。

根据校验重新思考UI和命令

客户端可以通过查询数据库校验命令数据。例如,在提交客户搬迁的命令之前,我们可以检查查询数据存储,搬迁街道名称是否存在。

此时,我们可能会重新考虑用户界面并为街道名称提供一个自动填写的文本框,从而确保我们将在命令中传递的街道名称有效。但为什么不更近一步呢?为什么不直接传递街道ID而传递街道名称?不是让命令将街道表示为字符串,而是表示为ID(int,guid,等等)。

在服务器端,命令失败的唯一原因就是并发 - 有人删除了那条街道,而且还没有反映在查询存储中; 这是一个相当极端的情况。

有效命令失败的原因及处理

即使客户端会发送有效的命令,但服务器仍然可能拒绝它们。通常拒绝的情况与其他参与者改变与该命令的处理相关的状态有关。

在上面的CRM示例中,仅仅因为账单事件首先到达了。但是“先”可能是我们命令之前的一毫秒。如果我们的用户提前一毫秒按下按钮怎么办?这会改变 业务结果 吗?不是应该无论谁先执行,那么结果不应都是相同的么?

因此,如果账单事件后到达,那么不应该将“优先”客户恢复为常规客户吗?不仅如此,而且用户不是应该收到一个通知吗?在这种情况下,为什么不将此作为账单事件首先到达的情况的行为? 而且,如果我们已经设置了通知模板,我们是否真的需要向客服人员返回错误?我的意思是,除了通知客户之外,他们不能做任何事情。

因此,如果我们没有将错误返回给客户端(他们已经向我们发送了有效的命令),那么在发送命令时我们需要在客户端上做的就是告诉用户“谢谢你,很快你将通过电子邮件收到确认”。我们甚至不需要UI插件显示挂起的命令。

命令和自治

我们看到的是,在这个模型中,命令不需要立即处理 - 它们可以排队。处理它们的速度是服务级别协议(SLA)的问题,而且在架构上并不重要。从运行角度上说,这也是使节点可以自治的处理命令的意义之一 - 我们不需要始终在线连接到客户端。

此外,我们不需要访问查询存储来处理命令 - 任何需要的状态都应该由自治组件管理 - 这是自治意义的一部分。

另一部分是由于数据库关闭或遇到死锁而导致消息处理失败的问题。没有理由将这样的错误返回给客户端 - 我们可以回滚并再试一次。当管理员重新启动数据库时,队列中等待的所有消息将成功处理,我们的用户将收到确认。

系统作为一个整体,在任何错误发生时,都会非常健壮。

此外,由于我们不再通过此数据库进行查询,因此数据库本身能够在内存中保留更多行/页以供命令使用,从而提高性能。当两个命令和查询都是从相同的表中提供数据时,数据库总是不停的在同步数据。

自主组件

虽然在上图中我们看到所有命令都转到同一个AC,但我们逻辑上可以让每个命令由不同的AC处理,每个AC都有自己的队列。 我们通过了解哪个队列最长,分析哪部分是瓶颈。 虽然这对开发人员来说只是一组数字,但对系统管理员来说却很重要。

一旦命令在队列中等待,我们可以在这些队列后面添加更多处理节点(使用具有NServiceBus的分发器),这样我们只是扩展系统中缓慢的部分。无需在任何其他请求上浪费服务器。

服务层

我们在各种自治组件中的命令处理对象实际构成了我们的服务层。CQRS中没有明确表示服务层的是因为它不是真的存在,至少不是相关对象的可识别逻辑集合 - 这就是原因:

分层体系结构(AKA 3-Tier)方法中,没有关于同一层内对象之间的依赖关系的陈述,或者更确切地说它是允许的。但是,在服务层上采用面向命令的视角时,我们看到的是处理不同类型命令的对象。命令之间是彼此独立的,那么我们为什么要允许处理它们的对象相互依赖呢?

除非有充分的理由,否则依赖是应该避免的事情。

保持命令处理对象彼此独立将允许我们更容易地对我们的系统进行版本控制,一次一个命令,甚至不需要关闭整个系统,因为新版本向后兼容前一个版本。

因此,将每个命令处理程序保存在自己的VS项目中,甚至可能保留在自己的solution中,从而使开发人员不再以重用的名义引入依赖关系(这是一个 谬论)。如果想要将所有自治组件作为一个程序部署,把它们全部放在同一个队列的同一个进程中,你可以ILMerge这些程序集并将它们托管在一起,但要明白这会是你丧失许多自治组件带来的好处。

使用哪个域模型?

虽然在上图中可以看到命令处理自治组件旁边的域模型(Domain Model),但它实际上是一个实现上的细节。不是所有命令必须由同一域模型处理。可以说,您可以通过 事务脚本 处理某些命令,其他命令使用 表模块(AKA active record )或 域模型。事件溯源(Event sourcing)是另一种可能的实现方式。

关于域模型的另一个要理解是它现在不用于提供查询。所以为什么需要在域模型中的实体之间建立这么多关系?

(你可能需要花一秒钟才能让开始思考这个问题。)

我们真的需要在客户实体中保存订单集合吗?什么命令会会在客户实体中使用订单集合?或者说,什么命令需要一对多的关系?如果不需要一对多,那么肯定也不需要多对多。我的意思是,大多数命令无论如何都只包含一个或两个ID。

任何可以通过循环计算子实体的聚合操作都应该预先计算好,并存储为父实体的属性。领域中所有实体执行此过程之后,每个实体只需要保存其关联的实体的ID,类似于数据库中“子数据”保存“父数据”的ID。

在这种形式中,命令可以完全由单个实体处理 - 一致性边界的聚合根。

持久性命令处理

鉴于用于命令处理的数据库不用于查询,并且大多数(如果不是全部)命令包含它们将修改的数据ID,我们是否真有必要为每一个领域对象的属性都在数据库中建一个列?可不可以只是将域实体序列化后存储在单独的一列中,并在另外一列中存储领域实体的ID?这听起来与各种云提供商提供的键值存储(key-value storage)非常相似。 在这种情况下,你真的需要一个对象关系映射坚(关系型数据库)来持久化数据么?

同时还可以为每个数据增加一个额外的属性,用于保证数据的唯一性。

我不是建议你在所有情况下都这样做 - 而只是试图让你重新思考一些基本的假设。

让我重申一下
处理命令的方式是实现CQRS的细节。

保持查询数据库同步

在命令处理自治组件决定接受某个命令,根据需要修改其持久化存储之后,将发布一个事件。此事件通常是命令的“过去时”形式:

MakeCustomerPerferredCommand - > CustomerHasBeenMadePerferredEvent

事件的发布、命令的处理和数据库更改应该在一个事务中完成完成。 这样,任何失败都不会发布事件。默认情况下应由消息总线发布事件,如果使用MSMQ作为底层传输,则需要使用事务队列。

处理事件并更新查询数据存储的自治组件非常简单,将事件的结构转换为持久视图模型的结构。我建议为每个视图模型类建一个事件处理器(event handler)(AKA per table)。

再次放一张CQRS的图片:

界限上下文

虽然CQRS涉及许多软件架构,但它仍然不是食物链的顶端。 CQRS如果在界限上下文(DDD)或业务组件(SOA)中使用。从一个BC发布的事件被其他BC订阅,每个BC都根据需要更新其查询和命令数据存储。

综述

CQRS旨在为多用户协作应用程序提供适当的架构。CQRS明确地处理了数据陈旧性和波动性等因素,并创建更简单,更具可扩展性的结构。

如果不考虑用户界面,就无法真正享受CQRS带来的好处,使其明确地捕获用户意图。在考虑客户端验证时,可以稍微调整命令结构。通过思考处理命令和事件的顺序,可以使用通知模式,从而避免直接返回错误给用户。

虽然使用CQRS可以是代码更易维护、性能更好,但这种简单性和可扩展性需要了解详细的业务需求,而不是技术上的“最佳实践”的结果。如果有的话,我们可以看到很多方法用来解决彼此类似的问题 - 数据读取和域模型,单向消息传递和同步调用。

虽然这篇文章超过3000字,但我知道它没有深入讨论这个主题 - 我的 高级分布式系统设计课程 需要大约3天的时间来覆盖所有内容。尽管如此,我希望这篇文章可以让你理解什么是CQRS,并且可以用另外的视角来审视分布式系统的设计。

猜你喜欢

转载自www.cnblogs.com/humphrycc/p/9375320.html
今日推荐