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创建订单时使用的临时数据。企业只需要存储关于实际购买座位的信息,而不需要存储注册者请求的座位和注册者购买的座位之间的差异。
这样做的结果是,关于注册者请求多少座位的信息只需要存在于读取端模型中。
Jana(软件架构师)发言:
您不能将此信息存储在HTTP Session中,因为注册者可能在请求座位和完成订单之间离开站点。
进一步的结果是,读端的底层存储不能是简单的SQL视图,因为它包含的数据没有存储在写端的底层表存储中。因此,必须使用事件将此信息传递给读取方。
下面的架构图显示了订单(Order)和可用座位(SeatsAvailability)聚合使用的所有命令和事件,以及订单(Order)聚合如何通过引发事件将更改推送到读取端。
OrderViewModelGenerator类处理OrderPlaced、OrderUpdated、OrderPartiallyReserved、OrderRegistrantAssigned和OrderReservationCompleted事件,并使用DraftOrder和DraftOrderItem实例将更改持久化到视图表中。
Gary(CQRS专家)发言:
如果您提前阅读第5章“准备发布V1版本”,您将看到团队扩展了事件的使用,并迁移了订单和注册上下文,以使用事件源。
CQRS命令校验
在实现写模型时,应该尽量确保命令很少失败。这将提供最佳的用户体验,并使您的应用程序更容易实现异步行为。
团队采用的一种方法是使用ASP.NET MVC中的模型验证功能。
您应该小心区分系统错误和业务错误。系统错误的例子包括:
- 由于消息传递基础设施出现故障,无法传递消息。
- 由于与数据库的连接问题,数据没有持久化。
在许多情况下,特别是在云中,您可以通过重试操作来处理这些错误。
Markus(软件开发人员)发言:
来自Microsoft patterns & practices的Transient Fault Handling Application Block的设计目的是使任何Transient Fault更容易实现一致的重试行为。它提供了一组针对Azure SQL数据库、Azure存储、Azure缓存和Azure服务总线的内置检测策略,还允许您定义自己的策略。类似地,它提供了一组方便的内置重试策略,并支持自定义策略。更多信息请参见The Transient Fault Handling Application Block
业务错误应该有预先定好的逻辑响应。例如:
- 如果系统因为没有剩余的座位而无法预订座位,那么它应该将请求添加到等待列表中。
- 如果信用卡支付失败,用户应该有机会尝试另一种信用卡,或者使用发票付款。
Gary(CQRS专家)发言:
您的领域专家应该帮助您识别可能发生的业务失败,并确定您处理它们的方法:使用自动化流程或手动方式。
倒计时器和读取模型
向注册者显示完成订单所需时间的倒计时器是系统中的业务的一部分,而不仅仅是基础设施的一部分。当注册者创建一个订单并预订座位时,倒计时就开始了。即使登记人离开会议网站,倒计时仍在继续。如果注册用户返回网站,UI必须能够显示正确的倒计时值,因此,保留过期时间是读模型中可用数据的一部分。
实现细节
本节描述订单和注册限界上下文的实现的一些重要特性。您可能会发现拥有一份代码副本很有用,这样您就可以继续学习了。您可以从Download center下载一个副本,或者在GitHub上查看存储库中的代码:https://github.com/mspnp/cqrs- jourcode
不要期望代码示例与参考实现中的代码完全匹配。本章描述了CQRS过程中的一个步骤,但是随着我们了解更多并重构代码,实现可能会发生变化。
订单访问代码和记录定位器
注册者可能需要检索订单,或者查看订单,或者完成对参会人员座位的分配。这可能发生在不同的web会话中,因此注册者必须提供一些信息来定位以前保存的订单。
下面的代码示例显示Order类如何生成一个新的五个字符的订单访问代码,该代码作为Order实例的一部分被持久化。
public string AccessCode { get; set; }
protected Order()
{
...
this.AccessCode = HandleGenerator.Generate(5);
}
要检索订单实例,注册者必须提供其电子邮件地址和订单访问代码。系统将使用这两项来定位正确的Order。这是读取端的逻辑。
下面的代码示例来自web应用程序中的OrderController类,展示了MVC控制器如何使用LocateOrder方法向读取端提交查询,以发现唯一的OrderId值。这个Find action将OrderId值传递给一个Display action,该action将订单信息显示给注册者。
[HttpPost]
public ActionResult Find(string email, string accessCode)
{
var orderId = orderDao.LocateOrder(email, accessCode);
if (!orderId.HasValue)
{
return RedirectToAction("Find", new { conferenceCode = this.ConferenceCode });
}
return RedirectToAction("Display", new { conferenceCode = this.ConferenceCode, orderId = orderId.Value });
}
倒计时器
当注册者创建一个订单并预订座位时,这些座位将保留一段固定的时间。RegistrationProcessManager实例将预订从可用座位(SeatsAvailability)聚合中转发,它将预订过期的时间传递给订单(Order)聚合。下面的代码示例显示订单(Order)聚合如何接收和存储预订过期时间。
public DateTime? ReservationExpirationDate { get; private set; }
public void MarkAsReserved(DateTime expirationDate, IEnumerable<SeatQuantity> seats)
{
...
this.ReservationExpirationDate = expirationDate;
this.Items.Clear();
this.Items.AddRange(seats.Select(seat => new OrderItem(seat.SeatType, seat.Quantity)));
}
Markus(软件开发人员)发言:
在Order的构造函数中,ReservationExpirationDate最初被设置为在Order实例化后的15分钟。RegistrationProcessManager类可能会根据实际预订的时间进行修改。实际时间指的是流程管理器向订单(Order)聚合发送MarkSeatsAsReserved命令的时间。
当RegistrationProcessManager将MarkSeatsAsReserved命令发送到订单(Order)聚合(携带UI将显示的过期时间)时,它还向自己发送一条命令,以启动释放预订座位的过程。这个ExpireRegistrationProcess命令在过期区间加上一个5分钟的缓冲来保存。这个缓冲是为了确保服务器之间的时间差不会导致RegistrationProcessManager类在UI中的倒计时器清零之前就释放预留的座位。下面的代码示例展示RegistrationProcessManager类,UI使用MarkSeatsAsReserved命令中的Expiration属性来显示倒计时器,而ExpireRegistrationProcess命令中的Delay属性确定何时释放保留的座位。
public void Handle(SeatsReserved message)
{
if (this.State == ProcessState.AwaitingReservationConfirmation)
{
var expirationTime = this.ReservationAutoExpiration.Value;
this.State = ProcessState.ReservationConfirmationReceived;
if (this.ExpirationCommandId == Guid.Empty)
{
var bufferTime = TimeSpan.FromMinutes(5);
var expirationCommand = new ExpireRegistrationProcess { ProcessId = this.Id };
this.ExpirationCommandId = expirationCommand.Id;
this.AddCommand(new Envelope<ICommand>(expirationCommand)
{
Delay = expirationTime.Subtract(DateTime.UtcNow).Add(bufferTime),
});
}
this.AddCommand(new MarkSeatsAsReserved
{
OrderId = this.OrderId,
Seats = message.ReservationDetails.ToList(),
Expiration = expirationTime,
});
}
...
}
MVC项目中的RegistrationController类在读取端检索订单信息。DraftOrder类包含控制器使用ViewBag类传递给视图的预约过期时间,如下面的代码示例所示。
[HttpGet]
public ActionResult SpecifyRegistrantDetails(string conferenceCode, Guid orderId)
{
var repo = this.repositoryFactory();
using (repo as IDisposable)
{
var draftOrder = repo.Find<DraftOrder>(orderId);
var conference = repo.Query<Conference>()
.Where(c => c.Code == conferenceCode)
.FirstOrDefault();
this.ViewBag.ConferenceName = conference.Name;
this.ViewBag.ConferenceCode = conference.Code;
this.ViewBag.ExpirationDateUTCMilliseconds =
draftOrder.BookingExpirationDate.HasValue ?
((draftOrder.BookingExpirationDate.Value.Ticks - EpochTicks) / 10000L) : 0L;
this.ViewBag.OrderId = orderId;
return View(new AssignRegistrantDetails { OrderId = orderId });
}
}
然后MVC的视图使用JavaScript显示动画倒计时器。
使用ASP.NET MVC validation来验证命令
您应该确保应用程序中的MVC控制器发送给写模型的任何命令都将成功。在将命令发送到写模型之前,可以使用MVC中的特性在客户端和服务器端验证命令。
Markus(软件开发人员)发言:
客户端验证对用户来说主要是比较方便,因为它不用往返于服务器就可以帮助用户正确完成表单填写。但您仍然需要实现服务器端验证,以确保在将数据转发到写模型之前对其进行过验证。
下面的代码示例显示了AssignRegistrantDetails命令类,它使用DataAnnotations指定验证需求;在本例中,要求FirstName、LastName和Email字段不为空。
using System;
using System.ComponentModel.DataAnnotations;
using Common;
public class AssignRegistrantDetails : ICommand
{
public AssignRegistrantDetails()
{
this.Id = Guid.NewGuid();
}
public Guid Id { get; private set; }
public Guid OrderId { get; set; }
[Required(AllowEmptyStrings = false)]
public string FirstName { get; set; }
[Required(AllowEmptyStrings = false)]
public string LastName { get; set; }
[Required(AllowEmptyStrings = false)]
public string Email { get; set; }
}
MVC视图使用这个命令类作为它的模型类。下面的代码示例来自SpecifyRegistrantDetails.cshtml文件,它显示了如何填充模型。
@model Registration.Commands.AssignRegistrantDetails
...
<div class="editor-label">@Html.LabelFor(model => model.FirstName)</div><div class="editor-field">@Html.EditorFor(model => model.FirstName)</div>
<div class="editor-label">@Html.LabelFor(model => model.LastName)</div><div class="editor-field">@Html.EditorFor(model => model.LastName)</div>
<div class="editor-label">@Html.LabelFor(model => model.Email)</div><div class="editor-field">@Html.EditorFor(model => model.Email)</div>
Web.config文件根据DataAnnotations属性配置客户端验证,如下面的代码片段所示:
<appSettings>
...
<add key="ClientValidationEnabled" value="true" />
<add key="UnobtrusiveJavaScriptEnabled" value="true" />
</appSettings>
服务器端验证发生在发送命令之前的控制器中。下面来自RegistrationController类的代码示例展示了控制器如何使用IsValid属性来验证命令。请记住,这个示例使用的是命令的一个实例作为模型。
[HttpPost]
public ActionResult SpecifyRegistrantDetails(string conferenceCode, Guid orderId, AssignRegistrantDetails command)
{
if (!ModelState.IsValid)
{
return SpecifyRegistrantDetails(conferenceCode, orderId);
}
this.commandBus.Send(command);
return RedirectToAction("SpecifyPaymentDetails", new { conferenceCode = conferenceCode, orderId = orderId });
}
有关其他示例,请参见RegistrationController类中的RegisterToConference命令和StartRegistration action方法。
更多信息,请参考MSDN上的Models and Validation in ASP.NET MVC 。
推送更新到读端
关于订单的一些信息只需要存在于读取端。特别是,关于部分已完成订单的信息只在UI中使用,而不是写端领域模型保存的业务信息的一部分。
这意味着系统不能使用SQL视图作为读取端上的底层存储机制,因为视图不包含它们所基于的表中不存在的数据。
系统将非规范化的订单数据存储在SQL数据库实例中的两个表中:OrdersView和OrderItemsView表。OrderItemsView表包含RequestedSeats列,该列包含仅存在于读取端上的数据。
OrdersView表
列 | 说明 |
---|---|
OrderId | Order的唯一ID |
ReservationExpirationDate | 预订座位的过期时间 |
StateValue | 订单的状态,包括:Created, PartiallyReserved, ReservationCompleted, Rejected, Confirmed |
RegistrantEmail | 预订时填写的Email地址 |
AccessCode | 订单的访问码 |
OrderItemsView
列 | 说明 |
---|---|
OrderItemId | 订单项的唯一ID |
SeatType | 预订的座位类型 |
RequestedSeats | 请求预订座位的数量 |
ReservedSeats | 预留座位的数量 |
OrderId | 关联的父Order的ID |
要将这些表填充到读模型中,读端需要处理由写端引发的事件,用它们对这些表进行写操作。有关详细信息,请参见上面章节中的架构图。
OrderViewModelGenerator类处理这些事件并更新读端存储库。
public class OrderViewModelGenerator :
IEventHandler<OrderPlaced>, IEventHandler<OrderUpdated>,
IEventHandler<OrderPartiallyReserved>, IEventHandler<OrderReservationCompleted>,
IEventHandler<OrderRegistrantAssigned>
{
private readonly Func<ConferenceRegistrationDbContext> contextFactory;
public OrderViewModelGenerator(Func<ConferenceRegistrationDbContext> contextFactory)
{
this.contextFactory = contextFactory;
}
public void Handle(OrderPlaced @event)
{
using (var context = this.contextFactory.Invoke())
{
var dto = new DraftOrder(@event.SourceId, DraftOrder.States.Created)
{
AccessCode = @event.AccessCode,
};
dto.Lines.AddRange(@event.Seats.Select(seat => new DraftOrderItem(seat.SeatType, seat.Quantity)));
context.Save(dto);
}
}
public void Handle(OrderRegistrantAssigned @event)
{
...
}
public void Handle(OrderUpdated @event)
{
...
}
public void Handle(OrderPartiallyReserved @event)
{
...
}
public void Handle(OrderReservationCompleted @event)
{
...
}
...
}
下面的代码示例展示ConferenceRegistrationDbContext类:
public class ConferenceRegistrationDbContext : DbContext
{
...
public T Find<T>(Guid id) where T : class
{
return this.Set<T>().Find(id);
}
public IQueryable<T> Query<T>() where T : class
{
return this.Set<T>();
}
public void Save<T>(T entity) where T : class
{
var entry = this.Entry(entity);
if (entry.State == System.Data.EntityState.Detached)
this.Set<T>().Add(entity);
this.SaveChanges();
}
}
Jana(软件架构师)发言:
注意,读端中的这个ConferenceRegistrationDbContext类包含一个Save方法,以保存从写端发送的更改,并通过OrderViewModelGenerator类来调用。
在读端查询
下面的代码示例显示了一个非通用的DAO类,MVC控制器使用该类在读端查询会议信息。它封装了前面展示的ConferenceRegistrationDbContext类。
public class ConferenceDao : IConferenceDao
{
private readonly Func<ConferenceRegistrationDbContext> contextFactory;
public ConferenceDao(Func<ConferenceRegistrationDbContext> contextFactory)
{
this.contextFactory = contextFactory;
}
public ConferenceDetails GetConferenceDetails(string conferenceCode)
{
using (var context = this.contextFactory.Invoke())
{
return context
.Query<Conference>()
.Where(dto => dto.Code == conferenceCode)
.Select(x => new ConferenceDetails { Id = x.Id, Code = x.Code, Name = x.Name, Description = x.Description, StartDate = x.StartDate })
.FirstOrDefault();
}
}
public ConferenceAlias GetConferenceAlias(string conferenceCode)
{
...
}
public IList<SeatType> GetPublishedSeatTypes(Guid conferenceId)
{
...
}
}
Jana(软件架构师)发言:
注意,这个ConferenceDao类只包含返回数据的方法。MVC控制器使用它来检索要在UI中显示的数据。
重构可用座位(SeatsAvailability)聚合
在我们CQRS之旅的第一阶段,领域包含一个ConferenceSeatsAvailabilty聚合根类,这是对会议剩余座位数量进行的建模。在旅程的现在这个阶段,团队将ConferenceSeatsAvailabilty聚合替换为SeatsAvailability,以反映特定会议可能有多种座位类型。例如,完整会议的席位、会前研讨会的席位和鸡尾酒会的席位。下图显示了新的SeatsAvailability聚合及其组成类。
这个聚合反应了下面两个模型:
- 一个会议可能有多种座位类型。
- 每个座位类型可能有不同的座位数量。
领域现在包括一个SeatQuantity值类型,您可以使用它来表示特定座椅类型的数量。
之前,聚合会根据是否有足够的座位数量来引发ReservationAccepted或ReservationRejected事件,现在,聚合引发一个SeatsReserved事件,该事件报告它可以预订多少个特定类型的座位。这意味着预留的座位数目可能与所要求的座位数目不相符。此信息被传递回UI,以便注册者决定如何继续预订。
AddSeats方法
您可能在最上面的架构图中注意到,SeatsAvailability聚合包含一个AddSeats方法,但没有相应的命令。AddSeats方法调整给定类型的可用座位总数。业务客户负责进行任何此类调整,并在Conference Management限界上下文中进行。当可用座位总数发生更改时,Conference Management限界上下文将引发事件。然后,SeatsAvailability类在其处理程序中调用AddSeat方法来处理事件。
对测试的影响
本节将讨论在现在这个阶段解决的一些测试问题。
验收测试和领域专家
在第3章“订单和注册限界上下文”中,您看到了一些UI原型,开发人员和领域专家一起工作,以改进系统的一些功能需求。这些UI原型的计划用途之一是为系统形成一组验收测试的基础。
对于验收测试方法,团队有以下目标:
- 验收测试应该以领域专家能够理解的格式清楚地表达出来。
- 应该可以自动执行验收测试。
为了实现这些目标,领域专家与测试团队的成员配对,并使用SpecFlow来指定核心验收测试。
使用SpecFlow feature来定义验收测试
使用SpecFlow定义验收测试的第一步是使用SpecFlow notation。这些测试被保存为feature文件在一个Visual Studio项目中。以下代码示例来自于ConferenceConfiguration.feature文件,该文件在Features\UserInterface\Views\Management文件夹下。它显示了Conference Management限界上下文的验收测试。典型的SpecFlow测试场景由一组Given、When和Then语句组成。其中一些语句包含测试使用的数据。
Markus(软件开发人员)发言:
事实上,SpecFlow feature文件使用Gherkin语言,这是一种专门为行为描述创建的领域特定语言(DSL)。
Feature: Conference configuration scenarios for creating and editing Conference settings
In order to create or update a Conference configuration
As a Business Customer
I want to be able to create or update a Conference and set its properties
Background:
Given the Business Customer selected the Create Conference option
Scenario: An existing unpublished Conference is selected and published
Given this conference information
| Owner | Email | Name | Description | Slug | Start | End |
| William Flash | william@fabrikam.com | CQRS2012P | CQRS summit 2012 conference (Published) | random | 05/02/2012 | 05/12/2012 |
And the Business Customer proceeds to create the Conference
When the Business Customer proceeds to publish the Conference
Then the state of the Conference changes to Published
Scenario: An existing Conference is edited and updated
Given an existing published conference with this information
| Owner | Email | Name | Description | Slug | Start | End |
| William Flash | william@fabrikam.com | CQRS2012U | CQRS summit 2012 conference (Original) | random | 05/02/2012 | 05/12/2012 |
And the Business Customer proceeds to edit the existing settings with this information
| Description |
| CQRS summit 2012 conference (Updated) |
When the Business Customer proceeds to save the changes
Then this information appears in the Conference settings
| Description |
| CQRS summit 2012 conference (Updated) |
...
Carlos(领域专家)发言:
我发现这些验收测试是我向开发人员阐明系统预期行为定义的好方法。
有关其他示例,请参见源代码里的Conference.AcceptanceTests解决方案
让测试可执行
feature文件中的验收测试不能直接执行。您必须提供一些管道代码来连接SpecFlow feature文件和应用程序。
有关实现的示例,请参见源代码Conference.AcceptanceTests解决方案下的Conference.Specflow项目下的Steps文件夹中的类。
这些步骤使用两种不同的方法实现
第一种运行测试的方法是模拟系统的一个用户,它通过使用第三方开源库WatiN直接驱动web浏览器来实现。这种方法的优点是,它运行系统的方式和实际用户与系统交互的的方式完全相同,并且最初实现起来很简单。然而,这些测试是脆弱的,将需要大量的维护工作来保持它们在UI和系统更改后也会更新成最新的。下面的代码示例展示了这种方法的一个示例,定义了前面所示的feature文件中的一些Given、When和Then步骤。SpecFlow使用Given、When和Then标记把步骤和feature文件中的子句链接起来,并把它当做参数值传递给测试方法:
public class ConferenceConfigurationSteps : StepDefinition
{
...
[Given(@"the Business Customer proceeds to edit the existing settings with this information")]
public void GivenTheBusinessCustomerProceedToEditTheExistingSettignsWithThisInformation(Table table)
{
Browser.Click(Constants.UI.EditConferenceId);
PopulateConferenceInformation(table);
}
[Given(@"an existing published conference with this information")]
public void GivenAnExistingPublishedConferenceWithThisInformation(Table table)
{
ExistingConferenceWithThisInformation(table, true);
}
private void ExistingConferenceWithThisInformation(Table table, bool publish)
{
NavigateToCreateConferenceOption();
PopulateConferenceInformation(table, true);
CreateTheConference();
if(publish) PublishTheConference();
ScenarioContext.Current.Set(table.Rows[0]["Email"], Constants.EmailSessionKey);
ScenarioContext.Current.Set(Browser.FindText(Slug.FindBy), Constants.AccessCodeSessionKey);
}
...
[When(@"the Business Customer proceeds to save the changes")]
public void WhenTheBusinessCustomerProceedToSaveTheChanges()
{
Browser.Click(Constants.UI.UpdateConferenceId);
}
...
[Then(@"this information appears in the Conference settings")]
public void ThenThisInformationIsShowUpInTheConferenceSettings(Table table)
{
Assert.True(Browser.SafeContainsText(table.Rows[0][0]),
string.Format("The following text was not found on the page: {0}", table.Rows[0][0]));
}
private void PublishTheConference()
{
Browser.Click(Constants.UI.PublishConferenceId);
}
private void CreateTheConference()
{
ScenarioContext.Current.Browser().Click(Constants.UI.CreateConferenceId);
}
private void NavigateToCreateConferenceOption()
{
// Navigate to Registration page
Browser.GoTo(Constants.ConferenceManagementCreatePage);
}
private void PopulateConferenceInformation(Table table, bool create = false)
{
var row = table.Rows[0];
if (create)
{
Browser.SetInput("OwnerName", row["Owner"]);
Browser.SetInput("OwnerEmail", row["Email"]);
Browser.SetInput("name", row["Email"], "ConfirmEmail");
Browser.SetInput("Slug", Slug.CreateNew().Value);
}
Browser.SetInput("Tagline", Constants.UI.TagLine);
Browser.SetInput("Location", Constants.UI.Location);
Browser.SetInput("TwitterSearch", Constants.UI.TwitterSearch);
if (row.ContainsKey("Name")) Browser.SetInput("Name", row["Name"]);
if (row.ContainsKey("Description")) Browser.SetInput("Description", row["Description"]);
if (row.ContainsKey("Start")) Browser.SetInput("StartDate", row["Start"]);
if (row.ContainsKey("End")) Browser.SetInput("EndDate", row["End"]);
}
}
您可以看到这种方法是如何模拟在Web浏览器中点击UI元素并输入文本的。
第二种测试方法是通过与MVC控制器类交互来实现。长远的看,这种方法不会那么脆弱,成本就是在最初需要一个更复杂的实现,这需要对系统的内部实现比较熟悉。下面的代码示例展示了这种方法的一个示例。
首先,在Features\UserInterface\Controllers\Registration文件夹下的SelfRegistrationEndToEndWithControllers.feature文件展示了一个示例场景:
Scenario: End to end Registration implemented using controllers
Given the Registrant proceeds to make the Reservation
And these Order Items should be reserved
| seat type | quantity |
| General admission | 1 |
| Additional cocktail party | 1 |
And these Order Items should not be reserved
| seat type |
| CQRS Workshop |
And the Registrant enters these details
| first name | last name | email address |
| William | Flash | william@fabrikam.com |
And the Registrant proceeds to Checkout:Payment
When the Registrant proceeds to confirm the payment
Then the Order should be created with the following Order Items
| seat type | quantity |
| General admission | 1 |
| Additional cocktail party | 1 |
And the Registrant assigns these seats
| seat type | first name | last name | email address |
| General admission | William | Flash | William@fabrikam.com |
| Additional cocktail party | Jim | Corbin | Jim@litwareinc.com |
And these seats are assigned
| seat type | quantity |
| General admission | 1 |
| Additional cocktail party | 1 |
然后,展示了SelfRegistrationEndToEndWithControllersSteps类里的一些测试步骤:
[Given(@"the Registrant proceeds to make the Reservation")]
public void GivenTheRegistrantProceedToMakeTheReservation()
{
var redirect = registrationController.StartRegistration(
registration, registrationController.ViewBag.OrderVersion) as RedirectToRouteResult;
Assert.NotNull(redirect);
// Perform external redirection
var timeout = DateTime.Now.Add(Constants.UI.WaitTimeout);
while (DateTime.Now < timeout && registrationViewModel == null)
{
//ReservationUnknown
var result = registrationController.SpecifyRegistrantAndPaymentDetails(
(Guid)redirect.RouteValues["orderId"], registrationController.ViewBag.OrderVersion);
Assert.IsNotType<RedirectToRouteResult>(result);
registrationViewModel = RegistrationHelper.GetModel<RegistrationViewModel>(result);
}
Assert.False(registrationViewModel == null, "Could not make the reservation and get the RegistrationViewModel");
}
...
[When(@"the Registrant proceeds to confirm the payment")]
public void WhenTheRegistrantProceedToConfirmThePayment()
{
using (var paymentController = RegistrationHelper.GetPaymentController())
{
paymentController.ThirdPartyProcessorPaymentAccepted(
conferenceInfo.Slug, (Guid) routeValues["paymentId"], " ");
}
}
...
[Then(@"the Order should be created with the following Order Items")]
public void ThenTheOrderShouldBeCreatedWithTheFollowingOrderItems(Table table)
{
draftOrder = RegistrationHelper.GetModel<DraftOrder>(registrationController.ThankYou(registrationViewModel.Order.OrderId));
Assert.NotNull(draftOrder);
foreach (var row in table.Rows)
{
var orderItem = draftOrder.Lines.FirstOrDefault(
l => l.SeatType == conferenceInfo.Seats.First(s => s.Description == row["seat type"]).Id);
Assert.NotNull(orderItem);
Assert.Equal(Int32.Parse(row["quantity"]), orderItem.ReservedSeats);
}
}
您可以看到这种方法是如何直接使用RegistrationController类的。
在这些代码示例中,您可以看到是怎样通过标记把SpecFlow feature文件和测试步骤代码链接起来并传递参数的。
团队选择使用xUnit.net来实现测试步骤,要在Visual Studio里运行这些测试,您可以使用任何支持xUnit的第三方工具例如:ReSharper, CodeRush, TestDriven.NET等。
Jana(软件架构师)发言:
请记住,这些验收测试并不是在系统上执行的唯一测试。主要的解决方案里包括全面的单元测试和集成测试,测试团队还对应用程序进行了探索性和性能测试。
使用测试来帮助开发人员理解消息流
关于使用CQRS模式和大量使用消息,有一个常见说法是这让人很难理解系统是如何通过发送和接收消息把各个不同的部分配合在一起的。这里您可以通过设计适当的单元测试来帮助别人理解您的基本代码。
订单聚合的第一个单元测试示例:
public class given_placed_order
{
...
private Order sut;
public given_placed_order()
{
this.sut = new Order(
OrderId, new[]
{
new OrderPlaced
{
ConferenceId = ConferenceId,
Seats = new[] { new SeatQuantity(SeatTypeId, 5) },
ReservationAutoExpiration = DateTime.UtcNow
}
});
}
[Fact]
public void when_updating_seats_then_updates_order_with_new_seats()
{
this.sut.UpdateSeats(new[] { new OrderItem(SeatTypeId, 20) });
var @event = (OrderUpdated)sut.Events.Single();
Assert.Equal(OrderId, @event.SourceId);
Assert.Equal(1, @event.Seats.Count());
Assert.Equal(20, @event.Seats.ElementAt(0).Quantity);
}
...
}
这个单元测试只是创建一个Order实例,并直接调用UpdateSeats方法。它不向阅读测试代码的人提供有关调用此方法中命令或事件的任何信息。
现在看第二个示例,它执行的是相同的测试,但是在本示例中,是通过发送命令来测试的:
public class given_placed_order
{
...
private EventSourcingTestHelper<Order> sut;
public given_placed_order()
{
this.sut = new EventSourcingTestHelper<Order>();
this.sut.Setup(new OrderCommandHandler(sut.Repository, pricingService.Object));
this.sut.Given(
new OrderPlaced
{
SourceId = OrderId,
ConferenceId = ConferenceId,
Seats = new[] { new SeatQuantity(SeatTypeId, 5) },
ReservationAutoExpiration = DateTime.UtcNow
});
}
[Fact]
public void when_updating_seats_then_updates_order_with_new_seats()
{
this.sut.When(new RegisterToConference { ConferenceId = ConferenceId, OrderId = OrderId, Seats = new[] { new SeatQuantity(SeatTypeId, 20) }});
var @event = sut.ThenHasSingle<OrderUpdated>();
Assert.Equal(OrderId, @event.SourceId);
Assert.Equal(1, @event.Seats.Count());
Assert.Equal(20, @event.Seats.ElementAt(0).Quantity);
}
...
}
这个例子使用了一个helper类,它使您能够向Order实例发送命令。现在,阅读测试的人可以明白,当您发送RegisterToConference命令时,您期望看到OrderUpdated事件。
代码理解之旅
乔什·埃尔斯特讲述了一个关于痛苦、解脱和学习的故事
本节描述CQRS咨询委员会成员乔什·埃尔斯在探索Contoso会议管理系统的源代码时所经历的过程。
测试是很重要的
我曾经相信,优秀架构的应用程序很容易理解,不管代码库有多么庞大。每当我理解应用程序行为功能时遇到问题,都是代码的问题,而不是我的问题。
永远不要让你的自负掩盖住常识。
事实上,一直到我职业生涯的某个阶段,我都还没有接触到一个大型的、架构优秀的代码基本。如果不是它走过来打我的脸,我根本就不知道它是什么样子。值得庆幸的是,随着我阅读代码的经验越来越丰富,我学会了区分那些不同。
备注:在任何结构良好的项目中,测试都是开发人员理解项目的基础。各种命名约定,编码风格,设计方法和使用模式的主题都包含在测试套件中,为集成到代码库提供了一个很好的起点。这也是很好的代码专业性实践,熟能生巧!
克隆会议代码之后,我的第一个动作是浏览测试。在阅读了会议系统Visual Studio解决方案中的集成和单元测试套件之后,我将注意力集中在Conference.AcceptanceTests Visual Studio解决方案上,其中包含SpecFlow验收测试。项目团队的其他成员已经对那些.feature文件做了一些初步的工作,由于我不熟悉业务规则的细节,所以对我来说效果很好。把这些feature和代码绑定是一种很好的方式,既可以为项目做出贡献,又可以让人理解系统如何工作。
领域测试
当时我的目标是得到一个像这样的feature文件:
Feature: Self Registrant scenarios for making a Reservation for a Conference site with all Order Items initially available
In order to reserve Seats for a conference
As an Attendee
I want to be able to select an Order Item from one or many of the available Order Items and make a Reservation
Background:
Given the list of the available Order Items for the CQRS Summit 2012 conference with the slug code SelfRegFull
| seat type | rate | quota |
| General admission | $199 | 100 |
| CQRS Workshop | $500 | 100 |
| Additional cocktail party | $50 | 100 |
And the selected Order Items
| seat type | quantity |
| General admission | 1 |
| CQRS Workshop | 1 |
| Additional cocktail party | 1 |
Scenario: All the Order Items are available and all get reserved
When the Registrant proceeds to make the Reservation
Then the Reservation is confirmed for all the selected Order Items
And these Order Items should be reserved
| seat type |
| General admission |
| CQRS Workshop |
| Additional cocktail party |
And the total should read $749
And the countdown started
并将其绑定到执行操作、创建期望或作出断言的代码:
[Given(@"the '(.*)' site conference")]
public void GivenAConferenceNamed(string conference)
{
...
}
所有这些都位于"UI之下",但是在基础概念之上。测试紧密关注整个解决方案领域的行为,这就是为什么我将这些类型的测试称为领域测试。其他术语,如行为驱动开发(BDD),可以用来描述这种类型的测试。
Jana(软件架构师)发言:
这些“UI之下”测试也被称为皮下测试(参见Meszaros, G。Melnik, G的Acceptance Test Engineering Guide)。
重写一遍已经在网站上实现的应用程序逻辑似乎有点多余,但是有以下几个原因值得花时间:
- 您(由于某些原因)对网站或任何其他基础设施部分的行为测试不感兴趣。你只对领域有兴趣,单元级和集成级的测试将验证代码的功能是否正确,因此不需要重复这些测试。
- 当与产品所有者迭代用户故事时,将时间花在纯粹的UI关注点上会拖慢反馈周期,降低反馈的质量和有用性。
- 考虑到不同的人在讨论技术问题时使用的词汇之间有时会出现很大的不匹配,用更抽象的术语讨论一个功能可以更好的理解业务试图解决的问题。
- 在实现测试逻辑时遇到的障碍可以帮助提高系统的总体设计质量。基础设施代码与应用程序逻辑难以分离通常被视为一种坏味道。
备注:为什么这些类型的测试是一个好主意?还有更多的原因没有列出来,但是对于本例来说,这里列出的是那些重要的原因。
Contoso会议管理系统的体系结构是松耦合的,利用消息将命令和事件传递给相关方。命令通过命令总线路由到单个处理程序,而事件则通过事件总线路由到它们的1个或多个处理程序。就消费应用程序而言,总线不绑定任何特定的技术,允许以对用户透明的方式在整个系统中创建和使用任意的实现。
当涉及到松耦合消息体系结构的行为测试时,另一个好处是BDD(或类似风格的)测试本身不涉及应用程序代码的内部工作。它们只关心被测试程序的可观察行为。这意味着对于SpecFlow测试,我们只需要将一些命令发布到总线,并通过根据实际的流量/数据断言预期的消息流量和有效负载来检查外部结果。
备注:在适当的地方,可以使用mock和stub来进行这些类型的测试。一个适当的例子是使用mock出来的ICommandBus对象而不是真正的AzureCommandBus类型。但mock一个完整的领域服务是不合适的例子。尽量少的使用mock,只把它限制在基础设施方面,这样你的生活和测试压力都会小很多。
另一种情况
我刚刚花费了很多来描述事情是多么的棒和简单,哪里有痛苦呢?痛苦在于理解一个系统中发生了什么。松耦合的体系结构也有不好的一面:控制反转和依赖注入等技术从本质上阻碍了代码的可读性,因为如果不仔细检查容器的初始化,就永远无法确定在特定的点注入了什么具体的类。在journey的代码中,IProcess接口是一种表示长时间运行的业务流程(也称为Sagas或流程管理器)的类,这些类负责协调不同聚合之间的业务逻辑。为了维护系统数据和状态的完整性、幂等性和事务性,它发出的命令的实际发送是各个持久化仓储来实现的。由于控制反转和依赖注入对消费者隐藏了这些类型的详细信息,所以它和系统的一些其他属性会造成一点困难在回答一些表面上琐碎的问题时,比如:
- 谁会发出或已发出了特定的命令或事件?
- 什么样的类处理特定的命令或事件?
- 流程或聚合在哪里创建或持久化?
- 什么时候发出与其他命令或事件相关的命令?
- 为什么系统会这样运行?
- 应用程序的状态如何由特定的命令改变?
由于应用程序的依赖关系非常松散,许多传统的代码分析工具和方法要么变得不那么有用,要么完全没用。
让我们以RegistrationProcessManager作为示例,列出一些涉及到回答这些问题的启发式内容。
-
打开RegistrationProcessManager.cs文件,注意,与许多流程管理器一样,它有一个ProcessState枚举。我们注意进程的开始状态:NotStarted。接下来,我们要找到做下面事情之一的代码:
- 创建流程的新实例(流程在哪里创建或持久化?)
- 初始状态被更改为不同的状态(状态如何更改?)
-
找到源代码中出现上述任何一种情况或同时出现上述两种情况的代码位置。在本例中,它是RegistrationProcessManagerRouter类中的Handle方法。重要提示:这并不一定意味着该流程是一个命令处理程序!流程管理器负责从存储中创建和检索聚合根(AR),以便将消息路由到AR,因此尽管它们的方法在名称和签名上与ICommandHandler实现类似,但它们并不实现处理命令的逻辑。
-
请注意当状态发生变化时接收到的消息类型是作为方法参数被传入的,因此我们现在需要找出消息的来源。
- 我们还将注意到,RegistrationProcessManager发出了一个新的命令:MakeSeatReservation。
- 如上所述,这个命令实际上不是由发出它的进程发出的,相反,是当进程保存到磁盘时,才会发出。
- 对于其他任何作为进程处理命令的副作用的,被发出的命令,需要一定程度的重复这些启发。
-
查找OrderPlaced的引用,找到一个或多个顶部(外部)组件,这些组件通过ICommandBus接口上的Send方法发出该类型的消息。
- 由于内部发出的命令是在仓储的Save方法里,所以可以安全地假设直接调用Send方法的任何非基础设施逻辑都是外部入口点。
虽然启发式的内容肯定比这里所提到的要多,但是这里的这些内容很可能足够证明了。即使讨论交互也是一个相当漫长、繁琐的过程。这很容易造成误解。您可以通过这种方式理解各种命令/事件消息传递交互,但是这种方式不是很有效。
备注:一般来说,一个人在任何时候都只能在脑子里保持四到八个不同的想法。为了说明这一概念,让我们保守地计算一下你需要在短期记忆中同时保持的东西的数量,同时遵循上面的启发:
进程类型+进程状态属性+初始状态(NotStarted) + new()的位置+消息类型+中间路由类类型+ 2 *N^ N命令发出(位置、类型、步骤)+判别规则(逻辑也是数据!) > 8
当基础设施需求混合到等式中时,信息饱和的问题会变得更加明显。作为我们都是有能力的开发人员(对吧?),我们可以开始寻找方法来优化这些步骤,并提高相关信息的信噪比。
总之,我们有两个问题:
- 我们被迫记在脑子里的东西太多,无法有效理解。
- 用于消息传递交互的讨论和文档冗长、容易出错且复杂。
幸运的是,使用MIL(消息传递中间语言)可以一举两得。
MIL一开始是一系列LINQPad脚本和代码片段,我创建这些脚本和代码片段是为了在回答问题时帮助处理所有事情。最初,这些脚本完成的所有工作都是通过一个或多个项目程序集反映并输出各种类型的消息和处理程序。在与团队成员的讨论中,很明显其他人也遇到了与我相同的问题。在与模式和实践团队成员进行了几次聊天和头脑风暴会议之后,我们提出了引入一种小型领域特定语言(DSL)的想法,该语言将封装所讨论的交互。暂时命名为SawMIL toolbox,它位于http://jelster.github.com/CqrsMessagingTools/,它提供了实用工具、脚本和示例,使您能够将MIL用作开发和分析流程管理器的一部分。
在MIL中,消息传递组件和交互以特定的方式表示:命令(因为它们是系统执行某些操作的请求)用?表示,比如DoSomething?。事件表示系统中发生的确定的事情,因此获得一个!后缀,如SomethingHappened!
MIL的另一个重要元素是消息发布和接收。从消息源(如Azure服务总线、NServiceBus等)接收的消息总是在前面加上“->”符号,为了让示例暂时保持简单,有一个可选的nil元素(句号.)。用于显式地指示no-op(换句话说,没有接收到任何消息)。下面的代码片段展示了nil元素语法的一个例子:
SendCustomerInvoice? -> .
CustomerInvoiceSent! -> .
一旦发布了命令或事件,就需要对其进行处理。命令只有一个处理程序,而事件可以有多个处理程序。MIL通过将处理程序的名称放在消息传递操作的另一侧来表示消息与处理程序之间的这种关系,如下面的代码片段所示:
SendCustomerInvoice? -> CustomerInvoiceHandler
CustomerInvoiceSent! ->
-> CustomerNotificationHandler
-> AccountsAgeingViewModelGenerator
注意,命令和命令处理程序位于同一行,是因为命令和命令处理程序是1对1的。事件因为可能有多个事件处理程序,所以把他们放到多行上。
聚合根以@符号作为前缀,使用过twitter的人都会很熟悉它。聚合根从不处理命令,但偶尔可能处理事件。聚合根是最常见的事件源,它引发事件以响应在聚合上调用的业务操作。但是,关于这些事件应该清楚的一点是,在大多数系统中,有其他元素决定并实际执行领域事件的发布。这是一个有趣的案例,其中业务和技术需求模糊了边界,由基础设施逻辑而不是应用程序或业务逻辑来满足需求。旅程代码就是一个例子:为了确保事件源和事件订阅者之间的一致性,持久化聚合根的存储库的实现才是负责将事件实际发布到总线的。下面的代码片段显示了AggregateRoot语法的一个示例:
SendCustomerInvoice? -> CustomerInvoiceHandler
@Invoice::CustomerInvoiceSent! -> .
在上面的示例中,一个名为Scope上下文操作符的新语言元素出现在@AggregateRoot旁边。范围上下文元素由双冒号(::)表示,它的两个字符之间可能有空格,也可能没有空格,用于标识两个对象之间的关系。上面,聚合根 '@Invoice'生成CustomerSent!事件来响应CustomerInvoiceHandler调用的逻辑。下一个例子演示了在聚合根上使用Scope元素,它生成多个事件来响应单个命令:
SendCustomerInvoice? -> CustomerInvoiceHandler
@Invoice:
:CustomerInvoiceSent! -> .
:InvoiceAged! -> .
Scope上下文还用于表示不涉及基础设施消息传递设备的元素内路由:
SendCustomerInvoice? -> CustomerInvoiceHandler
@Invoice::CustomerInvoiceSent! ->
-> InvoiceAgeingProcessRouter::InvoiceAgeingProcess
我将介绍的最后一个元素是State Change。状态变化是跟踪系统中发生的事情的最好方法之一,因此MIL将它们视为一等公民。这些语句必须出现在它们自己的文本行中,并以“*”字符作为前缀。这是MIL中唯一一次提到或出现任务,因为它非常重要!下面的代码片段显示了State Change元素的一个例子:
SendCustomerInvoice? -> CustomerInvoiceHandler
@Invoice::CustomerInvoiceSent! ->
-> InvoiceAgegingProcessRouter::InvoiceAgeingProcess
*InvoiceAgeingProcess.ProcessState = Unpaid
总结
我们刚刚介绍了在松耦合应用程序中描述消息传递交互时使用的基本步骤。尽管所描述的交互只是可能交互的子集,但是MIL正在发展成为一种简洁地描述基于消息的系统交互的方法。不同的名词和动词(元素和动作)由不同的、有记忆意义的符号表示。这提供了一种跨基板(粘糊糊的人脑< - >硅CPU)的方法来通信有关整个系统的有意义的信息。尽管该语言很好地描述了某些类型的消息传递交互,但它仍然是一项正在进行的工作,需要开发或改进该语言的许多元素和工具。这提供了一些很好的机会去为OSS贡献代码,如果你一直在观望或思考参与OSS去贡献代码,没有时间犹豫了,现在就去http://jelster.github.com/CqrsMessagingTools/,fork仓库,马上开始吧!