CQRS之旅——旅程4(扩展和增强订单和注册限界上下文)

旅程4:扩展和增强订单和注册限界上下文进一步探索订单和注册的有界上下文。“我明白,如果一个人想看些新鲜的东西,旅行并不是没有意义的。”儒勒�凡尔纳,环游世界80天对限界上下文的更改:前一章详细描述了订单和注册限界上下文。本章描述了在CQRS之旅的第二阶段,团队在这个限界上下文中所做的一些更改。本章的主题包括:改进RegistrationProcessManager类中消息相关的工作方式。这说明了限...

CQRS之旅——旅程4(扩展和增强订单和注册限界上下文)

旅程4:扩展和增强订单和注册限界上下文

进一步探索订单和注册的有界上下文。“我明白,如果一个人想看些新鲜的东西,旅行并不是没有意义的。”儒勒�凡尔纳,环游世界80天

对限界上下文的更改:

前一章详细描述了订单和注册限界上下文。本章描述了在CQRS之旅的第二阶段,团队在这个限界上下文中所做的一些更改。

本章的主题包括:

  • 改进RegistrationProcessManager类中消息相关的工作方式。这说明了限界上下文中的聚合实例如何以复杂的方式进行交互。
  • 实现一个记录定位器,使注册者能够检索她在前一个会话中保存的订单。这说明了如何向写端(Write Side)添加一些额外的逻辑,使您能够在不知道聚合实例惟一ID的情况下定位它。
  • 在UI中添加一个倒计时器,使注册者能够跟踪他们需要在多长时间内完成订单。这说明了对写端(Write Side)进行的增强,以支持在UI中显示丰富的信息。
  • 同时支持多种座位类型的预定。例如,注册者为会前的活动申请5个座位,为会议申请8个座位。这需要在写端(Write Side)使用更复杂的业务逻辑。
  • CQRS命令验证。这说明了如何在将CQRS命令发送到领域之前使用MVC中的模型验证特性来验证它们。

本章描述的Contoso会议管理系统并不是该系统的最终版本。本旅程描述的是一个过程,因此一些设计决策和实现细节将在过程的后续步骤中更改。这些变化将在后面的章节中描述。

本章的工作术语定义:

本章使用了一些术语,我们将在下一章进行描述。有关更多细节和可能的替代定义,请参阅参考指南中的“深入CQRS和ES”。

  • Command(命令):命令是要求系统执行更改系统状态的操作。命令是必须服从(执行)的一种指令,例如:MakeSeatReservation。在这个限界上下文中,命令要么来自用户发起请求时的UI,要么来自流程管理器(当流程管理器指示聚合执行某个操作时)。单个接收方处理一个命令。命令总线(command bus)传输命令,然后命令处理程序将这些命令发送到聚合。发送命令是一个没有返回值的异步操作。

  • Event(事件):事件就是系统中发生的一些事情,通常是一个命令的结果。领域模型中的聚合会引发(raise)事件。多个事件订阅者(subscribers)可以处理特定的事件。聚合将事件发布到事件总线, 处理程序订阅特定类型的事件,事件总线(event bus)将事件传递给订阅者。在这个限界上下文中,唯一的订阅者是流程管理器。

  • 流程管理器。在这个限界上下文中,流程管理器是一个协调领域域中聚合行为的类。流程管理器订阅聚合引发的事件,然后遵循一组简单的规则来确定发送一个或一组命令。流程管理器不包含任何业务逻辑,它唯一的逻辑是确定下一个发送的命令。流程管理器被实现为一个状态机,因此当它响应一个事件时,除了发送一个新命令外,还可以更改其内部状态。

    Gregor Hohpe和Bobby Woolf合著的《Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions》(Addison-Wesley Professional, 2003)书中312页讲述了流程管理器实现模式。我们的流程管理器就是依照这个模式实现的。

用户故事(User stories)

除了描述订单和注册限界上下文的一些更改和增强之外,本章还讨论了两个用户故事的实现。

使用记录定位器作为登录

当注册者创建会议座位的订单时,系统生成一个5个字符的订单访问代码,并通过电子邮件发送给注册者。登记人可以使用她的电子邮件地址和会议系统网站上的订单访问代码作为记录定位器,以便稍后从系统中检索订单。注册者可能希望检索订单以查看它,或者通过分配与会者到座位来完成注册过程。

Carlos(领域专家)发言:

从商业的角度来看,对我们来说,尽可能地做到用户友好是很重要的。我们不想阻止或不必要地增加任何试图注册会议的人的负担。因此,我们不要求用户在注册之前在系统中创建帐户,特别是要求用户无论如何都必须在标准的结帐过程中输入大部分信息。

告诉会议注册者还剩余多少时间来完成订单

当注册者创建一个订单时,系统将保留注册者请求的座位,直到完成订单或预订过期。要完成订单,注册者必须提交她的详细信息,如姓名和电子邮件地址,并成功付款。

为了帮助注册者,系统会显示一个倒计时计时器,告诉她还有多少时间可以在预定到期前完成订单。

使注册者能够创建包含多个座位类型的订单

当注册者创建一个订单,她可以申请不同数量的座位,并且这些座位类型可以不相同。例如,登记人可要求五个会议座位和三个会前讲习班座位。

架构

该应用程序旨在部署到Microsoft Azure。在旅程的那个阶段,应用程序由两个角色组成,一个包含ASP.Net MVC Web应用程序的web角色和一个包含消息处理程序和领域对象的工作角色。应用程序在写端和读端都使用Azure SQL DataBase实例进行数据存储。应用程序使用Azure服务总线来提供其消息传递基础设施。下图展示了这个高级体系结构。

在研究和测试解决方案时,可以在本地运行它,可以使用Azure compute emulator,也可以直接运行MVC web应用程序,并运行承载消息处理程序和领域域对象的控制台应用程序。在本地运行应用程序时,可以使用本地SQL Server Express数据库,并使用一个在SQL Server Express数据库实现的简单的消息传递基础设施。

有关运行应用程序的选项的更多信息,请参见附录1“发布说明”。

模式和概念

本节介绍了在团队旅程的当前阶段,应用程序的一些关键地方,并介绍了团队在处理这些地方时遇到的一些挑战。

记录定位器

该系统使用访问码而不是密码,这样注册者就不会被迫在该系统中设置帐户。许多注册者可能只使用系统一次,因此不需要创建一个带有用户ID和密码的永久帐户。

系统需要能够根据注册者的电子邮件地址和访问代码快速检索订单信息。为了提供最低程度的安全性,系统生成的访问代码不应该是可预测的,注册者可以检索的订单信息不应该包含任何敏感信息。

在读端查询数据

前一章重点介绍了写端模型及其实现,在本章中,我们将更详细地探讨读端的实现。特别地,我们将解释如何从MVC控制器实现读取模型和查询机制。

在对CQRS模式的初步研究中,团队决定使用数据库中的SQL视图作为读取端MVC控制器查询数据的基础数据源。为了最小化读端查询必须执行的工作,这些SQL视图提供了数据的反规范化(denormalised)版本。这些视图目前与写模型使用的规范化(normalized)表存在同一个数据库中。

Jana(软件架构师)发言:

该团队将把数据库分为两个部分,并在旅程的后期将探索其他的选择来从规范化的写端推送数据到反规范化的读端。有关使用Azure blob存储而不是SQL表存储读取端数据的示例,请参见SeatAssignmentsViewModelGenerator类。

在数据库存储反规范化的视图

存储读端数据的一个常见选项是使用一组关系数据库表来保存。您应该优化读取端以实现快速读取,因此存储规范化数据通常没有任何好处,因为这将需要复杂的查询来为客户端构造数据。这意味着读取端的目标应该是使查询尽可能简单,并以能够快速有效地读取的方式在数据库中构建表。

Gary(CQRS专家)发言:

当人们选择使用CQRS模式时,可伸缩的应用程序和响应式UI通常是明确的目标。优化读端以提供对查询的快速响应,同时保持资源利用率较低,这将帮助您实现这些目标。

Jana(软件架构师)发言:

由于表连接操作过多,规范化数据库模式可能无法提供足够快的响应时间。尽管关系数据库技术有所进步,但是与单表读取相比,JOIN操作仍然非常昂贵。

译者注:读取端/查询端通常就是所说的前端UI,如果使用关系型数据库的关系表来存储UI层要展现的页面数据。每次读取都需要做连接查询或多次查询。所以把读取端需要的数据保存为反规范的数据可以实现快速读取。这个反规范化(denormalised)可以简单理解为,抛弃关系型数据库的关系,存储非关系型的数据。

一个需要重要考虑的地方就是读取端用来查询数据的接口。读取端就如ASP.Net MVC程序Controller的Action里发起的查询请求。

