CQRS 的解构

2025-06-09

CQRS 的解构

封面图片版权归 Fabian Oefner 所有,出自《Disintegrating II》系列。这是我最喜欢的汽车之一,福特 GT40。在 CQRS 早期,网页搜索“cqrs”会自动更正为“cars”。老版CQRS 博客幽默地配上了这样的副标题:你指的是汽车吗?

我获得了一个绝佳的机会,运用一些我闲暇时研究和实践过的策略,实现了一个系统。时间快进到今天。我遇到了一些意料之外的问题,也从中吸取了一些教训。这篇文章主要讨论一个方面:请求/回复 API。

概述

我使用一种称为命令/查询职责分离(CQRS)的策略。对于不熟悉它的人,这里简要概述一下 CQRS 支持的 API 操作。

返回数据 做出改变
询问 ✔️
命令 ✔️

为什么要使用这种模式?我喜欢它有几个原因。作为 API 的使用者,我永远不必担心提出问题会产生意想不到的后果。相反,我确切地知道哪些 API 调用会对系统进行更改。没有任何歧义。这使得 API易于理解。但从历史上看,这种模式之所以演变,是因为读取和写入的关注点通常非常不同。尝试创建一个统一的接口来同时处理这两种操作,会面临服务于两个主程序的典型问题。随着时间的推移,单一接口对于这两种用途都会变得越来越令人困惑。如果时间足够长,它很可能会形成一种“货物崇拜”。“我们为什么要更新这个字段?我们不使用它。” 答案是:“我不知道,但请继续这样做,否则可能会出现问题。”
📦🛐

所以,从本质上讲,CQRS 只是关注点分离(也就是良好的组织实践)的一个具体应用。既然我们已经对该模式进行了介绍,我将介绍一些实现细节和经验教训。

消息传递

我认为每个查询或命令都是一条消息。这意味着任何客户端系统都可以将它们表示为不带方法的普通数据(类或结构体)。然后以 JSON、CapnProto 或 w/e 等有线格式轻松传输它们。每条消息都有一个名称——通常只是类/结构体名称——用于在 API 中唯一标识它。例如SearchCustomers(查询)或DeactivateCourse(命令)。名称用于识别请求的操作,然后将其与消息解析器和处理函数匹配。安全授权可以简单到只需维护一个列表,列出哪些用户可以发送哪些消息名称。然后在处理任何用户的消息之前检查该列表即可。🤘🤘

如果您熟悉RPC,那么您也可以将消息传递视为该模式的超集。消息名称是“过程”,消息内容是过程参数。

行动

命令和查询的工作方式可能看起来很明显。但我发现了一些细微差别。

询问

嗯,查询通常和你预期的一样。具体来说,我们是这样处理的:

  • API 监听/query/[Query Name]
  • 验证用户是否有[Query Name]权限
  • 反序列化查询消息
  • 将查询消息传递给其处理函数,该函数将:
  • 验证查询消息
  • 从数据库加载和转换数据
  • 序列化并返回数据

我们倾向于创建针对特定页面或解答常见问题的查询。感觉我们在这里遵循了一条反向的 DRY 规则。如果我需要针对某个页面进行查询,我可能会使用现有查询。但前提是我不需要更改现有查询。如果需要更改,则意味着新页面的职责会略有不同,即使它显示的数据大部分相同。因此,我会创建一个新的查询。

命令

命令的目的是在系统上执行某些业务操作。在实践中,我们注意到命令是否需要更改一个或多个实体📌之间存在区别。出于架构原因,如何处理多实体更改至关重要。

📌实体 此处的实体
指的是逻辑单元。在高度规范化的表中,实体可能包含父实体以及一对多关系的任何后代实体。在领域驱动设计 (DDD) 术语中,您可以将其称为聚合。在事件溯源 (Event Sourcing) 中,这是一个事件流。

可扩展性

可以在单个事务中执行多实体更改,以实现“全有或全无”的语义。这种方法在代码中运行良好,但会限制可扩展性。要参与事务,所有受影响的实体必须位于同一数据库节点上。如果它们位于不同的节点,则会发生分布式事务(如果数据库支持)。并且随着负载的增加,分布式事务会逐渐变慢。跨实体事务对于内部业务应用程序(或任何不太可能超过单个数据库节点的应用程序)是一种有效的方法。但对于公开可用的互联网服务,可能并非如此。

