快速轻巧的CQRS和事件源解决方案

介绍 (Introduction)

If you are reading this article, then you probably already know something about Command Query Responsibility Segregation (CQRS) and Event Sourcing (ES), so I won't explain what that is, why you might want to use it, or why you might want to avoid it. There is a lot of good information available if you are looking for that kind of material.

如果您正在阅读本文,那么您可能已经了解有关命令查询责任隔离(CQRS)和事件源(ES)的知识,所以我将不解释其含义,为什么要使用它或为什么要使用它。想要避免它。 如果您正在寻找这种材料,那么有很多有用的信息。

For example:

例如:

The purpose of this article is to present a fast and lightweight implementation using the C# programming language and the .NET Framework.

本文的目的是提供使用C#编程语言和.NET Framework的快速,轻量级的实现。

This implementation is relatively full-featured, including support for SQL Server persistence of commands and events, scheduled commands, snapshots, sagas (i.e., process managers), and plug-and-play overrides for multitenant customization.

此实现功能比较全面,包括支持SQL Server命令和事件的持久性,计划的命令,快照,sagas(即流程管理器)以及用于多租户自定义的即插即用替代。

I will describe how the code is structured and illustrate how it works with a sample application that follows the Clean Architecture pattern.

我将描述代码的结构,并说明其如何与遵循Clean Architecture模式的示例应用程序一起使用。

何必呢? (Why Bother?)

That is almost certain to be your first question.

几乎可以肯定这是您的第一个问题。

There are many CQRS+ES frameworks and solutions already out there, fully-developed and well-proven. If you are researching your options and evaluating a build-vs-buy decision, then there are excellent commercial products and open-source alternatives from which to choose.

已经有很多CQRS + ES框架和解决方案,这些框架和解决方案已经得到充分开发和充分验证。 如果您正在研究选择方案并评估“建造与购买”决策,那么您可以选择出色的商业产品和开源替代方案。

For example:

例如:

Why implement another solution? Why have I started from scratch and developed my own?

为什么要实施另一种解决方案? 为什么我要从头开始并发展自己的?

I have studied CQRS and ES patterns for several years now. I have worked with commercial and open-source solutions, and I have used them to build and/or improve real-world production systems. If your background and experience is similar to mine, then you probably know the answer to this question already, but you might not want to believe it's true (as I did not for a long time):

我研究CQRS和ES模式已有几年了。 我曾经使用过商业和开源解决方案,并且已经使用它们来构建和/或改进实际的生产系统。 如果您的背景和经历与我的相似,那么您可能已经知道该问题的答案,但是您可能不想相信这是真的(因为我很久没有这样做):

If you are serious about adopting a CQRS+ES architecture, then you might have no choice except to build your own.

如果您认真考虑采用CQRS + ES架构,那么除了自行构建之外,别无选择。

As Chris Kiehl says in his article, "Event Sourcing is Hard":

就像克里斯·基尔(Chris Kiehl)在他的文章中说的那样,“ 事件搜索很难 ”:

... you're probably going to be building the core components from scratch. Frameworks in this area tend to be heavyweight, overly prescriptive, and inflexible in terms of tech stacks. If you want to get something up and running ... rolling your own is the way to go (and a suggested approach).

...您可能会从头开始构建核心组件。 就技术堆栈而言,该领域的框架往往是重量级,规范性强,缺乏灵活性的框架。 如果您想启动并运行某事,则应该自己动手做(这是建议的方法 )。

If that's the case, then how does it help you for me to write this article?

如果是这样,那么对我来说写这篇文章有什么帮助?

Simple: It's another example, with source code, so you can see my approach to solving some of the problems that arise in a CQRS+ES implementation. If you are starting out on a CQRS+ES project, then you should study all the examples you can find.

简单:这是另一个带有源代码的示例,因此您可以看到我解决CQRS + ES实现中出现的一些问题的方法。 如果您是从CQRS + ES项目开始的,那么您应该研究发现的所有示例。

I do not expect (or recommend) that you take this source code and incorporate it into any application you develop.

我不希望(或建议)使用此源代码并将其合并到您开发的任何应用程序中。

Instead, my intent is only to provide yet another example, from which you can draw some ideas - and perhaps (if I do a decent job of this) some small inspiration - for your own project.

相反,我的意图仅是提供另一个示例,您可以从中得出一些想法-也许(如果我做得不错的话)为您自己的项目提供一些灵感。

优先事项 (Priorities)

It is important to begin with a list of the priorities driving this implementation, because there are significant trade-offs to many of the design decisions I have made.

重要的是要从驱动实现的优先级列表开始,因为要做出的许多设计决策都需要进行重大的权衡。

CQRS+ES purists will object to some of my decisions, and flatly condemn others. I can live with that. I have been designing and developing software for a long time (longer than I am prepared to admit here). I have shed more than a little blood, sweat, and tears - and a few gray hairs too - so I am acutely aware of the cost associated with a poor choice in the face of a trade-off.

CQRS + ES的纯粹主义者会反对我的某些决定,并坚决谴责其他决定。 我可以忍受这一点。 我已经设计和开发软件很长时间了(比我准备在这里承认的时间还长)。 我流血,流汗,流泪,还流下了几根白发,因此,我敏锐地意识到,面对权衡取舍,选择不当所带来的成本。

The following priorities help to inform and guide those decisions. They are listed in rough order of importance, but all are requirements, so pour yourself a drink and settle in, because the preamble here is going to be a long one...

以下优先事项有助于告知和指导这些决定。 它们以重要性的高低顺序列出,但是所有都是必需的,所以请给自己倒酒并安顿下来,因为这里的序言将是很长的...

1.可读性 (1. Readability)

The code must be readable.

该代码必须可读

The more readable the code, the more usable and maintainable and extensible it is.

代码越可读,它就越有用,可维护和可扩展。

In some implementations I have used (and in some I have developed myself), the code for the underlying CQRS+ES backbone is virtually impossible for anyone to understand except the original author. We cannot allow that here. It must be possible - and relatively easy - for a small team of developers to share and use the code, with a full understanding of how it works and why it is written in the way that it is.

在我使用过的某些实现中(以及在一些我自己开发的实现中),除了原始作者之外,几乎任何人都无法理解底层CQRS + ES主干的代码。 我们不能在这里允许。 一小组开发人员必须能够并且相对容易地共享并使用代码,并且充分了解其工作方式以及为什么以这种方式编写代码。

It is especially important to keep the code for registering command handlers and event handlers as simple and explicit as possible.

保持注册命令处理程序和事件处理程序的代码尽可能简单明了尤其重要。

Many CQRS+ES frameworks use a mix of reflection and dependency injection to automate the registration of subscribers for handling commands and events. While this is often very clever, and often decreases the overall number of lines of code in the project, it hides the relationships between a command (or an event) and its subscriber(s), moving those relationships into an opaque, magical black-box. Many inversion-of-control (IoC) containers make this easy to do, hence the understandable temptation, but I believe it's a mistake.

许多CQRS + ES框架使用反射和依赖注入的混合来自动注册用户,以处理命令和事件。 尽管这通常很聪明,并且通常会减少项目中代码的总行数,但它隐藏了命令(或事件)与其订阅者之间的关系,从而将这些关系变成了不透明,不可思议的黑色元素,框。 许多控制反转(IoC)容器使此操作变得容易,因此可以理解,但是我相信这是一个错误。

To be clear: it is not a mistake to use an IoC container in your project. Dependency injection is an excellent best practice and an important technique to perfect. However, the dependency injection pattern is not itself a publish-subscribe pattern, and conflating the two can lead to a lot of misery and woe. It is a mistake (I have made myself) to use highly specialized features in an IoC container library to automate functionality that is on the outside edge of that library's intended purpose, and then tightly couple the most critical components in your software architecture to that. When something in your application behaves unexpectedly, this can make it extraordinarily difficult and time-consuming to troubleshoot and debug.

需要明确的是:在项目中使用IoC容器并不是错误。 依赖注入是一种出色的最佳实践,也是一种完善的重要技术。 但是, 依赖项注入模式本身并不是发布-订阅模式 ,将两者混为一谈会导致很多痛苦和灾难。 在IoC容器库中使用高度专业化的功能来自动化该库预期用途之外的功能,然后将软件体系结构中最关键的组件紧密结合在一起是一个错误(我已经犯了一个错误)。 当您的应用程序中的某些行为异常时,这将使故障排除和调试异常困难且耗时。

Therefore, as part of this readability goal, the registration of command handlers and event handlers must be defined explicitly in the code, and not implicitly via convention or automation.

