2016 年 8 月

第 31 卷,第 8 期

领先技术 - 超越 CRUD: 命令、事件和总线

作者 Dino Esposito | 2016 年 8 月

Dino Esposito在本专栏的最近内容中,我讨论了构建历史创建、读取、更新和删除 (H-CRUD) 所需的操作。H-CRUD 是对经典 CRUD 的简单扩展,你可以在其中使用两个概念上不同的数据存储,以保持对象的当前状态以及单个对象生存期中发生的所有事件。如果只将你的视野限制在包含当前状态的数据存储,那么所有操作与在经典 CRUD 中几乎相同。你有客户记录、发票、订单以及组成业务域数据模型的一切。

这里关键的一点是,这个摘要数据存储并非你创建的主要数据存储,而是派生为事件数据存储的投影。换句话说,构建历史 CRUD 的本质是在事件发生时进行保存,然后为你需要创建的任何 UI 推断系统的当前状态。

 为相关业务事件设计你的解决方案是相对较新的方法,并保持着快速发展的势头(尽管使其成为主流模式还有很长的路要走)。将你的设计聚焦事件是有益的,因为你将永远不会错过发生在系统中的任何操作;你可以随时重新读取和重播事件,并在同一核心数据的之上构建新的投影,以用于诸如商业智能等。更有趣的是,借助事件,架构师有最大的机会设计有关特定业务通用语言的系统。通用语言不仅仅是领域驱动设计 (DDD) 的重要组成部分,从实用角度来看,它还可以极大地帮助你理解相关的业务域,并计划协同部件最有效的体系结构图及任务和工作流的内部动态。

你在我 2016 年 5 月份的 (msdn.com/magazine/mt703431) 和 6 月份的 (msdn.com/magazine/mt707524) 专栏看到的事件实现可能非常简单,从某种程度上来说,甚至是简化的。但其主要目的是说明,只需很小的工作量,任何 CRUD 均可转换为 H-CRUD,且仍可从业务事件的引入获得一些好处。H-CRUD 方法与当今流行的缩写词和关键字(如 CQRS 和事件源)有一些明显的重叠。在本专栏中,我将进一步扩展 H-CRUD 的理念,使其与事件源的核心原理合并。你会看到 H-CRUD 如何转换为由命令、总线和事件组成的实现(最初可能类似于对数据库进行基本读取和编写的过于复杂的方法)。

一个事件,多个聚合

在我看来,软件编写有时候难以在预算内按时完成的其中一个原因是,缺少对域专家所说的商业语言的关注。大多数时候,确认需求意味着将理解的需求映射到某种关系数据模型。然后,构建业务逻辑以在持久性层和表示层之间隧道传输数据,并在此过程中进行必要的调整。虽然并不完美,但该模式仍然使用了很长时间,而极大的复杂性使其无法使用的案例的数量无关紧要,无论如何,将其引入 DDD 的公式仍然是当今处理任何软件项目最有效的方法。

事件在这里是有益的,因为它们强制进行不同形式的域分析,更以任务为导向,并且没有找出保存数据所在的完美关系模型的紧迫性。但是,当你查看事件时,基数是关键。在我之前专栏中讨论的 H-CRUD 示例中,我做了一个假设,如果对其置之不理(即不做进一步的考虑和解释),将是很危险的。在我的示例中,我使用了一对一的事件对聚合关联。事实上,我使用了独特的聚合标识符,它被保存为链接事件的外键。在这里使用文章中的示例,无论房间何时被预订,系统都会记录涉及给定预订 ID 的预订创建的事件。若要检索聚合(也就是预订)的所有事件,只需查询指定预订 ID 的事件数据存储,就足以获得所有信息。这确实有效,但它是一个很简单的方案。危险在于,当简单方案的各方面变成常规做法,你通常会从简单的解决方案移至简化的解决方案。这并不完全是一件好事。

聚合和对象

事件/聚合关联的真正基数是以业务域的通用语言编写的。不管怎样,与更简单的一对一关联相比,一对多关联更可能发生。具体来说,事件和聚合之间的一对多关联意味着事件有时候与多聚合相关,且可能不止一个聚合关注处理该事件,并可能由于该事件更改其状态。

例如,设想一个方案:在系统中将发票注册为正在进行的工作订单的成本。这意味着,在你的域模型中可能有两个聚合 - 发票和工作订单。注册的事件发票会因为新的发票进入到系统中而捕获发票聚合的关注,但如果发票涉及与订单相关的一些活动,它可能还会捕获 JobOrder 聚合的关注。很明显,只有在完全理解业务域后,才能确定发票是否与工作订单相关。在这里可能有发票独立存在的域模型(和应用程序)和发票可能在工作订单的记帐中注册并随后更改当前余额的域模型(和应用程序)。

但是,知道事件可能与很多聚合相关这一点完全改变了解决方案的体系结构和可行技术的前景。

调度事件分解复杂性

事件被绑定到单个聚合的重大约束是 CRUD 和 H-CRUD 的基础所在。当业务事件涉及多个聚合时,你编写业务逻辑代码以确保状态被适当地更改和跟踪。当聚合和事件的数量超过严重阈值时,业务逻辑代码的复杂性可能变得难以处理和演变。

在此上下文中,CQRS 模式代表了在正确方向上迈出的第一步,因为它基本上建议你对系统当前状态的“仅读取”或“仅修改”操作进行单独推断。事件源是另一种流行的模式,它建议你将所有发生在系统中的操作记录为事件。跟踪系统的整个状态,并将系统中聚合的实际状态构建为事件的投影。换句话说,你将事件的内容映射到其他属性,它们全部一起组成了软件中可用的对象状态。事件源围绕知道如何保存和检索事件的框架构建。事件源机制为仅追加,支持事件流的重播,并知道如何保存可能具有截然不同布局的相关数据。