一种更有利于扩展的方法是仅使用单实体命令进行更改。当用例需要更改多个实体时,请使用元命令,该命令本身不进行任何更改,而是协调并运行单实体命令。我将单实体命令称为“基本命令”,将多实体命令称为“工作流”。

⚠️这些并非后端工作流。
工作流命令是为了方便前端使用而创建的。它们通常由用户通过 UI 自行执行的单个操作组成。但我们不会让用户跳转到多个页面,而是提供一个表单,并将所有所需数据汇总到工作流命令中。由于这些命令是请求/回复模式,因此会尽力执行并设定时间限制,因此失败通常只会导致工作流不完整,用户可以重试或单独修复剩余项目。这些工作流并非旨在取代后端流程或提供强大的故障处理能力。

客户端工作流程

可以在客户端实现一个工作流——让 UI 协调所有必要的基本命令。但是,我选择将它们放在 API 端,主要原因在于:清晰易懂(尤其是安全性)。我将用我们系统中的一个真实示例来说明。我们有一个培训师角色。该角色无权创建课程。但是,他们可以记录他们为员工提供的培训。记录培训用例的一部分可能包括创建一个选项有限的新课程。通过将记录培训用例作为 API 工作流执行,它可以表示为一个单一的细粒度权限。“培训师可以记录培训,但不能创建课程”。也就是说,在权限 UI 上,一个复选框被选中,而另一个复选框没有被选中。

要在客户端执行相同的操作,我们需要添加一个基本命令:创建培训课程。然后,管理员用户必须被告知:“要授予某人录制培训的权限,您必须检查Create Trainer CoursePermission XPermission Y。” 因此,像这样的客户端工作流程会给文档/最终用户培训带来负担。我们也可以创建一个仅用于权限目的的虚假命令,该命令映射到所需的基本命令。但这反而会给开发人员带来额外的负担,需要不断更新。我不喜欢这两种结果,所以我更喜欢 API 端工作流程。

2021年9月11日更新

为了批量运行同类命令,我们使用了一些客户端工作流。客户端会创建一个命令列表,然后以一次一个或并行的方式发送这些命令。当成功响应返回时,会将它们标记为“已完成”。这也使得客户端可以轻松地只重试失败的命令。

缺点:这种方法比较“繁琐”——每个命令都需要与服务器进行一次往返通信。相比于“突发性”的服务器端工作流程,这会增加服务器/网络负载。此外,网络延迟较高的用户会发现,每次执行一个客户端工作流程的速度会慢得像爬行一样。问我是怎么知道的。

无论您是单独发送还是批量发送,服务器每个命令的工作量都是相同的。但是,服务器还会使用 CPU 和内存来发送/接收网络请求。因此,通信量越大,可用于后端工作的资源就越少。减少多少取决于服务器硬件支持的卸载类型。

如果您明智地使用客户端工作流程,那么它们将会很有意义。

指导原则

在实现 CQRS API 时,人们经常会问一些非常常见的问题。我将以标题的形式列出我所遵循的原则,然后详细阐述这些原则背后的常见问题。

返回错误与返回数据不同。

一个常见的误解是命令应该什么都不返回。这源于CQS模式(CQRS 只是对其进行了扩展)。该模式应用于特定语言中的对象及其方法。许多语言使用异常作为错误传播策略。“命令”方法尤其引人注目,因为它会返回void。因此,命令不返回任何内容的概念应运而生。然而,这暗示着错误会引发异常,这实际上只是一种不同的返回路径。

所以,事实是,命令确实会返回一些东西。它们返回操作本身的元信息(操作成功还是失败以及原因)。这与返回业务数据(查询的任务)截然不同。

命令成功且不做任何改变也是可以的。

命令可以进行零次或多次更改。换句话说,“进行更改”是命令的目的,而不是所需的结果。因此,命令成功运行但结果没有任何改变是完全正常的。

我们遇到过类似的情况,我们会比较某个实体在运行命令前后的差异。如果它们完全相同,那么我们选择不做任何更改并成功返回。

命令处理代码调用查询是可以的。

很多问题源于一个误解,即 CQRS 原则应该适用于 API 的内部和外部。具体来说,有很多问题是关于命令处理代码是否可以运行查询。直觉上,这似乎违反了 CQRS 原则。但 CQRS 只对 API 的外部接口提出建议。命令的内部是实现细节,除了“做出更改”之外,CQRS 不会发表任何意见。