因此,作为此可读性目标的一部分,必须在代码中显式定义命令处理程序和事件处理程序的注册,而不是通过约定或自动化来隐式定义。

2.表现 (2. Performance)

The code must be fast.

代码必须是快速的

Handling commands and events is at the heart of any system developed on a CQRS+ES architecture, so throughput optimization is a key performance indicator.

处理命令和事件是在CQRS + ES架构上开发的任何系统的核心,因此吞吐量优化是关键的性能指标。

The implementation must handle the maximum possible volume, in terms of concurrent users and systems issuing commands and observing the effect of published events.

就并发用户和系统发出命令并观察已发布事件的影响而言,实现必须处理最大可能的数量。

In some of my previous implementations, a lot of pain and suffering was caused by concurrency violations that occurred when commands were sent to large aggregates (e.g., long-lived aggregates with massive event streams). More often than not, the root cause was poor-performing code. Therefore algorithm optimization is critical.

在我以前的一些实现中,很多痛苦和苦难是由于将命令发送到大型聚合(例如,具有大量事件流的长寿命聚合)时发生的并发冲突引起的。 根本原因通常是性能不佳的代码。 因此,算法优化至关重要。

Snapshots are integral to satisfying this requirement, therefore must be integral to the solution. The implementation must have built-in support for automated snapshots on every aggregate root.

快照是满足此要求所不可或缺的,因此必须是解决方案所不可或缺的。 该实现必须具有对每个聚合根上的自动快照的内置支持。

In-memory cache is another important part of run-time optimization, therefore must be integral to the solution as well.

内存中缓存是运行时优化的另一个重要部分,因此,它也必须是解决方案不可或缺的一部分。

3.可调试性 (3. Debuggability)

It must be easy to trace the code and follow its execution using a standard debugger like the Visual Studio IDE debugger.

使用标准调试器(例如Visual Studio IDE调试器)来跟踪代码并跟踪其执行必须很容易。

Many CQRS+ES implementations seem to rely on complex algorithms for dynamic registration, lookup, and invocation of methods for handling commands and events.

许多CQRS + ES实现似乎都依赖复杂的算法来动态注册,查找和调用用于处理命令和事件的方法。

Again, many of these algorithms are extremely clever: they pack a lot of power and flexibility, and can significantly decrease the number of lines of code in the solution.

同样,这些算法中的许多算法都非常聪明:它们具有强大的功能和灵活性,并且可以显着减少解决方案中的代码行数。

For example, I have used a DynamicInvoker class in some of my own past projects. It's an ingenious bit of code - less than 150 lines - and it works beautifully. (I didn't write it, so I'm not boasting when I say that.) However, if something goes haywire in code you've written that calls methods on this kind of class, and if you need to step through it with a debugger, then you'll need to be especially adept at the mental gymnastics required to follow what's going on. I am not, so if any dynamic invocation is used, then it must be trivially easy to understand the code and follow the thread of its execution when using a debugger.

例如,我在过去的一些项目中使用过DynamicInvoker类。 这是一段巧妙的代码-少于150行-而且效果很好。 (我没有写它,所以当我这么说的时候我并不自夸。)但是,如果代码中有些杂乱无章的东西,您已经编写了调用此类的方法的代码,并且如果需要使用调试器,那么您将需要特别擅长于跟随正在发生的事情的精神体操。 我不是,因此,如果使用任何动态调用,那么在使用调试器时,必须易于理解代码并遵循其执行线程。

4.最小的依赖 (4. Minimal Dependencies)

External dependencies must be kept to an absolute bare-metal minimum.

外部依赖性必须保持在绝对的最低限度。

Too many dependencies lead to code that is slower, heavier, and more brittle than you are likely to want in any critical components of your system. Minimizing dependencies helps to ensure your code is faster, lighter, and more robust.

过多的依赖性导致代码比您在系统的任何关键组件中可能需要的速度更慢,更重且更脆弱。 最小化依赖关系有助于确保您的代码更快,更轻巧,更健壮。

Most important, minimizing dependencies helps to ensure the solution is not tightly coupled with any external assembly, service, or component unless that dependency is critical.

最重要的是,最小化依赖性有助于确保解决方案不会与任何外部程序集,服务或组件紧密耦合,除非该依赖性至关重要。

If the fundamental architecture of your software is dependent upon some external third-party component, then you must be prepared for the potential that changes to it might someday have an impact on your project. Sometimes this is an acceptable risk, other times it is not.

如果软件的基本体系结构依赖于某些外部第三方组件,则必须做好准备,有可能某天对其进行更改可能会影响您的项目。 有时这是可以接受的风险,而其他时候则不是。

In this particular implementation, tolerance for this risk is very, very low.

在该特定实施方式中,对该风险的容忍度非常低。

Therefore, you will notice the core Timeline assembly (which implements the CQRS+ES backbone in my solution) has one and only one external dependency: i.e., the System namespace in the .NET Framework.

因此,您会注意到核心的Timeline程序集(在我的解决方案中实现了CQRS + ES主干)具有一个并且只有一个外部依赖关系:即.NET Framework中的System名称空间。

Just a quick aside here, because it is an interesting article that illustrates my point: At the time this article was written in 2018, the NPM JavaScript package "is-odd" had over 2.8 million installations in a single week. Rather than write the basic code for a function to return true if a number is odd, all those developers chose to incorporate the is-odd package into their solutions, along with its chain of 300+ dependencies!

此处不多提,因为这是一篇有趣的文章,阐明了我的观点:在撰写本文时,2018年,NPM JavaScript软件包“单数”在一周内有280万以上的安装。 所有这些开发人员都没有选择编写基本代码来让函数在数字为奇数时返回true的情况,而是选择将is-odd软件包及其300多个依赖项链合并到他们的解决方案中!

5.分开的命令和事件 (5. Separate Commands and Events)

Many CQRS+ES frameworks implement a Command class and an Event class in such a way that both derive from a common base class.

许多CQRS + ES框架都以从公共基类派生的方式实现Command类和Event类。

The rationale for this is obvious: it is natural to think of both Commands and Events as subtypes of a general-purpose Message. Both are "sent" using some form of "service bus", so why not implement common features in a shared base class, and write one dual-purpose class for routing messages - rather than write a lot of duplicate code?

这样做的理由很明显:将命令和事件都视为通用消息的子类型是很自然的。 两者都是使用某种形式的“服务总线”“发送”的,那么为什么不在共享基类中实现共同的功能,而编写一个双重用途的类来路由消息-而不是编写大量重复的代码呢?

This is an approach I have taken in the past, and there are good arguments for it.

这是我过去采用的方法,对此有很好的论据。

However, I now believe it might be a mistake. To quote Robert C. Martin:

但是,我现在认为这可能是一个错误。 引用罗伯特·马丁(Robert C. Martin)的话

Software developers often fall into a trap - a trap that hinges on their fear of duplication. Duplication is generally a bad thing in software. But there are different kinds of duplication. There is true duplication, in which every change to one instance necessitates the same change to every duplicate of that instance. Then there is false or accidental duplication. If two apparently duplicated sections of code evolve along different paths - if they change at different rates, and for different reasons - then they are not true duplicates... When you are vertically separating use cases from one another, you will run into this issue, and your temptation will be to couple the use cases because they have similar user interfaces, or similar algorithms, or similar database schemas. Be careful. Resist the temptation to commit the sin of knee-jerk elimination of duplication. Make sure the duplication is real.

软件开发人员经常陷入陷阱-陷阱取决于他们对重复的恐惧。 在软件中,复制通常是一件坏事。 但是有不同种类的重复。 确实存在重复,其中对一个实例的每次更改都必须对该实例的每个副本进行相同的更改。 然后是虚假或偶然的重复。 如果两个明显重复的代码部分沿着不同的路径发展-如果它们以不同的速率变化并且由于不同的原因-那么它们就不是真正的重复...当您将用例彼此垂直分离时,就会遇到这个问题,您的诱惑是将用例耦合在一起,因为它们具有相似的用户界面,相似的算法或相似的数据库模式。 小心。 抵制诱惑,避免重复犯下膝盖罪行。 确保重复是真实的。

Commands and events are sufficiently different from one another to warrant separate paths along which they can evolve and adapt to the requirements of your system.

命令和事件彼此之间有足够的不同,以保证它们可以沿着不同的路径发展并适应系统需求。

I have not (yet) experienced any scenario in which code quality, performance, or readability is improved by eliminating "duplicate" code for A) sending/handling commands, and B) publishing/handling events.

我还没有经历过通过消除A)发送/处理命令和B)发布/处理事件的“重复”代码来提高代码质量,性能或可读性的任何情况。