诸如 EventStore (bit.ly/1UPxEUP) 和 NEventStore (bit.ly/1UdHcfz) 的事件存储框架抽象出真正的持久性框架,并提供高级 API 以在代码中直接使用事件进行处理。从本质上而言,你所看到的事件流具有一定相关性,对于这些事件进行关注的目的是聚合。这将会顺利运行。但是,当某个事件对多个聚合具有影响时,你应该找到一种方法,使每个聚合都能够跟踪其关注的所有事件。此外,你应当设法构建一个软件基础结构,该结构不仅关注事件持久性,还允许向所有运行中的聚合通知所关注的事件。

要实现将事件正确调度到聚合和适当的事件持久性,H-CRUD 是不够的。必须再次讨论业务逻辑背后的模式和用于保存事件相关数据的技术。

定义聚合

聚合的概念来自 DDD,简单地说,是指组合到一起以匹配事务一致性的域对象群集。事务一致性仅意味着,保证在聚合内组成的任何事务在业务操作结尾均保持一致且处于最新状态。下面的代码片段演示了总结任何聚合类的主要方面的界面。可能还有更多,但我敢说这绝对是最少的值:

public interface IAggregate
{
  Guid ID { get; }
  bool HasPendingChanges { get; }
  IList<DomainEvent> OccurredEvents { get; set; }
  IEnumerable<DomainEvent> GetUncommittedEvents();
}

无论何时,聚合均包含所发生事件的列表,并可区分已提交的事件和未提交的事件(导致挂起更改)。实现 IAggregate 界面的基类需要非公共成员设置 ID 并实施已提交和未提交事件的列表。此外,聚合基类也有一些 RaiseEvent 方法,用于向未提交事件的内部列表添加事件。有趣的是,事件是如何供内部使用以更改聚合状态的呢?假设你有一个客户聚合,并想要更新客户的公共名称。在 CRUD 方案中,只需进行以下简单的分配:

customer.DisplayName = "new value";

如果使用事件,将会是一个更复杂的路线:

public void Handle(ChangeCustomerNameCommand command)
{
 var customer = _customerRepository.GetById(command.CompanyId);
 customer.ChangeName(command.DisplayName);
 customerRepository.Save(customer);
}

此刻,让我们先跳过 Handle 方法和运行此方法的人员,而将注意力集中在实现上。起初,ChangeName 看起来似乎仅仅是先前检查的 CRUD 样式代码的包装器。其实并不完全是这样:

public void ChangeName(string newDisplayName)
{
  var evt = new CustomerNameChangedEvent(this.Id, newDisplayName);
  RaiseEvent(e);
}

在聚合基类上定义的 RaiseEvent 方法将在未提交事件的内部列表中追加事件。未提交的事件最终会在聚合保存时处理。

通过事件保存状态

随着对事件更深入的了解,可将存储库类的结构设为泛型。目前描述的设计用于使用聚合类运行的存储库的 Save 方法仅遍历聚合未提交事件的列表,并调用聚合必须提供的新方法 - ApplyEvent 方法:

public void ApplyEvent(CustomerNameChangedEvent evt)
{
  this.DisplayName = evt.DisplayName;
}

聚合类将拥有对每个所关注事件的 ApplyEvent 方法的一个重载。过去考虑的 CRUD 样式代码会在此找到它的位置。

还有一个缺少的链接: 如何安排前端用例、具有多聚合的最终用户操作、业务工作流和持久性? 你需要一个总线组件。

介绍总线组件

总线组件可定义为运行在已知业务流程的实例间的共享路径。最终用户通过表示层执行操作,并为系统设置要处理的指令。应用程序层接收这些输入并将其转换为具体的业务操作。在 CRUD 方案中,应用程序层将直接调用对请求的操作负责的业务流程(即工作流)。

当聚合和业务规则数量过多时,总线将大大简化整体设计。应用程序层将命令或事件推送至总线,以便侦听器正确地作出响应。侦听器是常被称为“sagas”的组件,它是已知业务流程的最终实例。Saga 知道如何对大量命令和事件做出响应。Saga 具有持久性层的访问权限,并可将命令和事件推送回总线。Saga 是上述 Handle 方法所属的类。通常,每个工作流或用案都有一个 saga 类,它可以通过其可处理的事件和命令完全识别。整体生成的体系结构如图 1 所示。

使用总线调度事件和命令
图 1 使用总线调度事件和命令

最后请注意,事件也必须保存并回到其源进行查询。而这引出了另一要点: 经典关系数据库是否是存储事件的理想之选? 不同事件可以在开发过程中及后期生产过程中随时添加。此外,每个事件都有其各自的架构。在此上下文中,非关系数据存储是适合的(尽管使用关系数据库仍然是一种可选方案 - 至少是依照充分证据考虑和排除的方案)。

总结

我敢说,对软件复杂性的大部分看法是来自以下事实:尽管基于缩写词(创建、读取、更新、删除)中的基本四项操作不再像读取和编写单个表或聚合那样简单,但我们仍继续考虑对系统使用 CRUD 方法。本文是对模式和工具更深入分析的预告,下个月我会继续对此讨论,到时我将演示试图使此类开发更快和可持续的框架。


Dino Esposito*是《Microsoft .NET: 构建面向企业的应用程序》(Microsoft Press,2014 年)和《使用 ASP.NET 构建新型 Web 应用程序》(Microsoft Press,2016 年)的作者。Esposito 是 JetBrains 公司 .NET 和 Android 平台的技术推广专家,经常在全球性行业活动上发表演讲,他在 software2cents.wordpress.com 和 Twitter: @despos.*上分享了他的软件构想。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Jon Arne Saeteras