旅游网站建设推广,网站环境搭建,终身免费网站建设,北京网站设计制作关键词优化旅程4#xff1a;扩展和增强订单和注册限界上下文进一步探索订单和注册的有界上下文。“我明白#xff0c;如果一个人想看些新鲜的东西#xff0c;旅行并不是没有意义的。”儒勒凡尔纳#xff0c;环游世界80天对限界上下文的更改#xff1a;前一章详细描述了订单和注册限界…旅程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。在旅程的这个阶段应用程序由两个角色组成一个包含http://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)可以简单理解为抛弃关系型数据库的关系存储非关系型的数据。一个需要重要考虑的地方就是读取端用来查询数据的接口。读取端就如http://ASP.Net MVC程序Controller的Action里发起的查询请求。在下图中读取端(如MVC Controller里的Action)调用ViewRepository类上的方法来请求它需要的数据。然后ViewRepository类对数据库中的非规范化数据运行查询。Jana(软件架构师)发言仓储(Repository)模式使用类似集合的接口在领域和数据映射层之间进行转换以访问领域对象。有关更多信息请参考Martin FowlerCatalog of Patterns of Enterprise Application ArchitectureRepository。 Contoso的团队评估了实现ViewRepository类的两种方法:使用IQueryable接口和使用非通用的数据访问对象(DAOs)。使用IQueryable接口ViewRepository类考虑的一种方法是让它返回一个IQueryable实例该实例允许客户端使用LINQ来指定其查询。返回IQueryable实例很简单很多ORM框架都可以例如Entity Framework或NHibernate下面的代码片段演示了客户端如何做此类查询。var ordersummary repository.QueryOrderSummary().Where(LINQ query to retrieve order summary);
var orderdetails repository.QueryOrderDetails().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命令校验在实现写模型时应该尽量确保命令很少失败。这将提供最佳的用户体验并使您的应用程序更容易实现异步行为。团队采用的一种方法是使用http://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, IEnumerableSeatQuantity 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 EnvelopeICommand(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.FindDraftOrder(orderId);var conference repo.QueryConference().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显示动画倒计时器。使用http://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 classeditor-labelHtml.LabelFor(model model.FirstName)/divdiv classeditor-fieldHtml.EditorFor(model model.FirstName)/div
div classeditor-labelHtml.LabelFor(model model.LastName)/divdiv classeditor-fieldHtml.EditorFor(model model.LastName)/div
div classeditor-labelHtml.LabelFor(model model.Email)/divdiv classeditor-fieldHtml.EditorFor(model model.Email)/div
Web.config文件根据DataAnnotations属性配置客户端验证如下面的代码片段所示appSettings...add keyClientValidationEnabled valuetrue /add keyUnobtrusiveJavaScriptEnabled valuetrue /
/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 -- 订单的访问码OrderItemsViewOrderItemId -- 订单项的唯一ID SeatType -- 预订的座位类型 RequestedSeats -- 请求预订座位的数量 ReservedSeats -- 预留座位的数量 OrderId -- 关联的父Order的ID要将这些表填充到读模型中读端需要处理由写端引发的事件用它们对这些表进行写操作。有关详细信息请参见上面章节中的架构图。OrderViewModelGenerator类处理这些事件并更新读端存储库。public class OrderViewModelGenerator :IEventHandlerOrderPlaced, IEventHandlerOrderUpdated,IEventHandlerOrderPartiallyReserved, IEventHandlerOrderReservationCompleted,IEventHandlerOrderRegistrantAssigned
{private readonly FuncConferenceRegistrationDbContext contextFactory;public OrderViewModelGenerator(FuncConferenceRegistrationDbContext 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 FindT(Guid id) where T : class{return this.SetT().Find(id);}public IQueryableT QueryT() where T : class{return this.SetT();}public void SaveT(T entity) where T : class{var entry this.Entry(entity);if (entry.State System.Data.EntityState.Detached)this.SetT().Add(entity);this.SaveChanges();}
}
Jana(软件架构师)发言注意读端中的这个ConferenceRegistrationDbContext类包含一个Save方法以保存从写端发送的更改并通过OrderViewModelGenerator类来调用。 在读端查询下面的代码示例显示了一个非通用的DAO类MVC控制器使用该类在读端查询会议信息。它封装了前面展示的ConferenceRegistrationDbContext类。public class ConferenceDao : IConferenceDao
{private readonly FuncConferenceRegistrationDbContext contextFactory;public ConferenceDao(FuncConferenceRegistrationDbContext contextFactory){this.contextFactory contextFactory;}public ConferenceDetails GetConferenceDetails(string conferenceCode){using (var context this.contextFactory.Invoke()){return context.QueryConference().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 IListSeatType 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文件该文件在FeaturesUserInterfaceViewsManagement文件夹下。它显示了Conference Management限界上下文的验收测试。典型的SpecFlow测试场景由一组Given、When和Then语句组成。其中一些语句包含测试使用的数据。Markus(软件开发人员)发言事实上SpecFlow feature文件使用Gherkin语言这是一种专门为行为描述创建的领域特定语言(DSL)。Feature: Conference configuration scenarios for creating and editing Conference settingsIn order to create or update a Conference configurationAs a Business CustomerI want to be able to create or update a Conference and set its propertiesBackground:
Given the Business Customer selected the Create Conference optionScenario: An existing unpublished Conference is selected and published
Given this conference information
| Owner | Email | Name | Description | Slug | Start | End |
| William Flash | williamfabrikam.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 PublishedScenario: An existing Conference is edited and updated
Given an existing published conference with this information
| Owner | Email | Name | Description | Slug | Start | End |
| William Flash | williamfabrikam.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 pageBrowser.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控制器类交互来实现。长远的看这种方法不会那么脆弱成本就是在最初需要一个更复杂的实现这需要对系统的内部实现比较熟悉。下面的代码示例展示了这种方法的一个示例。首先在FeaturesUserInterfaceControllersRegistration文件夹下的SelfRegistrationEndToEndWithControllers.feature文件展示了一个示例场景Scenario: End to end Registration implemented using controllersGiven the Registrant proceeds to make the ReservationAnd 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 | williamfabrikam.com |And the Registrant proceeds to Checkout:PaymentWhen the Registrant proceeds to confirm the paymentThen 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 | Williamfabrikam.com || Additional cocktail party | Jim | Corbin | Jimlitwareinc.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 redirectionvar timeout DateTime.Now.Add(Constants.UI.WaitTimeout);while (DateTime.Now timeout registrationViewModel null){//ReservationUnknownvar result registrationController.SpecifyRegistrantAndPaymentDetails((Guid)redirect.RouteValues[orderId], registrationController.ViewBag.OrderVersion);Assert.IsNotTypeRedirectToRouteResult(result);registrationViewModel RegistrationHelper.GetModelRegistrationViewModel(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.GetModelDraftOrder(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 EventSourcingTestHelperOrder sut;public given_placed_order(){this.sut new EventSourcingTestHelperOrder();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.ThenHasSingleOrderUpdated();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 availableIn order to reserve Seats for a conferenceAs an AttendeeI want to be able to select an Order Item from one or many of the available Order Items and make a ReservationBackground: 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 reservedWhen the Registrant proceeds to make the Reservation Then the Reservation is confirmed for all the selected Order ItemsAnd these Order Items should be reserved| seat type || General admission || CQRS Workshop || Additional cocktail party |And the total should read $749And 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仓库马上开始吧!