Therefore commands and events must not have any shared base class, and the mechanism used to send/publish commands/events must not be a shared queue.

因此,命令和事件一定不能具有任何共享基类,并且用于发送/发布命令/事件的机制一定不能是共享队列。

6.多租户 (6. Multitenancy)

Multitenancy must be integral to the solution, and not a feature or facility that is bolted on after-the-fact.

多租户必须是解决方案不可或缺的组成部分,而不是事后必须附加的功能或设施。

These days I build and maintain enterprise, multitenant systems exclusively. That means I have a single instance of a single application serving multiple concurrent tenants with multiple concurrent users.

这些天来,我专门构建和维护企业多租户系统。 这意味着我只有一个应用程序的单个实例,该实例为具有多个并发用户的多个并发租户提供服务。

There are several reasons for making multitenancy a priority in this implementation:

在此实施中将多租户作为优先事项有几个原因:

  • Every aggregate must be assigned to a tenant. This makes ownership of data clear and well-defined.

    每个集合必须分配给一个租户。 这使得数据的所有权清晰且定义明确。
  • Sharding must be easy to implement when the need arises to scale up. Sharding is the distribution of aggregates to multiple write-side nodes, and "tenant" is the most natural boundary along which to partition aggregates.

    当需要扩大规模时,分片必须易于实现。 分片是将聚合分布到多个写侧节点,“承租人”是划分聚合的最自然边界。
  • Tenant-specific customizations must be easy to implement. Every application has core default behavior for every command and every event, but in a large and complex application that serves many different organizations and/or stakeholders, different tenants are certain to have a variety of specific needs. Sometimes the differences are slight; sometimes they are significant. The solution here must allow the developer to override the default handling of a command and/or event with functionality that is custom to a specific tenant. Overrides must be explicit, so they are easy to identify and enable or disable.

    特定于租户的自定义必须易于实现。 每个应用程序对于每个命令和每个事件都有核心的默认行为,但是在为许多不同的组织和/或利益相关者服务的大型复杂应用程序中,不同的租户肯定具有各种特定需求。 有时差异很小。 有时它们很重要。 此处的解决方案必须允许开发人员使用针对特定租户定制的功能来覆盖命令和/或事件的默认处理。 覆盖必须是明确的,因此易于识别,启用或禁用。

7. Sagas /流程经理 (7. Sagas / Process Managers)

The number of steps required to implement a process manager must be relatively small, and the code for a process manager must be relatively easy to write.

实现流程管理器所需的步骤数量必须相对较少,并且流程管理器的代码必须相对易于编写。

A process manager (sometimes called a saga) is an independent component that reacts to domain events in a cross-aggregate, eventually consistent manner. A process manager is sometimes purely reactive, and sometimes represents a workflow.

流程管理器(有时称为saga)是一个独立的组件,它以交叉聚合,最终一致的方式对域事件做出React。 流程管理器有时纯粹是被动的,有时代表工作流。

From a technical perspective, a process manager is a state machine driven forward by incoming events, which might be published from multiple aggregates. Each state can have side effects (e.g., sending commands, communicating with external web services, sending emails).

从技术角度来看,流程管理器是一种状态机,受传入事件的驱动,这些事件可能是从多个聚合发布的。 每个状态都有副作用(例如,发送命令,与外部Web服务通信,发送电子邮件)。

I have worked with some CQRS+ES frameworks that do not support process managers at all, and others that support the concept but not in a way that is easy to understand or configure.

我曾使用过一些CQRS + ES框架,这些框架根本不支持流程管理器,而其他框架则支持该概念,但并不以易于理解或配置的方式。

For example, in one of my own past implementations, an event was published by the event store immediately after the event was appended to the database log. It was not published by the aggregate or by the command handler. This made it unusually difficult to implement even the most basic workflow: I could not send a synchronous command to an aggregate from within an event handler, because the event store's Save method executed inside a synchronization lock (to maintain thread-safety), and new events could not be published without creating a deadlock.

例如,在我自己过去的实现中, 事件存储在事件附加到数据库日志之后立即由事件存储发布。 它不是由聚合或命令处理程序发布的。 这甚至使执行最基本的工作流程也变得异常困难:我无法从事件处理程序中向聚合发送同步命令,因为事件存储区的Save方法在同步锁(以维护线程安全)内执行,而new不创建死锁就无法发布事件。

Regardless of how simple or complex the state-machine for a workflow happens to be, coordinating the events in that process requires code that has side effects, such as sending commands to other aggregates, sending requests to external web services, or sending emails. Therefore, the solution here must have native, built-in support for achieving this.

无论工作流的状态机多么简单或复杂,要协调该流程中的事件,都需要具有副作用的代码,例如向其他聚合发送命令,向外部Web服务发送请求或发送电子邮件。 因此,此处的解决方案必须具有本地的内置支持才能实现此目的。

8.排程 (8. Scheduling)

The scheduling of commands must be integral to the solution.

命令调度必须是解决方案不可分割的一部分。

It must be easy to send a command with a timer, so the command executes only after the timer elapses. This enables the developer to indicate a specific date and time for the execution of any command.

使用计时器发送命令必须很容易,因此该命令仅在计时器经过后才执行。 这使开发人员可以指示执行任何命令的特定日期和时间。

This is useful for commands that must be triggered on a time-dependency.

这对于必须依赖时间触发的命令很有用。

It is also useful for commands that must be executed "offline", in a background process outside the normal flow of execution. This type of totally asynchronous operation is ideal for a command that is expected to require a long time to complete.

这对于在正常执行流程之外的后台进程中必须“脱机”执行的命令也很有用。 这种类型的完全异步操作非常适合需要较长时间才能完成的命令。

For example, suppose you have a command that requires the invocation of a method on some external third-party web service, and suppose that service often takes more than 800,000 milliseconds to respond. Such a command must be scheduled to execute during off-peak hours, and/or outside the main thread of execution.

例如,假设您有一条命令要求在某些外部第三方Web服务上调用方法,并且该服务通常需要超过80万毫秒才能响应。 必须安排此类命令在非高峰时间执行和/或在执行的主线程之外执行。

9.累计到期 (9. Aggregate Expiration)

The solution must have native, built-in support for aggregate expiration and cleanup.

解决方案必须具有对聚合到期和清除的本机内置支持。

I need a CQRS+ES solution that makes it easy to copy an aggregate event stream from the online structured log to offline storage, and purge it from the event store.

我需要一个CQRS + ES解决方案,该解决方案可以轻松地将聚合事件流从联机结构化日志复制到脱机存储,并从事件存储中清除它。

Event sourcing purists will red-flag this immediately and say the event stream for an aggregate must never be altered or removed. They'll say that events (and therefore aggregates) are immutable by definition.

事件源极简主义者将立即对此进行红色标记,并说绝不可更改或删除聚合事件流。 他们会说事件(因此是聚集)根据定义是不可变的。

However, I have scenarios in which this is a non-negotiable business requirement.

但是,在某些情况下,这是不可协商的业务需求。

  • First: When a customer does not renew their subscription to a multitenant application, the service provider hosting the application often has a contractual obligation to remove that customer's data from its systems.

    第一:当客户不续签对多租户应用程序的订阅时,托管该应用程序的服务提供商通常具有合同义务,要求从其系统中删除该客户的数据。

  • Second: When a project team runs frequent integration tests to confirm system functions are operating correctly, the data input to and output from those tests is temporary by definition. Permanent storage of the event streams for test aggregates is a waste of disk space with no current or future business value; we need a mechanism for removing it.

    第二:当项目团队进行频繁的集成测试以确认系统功能正常运行时,从定义上看,输入和输出这些测试的数据是临时的。 用于测试聚合的事件流的永久存储是浪费磁盘空间,没有当前或将来的业务价值; 我们需要一种删除它的机制。

Therefore, the solution here must provide an easy way to move aggregates out of the operational system and into "cold storage", so to speak.

因此,可以说,这里的解决方案必须提供一种简便的方法将聚合从操作系统中移出并进入“冷存储”。

10.异步/等待是邪恶的 (10. Async/Await is Evil)

I am joking, of course.

我当然在开玩笑。

But not really.

但事实并非如此。

The async/await pattern in C# produces very high-performance code. There is no question about this. In some cases, I have seen it boost performance by an order of magnitude or more.

C#中的异步/等待模式产生了非常高性能的代码。 毫无疑问。 在某些情况下,我已经看到它将性能提高了一个数量级或更多。

The async/await pattern may be applied in a future iteration of this solution, but - despite the second priority in this list - it is disallowed in this solution, because it breaks the first priority.

