CQRS之旅——旅程5(准备发布V1版本)
旅程5:准备发布V1版本
添加功能和重构,为V1版本发布做准备。
“大多数人在完成一件事之后,就像留声机的唱片一样,一遍又一遍地使用它,直到它破碎,忘记了过去是用来创造更多未来的东西。” -- 弗雷娅.斯塔克
发布Contoso会议管理系统V1版本:
本章描述了团队为准备Contoso会议管理系统的第一个产品版本所做的更改。这项工作包括对前两章介绍的订单(Order)和注册(Registrations)限界上下文的一些重构和功能添加,以及一个新的会议管理(Conference Management)限界上下文和一个新的支付(Payment)限界上下文。
团队在此过程中进行的一个关键重构是将事件源(ES)引入订单(Order)和注册(Registrations)限界上下文中。
实现CQRS模式的一个预期好处是,它将帮助我们在复杂系统中管理变化。在CQRS旅程中发布一个V1版本将帮助团队评估当我们从V1版本迁移到系统的下一个产品版本时使用CQRS和ES的好处。剩下的章节将描述V1版本发布后的情况。
本章描述了团队在此阶段添加到公共网站的用户界面(UI),并包括了对基于任务的UI的讨论。
本章的工作术语定义:
本章使用了一些术语,我们将在下面进行描述。有关更多细节和可能的替代定义,请参阅参考指南中的“深入CQRS和ES”。
-
访问代码(Access code):当业务客户创建一个新的会议时,系统生成一个5个字符的访问代码并通过电子邮件发送给业务客户。业务客户可以使用其电子邮件地址和会议管理网站上的访问代码在稍后的日子从系统中检索会议详细信息。该系统使用访问码而不是密码,因此业务客户不需要仅为了创建一个支付而注册账户。
-
事件源(Event sourcing):事件源是在系统中持久化和重新加载聚合状态的一种方法。每当聚合的状态发生更改时,聚合将引发详细说明状态更改的事件。然后,系统将此事件保存到事件存储中。系统可以通过重播与聚合实例关联的所有先前保存的事件来重新创建聚合的状态。事件存储成为系统存储数据的记录簿。此外,您还可以使用事件源作为审计数据的来源,作为查询历史状态、从过去的数据获得新的业务见解以及重播事件以进行调试和问题分析的方法。
-
最终一致性(Eventual consistency):最终一致性是一个一致性模型,它不能保证立即访问更新的值。对数据对象进行更新后,存储系统不保证对该对象的后续访问将返回更新后的值。然而,存储系统确实保证,如果在足够长的时间内没有对对象进行新的更新,那么最终所有访问都可以返回最后更新的值。
用户故事(User stories)
在这个过程的这个阶段,团队实现了下面描述的用户故事。
定义通用语言
-
业务客户:业务客户代表使用会议管理系统运行其会议的组织。
-
座位:座位代表会议上的一个空间或进入会议上特定会议如欢迎招待会、教程或研讨会的通道。
-
注册者:注册者是与系统交互下订单并为这些订单付款的人。注册者还创建与订单关联的注册。
会议管理限界界的上下文的用户故事
业务客户可以创建新的会议并管理它们。在业务客户创建新会议之后,他可以使用电子邮件地址和会议定位器访问代码访问会议的详细信息。当业务客户创建会议时,系统生成访问代码。
业务客户可以指定以下关于会议的信息:
- 名称、描述和Slug(用于访问会议的URL的一部分)。
- 会议的开始和结束日期。
- 会议提供的不同座位类型和配额。
此外,业务客户可以通过发布或取消发布会议来控制会议在公共网站上的可见性。
业务客户可以使用会议管理网站查看订单和与会者列表。
订单和注册限界的上下文的用户故事
当注册者创建一个订单时,可能无法完全完成该订单。例如,注册者申请5个座位参加整个会议,5个座位参加欢迎招待会,3个座位参加会前讲习班。整个会议可能只有3个座位,欢迎招待会只有1个座位,但会前讲习班有3个以上的座位。系统会将此信息显示给注册者,并让她有机会在继续付款过程之前按顺序调整每种座位的数量。
当注册者选择了每种座位类型的数量后,系统会计算订单的总价,然后注册者可以使用在线支付服务支付这些座位。Contoso不代表客户处理付款。每个业务客户必须有一个通过在线支付服务接受支付的机制。在项目的后期,Contoso将添加对业务客户的支持,以将他们的发票系统与会议管理系统集成在一起。在将来的某个时候,Contoso可能会提供一项代表客户收款的服务。
备注:在系统的这个版本中,实际上支付系统是模拟的。
注册者在会议上购买了座位后,可以为参会者分配这些座位。系统存储每个参会者的姓名和联系方式。
架构
下图说明了在V1版本中Contoso会议管理系统的关键体系架构。该应用程序由两个网站和三个限界上下文组成。基础设施包括Microsoft Azure SQL数据库(SQL Database)实例、事件存储和消息传递基础设施。
图后面的表列出了图中显示的构件(聚合、MVC控制器、读取模型生成器和数据访问对象)相互交换的所有消息。
备注:为了清晰,图中没有展示Handlers(把消息发送给领域对象的类,例如:OrderCommandHandler)。
元素 | 类型 | 发送 | 接收 |
---|---|---|---|
ConferenceController | MVC Controller | N/A | ConferenceDetails |
OrderController | MVC Controller | AssignSeat UnassignSeat |
DraftOrder OrderSeats PricedOrder |
RegistrationController | MVC Controller | RegisterToConference AssignRegistrantDetails InitiateThirdPartyProcessorPayment |
DraftOrder PricedOrder SeatType |
PaymentController | MVC Controller | CompleteThirdPartyProcessorPayment CancelThirdPartyProcessorPayment |
ThirdPartyProcessorPaymentDetails |
Conference Management | CRUD Bounded Context | ConferenceCreated ConferenceUpdated ConferencePublished ConferenceUnpublished SeatCreated SeatUpdated |
OrderPlaced OrderRegistrantAssigned OrderTotalsCalculated OrderPaymentConfirmed SeatAssigned SeatAssignmentUpdated SeatUnassigned |
Order | Aggregate | OrderPlaced OrderExpired OrderUpdated OrderPartiallyReserved OrderReservationCompleted OrderPaymentConfirmed OrderRegistrantAssigned |
RegisterToConference MarkSeatsAsReserved RejectOrder AssignRegistrantDetails ConfirmOrderPayment |
SeatsAvailability | Aggregate | SeatsReserved AvailableSeatsChanged SeatsReservationCommitted *SeatsReservationCancelled |
MakeSeatReservation CancelSeatReservation CommitSeatReservation AddSeats RemoveSeats |
SeatAssignments | Aggregate | SeatAssignmentsCreated SeatAssigned SeatUnassigned SeatAssignmentUpdated |
AssignSeat UnassignSeat |
RegistrationProcessManager | Process manager | MakeSeatReservation ExpireRegistrationProcess MarkSeatsAsReserved CancelSeatReservation RejectOrder CommitSeatReservation ConfirmOrderPayment |
OrderPlaced PaymentCompleted SeatsReserved ExpireRegistrationProcess |
OrderViewModelGenerator | Handler | DraftOrder | OrderPlaced OrderUpdated OrderPartiallyReserved OrderReservationCompleted OrderRegistrantAssigned |
PricedOrderViewModelGenerator | Handler | N/A | SeatTypeName |
ConferenceViewModelGenerator | Handler | Conference AddSeats RemoveSeats |
ConferenceCreated ConferenceUpdated ConferencePublished ConferenceUnpublished SeatCreated SeatUpdated |
ThirdPartyProcessorPayment | Aggregate | PaymentCompleted PaymentRejected PaymentInitiated |
InitiateThirdPartyProcessorPayment CompleteThirdPartyProcessorPayment CancelThirdPartyProcessorPayment |
标记*的这些事件仅用于使用事件源持久化聚合状态。
标记**的是ConferenceViewModelGenerator从SeatCreated和SeatUpdated事件创建的这些命令,这些事件在会议管理限界上下文中处理。
下面的列表概述了Contoso会议管理系统中的消息命名约定
- 所有事件在命名约定中都使用过去时。
- 所有命令都使用命令式命名约定。
- 所有的DTO都是名词。
该应用程序旨在部署到Microsoft Azure。在旅程的那个阶段,应用程序由两个角色组成,一个包含ASP.Net MVC Web应用程序的web角色和一个包含消息处理程序和领域对象的工作角色。应用程序在写端和读端都使用Azure SQL DataBase实例进行数据存储。订单(Order)和注册(Registrations)限界上下文现在使用事件存储在写端持久化状态。此事件存储是使用Azure table storage来实现的。应用程序使用Azure服务总线来提供其消息传递基础设施。
在研究和测试解决方案时,可以在本地运行它,可以使用Azure compute emulator,也可以直接运行MVC web应用程序,并运行承载消息处理程序和领域域对象的控制台应用程序。在本地运行应用程序时,可以使用本地SQL Server Express数据库,使用在SQL Server Express数据库实现的简单的消息传递基础设施和简单事件存储。
备注:事件存储和消息传递基础设施的基于sql的实现只是为了帮助您在本地运行应用程序以进行探索和测试。它们并不是想要说明一种用于实际产品的方法。
有关运行应用程序的选项的更多信息,请参见附录1“发布说明”。
会议管理有界上下文
会议管理限界上下文是一个简单的两层,创建/读取/更新(CRUD)风格的web应用程序。它使用ASP.NET MVC和Entity Framework。
这个限界上下文必须与实现CQRS模式的其他限界上下文集成。
模式和概念
本节介绍了在团队旅程的当前阶段,应用程序的一些关键地方,并介绍了团队在处理这些地方时遇到的一些挑战。
事件源
Contoso的团队最初在没有使用事件源的情况下实现了订单和注册的限界上下文。然而,在实现过程中,很明显,使用事件源将有助于简化这个限界上下文。
在第4章“扩展和增强订单和注册限界上下文”中,团队发现我们需要使用事件将更改从写端推到读端。在读端,OrderViewModelGenerator类订阅Order聚合发布的事件,并使用这些事件更新由读取模型查询的数据库中的视图。
这已经是事件源实现的一半了,因此在整个限界上下文中使用基于事件的单一持久性机制是有意义的。
事件源基础设施可在其他限界上下文中重用,订单和注册的实现也变得更加简单。
Poe(IT运维人员)发言:
作为一个实际的问题,在V1发布之前,团队只有有限的时间来实现一个产品级别的事件存储。他们基于Azure表创建了一个简单的基本事件存储作为临时解决方案。但是,在将来从一个事件存储迁移到另一个事件存储时,他们可能会面临问题。
关键是演进:例如,可以展示如何实现事件源使您摆脱那些冗长的数据迁移,甚至允许您从过去构建报告。
- tom Janssens - CQRS Advisors邮件列表
团队使用Azure表存储实现了基本的事件存储。如果您将应用程序托管在Azure中,还可以考虑使用Azure blobs或SQL数据库来存储事件。
在为事件存储选择基础技术时,应该确保您的选择能够提供应用程序所需的可用性、一致性、可靠性、可伸缩性和性能需要。
Jana(软件架构师)发言:
在选择Azure中的存储机制时要考虑的问题之一是成本。如果使用SQL数据库,则根据数据库的大小进行计费。如果使用Azure table或blob存储,则根据使用的存储量和存储事务的数量进行计费。您需要仔细评估系统中不同聚合上的使用模式,以确定哪种存储机制的成本效率最高。可能会发现,不同的存储机制对于不同的聚合类型是有意义的。您可以引入一些优化来降低成本,例如使用缓存来减少存储事务的数量。
根据我的经验,如果您正在进行新手开发,那么您需要非常好的辩论来选择一种SQL数据库。Azure存储服务应该是默认的选择。但是,如果您已经有一个想要迁移到云中的SQL Server数据库,那么情况就不同了。
- mark Seemann - CQRS Advisors邮件列表
确定聚合
在团队为V1版本创建的基于Azure表存储的事件存储实现中,我们使用聚合ID作为分区键。这使得定位包含任何特定聚合事件的分区非常有效。
在某些情况下,系统必须定位相关的聚合。例如,订单聚合可能具有相关的注册聚合,其中包含分配到特定座位的参会者的详细信息。在这个场景中,团队决定为相关的聚合对(订单和注册聚合)重用相同的聚合ID,以便于查找。
Gary(CQRS专家)发言:
在这种情况下,您需要考虑是否应该有两个聚合。您可以将注册建模为订单聚合内的实体。
更常见的场景是聚合之间存在一对多的关系,而不是一对一的关系。在这种情况下,不可能共享聚合ID,相反,“一”的聚合可以存储“多”聚合的ID列表,而“多”的每个聚合可以存储“一”聚合的ID。
当聚合存在于不同的限界上下文中时,共享聚合ID是很常见的。如果您在不同的限界上下文中使用聚合对同一个现实实体的不同方面建模,那么它们共享相同的ID是有意义的。
Greg Young --与模式和实践团队的对话
基于任务的用户界面
UI的设计在过去的十年中有了很大的改进。应用程序比以前更容易使用,更直观,导航也更简单。一些UI设计指南的例子可以帮助您创建这样的现代的、用户友好的应用程序,如Microsoft Inductive User Interface Guidelines和Index of UX guidelines。
影响UI设计和可用性的一个重要因素是UI如何与应用程序的其他部分通信。如果应用程序基于CRUD风格的体系结构,这可能会泄漏到UI。如果开发人员专注于CRUD风格的操作,这可能会导致出现类似下图(左边)中第一个屏幕设计所示的UI。
在第一个屏幕上,按钮上的文字反映了当用户单击Submit按钮时系统将执行的底层CRUD操作,而不是显示用户更关心的操作的文字。不幸的是,第一个屏幕还要求用户推理一些关于屏幕和应用程序功能的知识。例如,Add按钮的功能并不是立即可见的。
第一个屏幕背后的典型实现将使用数据传输对象(DTO)在后端和UI之间交换数据。UI从后端请求数据,这些数据封装在DTO中,UI将修改数据,然后将DTO发回到后端。后端将使用DTO来确定它必须对底层数据存储执行哪些CRUD操作。
第二个屏幕更明确地显示了业务流程方面正在发生的事情:用户正在选择座位类型的数量作为会议注册任务的一部分。根据用户正在执行的任务来考虑UI,可以更容易地将UI与CQRS模式实现中的写模型关联起来。UI可以向写端发送命令,这些命令是写端领域模型的一部分。在实现CQRS模式的限界上下文中,UI通常查询读端并接收DTO,然后向写端发送命令。
上图显示了一系列页面,这些页面使注册者能够完成“在会议上购买座位”的任务。在第一页,注册者选择座位的类型和数量。在第二页,注册者可以查看她所预订的座位,输入她的联系方式,并完成必要的付款信息。然后系统将注册者重定向到支付提供者,如果支付成功完成,系统将显示第三个页面。第三个页面显示了订单的摘要,并提供了到注册者可以启动其他任务的页面的链接。
为了突出显示基于任务的UI中命令和查询的角色,故意简化了上图中所示的序列。例如,实际流程包括系统根据注册者选择的支付类型显示的页面,以及如果支付失败系统显示的错误页面。
Gary(CQRS专家)发言:
您并不总是需要使用基于任务的UI。在某些场景中,简单的CRUD风格的UI工作得很好。您必须评估基于任务的UI的好处是否大于所需的额外实现工作。通常,选择实现CQRS模式的限界上下文也是受益于基于任务的UI的限界上下文,因为它们具有更复杂的业务逻辑和更复杂的用户交互。
我想一劳永逸地声明,CQRS不需要基于任务的UI。我们可以将CQRS应用于基于CRUD的接口(尽管创建分离的数据模型之类的事情要困难得多)。
然而,有一件事确实需要基于任务的UI。这就是领域驱动设计。
-Greg Young, CQRS, Task Based UIs, Event Sourcing agh!
更多信息,请参见参考指南中的第4章“深入CQRS和ES”。
CRUD
您不应该将CQRS模式用作顶层体系结构的一部分。您应该只在模式带来明显好处的限界上下文中实现模式。在Contoso会议管理系统中,会议管理限界上下文是整个系统中相对简单、稳定和低容量的一部分。因此,团队决定使用传统的两层CRUD风格的体系结构来实现这个限界上下文。
有关CRUD风格的体系结构何时适合(或不适合)的讨论,请参阅博客文章:Why CRUD might be what they want, but may not be what they need
限界上下文之间的集成
会议管理限界上下文需要与订单和注册限界上下文集成。例如,如果业务客户更改会议管理限界上下文中座位类型的配额,则必须将此更改传播到订单和注册限界上下文中。此外,如果注册者向会议添加了一个新的参会者,业务客户必须能够在会议管理网站的列表中查看到参会者的详细信息。
从会议管理限界上下文中推送更新
下面是几位开发人员和领域专家之间的对话,这些对话强调了团队在计划如何实现此集成时需要解决的一些关键问题。
- 开发人员1:我想谈谈我们应该如何实现与我们CRUD风格的会议管理限界上下文相关联的集成任务的两部分。首先,当业务客户在此限界上下文中为现有会议创建新会议或定义新座位类型时,其他限界上下文中(如订单和注册限界上下文中)需要知道更改。其次,当业务客户更改座位类型的配额时,其他限界上下文也需要知道这种更改。
- 开发人员2:所以在这两种情况下,您都会从会议管理有限上下文中推送更改。这是一个方法。
- 开发人员1:是的。
- 开发人员2:您所说的场景之间有什么显著的区别吗?
- 开发人员1:在第一个场景中,这些更改相对较少,通常在业务客户创建会议时发生。此外,这些都仅是追加的更改。我们不允许业务客户在会议首次发布后删除会议或座位类型。在第二种场景中,更改可能更频繁,业务客户可能会增加或减少座位配额。
- 开发人员2:对于这些集成场景,您考虑哪些实现方法?
- 开发人员1:因为我们有一个两层的CRUD样式的限界上下文,对于第一个场景,我计划将会议和座位类型的信息直接从数据库中公开成一个简单的只读服务。对于第二个场景,我计划在业务客户更新座位配额时发布事件。
- 开发人员2:为什么这里使用两种不同的方法?使用单一的方法会更简单。从长远来看,使用事件更加灵活。如果其他限界上下文需要此信息,则可以轻松订阅事件。使用事件可以减少限界上下文之间的耦合。
- 开发人员1:我可以看到,如果我们使用事件,将更容易适应未来不断变化的需求。例如,如果一个新的限界上下文需要知道关于谁更改了配额的信息,我们可以将此信息添加到事件中。对于现有的限界上下文,我们可以添加一个适配器,将新的事件格式转换为旧的。
- 开发人员2:您的意思是,通知订阅者配额更改的事件发送的是配额的更改。例如,假设业务客户将座位配额增加了50个。这样订阅者如果一开始没有订阅,就不能收到完整的更新历史记录,会发生什么?
- 开发人员1:我们可以包含一些使用当前状态快照的同步机制。不管怎样,在这种情况下,事件都可以简单的报告配额的新值。如果有必要,事件可以报告座位配额的变化和当前值。
- 开发人员2:如何确保一致性?您需要确保限界上下文将其数据持久存储并在消息队列上发布事件。
- 开发人员1:我们可以将数据库写操作和add-to-queue操作封装在一个事务中。
- 开发人员2:当网络数据的大小增加、响应时间变长和失败的概率增加时,有两个原因会导致以后出现问题。首先,我们的基础设施使用Azure服务总线来处理消息。不能使用单个事务将服务总线上的消息发送和对数据库的写入结合起来。其次,我们试图避免两阶段提交,因为从长远来看,它们总是会导致问题。
- 领域专家:我们有一个与另一个限界上下文类似的场景,我们将在稍后查看。在这种情况下,我们不能对限界上下文做任何更改,我们不再拥有源代码的最新副本。
- 开发人员1:我们可以做些什么来避免使用两阶段提交?如果我们不能访问源代码,因此不能做任何更改,我们可以做什么呢?
- 开发人员2:在这两种情况下,我们使用相同的技术来解决问题。我们可以使用另一个进程来监视数据库,并在它检测到数据库中的更改时发送事件,而不是从应用程序代码中发布事件。这个解决方案可能会引入少量延迟,但是它确实避免了两阶段提交的需要,并且您可以在不更改应用程序代码的情况下实现它。
另一个问题涉及何时何地持久化集成事件。在上面讨论的示例中,会议管理限界上下文发布事件,订单和注册限界上下文处理这些事件并使用它们填充其读模型。如果发生了导致系统丢失读模型数据的故障,那么不保存事件就无法重新创建读模型。
是否需要持久化这些集成事件将取决于应用程序的特定需求和实现。例如:
- 写端可以处理集成来替代在读端处理,例如:事件将导致写入端发生更改,这些更改将作为其他事件保存。
- 集成事件可以当做临时数据不做持久化。
- 来自CRUD风格的限界上下文的集成事件可能包含状态数据,因此只需要最后一个事件。例如,如果来自会议管理限界上下文的事件包含当前座位配额,您可能对以前的值不感兴趣。
另一种要考虑的方法是使用多个限界上下文共享的事件存储。这样,原始的限界上下文(例如CRUD风格的会议管理限界上下文)可以负责持久化集成事件。
- greg Young -与模式和实践团队的对话。
关于Azure服务总线的一些说明
前面的讨论提出了一种在会议管理限界上下文中避免使用分布式两段提交的方法。然而,也有其他的方法。
虽然Azure服务总线不支持分布式事务(把总线上的一个操作和数据库上的一个操作合并),但您可以在发送消息时使用RequiresDuplicateDetection属性,和在收到消息使用PeekLock模式。这样可以创建出所需级别的健壮性而不使用分布式事务。
作为替代方案,您可以使用分布式事务来更新数据库,并使用本地Microsoft消息队列(MSMQ)发送消息。然后可以使用桥接器将MSMQ队列连接到Azure服务总线队列。
有关实现从MSMQ到Azure服务总线的桥接的示例,请参阅Microsoft Azure AppFabric SDK中的示例。
有关Azure服务总线的更多信息,请参见参考指南中的第7章“在参考实现中使用的技术”。
推送更改到会议管理限界上下文
将关于已完成订单和注册的信息从订单和注册限界上下文中推送到会议管理限界上下文中引发了一系列不同的问题。
订单和注册限界上下文通常在创建订单时引发以下许多事件:OrderPlaced,OrderRegistrantAssigned,OrderTotalsCalculated,OrderPaymentConfirmed,SeatAssignmentsCreated,SeatAssignmentUpdated,SeatAssigned和 SeatUnassigned。限界上下文使用这些事件在聚合和事件源之间进行通信。
对于会议管理限界上下文来说,要捕获显示注册和参会者详细信息所需的信息,它必须处理所有这些事件。它可以使用这些事件包含的信息来创建数据的非规范化SQL表,然后业务客户可以在UI中查看这些数据。
这种方法的问题是会议管理限界上下文需要从另一个限界上下文理解一组复杂的事件。这是一个脆弱的解决方案,因为订单和注册限界上下文的更改可能会破坏会议管理限界上下文中的这一特性。
Contoso计划为系统的V1版本保留这个解决方案,但是将在旅程的下一阶段评估其他方案。这些替代方案将包括:
- 修改订单和注册限界上下文,以生成为集成而显式设计的更有用的事件。
- 在订单和注册限界上下文中生成非规范化数据,并在数据准备好时通知会议管理限界上下文。然后,会议管理有界上下文可以通过服务调用请求信息。
备注:要查看当前方法如何工作,请查看源代码中Conference项目里的OrderEventHandler类。
选择何时更新读端数据
在会议管理有界上下文中,业务客户可以更改座位类型的描述。这将引发一个SeatUpdated事件,由ConferenceViewModelGenerator类在订单和注册限界上下文中处理。该类更新读模型数据,以反映有关座椅类型的新信息。当注册者下订单时,UI显示新的座位描述。
然而,如果注册者查看先前创建的订单(例如为参会者分配座位),注册者将看到原始的座位描述。
Carlos(领域专家)发言:
这是一个要反复思考的商业决策。我们不想让注册者因为在创建订单后更改座位描述而混淆。
Gary(CQRS专家)发言:
如果我们想要更新现有订单上的座位描述,我们需要修改PricedOrderViewModelGenerator类来处理SeatUpdated事件并调整它的视图模型。
分布式事务和事件源
上一节讨论了会议管理限界上下文的集成选项,提出了使用分布式两段提交事务的问题,以确保存储会议管理数据的数据库和向其他限界上下文发布更改的消息传递基础设施之间的一致性。
实现事件源时也会出现同样的问题:必须确保存储所有事件的限界上下文中的事件存储与将这些事件发布到其他限界上下文中的消息传递基础设施之间的一致性。
事件存储实现的一个关键特性应该是,它提供了一种方法来确保其存储的事件与限界上下文发布到其他限界上下文的事件之间的一致性。
Carlos(领域专家)发言:
如果您决定自己实现一个事件存储,这是您应该解决的一个关键挑战。如果您正在设计一个可伸缩的事件存储,并计划将其部署到分布式环境(如Azure)中,那么您必须非常小心,以确保满足这一需求。
自治和授权
订单和注册限界上下文负责代表注册者创建和管理订单。支付限界上下文负责管理与外部支付系统的交互,以便注册者可以为他们订购的座位付费。
当团队检查这两个限界上下文的领域模型时,发现两个上下文都不知道定价。订单和注册上下文创建了一个订单,其中列出了注册者请求的不同座位类型的数量。支付绑定上下文只是将总数传递给外部支付系统。在某个时候,系统需要在调用支付流程之前计算订单的总数。
团队考虑了两种不同的方法来解决这个问题:支持自治和支持权威。
支持自治
自治方法将计算订单总数的任务分配给订单和注册限界上下文。它在需要执行计算时不依赖于另一个限界上下文,因为它已经拥有了必要的数据。在过去的某个时候,它将从其他限界上下文(例如会议管理限界上下文)收集所需的定价信息并缓存它。
这种方法的优点是订单和注册限界上下文是自治的。它不依赖于另一个限界上下文或服务的可用性。
缺点是价格信息可能已经过时。业务客户可能在会议管理限界上下文中更改了定价信息,但该更改可能尚未到达订单和注册有界上下文中。
支持授权
在这种方法中,计算订单总数的系统部分在执行计算时从限界上下文中(例如会议管理限界上下文中)获取定价信息。订单和注册限界上下文仍然可以执行计算,或者可以将计算委托给系统中的另一个限界上下文或服务。
这种方法的优点是,每当计算订单总数时,系统总是使用最新的定价信息。
缺点是,当需要确定订单总数时,订单和注册限界上下文依赖于另一个限界上下文。它要么需要查询会议管理限界上下文以获得最新的定价信息,要么调用另一个执行计算的服务。
**在自治和授权之间选择
这两种之间的选择是一个业务决策。场景的特定业务需求决定采用哪种方法。自治通常是大型在线系统的首选。
Jana(软件架构师)发言:
这个选择可能会根据系统的状态而改变。考虑一个超额预订的场景。当大量会议席位仍然可用时,自治策略可能会在正常情况下进行优化,但是随着特定会议的满员,系统可能需要变得更加保守,并使用关于座位可用性的最新信息来支持授权。
会议管理系统计算订单总数的方法是选择自治而不是授权的一个例子。
Carlos(领域专家)发言:
对于Contoso来说,自治是明确的选择。注册者因为其他一些限界上下文挂了而不能购买座位是一个严重的问题。无论怎样,我们并不真正关心业务客户修改的定价信息和用于计算订单总数的新定价信息之间是否存在短暂的延迟。
下面的计算汇总部分描述了系统如何执行此计算。
读端的实现方法
在前几章对读端进行的讨论中,您看到了团队如何使用基于sql的存储来从写端对数据进行非规范化的映射。
您可以为读取模型数据使用其他存储机制。例如,您可以使用文件系统或Azure table或blob来存储。在订单和注册限界上下文中,系统使用Azure blob存储关于座位分配的信息。
Gary(CQRS专家)发言:
当您为读端选择底层存储机制时,除了要求读端上的查询方便且高效外,还应该考虑与存储相关的成本(尤其是在云中)。
备注:请参阅SeatAssignmentsViewModelGenerator类,以了解如何将数据持久化到blob存储,以及SeatAssignmentsDao类,以了解UI如何检索数据以供显示。
最终一致性
在测试期间,团队发现了一个场景,在这个场景中,注册者可能会看到操作中最终一致性的证明。如果注册者将参会者分配到订单上购买的座位,然后快速导航到查看分配,那么有时该视图只显示部分更新。然而,刷新页面会显示正确的信息。这是因为记录座位分配的事件传播到读模型需要时间,有时测试人员会过早地查看从读模型查询的信息。
尽管生产系统更新读取模型的速度可能比本地运行的应用程序的调试版本要快,但是团队决定在视图页面中添加一个注释,警告用户这种可能性。
Carlos(领域专家)发言:
只要注册者知道更改已经被持久化,并且UI显示的内容可能过期了几秒钟,他们就不会担心。
实现细节
本节描述订单和注册限界上下文的实现的一些重要功能。您可能会发现拥有一份代码拷贝很有用,这样您就可以继续学习了。您可以从Download center下载一个副本,或者在GitHub上查看存储库:https://github.com/mspnp/cqrs-journey-code。您可以从GitHub上的Tags页面下载V1发行版的代码。
备注:不要期望代码示例与参考实现中的代码完全匹配。本章描述了CQRS过程中的一个步骤,随着我们了解更多并重构代码,实现可能会发生变化。
会议管理限界上下文
会议管理限界上下文允许业务客户定义和管理会议,它是一个简单的两层、CRUD风格的应用程序,使用ASP.MVC。
在Visual Studio解决方案中,Conference项目包含模型代码和Conference.Web项目。Conference.Web项目包含MVC View和Controller。
与订单和注册限界上下文进行集成
会议管理限界上下文通过发布以下事件将更改通知推送到会议。
- ConferenceCreated。在业务客户创建新会议时发布。
- ConferenceUpdated。在业务客户更新现有会议时发布。
- ConferencePublished。每当业务客户发布会议时发布。
- ConferenceUnpublished。每当业务客户取消发布新会议时发布。
- SeatCreated。每当业务客户定义新座位类型时发布。
- SeatsAdded。每当业务客户增加座位类型的配额时发布。
Conference项目中的ConferenceService类将这些事件发布到事件总线。
Markus(软件开发人员)发言:
目前,还没有分布式事务来把数据库更新和消息发布包装到一起。
支付限界上下文
支付限界上下文负责和支付的外部系统交互,进行支付的处理和验证。在V1版本中,支付可以通过模拟的外部第三方支付处理器(模仿PayPal等系统的行为)或发票系统进行处理。外部系统可以报告付款成功或失败。
下图中的序列图演示了支付过程中涉及的关键元素如何相互交互。该图显示了一个简化的视图,忽略了处理程序类以更好地描述流程。
上图显示了订单和注册限界上下文、支付限界上下文和外部支付服务如何相互交互。在未来,注册用户也可以通过发票支付来替代第三方支付服务。
注册者将支付作为UI中整个流程的一部分,如上图所示。PaymentController控制器类先不显示视图,它必须等待系统创建第三方ThirdPartyProcessorPayment聚合实例。它的作用是将从注册者收集的支付信息转发给第三方支付处理程序。
通常,当您实现CQRS模式时,您使用事件作为限界上下文之间通信的机制。然而,在本例中,RegistrationController和PaymentController控制器类向支付限界上下文发送命令。支付限界上下文使用事件与订单和注册限界上下文中的RegistrationProcessManager实例通信。
支付限界上下文的实现使用了CQRS模式,但没有事件源。
写端模型包含一个名为ThirdPartyProcessorPayment的聚合,它由两个类组成:ThirdPartyProcessorPayment和ThirdPartyProcessorPaymentItem。通过使用Entity Framework将这些类的实例持久化到SQL数据库实例中。PaymentsDbContext类实现了一个Entity Framework dbcontext。
ThirdPartyProcessorPaymentCommandHandler是一个在写端实现的命令处理程序。
读取端模型也使用Entity Framework实现。PaymentDao类在读端导出支付数据。请参见GetThirdPartyProcessorPaymentDetails方法。
下图说明了组成支付限界上下文的读端和写端的不同部分。
与在线支付服务的集成、最终的一致性和命令验证
通常,在线支付服务提供两种级别的集成方式:
- 简单的方法是通过一种简单的重定向机制来工作,您不需要与支付提供者建立一个商家帐户。您将客户重定向到支付服务。支付服务接受支付,然后将客户重定向回网站上的一个页面,并附带一个确认码。
- 复杂的方法(您确实需要一个商家帐户)是基于API的。它通常分两步执行。首先,支付服务验证您的客户是否可以支付所需的金额,并向您发送一个令牌。其次,您可以在固定的时间内使用令牌,通过将令牌发送回支付服务来完成支付。
Contoso假定其业务客户没有商户帐户,必须使用简单的方法。这样做的一个后果是,在客户完成付款时,座位预订可能会过期。如果发生这种情况,系统会尝试在客户付款后重新获得座位。如果无法重新获得座位,系统会将此问题通知业务客户,业务客户必须手动解决此情况。
备注:该系统允许一点额外的时间,显示在倒计时时钟上,来完成支付过程。
在这个特定的场景中,如果没有用户(在本例中是业务所有者,他必须发起退款或覆盖座位配额)的手动干预,系统无法使自己完全一致,这说明了与最终一致性和命令验证相关的以下更普遍的观点。
接受最终一致性的一个关键好处是消除了使用分布式事务的需求,由于大型系统中必须持有的锁的数量和持续时间,分布式事务对可伸缩性和性能有显著的负面影响。在这个特定的场景中,您可以采取以下两种方式来避免在没有座位的情况下接受付款的潜在问题:
- 把系统更改成在付款前重新检查座位是否有空位。但这是不现实的,因为与支付系统的集成是在没有商户帐户的情况下工作的。
- 保留座位直到付款完毕。这也很困难,因为你不知道付款过程需要多长时间。您必须预留(锁定)座位一段不确定的时间,等待注册人完成付款。
团队选择允许这样一种可能性,即注册者可以付费购买座位,却发现座位已不再可用。在实际中不太可能发生超时,除非注册者要付费的座位很多。这种方法对系统的影响最小,因为它不需要对任何座位进行长期预订(锁定)。
Markus(软件开发人员)发言:
为了进一步减少发生这种情况的机会,团队决定将释放预留座位的缓冲时间从5分钟增加到14分钟。选择5分钟的原始值是为了考虑服务器之间任何可能的时钟倾斜使得在UI中的15分钟倒计时器过期之前不会释放预订。
在更通常的情况下,你可以重申上述两个选项:
- 在执行命令之前验证命令,以确保命令成功。
- 锁定所有资源,直到命令完成。
如果命令只影响单个聚合,并且不需要引用聚合定义的一致性边界之外的任何内容,那么就没有问题,因为验证命令所需的所有信息都在聚合中。目前的情况并非如此。如果您能在付款之前验证座位是否仍然可用,那么这个信息将需要检查当前汇总之外的信息。
如果选择验证命令,您需要查看聚合之外的数据,例如,通过查询读模型或查看缓存,系统的可伸缩性将受到负面影响。另外,如果您正在查询一个读模型,请记住读模型最终是一致的。在当前场景中,您需要查询最终一致的读模型来检查座位的可用性。
如果您决定在命令完成之前锁定所有相关资源,请注意这将对系统的可伸缩性造成的影响。
从业务角度处理这样的问题要比在系统上设置大型架构约束好得多。
-- Greg Young
有关这个问题的详细讨论,请参阅Q/A Greg Young's Blog。
事件源
事件源基础设施的初始实现是非常基本的:团队打算在不久的将来用产品质量的事件存储来替换它。本节描述了初始的、基本的实现,并列出了改进它的各种方法。
这个基本事件源解决方案的核心要素是:
- 每当聚合实例的状态发生更改时,实例将引发一个事件,该事件将完整地描述状态更改。
- 系统将这些事件保存在事件存储中。
- 聚合可以通过重播其过去的事件流来重建状态。
- 其他聚合和流程管理器(可能在不同的限界上下文中)可以订阅这些事件。
当聚合状态发生更改时引发事件
订单(Order)聚合中的以下两个方法是OrderCommandHandler类在接收订单命令时调用的方法的示例。这两种方法都不会更新订单(Order)聚合的状态。相反,它们引发一个事件,该事件将由订单(Order)聚合处理。在MarkAsReserved方法中,有一些最小的逻辑来确定要引发哪两个事件。
public void MarkAsReserved(DateTime expirationDate, IEnumerable<SeatQuantity> reservedSeats)
{
if (this.isConfirmed)
throw new InvalidOperationException("Cannot modify a confirmed order.");
var reserved = reservedSeats.ToList();
// Is there an order item which didn't get an exact reservation?
if (this.seats.Any(item => !reserved.Any(seat => seat.SeatType == item.SeatType && seat.Quantity == item.Quantity)))
{
this.Update(new OrderPartiallyReserved { ReservationExpiration = expirationDate, Seats = reserved.ToArray() });
}
else
{
this.Update(new OrderReservationCompleted { ReservationExpiration = expirationDate, Seats = reserved.ToArray() });
}
}
public void ConfirmPayment()
{
this.Update(new OrderPaymentConfirmed());
}
Order类的抽象基类定义了Update方法。下面的代码示例显示了这个方法以及EventSourced类中的Id和Version属性。
private readonly Guid id;
private int version = -1;
protected EventSourced(Guid id)
{
this.id = id;
}
public int Version { get { return this.version; } }
protected void Update(VersionedEvent e)
{
e.SourceId = this.Id;
e.Version = this.version + 1;
this.handlers[e.GetType()].Invoke(e);
this.version = e.Version;
this.pendingEvents.Add(e);
}
Update方法设置Id并递增聚合的版本。它还确定应该调用聚合中的哪个事件处理程序来处理事件类型。
Markus(软件开发人员)发言:
每次系统更新聚合的状态时,都会增加聚合的版本号。
下面的代码示例显示Order类中的事件处理程序方法,这些方法是在调用上面显示的命令方法时调用的。
private void OnOrderPartiallyReserved(OrderPartiallyReserved e)
{
this.seats = e.Seats.ToList();
}
private void OnOrderReservationCompleted(OrderReservationCompleted e)
{
this.seats = e.Seats.ToList();
}
private void OnOrderExpired(OrderExpired e)
{
}
private void OnOrderPaymentConfirmed(OrderPaymentConfirmed e)
{
this.isConfirmed = true;
}
这些方法更新聚合的状态。
聚合必须能够处理来自其他聚合的事件和它自己引发的事件。Order类中的受保护构造函数列出Order聚合可以处理的所有事件。
protected Order()
{
base.Handles<OrderPlaced>(this.OnOrderPlaced);
base.Handles<OrderUpdated>(this.OnOrderUpdated);
base.Handles<OrderPartiallyReserved>(this.OnOrderPartiallyReserved);
base.Handles<OrderReservationCompleted>(this.OnOrderReservationCompleted);
base.Handles<OrderExpired>(this.OnOrderExpired);
base.Handles<OrderPaymentConfirmed>(this.OnOrderPaymentConfirmed);
base.Handles<OrderRegistrantAssigned>(this.OnOrderRegistrantAssigned);
}
事件持久化
当聚合在EventSourcedAggregateRoot类的Update方法中处理事件时,它将该事件添加到挂起事件的私有列表中。此列表将在名为Events的类(是EventSourced抽象类的实现类)中暴露成IEnumerable类型的公开属性。
来自OrderCommandHandler类的以下代码示例展示了处理程序如何调用Order类中的方法来处理命令,然后使用存储库将所有挂起事件附加到存储中,从而持久存储Order聚合的当前状态。
public void Handle(MarkSeatsAsReserved command)
{
var order = repository.Find(command.OrderId);
if (order != null)
{
order.MarkAsReserved(command.Expiration, command.Seats);
repository.Save(order);
}
}
下面的代码示例显示了SqlEventSourcedRepository类中Save方法的初始简单实现。
备注:这些示例引用的是一个基于SQL Server实现的事件存储。这是最初的方法,后来被基于Azure表存储的实现所取代。基于SQL server实现的事件存储仍然保留在解决方案中,这是为了方便您可以在本地运行应用程序,并使用这个实现来避免对Azure的任何依赖。
public void Save(T eventSourced)
{
// TODO: guarantee that only incremental versions of the event are stored
var events = eventSourced.Events.ToArray();
using (var context = this.contextFactory.Invoke())
{
foreach (var e in events)
{
using (var stream = new MemoryStream())
{
this.serializer.Serialize(stream, e);
var serialized = new Event { AggregateId = e.SourceId, Version = e.Version, Payload = stream.ToArray() };
context.Set<Event>().Add(serialized);
}
}
context.SaveChanges();
}
// TODO: guarantee delivery or roll back, or have a way to resume after a system crash
this.eventBus.Publish(events);
}
通过重播事件来重建状态
当处理程序类从存储中加载聚合实例时,它通过重播存储的事件流来加载实例的状态。
Poe(IT运维人员)发言:
我们后来发现,使用事件源并能够重播事件对于分析运行在云中的生产系统中的bug是非常宝贵的技术。我们可以创建事件存储的本地副本,然后在本地重播事件流,并在Visual Studio中调试应用程序,以准确理解生产系统中发生了什么。
下面来自OrderCommandHandler类的代码示例显示了如何调用存储库中的Find方法来启动此过程。
public void Handle(MarkSeatsAsReserved command)
{
var order = repository.Find(command.OrderId);
...
}
下面的代码示例显示了SqlEventSourcedRepository类如何加载与聚合关联的事件流。
Jana(软件架构师)发言:
该团队后来使用Azure表而不是SqlEventSourcedRepository开发了一个简单的事件存储。下一节将描述这种基于Azure表存储的实现。
public T Find(Guid id)
{
using (var context = this.contextFactory.Invoke())
{
var deserialized = context.Set<Event>()
.Where(x => x.AggregateId == id)
.OrderBy(x => x.Version)
.AsEnumerable()
.Select(x => this.serializer.Deserialize(new MemoryStream(x.Payload)))
.Cast<IVersionedEvent>()
.AsCachedAnyEnumerable();
if (deserialized.Any())
{
return entityFactory.Invoke(id, deserialized);
}
return null;
}
}
下面的代码示例显示了当前面的代码调用Invoke方法时候,Order类中的构造函数是怎样从自己的事件流里重建状态的。
public Order(Guid id, IEnumerable<IVersionedEvent> history) : this(id)
{
this.LoadFrom(history);
}
LoadFrom方法在EventSourced类中定义,如下面的代码示例所示。对于历史中存储的每个事件,它确定要在Order类中调用的适当处理程序方法,并更新聚合实例的版本号。
protected void LoadFrom(IEnumerable<IVersionedEvent> pastEvents)
{
foreach (var e in pastEvents)
{
this.handlers[e.GetType()].Invoke(e);
this.version = e.Version;
}
}
简单事件存储实现的一些问题
前面几节中概述的事件源和事件存储的简单实现有许多缺点。下面的列表列出了在生产质量的实现中应该克服的一些缺点。
- SqlEventRepository类中的Save方法不能保证将事件持久存储并发布到消息传递基础设施。失败可能导致事件被保存到存储区,但不会发布。
- 没有检查当系统持久保存一个事件时,它是否是比前一个事件晚一些的事件。事件需要被按顺序存储。
- 对于事件流中有大量事件的聚合实例,没有适当的优化。这可能会在重播事件时导致性能问题。
基于Azure表的事件存储
基于Azure表实现的事件存储解决了简单的基于SQL server实现的事件存储的一些缺点。然而,在这一点上,它仍然不是一个生产质量的实现。
团队设计此实现是为了确保事件既被持久化到存储中,又被发布在消息总线上。为了实现这一点,它使用了Azure表的事务功能。
Markus(软件开发人员)发言:
Azure表存储支持跨共享相同分区键的记录的事务。
EventStore类最初保存要持久化的每个事件的两个副本。一个副本是该事件的永久记录,另一个副本成为必须在Azure服务总线上发布的事件虚拟队列的一部分。下面的代码示例显示了EventStore类中的Save方法。前缀“Unpublished”标识事件的副本,该副本是未发布事件的虚拟队列的一部分。
public void Save(string partitionKey, IEnumerable<EventData> events)
{
var context = this.tableClient.GetDataServiceContext();
foreach (var eventData in events)
{
var formattedVersion = eventData.Version.ToString("D10");
context.AddObject(
this.tableName,
new EventTableServiceEntity
{
PartitionKey = partitionKey,
RowKey = formattedVersion,
SourceId = eventData.SourceId,
SourceType = eventData.SourceType,
EventType = eventData.EventType,
Payload = eventData.Payload
});
// Add a duplicate of this event to the Unpublished "queue"
context.AddObject(
this.tableName,
new EventTableServiceEntity
{
PartitionKey = partitionKey,
RowKey = UnpublishedRowKeyPrefix + formattedVersion,
SourceId = eventData.SourceId,
SourceType = eventData.SourceType,
EventType = eventData.EventType,
Payload = eventData.Payload
});
}
try
{
this.eventStoreRetryPolicy.ExecuteAction(() => context.SaveChanges(SaveChangesOptions.Batch));
}
catch (DataServiceRequestException ex)
{
var inner = ex.InnerException as DataServiceClientException;
if (inner != null && inner.StatusCode == (int)HttpStatusCode.Conflict)
{
throw new ConcurrencyException();
}
throw;
}
}
备注:此代码示例还说明了如何使用重复键错误来标识并发错误。
repository类中的Save方法如下所示。此方法由事件处理程序类调用,它调用前面代码示例中所示的Save方法,并调用EventStoreBusPublisher类的SendAsync方法。
public void Save(T eventSourced)
{
var events = eventSourced.Events.ToArray();
var serialized = events.Select(this.Serialize);
var partitionKey = this.GetPartitionKey(eventSourced.Id);
this.eventStore.Save(partitionKey, serialized);
this.publisher.SendAsync(partitionKey);
}
EventStoreBusPublisher类负责从Azure表存储中的虚拟队列中读取聚合的未发布事件,将事件发布到Azure服务总线上,然后从虚拟队列中删除未发布的事件。
如果系统在将事件发布到Azure服务总线和从虚拟队列中删除事件之间失败,那么当应用程序重新启动时,将第二次发布事件。为了避免重复发布事件引起的问题,Azure服务总线被配置为检测重复消息并忽略它们。
Markus(软件开发人员)发言:
在出现故障的情况下,系统必须包含一种机制,用于扫描表存储中的所有分区,寻找包含未发布事件的聚合,然后发布这些事件。这个过程需要一些时间来运行,但是只需要在应用程序重新启动时运行。
计算总数
为了保证其自主性,订单和注册限界上下文在不访问会议管理限界上下文的情况下计算订单总数。会议管理限界上下文负责维护会议座位的价格。
每当业务客户添加新的座位类型或更改座位的价格时,会议管理限界上下文就会引发一个事件。订单和注册限界上下文将处理这些事件,并将信息作为其读模型的一部分保存(详细信息,请参考解决方案中的ConferenceViewModelGenerator类)。
当订单聚合计算订单总数时,它使用读模型提供的数据。详细信息请参考订单聚合和PricingService类中的MarkAsReserved方法。
Jana(软件架构师)发言:
当注册者向订单添加座位时,UI还动态显示计算的总数。应用程序使用JavaScript计算这个值。当注册者付款时,系统使用订单总数计算的总数。
对测试的影响
Markus(软件开发人员)发言:
不要让通过的单元测试使您产生错误的安全感。当您实现CQRS模式时,有很多灵活的部分。您需要测试它们是否都能正确地协同工作。
Markus(软件开发人员)发言:
不要忘记为读模型创建单元测试。读模型生成器上的单元测试在V1版本发布之前就发现过一个bug,系统在更新订单时删除了订单项。
时间问题
当业务客户创建新的座位类型时,其中有一个验收测试来验证系统的行为。测试中的关键步骤是创建一个会议,为会议创建一个新的座位类型,然后发布会议。这将引发相应的事件序列:ConferenceCreated,SeatCreated和ConferencePublished。
订单和注册限界上下文处理这些集成事件。测试确定订单和注册限界上下文接收这些事件的顺序与会议管理限界上下文发送这些事件的顺序不同。
Azure服务总线只提供先入先出(FIFO),因此,它可能不会按照事件发送的顺序交付事件。在这个场景中,也有可能出现问题,因为在测试中创建消息并将其交付给Azure服务总线的步骤所花费的时间不同。在测试步骤之间引入人为的延迟为这个问题提供了一个临时的解决方案。
在V2版本中,团队计划解决消息排序的一般问题,或者修改基础设施以确保正确的排序,或者在消息确实出现顺序错误时使系统更加健壮。
关于领域专家
在第4章“扩展和增强订单和注册限界上下文”中,您看到了领域专家如何参与设计验收测试,以及他的参与如何帮助澄清领域知识。
您还应该确保领域专家参加错误分类会议。他或她可以帮助阐明系统的预期行为,并且在讨论期间可以发现新的用户场景。例如,对与在会议管理限界上下文中取消发布会议相关的bug进行分类时,领域专家确定了一个需求,以允许业务客户将未发布会议的重定向链接添加到新的会议或备用页面。
总结
在我们旅程的这个阶段,我们完成了Contoso会议管理系统的第一个伪生产版本。它现在包含了几个集成的限界上下文、一个更加完善的UI,并在订单和注册限界上下文中使用事件源。
我们还有更多的工作要做,下一章将描述CQRS之旅的下一个阶段,我们将走向V2发行版并解决与系统版本控制相关的问题。