在下图中,读取端(如MVC Controller里的Action)调用ViewRepository类上的方法来请求它需要的数据。然后,ViewRepository类对数据库中的非规范化数据运行查询。

Jana(软件架构师)发言:

仓储(Repository)模式使用类似集合的接口在领域和数据映射层之间进行转换,以访问领域对象。有关更多信息,请参考Martin Fowler,Catalog of Patterns of Enterprise Application Architecture,Repository。

Contoso的团队评估了实现ViewRepository类的两种方法:使用IQueryable接口和使用非通用的数据访问对象(DAOs)。

使用IQueryable接口

ViewRepository类考虑的一种方法是让它返回一个IQueryable实例,该实例允许客户端使用LINQ来指定其查询。返回IQueryable实例很简单,很多ORM框架都可以,例如Entity Framework或NHibernate,下面的代码片段演示了客户端如何做此类查询。

var ordersummary = repository.Query<OrderSummary>().Where(LINQ query to retrieve order summary);var orderdetails = repository.Query<OrderDetails>().Where(LINQ query to retrieve order details);

这种方法有几个优点:

简单

  • 这种方法在底层数据库上使用一个薄的抽象层。许多ORM都支持这种方法,它将您必须编写的代码量降到最低。
  • 您只需要定义一个仓储和一个查询方法。
  • 您不需要单独的查询对象。在读端,查询应该很简单,因为您已经对写端数据进行了反规范化,以支持读端。
  • 可以使用LINQ在客户端上提供对过滤、分页和排序等特性的支持。

可测试性

  • 您可以使用LINQ to object进行Mocking。

Markus(软件开发人员)发言:

在参考实现(RI)中,我们使用Entity Framework,我们根本不需要编写任何代码来获取IQueryable实例。我们也只有一个ViewRepository类。

可能有人反对这个方法,包括:

  • 把数据存储层替换为非关系型数据库将很不容易,因为需要提供IQueryable实例。但无论如何,您总是可以为不同的限界上下文选择使用适合的,不同的读取端实现方式。
  • 客户端在执行操作的时候可能会滥用IQueryable接口,您应该确保非规范化的数据完全满足客户的需求。
  • 使用IQueryable接口隐藏了查询办法。但是,由于在写端对数据进行过反规范化,因此对关系数据库表的查询没办法做更复杂的查询。
  • 很难知道您的集成测试是否覆盖了查询方法的所有不同用途。

使用非通用DAOs

另一种方法是让ViewRepository暴露出一个Find方法和一个Get方法,如下面的代码片段所示。

var ordersummary = dao.FindAllSummarizedOrders(userId);var orderdetails = dao.GetOrderDetails(orderId);

您还可以选择使用不同的DAO类。这将使访问不同数据源变得更容易。

var ordersummary = OrderSummaryDAO.FindAll(userId);var orderdetails = OrderDetailsDAO.Get(orderId);

这种方法有几个优点:

简单

  • 对客户端来说,依赖关系更加清晰。例如,客户端引用一个显式的IOrderSummaryDAO实例,而不是一个通用的IViewRepository实例。
    对于大多数查询,只有一到两种预定义的访问对象的方法。不同的查询通常返回不同的投射。

灵活性

  • Get和Find方法隐藏了数据存储分区的细节,还隐藏了使用ORM或显式执行SQL代码等数据访问方法。这使得将来更容易改变这些选择。
    Get和Find方法可以使用ORM、LINQ和IQueryable接口在背后从数据存储中获取数据。这是一个选择,您可以建立在一个方法接一个方法的基础上。

性能

  • 您可以轻松地优化Find和Get方法运行的查询。数据访问层执行所有查询。客户端没有任何风险试图去做复杂的效率低的查询。

可测试性

  • 为Find和Get方法创建单元测试要比为客户端所有可能的LINQ查询范围创建合适的单元测试更容易。

可维护性

  • 所有查询都定义在相同的位置DAO类中,从而更容易一致地修改系统。

对这个方法可能的反对意见包括:

使用IQueryable接口可以更容易地在UI中支持分页、过滤和排序等功能。无论如何,如果开发人员意识到这一缺点并尽力交付基于任务的UI,那么这应该不是问题。

把部分已完成的订单信息提供给读取端

UI层通过在读取端查询模型获得的订单数据来显示。UI显示给注册者的部分数据是关于部分已完成订单的信息:订单中的每种座位类型,请求的座位数量和可用的座位数量。这是系统仅在注册者使用UI创建

源文地址:https://www.guoxiongfei.cn/cntech/19223.html