异步/等待模式可能会在此解决方案的未来迭代中应用,但是-尽管此列表中具有第二优先级-但此解决方案不允许使用它,因为它破坏了第一优先级。

As soon as you introduce async/await into a method, you are forced to transform its callers so they use async/await (or you are forced to start wrapping clean code in dirty threading blocks), and then you are forced to transform the callers of those callers so they use async/await... and the async/await keywords spread throughout your entire code base like a contagious zombie virus. The resulting asynchronous mess is almost certain to be faster, but at the same time much more difficult to read, and even more difficult to debug.

在将async / await引入方法后,您将被迫转换其调用方,以便它们使用async / await (或者被迫将干净的代码包装在脏线程块中),然后被迫转换调用方这些调用者中的一部分,因此他们使用async / await ...,而async / await关键字像传染性的僵尸病毒一样散布在整个代码库中。 由此产生的异步混乱几乎可以肯定会更快,但同时更难阅读,甚至更难调试。

Readability is the highest priority here, therefore I am avoiding async/await until it is the only remaining option for boosting performance (and that added boost is itself a non-negotiable business requirement).

可读性是这里的重中之重,因此我避免使用async / await直到它是提高性能的唯一剩余选择(而且提高性能本身是不可商议的业务要求)。

清洁建筑 (Clean Architecture)

Matthew Renze has an excellent Pluralsight course on the topic of clean architecture. The source code for this solution contains five assemblies and it follows the clean architecture pattern that he advocates. This is overkill for a sample application, obviously, but it helps to establish the pattern that the larger enterprise implementation needs to follow.

马修· 伦兹( Matthew Renze)在清洁建筑主题方面有一门优秀的Pluralsight课程 。 该解决方案的源代码包含五个程序集,并且遵循他所倡导的干净架构模式。 显然,这对于示例应用程序来说是多余的,但是它有助于建立大型企业实现需要遵循的模式。

时间表项目 (Timeline Project)

The Timeline assembly implements the CQRS+ES backbone. This assembly has no upstream dependencies, and therefore it is not specific to any application. It can be unplugged from the sample application and integrated into a new solution to develop an entirely different application.

时间轴程序集实现了CQRS + ES主干。 该程序集没有上游依赖性,因此它并不特定于任何应用程序。 可以从示例应用程序中拔出它,并将其集成到新解决方案中以开发完全不同的应用程序。

The other four assemblies (Sample.*) implement the layers in a console application using the Timeline assembly to demonstrate my approach to common programming tasks in a CQRS+ES software system.

其他四个程序集( Sample。* )使用时间轴程序集在控制台应用程序中实现各层,以演示我在CQRS + ES软件系统中执行常见编程任务的方法。

The project dependency diagram is illustrated here:

项目依赖关系图如下所示:

Figure-1

样例项目 (Sample Projects)

Notice the Timeline assembly has no references to any Sample assembly.

注意,时间轴程序集没有引用任何Sample程序集。

Also notice the domain-centric approach: the Domain layer has no dependencies on the Presentation, Application, or Persistence layers.

还要注意以域为中心的方法:域层不依赖于Presentation,Application或Persistence层。

The entity relationship diagram for the sample domain is illustrated in Figure 2:

示例域的实体关系图如图2所示:

Figure-2

In this basic data model:

在此基本数据模型中:

  • a Person had 0..N bank Accounts;

    一个Person有0..N个银行Account

  • a Transfer withdraws money from one account and deposits it in another account;

    Transfer从一个帐户提取资金并将其存入另一个帐户;

  • a User may be an administrator with no personal data, or someone with personal data owned by multiple tenants

    User可以是没有个人数据的管理员,也可以是拥有多个租户拥有的个人数据的某人

Keep in mind: every Person, Account, and Transfer is an aggregate root, therefore each of these entities has a Tenant attribute.

请记住:每个PersonAccountTransfer是一个聚合根,因此每个这些实体都有一个Tenant属性。

总览 (Overview)

The overall approach to CQRS+ES in this solution is illustrated in Figure 3:

此解决方案中的CQRS + ES总体方法如图3所示:

Figure-3

Notice the Write Side (Commands) and Read Side (Queries) are well-delineated.

请注意,写面(命​​令)和读面(查询)已被很好地描述。

You can also see that Event Sourcing is very much like a plug-in to the Write Side. Although it is not demonstrated in this solution, you can see how a CQRS solution without Event Sourcing might look, and sometimes that (CQRS-alone) is a better pattern, depending on the requirements for your project.

您还可以看到,事件源非常类似于Write Side的插件。 尽管此解决方案中未进行演示,但是您可以看到不带事件源的CQRS解决方案的外观,有时(取决于CQRS)是更好的模式,具体取决于项目的要求。

Here are the key characteristics of the architecture:

以下是该体系结构的关键特征:

  • The command queue saves commands (required for scheduling) in a structured log.

    命令队列将命令(调度所需)保存在结构化日志中。
  • A command subscriber listens for commands on the command queue.

    命令订阅者在命令队列上侦听命令。
  • A command subscriber is responsible for creating an aggregate and invoking methods on an aggregate when commands are executed.

    命令订户负责创建聚合并在执行命令时在聚合上调用方法。
  • A command subscriber saves an aggregate (as an event stream) in a structured log.

    命令订阅者将聚合(作为事件流)保存在结构化日志中。
  • A command subscriber publishes events on the event queue.

    命令订阅者在事件队列上发布事件。
  • Published events are handled by event subscribers and process managers.

    已发布的事件由事件订阅者和流程管理器处理。
  • A process manager can send commands on the command queue, in response to events.

    进程管理器可以响应事件而在命令队列上发送命令。
  • An event subscriber creates and updates projections in a query store.

    事件订阅者在查询存储中创建和更新预测。
  • A query search is a lightweight data access layer for reading projections.

    查询搜索是用于读取投影的轻量级数据访问层。

入门 (Getting Started)

Before you compile and execute the source code:

在编译和执行源代码之前:

  1. Execute the script "Create Database.sql" to create a local SQL Server database.

    执行脚本“ Create Database.sql ”以创建本地SQL Server数据库。

  2. Update the connection string in Web.config.

    更新Web.config中的连接字符串。

  3. Update the appSetting value in Web.config for OfflineStoragePath.

    更新Web.config中的OfflineStoragePathappSetting值。

用法 (Usage)

Rather than start at the bottom, and describe how the Timeline assembly works, I will start at the top and demonstrate how to use it, then work my way down through the application stack to the nuts and bolts of the CQRS+ES backbone.

我将从顶部开始并演示如何使用它,然后从应用程序堆栈一直向下浏览到CQRS + ES主干的基本细节,而不是从底部开始描述时间轴程序集的工作方式。

If I have held your attention this long, then I owe you more than a little reward for staying with me thus far...

如果我长期以来一直引起您的注意,那么到目前为止,我欠您的酬劳多于一点……

方案A:如何创建和更新联系人 (Scenario A: How to Create and Update a Contact)

This is the simplest possible usage.

这是最简单的用法。

Here we create a new contact person, then perform a name-change, simulating a use case in which Alice gets married:

在这里,我们创建一个新的联系人,然后进行名称更改,以模拟Alice结婚的用例:

public static void Run(ICommandQueue commander)
{
    var alice = Guid.NewGuid();
    commander.Send(new RegisterPerson(alice, "Alice", "O'Wonderland"));
    commander.Send(new RenamePerson(alice, "Alice", "Cooper"));
}

Following this run the read-side projection looks good, just as expected:

在运行之后,读取侧投影看起来不错,正如预期的那样:

Figure-4
数据流 (Data Flow)

The steps performed by the system in this scenario are illustrated in the following diagram:

下图说明了系统在这种情况下执行的步骤:

Figure-5

方案B:如何对聚合进行快照 (Scenario B: How to Take a Snapshot of an Aggregate)

Snapshots are automated by the Timeline assembly; they are enabled for every aggregate by default, so you don't have to do anything at all to get this working.

快照由“时间轴”程序集自动执行。 默认情况下,每个聚合都启用了它们,因此您无需执行任何操作即可使此工作正常进行。

In this next test run, the Timeline assembly is configured to take a snapshot after every 10 events. We register a new contact person, then rename him 20 times. This produces a snapshot on event number 20, which is the second-to-last rename operation.

在下一个测试运行中,时间轴程序集被配置为每10个事件后拍摄一次快照。 我们注册一个新的联系人,然后将其重命名20次。 这将在事件编号20上生成快照,这是倒数第二个重命名操作。

