最全面的CQRS和事件溯源介绍 - Software House ASC

19-05-23 banq
                   

CQRS(Command-Query Responsibility Segregation) 是一种模式,它告诉我们将数据的查询与数据的操作分开。

它源于Bertrand Mayer设计的命令查询分离(CQS)原理。CQS声明一个类只能有两种方法:改变状态并返回void的方法和返回状态但不改变它的方法。

Greg Young 是负责命名这种模式为CQRS 并推广它的人。如果您在互联网上搜索CQRS,您会发现许多由Greg制作的优秀帖子和视频。例如,你可以找到在CQRS模式的优秀和非常简单的解释在这个帖子。

我们想要展示保险领域的例子 - PolicyService。负责管理保险单的服务。以下是在应用CQRS之前具有接口的代码段。所有方法(写入和读取)都在一个类中。

interface PolicyService {
    void ConvertOfferToPolicy(ConvertOfferRequest convertReq);
    PolicyDetailsDto GetPolicy(long id);
    void AnnexPolicy(AnnexRequestDto annexReq);
    List SearchPolicies(PolicySearchFilter filter);
    void TerminatePolicy(TerminatePolicyRequest terminateReq);
    void ChangePayer(ChangePayerRequest req);
    List FindPoliciesToRenew(RenewFilter filter);
}

如果我们在这种情况下使用CQRS模式,我们会得到两个独立的类,更好地满足SRP原则。

interface PolicyComandService {
    void ConvertOfferToPolicy(ConvertOfferRequest convertReq);
    void AnnexPolicy(AnnexRequestDto annexReq);
    void TerminatePolicy(TerminatePolicyRequest terminateReq);
    void ChangePayer(ChangePayerRequest req);
}

interface PolicyQueryService {
    PolicyDetailsDto GetPolicy(long id);
    List SearchPolicies(PolicySearchFilter filter);
    List FindPoliciesToRenew(RenewFilter filter);  
}

这是应用CQRS的第一步。什么是简单的转变会带来很大的后果并开辟新的可能性,我们将在本文的后面部分进行探讨。

CQRS能做什么?

大多数时候,改变状态所需的数据在形式或数量上都不同于用户需要查询所需的数据。使用相同的模型来一起处理查询和命令会会导致模型膨胀,只依靠一种类型来操作所需的所有东西,模型复杂性也会增加,聚合大小通常会更大。

CQRS使我们能够使用不同的模型来改变状态和不同的模型来支持查询。通常写操作的频率低于读操作。 具有单独的模型和分离的数据库引擎允许我们独立地扩展查询端并更好地处理并发访问,因为读取端不再堵塞写入或命令端(在相反的情况下)。

使用单独的命令和查询模型,我们可以将这些职责分配给具有不同技能的不同团队。例如,您可以为高技能的OOP开发人员分配命令端,而熟悉SQL开发人员可以实现查询端。CQRS让您扩展您的团队,让您最好的开发人员专注于核心的东西。

CQRS是一个架构吗?

人们常常弄错了。CQRS不是顶级/系统级架构。架构的示例包括:分层端口和适配器(六角形或六边形架构)。CQRS是您在服务/应用程序“内部”应用的模式,您只能将其应用于您的部分服务。(banq注:CQRS是一种服务模型,微服务的模型,也就是指导你怎么做微服务的)

实施示例 

有许多方法可以实现CQRS。它们具有不同的后果,这种解决方案的复杂性和适用性取决于您的系统环境。如果您是拥有1.5亿用户的Netflix,您需要采用不同的方法,并且不同的解决方案适用于仅有数百名用户的典型企业应用。我们认为,特别是在处理现有(遗留)项目时,最好的方法是解决CQRS的演变问题。

我们从不使用CQRS的解决方案开始。