因此,您可以随意运行查询来获取在命令中做出决策所需的一些信息。但需要注意的是,在更高级的场景中,查询数据可能来自缓存,或者可能落后于“当前”数据(通常称为最终一致性)。在这种情况下,您必须考虑查询中的陈旧数据对命令所做决策的影响。更多信息请参见此处。稍微陈旧的数据可能无关紧要,例如配置数据通常的情况。例如:当用户更改配置数据时,他们预期在旧配置下会发生某些事情,但未来的事情将在新配置下发生。他们可能不会注意到或关心用户在更改后的几百毫秒内最终一致性期间在旧配置下执行了操作。他们只会假设该操作在更改之前执行。

自动递增 ID 不应是主 ID。

对于命令不返回数据,一个常见的反对意见是:我需要返回自动生成的 ID。自增 ID 非常方便,但也存在一些弊端。一方面,它们无法扩展;另一方面,它们存在安全隐患。不过,我们先暂时忽略这个问题,专注于一个常见的使用问题:重试。

设想

用户填写表单以创建新实体并点击“提交”。请求超时。

自动增加冒险

如果自增字段是唯一的 ID,您的应用将无法获知请求是否成功。这种情况的补救措施通常取决于用户的认知和参与。

如果用户再次点击“提交”(可能性很大),但之前的请求确实创建了实体,尽管超时,那么现在就会有两个相同的实体,但 ID 不同。为了正确清理,用户现在应该搜索重复项并移除冗余实体​​(可能性很小)。

或者,在超时后,用户可以搜索他们可能创建的实体。如果找不到,可以返回重新填写表单。根据我的经验,这种情况不太可能发生。也许可以增加培训成本,让用户习惯这种思维方式,这样就有可能实现。

你可以添加外部重复检查系统,比如保存已见操作及其结果的记忆。但还有更好的方法……

预生成ID冒险

在用户开始输入任何内容之前,表单加载时就会生成一个 ID(或从服务器请求一个 ID)。

用户收到请求超时提示后,只需再次点击“提交”即可。UI 会使用之前生成的 ID 发送完全相同的请求。最好的情况下,请求会正常成功。最坏的情况下,API 会返回“此实体已存在”的响应。如果 UI 能够识别出这个特定的错误,它就可以假装请求正常成功。这种大胆的尝试可以带来更好的用户体验,并且避免重复操作。

我们的战略

我们倾向于使用 UUID 进行所有身份识别。它们在许多平台上都易于生成,并且难以进行趋势分析。我们的大多数创建表单无论如何都需要运行查询(例如,获取下拉列表数据),因此我们只需在结果中包含一个新的 UUID 即可。

2021年9月11日更新

上述方法对于我们自己使用的内部 API 来说效果很好。但随着我们接触到外部 API,我们正在考虑一种不同的策略。尤其是对于创建新实体的操作(例如创建订单)。我们不能相信外部客户端会提供符合我们约束的唯一 ID,甚至不能相信他们会重复之前查询得到的 ID。

相反,我们正在考虑使用客户端提供的 ID 作为参考数据。当一个实体创建时,我们会为其生成自己的 ID。但客户端的 ID 将附加到该实体并被索引以供查找。客户端可以使用其自己的 ID 来调用我们的 API。但我们内部依赖的 ID 仍然符合我们的标准。

另一种情况是客户端没有自己的 ID,而是使用我们的 ID。这种方法稍加修改仍然有效。客户端提供一个请求 ID。操作完成后,客户端可以使用该请求 ID 请求有关已创建实体(包括我们的 ID)的信息。

结论

命令是变化的守门人。查询是知识库。这就是 CQRS。我发现这种模式引导我走向正确的方向。它也是一种多功能模式。它不关心您的部署范围是单片的还是微型的。您甚至可以将命令和查询拆分成单独的服务,以便分别扩展读取负载和写入负载。

但请记住,这只是一个大型系统的一部分,并非万能的工具。CQRS 模式非常适合后端系统的边缘,用于与客户端应用程序交互。与任何模式一样,它只有在正确的情况下才会发挥作用。

/∞

鏂囩珷鏉ユ簮锛�https://dev.to/kspeakman/a-deconstruction-of-cqrs-apis-4df0
PREV
Elm 0.19 让我们崩溃了💔
NEXT
使用 Golang 和 Svelte.js 一起创建网站技巧