public static void Run(ICommandQueue commander)
{
    var henry = Guid.NewGuid();
    commander.Send(new RegisterPerson(henry, "King", "Henry I"));
    for (int i = 1; i <= 20; i++)
        commander.Send(new RenamePerson(henry, "King", "Henry " + (i+1).ToRoman()));
}

As expected, we have a snapshot at version 20, and the current-state projection after event number 21:

不出所料,我们有一个快照,版本20,以及事件编号21之后的当前状态预测:

Figure-6

方案C:如何使聚合脱机 (Scenario C: How to Take an Aggregate Offline)

In my solution the terms "boxing" and "unboxing" are used for taking an aggregate offline and bringing it back online.

在我的解决方案中,术语“装箱”和“取消装箱”用于使聚合脱机并使其重新联机。

When you send a command to box an aggregate, the Timeline assembly:

当您发送命令将汇总框装箱时 ,时间轴程序集:

  1. creates a snapshot; then

    创建快照; 然后
  2. copies that snapshot and the entire aggregate event stream to a JSON file stored in a directory on the file system; then

    将快照和整个聚合事件流复制到存储在文件系统目录中的JSON文件中; 然后
  3. deletes the snapshot and the aggregate from the SQL Server structured log tables.

    从SQL Server结构化日志表中删除快照和聚合。

This makes it a highly destructive operation, of course, and it should never be used except in circumstances where it is a mandatory business/legal requirement.

当然,这使它成为极具破坏性的操作,除非是强制性的业务/法律要求,否则永远不要使用它。

In the next test run, we register a new contact person, rename him 7 times, then box the aggregate.

在下一个测试运行中,我们注册一个新联系人,将其重命名7次,然后在汇总框中输入框。

public static void Run(ICommandQueue commander)
{
    var hatter = Guid.NewGuid();
    commander.Send(new RegisterPerson(hatter, "Mad", "Hatter One"));
    for (int i = 2; i <= 8; i++)
        commander.Send(new RenamePerson(hatter, "Mad", "Hatter " + i.ToWords().Titleize()));
    commander.Send(new BoxPerson(hatter));
}

As you can see, the aggregate no longer exists in the event store, and an offline copy of the final snapshot (along with the entire event stream) has been made on the file system.

如您所见,聚合在事件存储中不再存在,并且最终快照的脱机副本(以及整个事件流)已在文件系统上进行了制作。

Figure-7

方案D:如何创建具有唯一登录名的新用户 (Scenario D: How to Create a New User With a Unique Login Name)

The question I encounter most frequently online from developers trying to understand CQRS+ES is this:

在开发人员中,我最经常在线上尝试理解CQRS + ES的问题是:

"How do I enforce referential integrity to guarantee new users have unique login names?"

“如何强制执行参照完整性以确保新用户具有唯一的登录名?”

I asked this same question myself (more than once) in the early days of my research on the CQRS+ES pattern.

在研究CQRS + ES模式的初期,我自己(不止一次)问过同样的问题。

A lot of the answers from experienced practitioners look something like this:

来自经验丰富的从业人员的许多答案看起来像这样:

"Your question indicates you do not understand CQRS+ES."

“您的问题表明您不了解CQRS + ES。”

This is true (I realize now) but completely unhelpful, especially to someone who is making an effort to learn.

这是真的(我现在意识到),但是完全没有帮助,特别是对于那些努力学习的人。

Some of the answers are slightly better, offering a high-level recommendation in summary form, but loaded with CQRS+ES terminology, which is not always helpful either. One of my favorite recommendations was this (from the good folks at Edument):

一些答案稍好一些,以摘要形式提供了高级建议,但使用了CQRS + ES术语,但这也不总是有用。 我最喜欢的建议之一是(来自Edument的好人 ):

"Create a reactive saga to flag down and inactivate accounts that were nevertheless created with a duplicate user name, whether by extreme coincidence or maliciously or because of a faulty client."

“创建一个React式的传奇,以标记和停用那些通过重复的用户名创建的帐户,无论是由于极端巧合还是恶意或由于客户端故障引起的。”

The first time I read that I had only a vague sense of what it meant, and no idea at all how to begin to implement such a recommendation.

我第一次读到我对它的含义只有一个模糊的认识,根本不知道如何开始执行这样的建议。

The next test run shows one way (but not the only way) to create a new user with a unique name, using real, working code as an example.

下一个测试运行以真实的工作代码为例,展示了一种使用唯一名称创建新用户的方法(但不是唯一方法)。

In this scenario, the trick is to realize that you do in fact need a saga (or process manager, as I prefer to call it). Creating a new user account is not a single-step operation; it is a process, and therefore requires coordination. The flowchart (or state machine, if you prefer) might be very complex in your application, but even in the simplest of all possible cases, it looks something like this:

在这种情况下,诀窍是要意识到您实际上确实需要一个传奇人物 (或流程经理 ,我更喜欢称呼它)。 创建新的用户帐户不是一步一步的操作。 这是一个过程,因此需要协调。 流程图(或状态机 ,如果愿意的话)在您的应用程序中可能非常复杂,但是即使在所有可能的情况中,最简单的情况也是如此:

Figure-8

The code that relies on a process manager to implement this functionality is shown in the next figure:

下图显示了依赖于流程管理器来实现此功能的代码:

public void Run()
{
    var login = "jack@example.com";
    var password = "Let_Me_In!";

    if (RegisterUser(Guid.NewGuid(), login, password)) // succeeds.
        System.Console.WriteLine($"User registration for {login} succeeded");
    
    if (!RegisterUser(Guid.NewGuid(), login, password)) // fails; duplicate login.
        System.Console.WriteLine($"User registration for {login} failed");
}

private bool RegisterUser(Guid id, string login, string password)
{
    bool isComplete(Guid user) { return _querySearch.IsUserRegistrationCompleted(user); }
    const int waitTime = 200; // ms
    const int maximumRetries = 15; // 15 retries (~3 seconds)

    _commander.Send(new StartUserRegistration(id, login, password));

    for (var retry = 0; retry < maximumRetries && !isComplete(id); retry++)
        Thread.Sleep(waitTime);

    if (isComplete(id))
    {
        var summary = _querySearch.SelectUserSummary(id);
        return summary?.UserRegistrationStatus == "Succeeded";
    }
    else
    {
        var error = $"Registration for {login} has not completed after 
                    {waitTime * maximumRetries} ms";
        throw new IncompleteUserRegistrationException(error);
    }
}

Notice the caller in the example above does not assume synchronous handling of the command StartUserRegistration. Instead, it polls the status of the registration, waiting for it to complete.

请注意,上面示例中的调用者不假定对StartUserRegistration命令进行同步处理。 而是轮询注册的状态,等待注册完成。

Knowing the code in the Timeline assembly is synchronous, we can refactor the method RegisterUser so it is even simpler:

知道时间轴程序集中的代码是同步的 ,我们可以重构方法RegisterUser ,它甚至更简单:

private bool RegisterUserNoWait(Guid id, string login, string password)
{
    bool isComplete(Guid user) { return _querySearch.IsUserRegistrationCompleted(user); }

    _commander.Send(new StartUserRegistration(id, login, password));

    Debug.Assert(isComplete(id));

    return _querySearch.SelectUserSummary(id).UserRegistrationStatus == "Succeeded";
}

The code for the process manager itself is simpler than you might guess:

流程管理器本身的代码比您可能想象的要简单:

public class UserRegistrationProcessManager
{
    private readonly ICommandQueue _commander;
    private readonly IQuerySearch _querySearch;

    public UserRegistrationProcessManager
       (ICommandQueue commander, IEventQueue publisher, IQuerySearch querySearch)
    {
        _commander = commander;
        _querySearch = querySearch;

        publisher.Subscribe<UserRegistrationStarted>(Handle);
        publisher.Subscribe<UserRegistrationSucceeded>(Handle);
        publisher.Subscribe<UserRegistrationFailed>(Handle);
    }

    public void Handle(UserRegistrationStarted e)
    {
        // Registration succeeds only if no other user has the same login name.
        var status = _querySearch
            .UserExists(u => u.LoginName == e.Name 
			    && u.UserIdentifier != e.AggregateIdentifier)
            ? "Failed" : "Succeeded";

        _commander.Send(new CompleteUserRegistration(e.AggregateIdentifier, status));
    }

    public void Handle(UserRegistrationSucceeded e) { }

    public void Handle(UserRegistrationFailed e) { }
}

There you have a basic, reactive saga that flags inactivate accounts created with a duplicate user name. And there was much rejoicing.

那里有一个基本的React式传奇,它标记了使用重复用户名创建的不活动帐户。 有很多的欣喜。