UI(通过控制器层)使用服务外观层,该层负责协调域模型执行的业务操作。模型存储在关系数据库中。在我们的示例中,有PolicyService类(JavaC#),它负责处理与策略相关的所有业务方法。

我们使用一个模型进行读写。执行业务操作时,我们使用搜索功能也是通过相同类实现,这可能会导致您的域模型只具有搜索所需的属性,或者更糟糕的是,您可能会强制设计域模型以便更轻松地查询它。

在这个例子中,我们想要显示开发人员使用分离模型进行写入侧和读取侧的代码。

在该示例中,使用中介者模式如XXXHandler,中介的作用是确保将命令或查询传递给其处理程序。中介接收命令/查询,该命令/查询只不过是描述意图的消息,并将其传递给处理程序,然后处理程序负责调用领域模型执行预期的行为。因此,可以将此过程视为对服务层的调用 - 总线在其间进行消息的管道连接。在Java示例中,我们创建了Bus类,它是此模式的实现。Registry负责将处理程序与命令/查询相关联。在C#示例中,我们使用MediatR库为我们完成所有这些。您可以在我们的另一篇文章中阅读有关MediatR的更多信息。

现在我们有了单独的命令和查询入口点,我们可以引入不同的模型来处理它。NoCQRS解决方案使用RDBMS和ORM - 企业应用程序中的典型堆栈。通过此改变,我们可以将域模型用作命令模型。这个模型得到了简化:一些关联仅用于不再需要的读取查询,一些字段不再需要。

在查询模型上,我们可以在数据库中定义视图并使用ORM映射它,或者,对于查询模型,我们可以停止使用重量级ORM并将其替换为普通的旧JDBC模板或Java中的JOOQ或在.NET中的Dapper

如果要避免在数据库中定义视图的复杂查询,可以执行下一步,并使用旨在处理查询的表替换视图。这些表将具有简单的结构,数据映射为用户在屏幕上看到的内容以及用户需要搜索的内容。(banq注:专门为查询读取设计的数据表结构)。添加这种类型的表替代数据库视图消除了编写复杂查询的负担,并为扩展解决方案开辟了新的可能性,但它要求您以某种方式使您的域命令模型与查询模型表保持“同步”。

同步方式:

  • 使用Spring中的应用程序事件(示例)或使用域事件在同一事务中同步
  • 在命令处理程序中的同一事务中同步,
  • 异步使用某种内存事件总线,导致最终的一致性,
  • 异步使用像RabbitMQ这样的某种队列中间件,从而最终实现一致性。

最佳实践:

  • 您应该为每个屏幕/窗口小部件(屏幕片段)构建一个表/视图。
  • 表之间的关系应该是屏幕元素之间关系的模型。
  • “查看表格”包含屏幕上显示的每个字段的列。
  • 读取模型不应该进行任何计算,而是在命令模型中计算数据并更新读取模型。
  • 读模型应存储预先计算的数据。
  • 最后但同样重要的是:不要害怕重复。

命令模型和查询模型之间同步方法的选择取决于许多标准。即使使用数据库视图,您也可以获得很好的结果,因为您可以使用只读副本来扩展数据库,该副本仅用于查询您创建的视图。

具有单独的表简化了读取,因为您不必再​​编写复杂的SQL,但您必须自己编写用于更新查询模型的代码。

没有神奇的框架会为你做这件事。与给定命令模型部件相关的读取模型的数量也是决策因素。如果您有一个聚合的2-3个查询模型,您可以安全地调用命令处理程序中的所有更新程序。它不会影响性能,但是如果你有10个,那么你可以考虑在更新聚合的事务之外异步运行它。在这种情况下,您必须检查是否允许最终一致性。这比业务决策更具商业决策,必须与业务用户讨论。

拥有单独的查询表是将CQRS解决方案提升到新水平的一个很好的步骤。

如果您想了解更多信息,请查看我们的示例,使用JavaC#

单独的存储引擎

在这种方法中,我们为查询模型和命令模型使用不同的存储引擎,例如:

  • ElasticSearch用于查询端,JPA用于命令端,
  • ElasticSearch用于查询端,DocumentDb用于命令端,
  • 用于查询的DocumentDb,在命令端的RDBMS中将聚合存储为JSON。

每个命令处理程序都应该发出包含所发生事件 ,领域事件Event是一个命名对象,表示在指定对象中发生的某些更改。事件应提供有关在业务操作期间更改的数据的信息。事件是域的一部分。在我们的示例中,我们有一些关于保险政策的事件 - PolicyCreated,PolicyAnnexed,PolicyTerminated,PolicyAnnexCancelledJava示例C#示例)。

在读取方面,我们创建了事件处理程序(方法在特定类型的事件进入时执行),它们负责事件的投影创建(banq注:把事件再执行一遍更改查询数据表,此为事件的投影)。这些事件处理程序对持久性读取模型(Java示例C#示例)执行CRUD操作。

什么是投影?投影是将事件流转换(或聚合)为数据表结构或数据库视图的过程。投影是将事件流转换(或汇总)为结构表示。这可以称为许多名称:持久性读取模型,查询模型或视图。

通过这种方法,我们可以应用不同的工具来执行查询,并使用不同的工具来执 通过这种方式,我们可以实现更好的性能和可伸缩性,但却以复杂性为代价。在典型的业务系统中,系统中执行的绝大多数操作将使用读取侧/查询模型。该元素应该为更高的负载做好准备,它应该是可扩展的,并允许构建允许高级搜索的复杂查询。使用这种方法,我们将不得不处理最终的一致性,因为各种数据源之间的分布式事务是性能杀手,而大多数NoSQL数据库都不支持它。

CQRS与事件采购(CQRS-ES)

下一步是更改命令端以使用事件源。这个版本的架构非常类似于上面(当我们使用单独的存储引擎时)。

关键区别在于命令模型。我们使用Event Store作为持久存储,而不是RDBMS和ORM。我们不保存实际的对象状态,而是保存事件流。这种管理状态的模式被命名为Event Sourcing 

我们不是通过改变先前的状态来保持系统的当前状态,而是将事件(变化)附加到过去事件(变化)的顺序列表中。这样我们不仅可以了解系统的当前状态,还可以轻松跟踪我们是如何达到这种状态的。

下面的示例显示了基于足球游戏比赛域的不同状态管理方法。

上图显示了Game对象的传统状态管理。我们有关于比赛结果以及比赛开始/结束的信息。当然,我们可以在这里建模其他信息,例如得分目标列表,犯规犯规列表,角落列表。但是,您必须承认 - 足球比赛的领域理想地由一系列随时间发生的事件描述。

当使用Event Sourcing来管理Game对象的状态时,我们可以准确地重现整个比赛。我们有关于哪些事件影响了当前对象状态的信息。上图显示每个事件都反映在特定的类中。这就是Event Sourcing的神奇之处。

大多数文章中提到的主要事件溯源优势之一是您不会丢失任何信息。在传统模型中,每次更新都会删除以前的状态 之前的状态丢失了。您可以说,有像Envers这样的日志,备份和库,但它们并没有为您提供有关更改原因的明确信息。它们只显示数据已更改的内容,而不是原因。在事件源方法中,您可以在域中的业务事件之后为事件建模,因此它不仅显示数据更改,还显示更改原因。

下一个优点是,通过一系列事件保存域聚合可以极大地简化持久性模型。您不再需要设计表格和它之间的关系。您不再受ORM可以和不能映射的限制。在使用像Hibernate这样非常先进的解决方案时,我们发现了一些情况,当我们不得不从我们域中的某些设计概念中辞职时,因为很难或不可能映射到数据库。

有越来越多的解决方案支持使用Event Sourcing(EventStoreStreamstoneMartenAxonEventuate)创建应用程序。在我们的示例中,我们使用从Greg Young的示例派生的内存事件存储(Java示例C#示例)的自己实现。这不是生产就绪的实现。对于生产级解决方案,您应该应用更复杂的解决方案,如EventStoreAxon

哪些系统值得使用事件采购?

  • 你的系统有许多不是普通CRUD的行为,
  • 重建对象的历史状态非常重要,
  • 商业用户看到拥有统计,机器学习或其他目的的完整历史的优势,
  • 您的领域最好由事件描述(例如,跟踪辅助车辆活动的应用程序 banq注:物联网等跟踪系统,跟踪钱流,跟踪物流,跟踪信息流)。

我应该使用CQRS / ES框架吗?

如果您对CQRS / ES没有经验,则不应该从任何框架开始。从核心域开始,实现一些业务功能。当您的业务开始工作时,请关注技术内容。在开始实现自己的事件存储或命令总线之前,请评估Event Store或Axon等可用选项。有很多事情需要考虑,还有许多陷阱(并发,错误处理,版本控制,模式迁移)。

总结

有两个阵营:一个说你应该总是使用CQRS / ES,另一个说你应该只使用你的解决方案的一部分,并且只有当你需要具有高性能/可用性/可扩展性系统的高度并发系统时。您应该始终根据您的要求评估您的选择。

即使是最简单的CQRS形式也能在不增加复杂性的情况下为您提供良好的结果。例如,使用视图进行搜索而不是使用域模型可以简化事情。在我们的系统中,我们还发现很多地方添加专门的读取模型表并同步更新它们给了我们非常好的结果(比如摆脱20多个表连接4个联合的视图定义并用一个表替换它)。  只要允许最终的一致性,使用像ElasticSearch这样的专用搜索引擎也是一个安全的选择。

如果您选择使用不同的存储引擎,事件总线和其他技术组件,CQRS可能会产生非常复杂的技术解决方案。只有一些复杂的场景和可扩展性要求才能证明这种复杂性(如果你在Netflix规模上运行)。同时,您还可以使用简单的技术解决方案应用CQRS,并从此模式中受益 - 您不需要Kafka来执行CQRS。

我们为这篇博客文章准备了两个版本的demo,一个用于Java开发人员,第二个用于.NET开发人员。以下链接:

CQRS的利弊​​​​​​​

优点:

  • 更好的系统性能和可扩展性,
  • 更好的并发访问处理,
  • 更好的团队可扩展性,
  • 不太复杂的域模型和简单的查询模型。

缺点:

  • 读写模型必须保持同步,
  • 如果您选择两个不同的引擎进行读取和写入,维护和管理成本,
  • 最终的一致性并不总是允许的。

ES事件溯源利弊

优点:

  • 仅附加模型非常适合性能,可扩展性
  • 没有死锁
  • 事件(事实)被商业专家很好地理解,一些领域本质上是事件来源:会计,医疗保健,交易
  • 审计跟踪免费
  • 我们可以在任何时间点获得对象状态
  • 易于测试和调试
  • 数据模型与域模型分离
  • 无阻抗不匹配(对象模型与数据模型)
  • 灵活性 - 可以从相同的事件流构建许多不同的域模型
  • 我们可以将此模型用于逆转事件,追溯事件
  • 没有更多的ORM - 由于我们的对象是根据事件构建的,我们不必在关系数据库中反映它

缺点:

  • 开发人员管理状态和构建聚合不是很自然的方式,需要时间来习惯
  • 查询超出一个聚合更难(您必须为要添加到系统的每种类型的查询构建投影),
  • 事件模式更改比关系模型(缺少标准模式迁移工具)困难得多
  • 你必须从一开始就考虑版本控制处理。

​​​​​​​

 

                   

3