进一步探索订单和注册的有界上下文。
“我明白,若是一我的想看些新鲜的东西,旅行并非没有意义的。”儒勒·凡尔纳,环游世界80天
复制代码
前一章详细描述了订单和注册限界上下文。本章描述了在CQRS之旅的第二阶段,团队在这个限界上下文中所作的一些更改。html
本章的主题包括:前端
本章描述的Contoso会议管理系统并非该系统的最终版本。本旅程描述的是一个过程,所以一些设计决策和实现细节将在过程的后续步骤中更改。这些变化将在后面的章节中描述。ios
本章使用了一些术语,咱们将在下一章进行描述。有关更多细节和可能的替代定义,请参阅参考指南中的“深刻CQRS和ES”。git
Command(命令):命令是要求系统执行更改系统状态的操做。命令是必须服从(执行)的一种指令,例如:MakeSeatReservation。在这个限界上下文中,命令要么来自用户发起请求时的UI,要么来自流程管理器(当流程管理器指示聚合执行某个操做时)。单个接收方处理一个命令。命令总线(command bus)传输命令,而后命令处理程序将这些命令发送到聚合。发送命令是一个没有返回值的异步操做。github
Event(事件):事件就是系统中发生的一些事情,一般是一个命令的结果。领域模型中的聚合会引起(raise)事件。多个事件订阅者(subscribers)能够处理特定的事件。聚合将事件发布到事件总线, 处理程序订阅特定类型的事件,事件总线(event bus)将事件传递给订阅者。在这个限界上下文中,惟一的订阅者是流程管理器。web
流程管理器。在这个限界上下文中,流程管理器是一个协调领域域中聚合行为的类。流程管理器订阅聚合引起的事件,而后遵循一组简单的规则来肯定发送一个或一组命令。流程管理器不包含任何业务逻辑,它惟一的逻辑是肯定下一个发送的命令。流程管理器被实现为一个状态机,所以当它响应一个事件时,除了发送一个新命令外,还能够更改其内部状态。
Gregor Hohpe和Bobby Woolf合著的《Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions》(Addison-Wesley Professional, 2003)书中312页讲述了流程管理器实现模式。咱们的流程管理器就是依照这个模式实现的。数据库
除了描述订单和注册限界上下文的一些更改和加强以外,本章还讨论了两个用户故事的实现。c#
当注册者建立会议座位的订单时,系统生成一个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)。
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);
复制代码
这种方法有几个优势:
简单
可测试性
Markus(软件开发人员)发言:
在参考实现(RI)中,咱们使用Entity Framework,咱们根本不须要编写任何代码来获取IQueryable实例。咱们也只有一个ViewRepository类。
可能有人反对这个方法,包括:
另外一种方法是让ViewRepository暴露出一个Find方法和一个Get方法,以下面的代码片断所示。
var ordersummary = dao.FindAllSummarizedOrders(userId);
var orderdetails = dao.GetOrderDetails(orderId);
复制代码
您还能够选择使用不一样的DAO类。这将使访问不一样数据源变得更容易。
var ordersummary = OrderSummaryDAO.FindAll(userId);
var orderdetails = OrderDetailsDAO.Get(orderId);
复制代码
这种方法有几个优势:
简单
灵活性
性能
可测试性
可维护性
对这个方法可能的反对意见包括:
使用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版本”,您将看到团队扩展了事件的使用,并迁移了订单和注册上下文,以使用事件源。
在实现写模型时,应该尽可能确保命令不多失败。这将提供最佳的用户体验,并使您的应用程序更容易实现异步行为。
团队采用的一种方法是使用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上查看存储库中的代码:github.com/mspnp/cqrs-…
不要指望代码示例与参考实现中的代码彻底匹配。本章描述了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显示动画倒计时器。
您应该确保应用程序中的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中显示的数据。
在咱们CQRS之旅的第一阶段,领域包含一个ConferenceSeatsAvailabilty聚合根类,这是对会议剩余座位数量进行的建模。在旅程的如今这个阶段,团队将ConferenceSeatsAvailabilty聚合替换为SeatsAvailability,以反映特定会议可能有多种座位类型。例如,完整会议的席位、会前研讨会的席位和鸡尾酒会的席位。下图显示了新的SeatsAvailability聚合及其组成类。
这个聚合反应了下面两个模型:
领域如今包括一个SeatQuantity值类型,您可使用它来表示特定座椅类型的数量。
以前,聚合会根据是否有足够的座位数量来引起ReservationAccepted或ReservationRejected事件,如今,聚合引起一个SeatsReserved事件,该事件报告它能够预订多少个特定类型的座位。这意味着预留的座位数目可能与所要求的座位数目不相符。此信息被传递回UI,以便注册者决定如何继续预订。
您可能在最上面的架构图中注意到,SeatsAvailability聚合包含一个AddSeats方法,但没有相应的命令。AddSeats方法调整给定类型的可用座位总数。业务客户负责进行任何此类调整,并在Conference Management限界上下文中进行。当可用座位总数发生更改时,Conference Management限界上下文将引起事件。而后,SeatsAvailability类在其处理程序中调用AddSeat方法来处理事件。
本节将讨论在如今这个阶段解决的一些测试问题。
在第3章“订单和注册限界上下文”中,您看到了一些UI原型,开发人员和领域专家一块儿工做,以改进系统的一些功能需求。这些UI原型的计划用途之一是为系统造成一组验收测试的基础。
对于验收测试方法,团队有如下目标:
为了实现这些目标,领域专家与测试团队的成员配对,并使用SpecFlow来指定核心验收测试。
使用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)。
重写一遍已经在网站上实现的应用程序逻辑彷佛有点多余,可是有如下几个缘由值得花时间:
备注:为何这些类型的测试是一个好主意?还有更多的缘由没有列出来,可是对于本例来讲,这里列出的是那些重要的缘由。
Contoso会议管理系统的体系结构是松耦合的,利用消息将命令和事件传递给相关方。命令经过命令总线路由到单个处理程序,而事件则经过事件总线路由到它们的1个或多个处理程序。就消费应用程序而言,总线不绑定任何特定的技术,容许以对用户透明的方式在整个系统中建立和使用任意的实现。
当涉及到松耦合消息体系结构的行为测试时,另外一个好处是BDD(或相似风格的)测试自己不涉及应用程序代码的内部工做。它们只关心被测试程序的可观察行为。这意味着对于SpecFlow测试,咱们只须要将一些命令发布到总线,并经过根据实际的流量/数据断言预期的消息流量和有效负载来检查外部结果。
备注:在适当的地方,可使用mock和stub来进行这些类型的测试。一个适当的例子是使用mock出来的ICommandBus对象而不是真正的AzureCommandBus类型。但mock一个完整的领域服务是不合适的例子。尽可能少的使用mock,只把它限制在基础设施方面,这样你的生活和测试压力都会小不少。
我刚刚花费了不少来描述事情是多么的棒和简单,哪里有痛苦呢?痛苦在于理解一个系统中发生了什么。松耦合的体系结构也有很差的一面:控制反转和依赖注入等技术从本质上阻碍了代码的可读性,由于若是不仔细检查容器的初始化,就永远没法肯定在特定的点注入了什么具体的类。在journey的代码中,IProcess接口是一种表示长时间运行的业务流程(也称为Sagas或流程管理器)的类,这些类负责协调不一样聚合之间的业务逻辑。为了维护系统数据和状态的完整性、幂等性和事务性,它发出的命令的实际发送是各个持久化仓储来实现的。因为控制反转和依赖注入对消费者隐藏了这些类型的详细信息,因此它和系统的一些其余属性会形成一点困难在回答一些表面上琐碎的问题时,好比:
因为应用程序的依赖关系很是松散,许多传统的代码分析工具和方法要么变得不那么有用,要么彻底没用。
让咱们以RegistrationProcessManager做为示例,列出一些涉及到回答这些问题的启发式内容。
打开RegistrationProcessManager.cs文件,注意,与许多流程管理器同样,它有一个ProcessState枚举。咱们注意进程的开始状态:NotStarted。接下来,咱们要找到作下面事情之一的代码:
找到源代码中出现上述任何一种状况或同时出现上述两种状况的代码位置。在本例中,它是RegistrationProcessManagerRouter类中的Handle方法。重要提示:这并不必定意味着该流程是一个命令处理程序!流程管理器负责从存储中建立和检索聚合根(AR),以便将消息路由到AR,所以尽管它们的方法在名称和签名上与ICommandHandler实现相似,但它们并不实现处理命令的逻辑。
请注意当状态发生变化时接收到的消息类型是做为方法参数被传入的,所以咱们如今须要找出消息的来源。
查找OrderPlaced的引用,找到一个或多个顶部(外部)组件,这些组件经过ICommandBus接口上的Send方法发出该类型的消息。
虽然启发式的内容确定比这里所提到的要多,可是这里的这些内容极可能足够证实了。即便讨论交互也是一个至关漫长、繁琐的过程。这很容易形成误解。您能够经过这种方式理解各类命令/事件消息传递交互,可是这种方式不是颇有效。
备注:通常来讲,一我的在任什么时候候都只能在脑子里保持四到八个不一样的想法。为了说明这一律念,让咱们保守地计算一下你须要在短时间记忆中同时保持的东西的数量,同时遵循上面的启发:
进程类型+进程状态属性+初始状态(NotStarted) + new()的位置+消息类型+中间路由类类型+ 2 *N^ N命令发出(位置、类型、步骤)+判别规则(逻辑也是数据!) > 8
复制代码
当基础设施需求混合到等式中时,信息饱和的问题会变得更加明显。做为咱们都是有能力的开发人员(对吧?),咱们能够开始寻找方法来优化这些步骤,并提升相关信息的信噪比。
总之,咱们有两个问题:
幸运的是,使用MIL(消息传递中间语言)能够一箭双雕。
MIL一开始是一系列LINQPad脚本和代码片断,我建立这些脚本和代码片断是为了在回答问题时帮助处理全部事情。最初,这些脚本完成的全部工做都是经过一个或多个项目程序集反映并输出各类类型的消息和处理程序。在与团队成员的讨论中,很明显其余人也遇到了与我相同的问题。在与模式和实践团队成员进行了几回聊天和头脑风暴会议以后,咱们提出了引入一种小型领域特定语言(DSL)的想法,该语言将封装所讨论的交互。暂时命名为SawMIL toolbox,它位于jelster.github.com/CqrsMessagi…,它提供了实用工具、脚本和示例,使您可以将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去贡献代码,没有时间犹豫了,如今就去jelster.github.com/CqrsMessagi…,fork仓库,立刻开始吧!