As expected, the first registration succeeds and the second fails:

如预期的那样,第一次注册成功,而第二次失败:

Figure-9

方案E:如何安排命令 (Scenario E: How to Schedule a Command)

Scheduling a command to run at a future date/time is easy:

安排命令在将来的日期/时间运行很容易:

public static void Run(ICommandQueue commander)
{
    var alice = Guid.NewGuid();
    var tomorrow = DateTimeOffset.UtcNow.AddDays(1);
    commander.Schedule(new RegisterPerson(alice, "Alice", "O'Wonderland"), tomorrow);

    // After the above timer elapses, any call to Ping() executes the scheduled command.
    // commander.Ping();
}

Notice this creates no aggregate in the event log, and the command log now contains a scheduled entry:

请注意,这不会在事件日志中创建任何聚合,并且命令日志现在包含计划的条目:

Figure-10

方案F:如何使用一个命令更新多个聚合 (Scenario F: How to Update Multiple Aggregates With One Command)

This is another common question asked by developers who are trying to understand how to implement the CQRS+ES pattern. It is another question I asked (many, many times) when I was learning it myself.

这是试图了解如何实现CQRS + ES模式的开发人员提出的另一个常见问题。 当我自己学习时,这是我问过(很多次)的另一个问题。

Practitioners often answer by saying:

从业者经常回答说:

"You can't."

“你不能。”

This is not enormously helpful.

这不是很有帮助。

Some will offer a little more guidance with a statement that goes like this:

有些人会通过如下声明提供更多指导:

"The factoring of your aggregates and command handlers will make this idea impossible to express in code."

“聚合和命令处理程序的分解将使这种想法无法在代码中表达。”

The first several times you read that statement it seems cryptic, and in the end you discover it can be quite helpful for validating your implementation, but in the beginning it isn't super instructive.

最初几次阅读该语句似乎很神秘,最后发现它对于验证实现很有帮助,但是从一开始它就没有超级指导意义。

More helpful is an example with real, working code, which implements the type of functionality that motivated the question in the first place:

带有实际工作代码的示例更为有用,该示例首先实现了引起问题的功能类型:

  • Suppose I have two bank accounts, each of which is an aggregate root, and I want to transfer money from one account to the other. How do I achieve this using CQRS+ES?

    假设我有两个银行帐户,每个帐户都是一个总根,我想将资金从一个帐户转移到另一个帐户。 如何使用CQRS + ES做到这一点?

The next test run shows one way (and not the only way) this can be done.

下一次测试运行显示了可以完成此操作的一种方法(而非唯一方法)。

In this scenario, the trick is to realize you need another aggregate root - i.e., a money Transfer, which is not itself an Account - and you also need a process manager to coordinate the workflow.

在这种情况下,诀窍是要意识到您需要另一个汇总根-即汇款本身不是一个帐户-并且需要一个流程经理来协调工作流。

The simplest possible flowchart is illustrated in the next figure. (An accounting system obviously needs something more sophisticated than this.)

下图说明了最简单的流程图。 (会计系统显然需要比这更复杂的东西。)

Figure-11

The code that relies on a process manager to implement the workflow illustrated above is easy, once you have all the pieces in place:

一旦完成所有步骤,依靠流程管理器来实现上述工作流程的代码就很容易了:

public void Run()
{
    // Start one account with $100.
    var bill = Guid.NewGuid();
    CreatePerson(bill, "Bill", "Esquire");
    var blue = Guid.NewGuid();
    StartAccount(bill, blue, "Bill's Blue Account", 100);

    // Start another account with $100.
    var ted = Guid.NewGuid();
    CreatePerson(ted, "Ted", "Logan");
    var red = Guid.NewGuid();
    StartAccount(ted, red, "Ted's Red Account", 100);

    // Create a money transfer for Bill giving money to Ted.
    var tx = Guid.NewGuid();
    _commander.Send(new StartTransfer(tx, blue, red, 69));
}

private void StartAccount(Guid person, Guid account, string code, decimal deposit)
{
    _commander.Send(new OpenAccount(account, person, code));
    _commander.Send(new DepositMoney(account, deposit));
}

private void CreatePerson(Guid person, string first, string last)
{
    _commander.Send(new RegisterPerson(person, first, last));
}

After that test executes, Bill's blue account has a balance of $31, and Ted's red account has a balance of $169, as expected:

执行该测试后,Bill的蓝色帐户的余额为31美元,Ted的红色帐户的余额为169美元,如下所示:

Figure-12

The code for the money transfer process manager is not too difficult either:

汇款流程管理器的代码也不太困难:

public class TransferProcessManager
{
    private readonly ICommandQueue _commander;
    private readonly IEventRepository _repository;

    public TransferProcessManager
    (ICommandQueue commander, IEventQueue publisher, IEventRepository repository)
    {
        _commander = commander;
        _repository = repository;

        publisher.Subscribe<TransferStarted>(Handle);
        publisher.Subscribe<MoneyDeposited>(Handle);
        publisher.Subscribe<MoneyWithdrawn>(Handle);
    }

    public void Handle(TransferStarted e)
    {
        var withdrawal = new WithdrawMoney(e.FromAccount, e.Amount, e.AggregateIdentifier);
        _commander.Send(withdrawal);
    }

    public void Handle(MoneyWithdrawn e)
    {
        if (e.Transaction == Guid.Empty)
            return;

        var status = new UpdateTransfer(e.Transaction, "Debit Succeeded");
        _commander.Send(status);

        var transfer = (Transfer) _repository.Get<TransferAggregate>(e.Transaction).State;

        var deposit = new DepositMoney(transfer.ToAccount, e.Amount, e.Transaction);
        _commander.Send(deposit);
    }

    public void Handle(MoneyDeposited e)
    {
        if (e.Transaction == Guid.Empty)
            return;

        var status = new UpdateTransfer(e.Transaction, "Credit Succeeded");
        _commander.Send(status);

        var complete = new CompleteTransfer(e.Transaction);
        _commander.Send(complete);
    }
}

方案G:如何实现自定义事件处理程序 (Scenario G: How to Implement a Custom Event Handler)

In this next example, I demonstrate how to define a custom event handler that is intended for use by one and only one tenant in a multitenant system.

在下一个示例中,我将演示如何定义一个供多租户系统中的一个租户和仅一个租户使用的自定义事件处理程序。

In this scenario, Umbrella Corporation is one of our tenants, and the organization wants all the existing core functionality in our system. However, the company also wants an additional custom feature:

在这种情况下,Umbrella Corporation是我们的租户之一,并且该组织需要我们系统中所有现有的核心功能。 但是,该公司还需要其他自定义功能:

  • When a money transfer is started from or to any Umbrella account, if the dollar amount exceeds $10,000, then an email notification must be sent directly to the company owner.

    当从任何一个Umbrella帐户开始进行资金转帐时,如果金额超过10,000美元,则必须将电子邮件通知直接发送给公司所有者。

To satisfy this requirement, we implement a process manager for the tenant. The calling code that relies on this process manager is no different than it was in the previous scenario.

为了满足此要求,我们为租户实施了流程管理器。 依赖于此流程管理器的调用代码与之前的场景没有什么不同。

public void Run()
{
    // Start one account with $50,000.
    var ada = Guid.NewGuid();
    CreatePerson(ada, "Ada", "Wong");
    var a = Guid.NewGuid();
    StartAccount(ada, a, "Ada's Account", 50000);

    // Start another account with $25,000.
    var albert = Guid.NewGuid();
    CreatePerson(albert, "Albert", "Wesker");
    var b = Guid.NewGuid();
    StartAccount(albert, b, "Albert's Account", 100);

    // Create a money transfer for Ada giving money to Albert.
    var tx = Guid.NewGuid();
    _commander.Send(new StartTransfer(tx, a, b, 18000));
}

private void StartAccount(Guid person, Guid account, string code, decimal deposit)
{
    _commander.Send(new OpenAccount(account, person, code));
    _commander.Send(new DepositMoney(account, deposit));
}

private void CreatePerson(Guid person, string first, string last)
{
    _commander.Send(new RegisterPerson(person, first, last));
}

Here is a snapshot from the Visual Studio debugger, looking at the code for the process manager, with a breakpoint on the line that sends the email notification. Notice the body of the message in the popup is what we expect:

这是Visual Studio调试器的快照,查看流程管理器的代码,在发送电子邮件通知的行上有一个断点。 请注意,弹出窗口中的消息正文是我们期望的:

Figure-14

方案H:如何使用自定义处理程序覆盖命令 (Scenario H: How to Override a Command With a Custom Handler)

