type
status
date
slug
summary
tags
category
icon
password
Sub-item
Last edited time
Mar 24, 2024 03:12 AM
Parent item
领域
您很有可能知道至少一项服务:
- 具有一个难以理解和更改的大型的无可奈何的模型
- 或在新功能上并行工作的地方受到限制
- 或无法最佳地缩放
看到所有这些问题的服务并不少见。首先想到解决这些问题的想法是什么?让我们将其分成更多的微服务!
不幸的是,如果没有适当的研究和计划,盲目重构后的情况实际上可能比以前更糟:
- 商业逻辑和流程可能更难理解,如果它在一个地方,复杂的逻辑反而更容易理解
- 分布式交易 - - 有时事情在一起是有原因的;一个数据库中的大型事务比在多个服务中分布式交易要快得多,要复杂得多
- 如果其中一项服务归另一个团队拥有,则增加新更改可能需要额外的协调。
我不是微服务的敌人,我只是反对以一种引入不需要的复杂性和混乱的方式盲目应用微服务,而不是使我们的生活更轻松。
另一种方法是在先前描述的整洁架构中使用CQRS(命令查询责任隔离),它可以以更简单的方式解决上述问题。
CQRS 是不是一种复杂的技术?
CQRS 不是 C#/Java/über 企业模式中难以实现且代码混乱的一种吗?许多书籍、演示文稿和文章将 CQRS 描述为一种非常复杂的模式。但事实并非如此。
在实践中,CQRS 是一种非常简单的模式,不需要大量投资。它可以通过事件驱动架构、事件溯源或多语言持久性等更复杂的技术轻松扩展。但它们并不总是需要的。即使不应用任何额外的模式,CQRS 也可以提供更好的解耦和更易于理解的代码结构。
什么时候不应该在 Go 中使用 CQRS?如何获得 CQRS 的所有好处?您可以在本章中了解所有内容。像往常一样,我将通过重构 Wild Workouts 应用程序来做到这一点。
如何在 Go 中实现基本的 CQRS
CQRS(命令查询职责分离)最初由 Greg Young 描述。它有一个简单的假设:您应该拥有两个独立的模型,一种用于写入,一种用于读取,而不是拥有一个用于同时读取和写入的大模型。它还引入了命令和查询的概念,并将应用程序服务分为两种不同的类型:命令和查询处理程序。
命令与查询
用最简单的话来说:查询不应该修改任何内容,只返回数据。命令则相反:它应该在系统中进行更改,但不返回任何数据。因此,我们的查询可以更有效地缓存,并且我们降低了命令的复杂性。这听起来像是一个严重的限制,但实际上并非如此。我们执行的大多数操作都是读或写。同时操作都很少。
当然,对于查询,我们不会将日志或指标等操作视为修改任何内容。对于命令来说,返回错误也是很正常的事情。
注意:与大多数规则一样,打破它们是可以的……只要您完全理解引入它们的原因以及您所做的权衡。在实践中,您很少需要违反这些规则。我将在本章末尾分享示例。
最基本的实现在实践中看起来如何?在上一章中,Miłosz 介绍了执行app用例的应app服务。让我们首先将该服务分割成单独的命令和查询处理程序。
ApproveTrainingReschedule 命令
此前,重新安排训练是从TrainingService服务批准的。
那里有一些神奇的验证,它们现在是在领域层完成的。我还发现我们忘记调用外部trainer服务来转移训练。哎呀。让我们将其重构为 CQRS 方法。
注意,因为在应用程序中CQRS与域驱动设计结合使用最有效,因此在对CQRS进行重构时,我也将现有模型重构为DDD Lite。
我们定义一个command的命令结构。该结构(
ApproveTrainingReschedule
)提供了执行此命令所需的所有数据。如果命令只有一个字段,则可以跳过结构并将其作为参数传递。在命令中可以使用领域定义的类型,例如training.User。
第二部分是命令处理程序,实现了如何执行命令的过程。
现在流程更容易理解了。首先批准了
*training.Training
的重新安排,如果成功,我们将调用外部trainerService
服务。得益于领域驱动设计精简版中描述的技术,命令处理程序不需要知道何时可以执行此操作。这一切都由我们的领域层处理。这种清晰的流程在更复杂的命令中更加明显。幸运的是,当前的实现非常简单。那挺好的。我们的目标不是创建复杂的软件,而是创建简单的软件。
如果 CQRS 是团队中构建应用程序的标准方法,那么它还可以加快不了解该服务的队友学习该服务的速度。您只需要可用命令和查询的列表,并快速查看它们的执行方式。不需要在代码中的随机位置疯狂地跳跃。
这就是我团队最复杂的服务之一的样子:
您可能会问 - 不应该将其削减为多个服务吗?实际上,这将是一个糟糕的主意。这里很多操作都需要过渡一致。将其拆分为单独的服务将涉及几个分布式事务(Sagas)。这将使该流程变得更加复杂,更难以维护和调试。
还值得一提的是,所有这些操作都不是很复杂。复杂性在这里得到了很好的横向扩展。我们很快将更深入地讨论拆分微服务这个极其重要的主题。我是否已经提到过我们故意在Wild Workouts中搞砸了?
现在让我们回到我们的命令。是时候在我们的 HTTP的Port中使用它了。它可以通过注入的
Application
结构在 HttpServer
中使用,其中包含我们所有的命令和查询处理程序。在app的包中聚合所有的命令和查询:
在HTTP的Port中引入app的应用:
可以通过任何Port调用命令处理程序:HTTP、gRPC 或 CLI。它对于执行迁移和加载补丁也很有用(我们已经在 Wild Workouts 中做到了)。
RequestTrainingReschedule 命令
一些命令处理程序可能非常简单。
对于如此简单的情况,跳过这一层以节省一些样板文件可能很诱人。确实如此,但您需要记住,编写代码总是比维护便宜得多。添加这个简单的类型只需 3 分钟。后面阅读并扩展此代码的人们将会感谢这种努力。
availableHoursHandler 查询
应用层中的查询通常非常无聊。在最常见的情况下,我们需要编写一个读取模型接口(
AvailableHoursReadModel
)来定义如何查询数据。命令和查询也是解决所有横切(cross-cutting)问题(例如日志记录和检测)的好地方。由于将其放在这里,我们确信无论是从 HTTP 还是 gRPC 的Port调用,性能的测量方式都是相同的。
我们还需要定义查询返回的数据类型。在我们的例子中,它是
query.Date
。注意,这里不能使用OpenAPI定义的数据类型,是为了数据解耦。我们的查询模型比
hour.Hour
领域类型更复杂。这是一个常见的场景。通常,需要的数据是由网站的 UI 决定的,并且在后端直接组装好。随着应用程序的增长,领域模型和查询模型之间的差异可能会变得更大。由于分离和解耦,我们可以独立地对两者进行更改。这对于保持长期快速发展至关重要。
但是
AvailableHoursReadModel
从哪里获取数据呢?对于应用层来说,是完全透明的,不相关的。这使我们能够在未来添加性能优化时仅涉及应用程序这部分。实际上,当前的实现从我们的写入模型数据库中获取数据。您可以在适配器层中找到
DatesFirestoreRepository
的 AllTrainings
读取模型实现和测试。如果您之前了解过 CQRS,通常建议使用根据事件构建的单独数据库进行查询,在非常特殊的情况下这可能是个好主意。我将在未来的优化部分中描述它。在我们的例子中,只需从写入模型数据库获取数据就足够了。
HourAvailabilityHandler 查询
如果查询模型不需要进行多次查询后再聚合数据,那么我们不需要为每个查询添加读取模型接口。直接使用领域存储库并选择我们需要的数据也很好。
命名
命名是软件开发中最具挑战性和最重要的部分之一。在领域驱动设计精简版中,我描述了一条规则,即您应该坚持使用尽可能接近非技术人员(通常称为“业务”)交谈方式的语言。它也适用于命令和查询名称。您应该避免使用“创建训练”或“删除训练”之类的名称。这不是企业和用户理解您的域的方式。您应该使用“安排训练”和“取消训练”。我们将在有关通用语言的章节中更深入地讨论这个主题。在那之前,只需去找您的业务人员,听听他们如何称呼运营即可。如果您的任何命令名称确实需要以“创建/删除/更新”开头,请三思。
未来的优化
Basic CQRS 具有一些优势,例如更好的代码组织、解耦和简化模型。还有一个更重要的优势,它能够使用更强大、更复杂的模式扩展 CQRS 的能力。
异步命令
有些命令本质上很慢。他们可能正在执行一些外部调用或一些繁重的计算。在这种情况下,我们可以引入异步命令总线,它在后台执行命令。
使用异步命令有一些额外的基础设施要求,例如拥有队列或发布/订阅。幸运的是,Watermill库可以帮助您在 Go 中处理这个问题。您可以在 Watermill CQRS 文档中找到更多详细信息。 (顺便说一句,我们也是 Watermill 的作者,如果有什么不清楚的地方,请随时与我们联系!)
用于查询的单独数据库
我们当前的实现使用相同的数据库进行读取(查询)和写入(命令)。如果我们需要提供更复杂的查询或真正快速的读取,我们可以使用多语言持久性技术。
这个想法是以更优化的格式在另一个数据库中复制查询的数据。例如,我们可以使用Elastic来索引一些可以更容易搜索和过滤的数据。
在这种情况下,数据同步可以通过事件来完成。这种方法最重要的影响之一是最终一致性。您应该问问自己,这在您的系统中是否是可以接受的权衡。如果您不确定,您可以在没有多语言持久性的情况下开始,然后再迁移。推迟像这样的关键决定是件好事。
Watermill CQRS 文档中也描述了示例实现。也许随着时间的推移,我们也会在Wild Workouts中引入它,谁知道呢?
事件溯源
如果您在具有严格审计要求的领域工作,那么您一定需要检查事件溯源技术。例如,我目前在金融领域工作,事件溯源是我们默认的持久性选择。它提供开箱即用的审计并有助于恢复一些错误影响。
CQRS 通常与事件溯源一起描述。原因是,根据事件源系统的设计,我们不会以可供读取(查询)的格式存储模型,而只是以写入(命令)使用的事件列表。换句话说,提供任何 API 响应变得更加困难。
由于命令和查询模型的分离,这并不是一个大问题。我们的查询读取模型在设计上是独立存在的。事件溯源还有更多在金融系统中显而易见的优势。但让我们把它留到另一章吧。在此之前,您可以查看 Greg Young 的电子书 – Versioning in an Event Sourced System。描述 CQRS 的正是 Greg Young。
何时不使用 CQRS?
CQRS 并不是万能的灵丹妙药。一个很好的例子就是授权。您提供登录名和密码,如果成功,您会得到确认,也许还会得到一些令牌。
如果您的应用程序是简单的CRUD,接收并返回相同数据,那么它也不是 CQRS 的最佳情况。这就是 Wild Workouts 中的用户微服务不使用 Clean Architecture 和 CQRS 的原因。在简单的、面向数据的服务中,这些模式通常没有意义。另一方面,您应该密切关注此类服务。如果您注意到逻辑增长并且开发很痛苦,也许是时候进行一些重构了?
使用 CQRS 通过 API 返回创建的实体
我知道有些人在将 CQRS 用于 REST API 时遇到问题,该 API 将创建的实体作为 POST 请求的响应返回。这不是反对CQRS吗?并不真地!可以通过两种方式解决:
- 在HTTP服务的Port中调用命令,成功后调用查询获取要返回的数据
- 不返回创建的实体,而是返回带有header content-location的204 HTTP代码设置为创建的资源 URL
第二种方法在我看来更好,因为它不需要总是查询创建的实体(第一种方法即使客户端不需要这些数据,也需要查询)。使用第二种方法,客户端仅在需要时才会点击链接。它还可以通过该调用进行缓存。
唯一的问题是如何获取创建的实体的ID?常见的做法是提供要在命令中创建的实体的 UUID。这种方法的优点是,如果命令处理程序是异步的,它仍然可以按预期工作。如果您不想使用 UUID,作为最后的手段,您可以从处理程序返回 ID – 这不会是世界末日。