This final example is a variation on the preceding one. Umbrella Corporation wants to disable a core application feature entirely, and replace it with behavior that is entirely custom. The new business requirement looks like this:

最后一个示例是前一个示例的变体。 Umbrella Corporation希望完全禁用核心应用程序功能,并将其替换为完全自定义的行为。 新的业务需求如下所示:

  • Changing the name of a contact person in our system is not permitted. Ever.

    不允许在我们的系统中更改联系人的姓名。 曾经

To satisfy this requirement, we make a few simple changes to the process manager. We add one line of code to the constructor, specifying the override, and we add the replacement function:

为了满足此要求,我们对流程管理器进行了一些简单的更改。 我们向构造函数添加一行代码,指定覆盖,然后添加替换函数:

public class UmbrellaProcessManager
{
    private IQuerySearch _querySearch;

    public UmbrellaProcessManager
      (ICommandQueue commander, IEventQueue publisher, IQuerySearch querySearch)
    {
        _querySearch = querySearch;

        publisher.Subscribe<TransferStarted>(Handle);
        commander.Override<RenamePerson>(Handle, Tenants.Umbrella.Identifier);
    }

    public void Handle(TransferStarted e) { }

    public void Handle(RenamePerson c)
    {
        // Do nothing. Umbrella does not permit renaming people.
        
        // Throw an exception to make the consequences even more severe 
		// for any attempt to rename a person...
        // throw new DisallowRenamePersonException();
    }
}

Here is a basic test run to demonstrate this works as expected:

这是一个基本的测试运行,以证明此功能可以正常运行:

public static class Test08
{
    public static void Run(ICommandQueue commander)
    {
        ProgramSettings.CurrentTenant = Tenants.Umbrella;

        var alice = Guid.NewGuid();
        commander.Send(new RegisterPerson(alice, "Alice", "Abernathy"));
        commander.Send(new RenamePerson(alice, "Alice", "Parks"));
    }
}

Notice just one event in the log, and no change to the person's name:

请注意,日志中只有一个事件,并且此人的姓名没有变化:

Figure-15

介绍 (Presentation)

The presentation layer in the sample application is a console application intended only for writing and running test-case scenarios.

示例应用程序中的表示层是一个控制台应用程序,仅用于编写和运行测试用例场景。

There is nothing here that warrants special attention. You will notice I have not used a third-party component for dependency injection; instead I have written a very basic in-memory service-locator.

这里没有什么值得特别注意的。 您会注意到,我没有使用第三方组件进行依赖注入。 相反,我编写了一个非常基本的内存服务定位器。

This is done only for the sake of keeping the sample application as small and as focused as possible. In your own presentation layer, you'll implement dependency injection in whatever way works best for you, using whatever IoC container you prefer.

这样做仅是为了使示例应用程序尽可能小且尽可能集中。 在您自己的表示层中,将使用您喜欢的任何IoC容器以最适合您的方式实施依赖项注入。

应用 (Application)

The application layer is divided into two distinct parts: a Write side for commands, and a Read side for queries. This division helps to ensure we don't accidentally mix write-side and read-side functionality.

应用程序层分为两个不同的部分:用于命令的写面和用于查询的读面。 这种划分有助于确保我们不会意外地混用写侧和读侧功能。

Notice there are no references to external third-party assemblies here:

请注意,这里没有引用外部第三方程序集:

Figure-16

写面 (Write Side)

Commands are Plain Old C# Object (POCO) classes, so they can be easily used as Data Transfer Objects (DTOs) for easy serialization:

命令是普通的旧C#对象(PO​​CO)类,因此它们可以轻松地用作数据传输对象(DTO)以便于序列化:

public class RenamePerson : Command
{
    public string FirstName { get; set; }
    public string LastName { get; set; }

    public RenamePerson(Guid id, string firstName, string lastName)
    {
        AggregateIdentifier = id;
        FirstName = firstName;
        LastName = lastName;
    }
}

Note: I prefer the term "Packet" to "Data Transfer Object", and I know many readers will object to that, so choose terminology that works for you and your team.

注意 :与“数据传输对象”相比,我更喜欢“数据包”一词,并且我知道许多读者会反对,因此请选择适合您和您的团队的术语。

The registration of a command handler method is explicit in the constructor for a command subscriber class, and events are published after they have been saved to the event store:

命令处理程序方法的注册在命令订户类的构造函数中是显式的,并且事件在保存到事件存储中便会发布:

public class PersonCommandSubscriber
{
    private readonly IEventRepository _repository;
    private readonly IEventQueue _publisher;

    public PersonCommandSubscriber
      (ICommandQueue commander, IEventQueue publisher, IEventRepository repository)
    {
        _repository = repository;
        _publisher = publisher;

        commander.Subscribe<RegisterPerson>(Handle);
        commander.Subscribe<RenamePerson>(Handle);
    }

    private void Commit(PersonAggregate aggregate)
    {
        var changes = _repository.Save(aggregate);
        foreach (var change in changes)
            _publisher.Publish(change);
    }

    public void Handle(RegisterPerson c)
    {
        var aggregate = new PersonAggregate { AggregateIdentifier = c.AggregateIdentifier };
        aggregate.RegisterPerson(c.FirstName, c.LastName, DateTimeOffset.UtcNow);
        Commit(aggregate);
    }

    public void Handle(RenamePerson c)
    {
        var aggregate = _repository.Get<PersonAggregate>(c.AggregateIdentifier);
        aggregate.RenamePerson(c.FirstName, c.LastName);
        Commit(aggregate);
    }
}

阅读面 (Read Side)

Queries are POCO classes also, making them lightweight and easy to serialize.

查询也是POCO类,使其轻量且易于序列化。

public class PersonSummary
{
    public Guid TenantIdentifier { get; set; }

    public Guid PersonIdentifier { get; set; }
    public string PersonName { get; set; }
    public DateTimeOffset PersonRegistered { get; set; }

    public int OpenAccountCount { get; set; }
    public decimal TotalAccountBalance { get; set; }
}

The registration of an event handler method is also explicit in the constructor for an event subscriber class:

事件处理程序方法的注册在事件订阅者类的构造函数中也很明显

public class PersonEventSubscriber
{
    private readonly IQueryStore _store;

    public PersonEventSubscriber(IEventQueue queue, IQueryStore store)
    {
        _store = store;

        queue.Subscribe<PersonRegistered>(Handle);
        queue.Subscribe<PersonRenamed>(Handle);
    }

    public void Handle(PersonRegistered c)
    {
        _store.InsertPerson(c.IdentityTenant, c.AggregateIdentifier, 
                            c.FirstName + " " + c.LastName, c.Registered);
    }

    public void Handle(PersonRenamed c)
    {
        _store.UpdatePersonName(c.AggregateIdentifier, c.FirstName + " " + c.LastName);
    }
}

(Domain)

The domain contains only aggregates and events. Again, you'll see the list of References here is as bare-metal as possible:

该域仅包含聚合和事件。 再次,您将看到这里的参考列表尽可能裸机:

Figure-17

Each aggregate root class contains a function for each of the commands it accepts as a request to change its state:

每个聚合根类都包含一个函数,它接受其更改状态请求:

public class PersonAggregate : AggregateRoot
{
    public override AggregateState CreateState() => new Person();

    public void RegisterPerson(string firstName, string lastName, DateTimeOffset registered)
    {
        // 1. Validate command
        // Omitted for the sake of brevity.

        // 2. Validate domain.
        // Omitted for the sake of brevity.

        // 3. Apply change to aggregate state.
        var e = new PersonRegistered(firstName, lastName, registered);
        Apply(e);
    }

    public void RenamePerson(string firstName, string lastName)
    {
        var e = new PersonRenamed(firstName, lastName);
        Apply(e);
    }
}

Notice the aggregate state is implemented in a class that is separate from the aggregate root.

注意,聚合状态是在与聚合根分开的类中实现的。

This makes serialization and snapshots easier to manage, and it helps with overall readability because it forces a stronger delineation between command-related functions and event-related functions:

这使序列化和快照更易于管理,并有助于整体可读性,因为它在命令相关功能和事件相关功能之间进行了更强的划分:

public class Person : AggregateState
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTimeOffset Registered { get; set; }

    public void When(PersonRegistered @event)
    {
        FirstName = @event.FirstName;
        LastName = @event.LastName;
        Registered = @event.Registered;
    }

    public void When(PersonRenamed @event)
    {
        FirstName = @event.FirstName;
        LastName = @event.LastName;
    }
}

Events, like commands and queries, are lightweight POCO classes:

事件(如命令和查询)是轻量级的POCO类:

public class PersonRenamed : Event
{
    public string FirstName { get; set; }
    public string LastName { get; set; }

    public PersonRenamed(string first, string last) { FirstName = first; LastName = last; }
}

坚持不懈 (Persistence)

At the persistence layer, we begin to see a larger number of dependencies on external third-party components. For example, here we rely on:

在持久层,我们开始看到对外部第三方组件的更多依赖关系。 例如,在这里我们依靠:

  • Json.NET for JSON serialization and deserialization;

    Json.NET用于JSON序列化和反序列化;

  • System.Data for logging commands and events and snapshots in SQL Server using ADO.NET; and

    System.Data,用于使用ADO.NET在SQL Server中记录命令,事件和快照; 和

  • Entity Framework for query projections.

    用于查询投影的实体框架

Figure-18

The source code in this project implements a standard run-of-the-mill data access layer, and there should be nothing in this layer that it is new, or especially innovative, or surprising to any experienced developer - so it needs no special discussion.

该项目中的源代码实现了标准的常规数据访问层,并且在这一层中应该没有什么是新的,特别是创新的,或者任何有经验的开发人员都感到惊讶的,因此不需要进行特殊讨论。 。

CQRS + ES骨干网 (CQRS+ES Backbone)

And at long (long) last, ladies and gentlemen, comes the part of the evening you've been waiting for: the Timeline assembly that actually implements the CQRS+ES pattern, which makes all of the above possible.

而在长( )最后,女士们,先生们,来你一直在等待晚上的一部分:时间轴组件实际上实现了CQRS + ES模式,这使得所有可能的上方。

The funny thing is... now that we have arrived at the nuts and bolts, there should be very little mystery remaining.

有趣的是...现在我们已经到了基本要点,应该只剩下很少的谜了。

Figure-19

The first thing you'll notice is the Timeline assembly has no dependencies on external third-party components (besides the .NET Framework itself, obviously).

您会注意到的第一件事是时间轴程序集依赖于外部第三方组件(显然,.NET Framework本身除外)。

指令 (Commands)

There are just a few things to note here.

这里只有几件事要注意。

The Command base class contains properties for the aggregate identifier and version number, as you'd expect. It also contains properties for the identity of the tenant and user sending the command.

如您所料,Command基类包含用于聚合标识符和版本号的属性。 它还包含用于租户和发送命令的用户身份的属性。

/// <summary>
/// Defines the base class for all commands.
/// </summary>
/// <remarks>
/// A command is a request to change the domain. It is always are named with a verb in 
/// the imperative mood, such as Confirm Order. Unlike an event, a command is not a 
/// statement of fact; it is only a request, and thus may be refused. Commands are
/// immutable because their expected usage is to be sent directly to the domain model for 
/// processing. They do not need to change during their projected lifetime.
/// </remarks>
public class Command : ICommand
{
    public Guid AggregateIdentifier { get; set; }
    public int? ExpectedVersion { get; set; }

    public Guid IdentityTenant { get; set; }
    public Guid IdentityUser { get; set; }

    public Guid CommandIdentifier { get; set; }
    public Command() { CommandIdentifier = Guid.NewGuid(); }
}

The CommandQueue implements the ICommandQueue interface, which defines a small set of methods to register subscribers and overrides, as well as send and schedule commands. You can think of this as the service bus for your commands.

CommandQueue实现了ICommandQueue接口,该接口定义了一CommandQueue方法来注册订户和替代,以及发送和调度命令。 您可以将其视为命令的服务总线

Figure-20

大事记 (Events)

The Event base class contains properties for the aggregate identifier and version number, as well as properties for the identity of the tenant and user for whom the event was raised/published. This ensures every event log entry is associated with a specific tenant and user.

Event基类包含聚合标识符和版本号的属性,以及为其引发/发布事件的租户和用户的身份的属性。 这样可以确保每个事件日志条目都与特定的租户和用户相关联。

Figure-21

You can think of the EventQueue as the service bus for your events.

您可以将EventQueue视为事件的服务总线。

骨料 (Aggregates)

There is one small bit of black magic in the AggregateState class. The Apply method uses reflection to determine which method to invoke when an event is applied to the aggregate state. I don't especially like this, but I cannot find any way to avoid it. Fortunately, the code is very easy to read and understand:

AggregateState类中有一点点黑魔法。 Apply方法使用反射来确定将事件应用于聚合状态时要调用的方法。 我不是特别喜欢这样,但是我找不到任何避免它的方法。 幸运的是,该代码非常易于阅读和理解:

/// <summary>
/// Represents the state (data) of an aggregate. A derived class should be a POCO
/// (DTO/Packet) that includes a When method for each event type that changes its
/// property values. Ideally, the property values for an instance of  this class 
/// should be modified only through its When methods.
/// </summary>
public abstract class AggregateState
{
    public void Apply(IEvent @event)
    {
        var when = GetType().GetMethod("When", new[] { @event.GetType() });

        if (when == null)
            throw new MethodNotFoundException(GetType(), "When", @event.GetType());

        when.Invoke(this, new object[] { @event });
    }
}

快照 (Snapshots)

The source code to implement Snapshots is cleaner and simpler than I imagined it could be when I first started this project. The logic is somewhat intricate, but the Snapshots namespace contains only ~240 lines of code, so I won't add details on that here. I leave that as an exercise for you, the most patient of readers, if there are any of you still left at this point. :-)

实现快照的源代码比我最初启动该项目时想象的更加整洁和简单。 逻辑有些复杂,但是Snapshots命名空间仅包含约240行代码,因此在此不再赘述。 如果您还有其他人,我将其留给您(最耐心的读者)作为练习。 :-)

指标 (Metrics)

I will close the article with a few basic metrics. (More to come later.)

我将以一些基本指标结束本文。 (稍后再介绍。)

Here is the analysis report produced by NDepend on the Timeline assembly:

这是NDepend根据时间轴程序集生成的分析报告:

Figure-22

The source code is not perfect, as you can see, but does get an "A" rating, with technical debt estimated at only 1.3%. The project is also very compact, with only 439 lines of code, at the time I write this.

如您所见,源代码并不完美,但确实获得了“ A”级评级,技术债务估计仅为1.3%。 在我撰写本文时,该项目也非常紧凑,只有439行代码。

Note: NDepend counts lines of code (LOC) from the number of sequence points per method in the .pdb symbol file for an assembly. Visual Studio counts LOC differently; on the Timeline project it reports 1,916 lines of Source code, with 277 lines of Executable code.

注意 :NDepend从程序集.pdb符号文件中每个方法的序列点数中计算代码行(LOC) 。 Visual Studio对LOC的计数不同; 在时间轴项目中,它报告了1,916行源代码,以及277行可执行代码。

When time permits, I will update this article with run-time performance results.

如果时间允许,我将使用运行时性能结果更新本文。

In the meantime, your comments and criticisms are most welcome.

同时,也欢迎您提出意见和批评。

翻译自: https://www.codeproject.com/Articles/5264244/A-Fast-and-Lightweight-Solution-for-CQRS-and-Event

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
CQRS(Command Query Responsibility Segration)架构,大家应该不会陌生了。简单的说,就是一个系统,从架构上把它拆分为两部分:命令处理(写请求)+查询处理(读请求)。然后读写两边可以用不同的架构实现,以实现CQ两端(即Command Side,简称C端;Query Side,简称Q端)的分别优化。CQRS作为一个读写分离思想的架构,在数据存储方面,没有做过多的约束。所以,我觉得CQRS可以有不同层次的实现,比如: 1.CQ两端数据库共享,CQ两端只是在上层代码上分离;这种做法,带来的好处是可以让我们的代码读写分离,更好维护,且没有CQ两端的数据一致性问题,因为是共享一个数据库的。我个人认为,这种架构很实用,既兼顾了数据的强一致性,又能让代码好维护。 2.CQ两端数据库和上层代码都分离,然后Q的数据由C端同步过来,一般是通过Domain Event进行同步。同步方式有两种,同步或异步,如果需要CQ两端的强一致性,则需要用同步;如果能接受CQ两端数据的最终一致性,则可以使用异步。采用这种方式的架构,个人觉得,C端应该采用Event Sourcing(简称ES)模式才有意义,否则就是自己给自己找麻烦。因为这样做你会发现会出现冗余数据,同样的数据,在C端的db中有,而在Q端的db中也有。和上面第一种做法相比,我想不到什么好处。而采用ES,则所有C端的最新数据全部用Domain Event表达即可;而要查询显示用的数据,则从Q端的ReadDB(关系型数据库)查询即可。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值