2016 年 12 月

第 31 卷,第 13 期

领先技术 - 使用事件和 CQRS 重写 CRUD 系统

作者:Dino Esposito

Dino Esposito到处都是围绕关系数据库构建而成的典型“创建、读取、更新、删除 (CRUD)”系统,此类系统包含大量的业务逻辑,有时会埋于存储过程中,有时又会禁锢在黑盒组件中。此类黑盒的核心是 CRUD 的四项操作:新建、读取、更新和删除实体。如果抽象程度够高,任何系统在一定程度上都算是 CRUD 系统。实体有时可能相当复杂,更常采用聚合形式。

在领域驱动设计 (DDD) 中,聚合是具有根对象的实体的业务相关群集。因此,创建、更新或删除实体可能要遵循多项复杂精细的业务规则。即使读取聚合的状态通常也会导致问题发生,这主要是由于用户体验所致。用于改变系统状态的模型,不一定就是在所有用例中用于向用户显示数据的同一模型。

如果将 CRUD 的抽象程度提升至最高,产生的直接结果就是分离用于改变系统状态的模型与仅返回一个或多个视图的模型。这是命令查询职责分离 (CQRS) 的原始含义,即巧妙分离命令和查询职责。

不过,对此,软件架构师和开发者还必须考虑更多。系统状态是在命令堆栈中改变的。具体来说,这正是首次创建聚合的地方,也是以后更新和删除相同聚合的地方。这正是需要重新考虑的一点。

保留历史记录对几乎所有软件系统来说都至关重要。由于软件是为支持持续经营业务而编写,因此研究以往记录至关重要,原因以下有两个:一是为了避免错过已发生的任何一件事,二是为了改善为客户和员工提供的服务。

在我的 2016 年 5 月 (msdn.com/magazine/mt703431) 和 2016 年 6 月 (msdn.com/magazine/mt707524) 专栏中,我介绍了几种将典型 CRUD 扩展为历史 CRUD 的方法。在我的 2016 年 8 月 (msdn.com/magazine/mt767692) 和 2016 年 10 月 (msdn.com/magazine/mt742866) 专栏中,我介绍了 Event-Command-­Saga (ECS) 模式和 Memento FX 框架 (bit.ly/2dt6PVD),作为呈现满足日常需求的业务逻辑的新方式。

在本专栏和下一期中,我将介绍之前提到的在系统中保留历史记录的两大优势,具体是通过使用 CQRS 和事件源重写预订演示应用程序(我在 5 月和 6 月专栏中使用了同一示例应用程序)。

远景

我的示例应用程序是一个会议室内部预订系统。主要用例为,已登录的用户浏览日历,然后预订在一个或多个空闲时间段使用指定会议室。系统管理诸如 Room、RoomConfiguration 和 Booking 之类的实体。正如你所想,从概念上讲,整个应用程序就是用于添加和编辑会议室和配置(即,何时会议室可供预订和各个空闲时间段的长度),以及添加、更新和取消预订。图 1 概述了系统用户能够执行的操作,以及如何根据 ECS 模式在 CQRS 系统中构建这些操作。

用户操作和系统简要设计
图 1:用户操作和系统简要设计

用户可以输入新的预订、移动和取消预订,甚至还可以登记使用会议室,以便系统知道预订的会议室其实正在使用中。每个操作后方的工作流都是在 saga 中进行处理,saga 是一种在命令堆栈中定义的类。saga 类由多个处理程序方法组成,每个方法处理一个命令或事件。提交预订(或移动现有预订)是将命令推送到命令堆栈。一般来说,推送命令很简单,直接调用相应的 saga 方法即可,也可以由总线服务进行处理。

至少必须跟踪所有已处理命令的全部业务效果,才能保留历史记录。在某些情况下,可能还需要跟踪原始命令。命令就是传送一些输入数据的数据传输对象。通过 saga 执行命令的业务效果就是事件。事件是传送用于充分描述事件的数据的数据传输对象。事件保存在特定的数据存储中。对于要对事件使用的存储技术,并无严格限制。可以是普通的关系数据库管理系统 (RDBMS),也可以是 NoSQL 数据存储。(若要了解如何设置 MementoFX、RavenDB 和总线,请参阅 10 月专栏。)

协调命令和查询

假设用户发出命令,预订在某一时间段使用指定会议室。在 ASP.NET MVC 应用场景中,控制器获取已发布的数据,并向总线发出命令。总线配置为识别多个 saga,每个 saga 均声明其要处理的命令(和/或事件)。因此,总线将消息分派给 saga。saga 的输入内容是用户在 UI 窗体中键入的原始数据。saga 处理程序负责将收到的数据转换成与业务逻辑一致的聚合实例。

假设用户单击进行预订,如图 2 所示。按钮触发的控制器方法接收会议室 ID、日期和时间以及用户名。saga 处理程序必须将此类数据转换成专为处理预期业务逻辑而定制的预订聚合。业务逻辑将合理地解决权限、优先级、成本和普通并发这些方面存在的问题。不过,saga 方法至少必须创建并保存一个预订聚合。

在示例系统中预订会议室
图 2:在示例系统中预订会议室

初看之下,图 3 中的代码片段与普通 CRUD 几乎无差别,区别仅在于前者使用了工厂和最佳 Repository 属性。工厂和存储库写入在已配置事件中的综合效果是,存储在 Booking 类实现期间触发的所有事件。

图 3:saga 类的结构

public class ReservationSaga : Saga,
  IAmStartedBy<MakeReservationCommand>,
  IHandleMessages<ChangeReservationCommand>,
  IHandleMessages<CancelReservationCommand>
{
   ...
  public void Handle(MakeReservationCommand msg)
  {
    var slots = CalculateActualNumberOfSlots(msg);
    var booking = Booking.Factory.New(
      msg.FullName, msg.When, msg.Hour, msg.Mins, slots);
    Repository.Save(booking);
  }
}

最后,存储库并不保存包含 Booking 类(其中属性以某种方式映射到列)当前状态的记录。只是将业务事件保存到存储中。最后,在这个阶段,你会确切地知道与预订相关的操作(创建时间和方式),但你还没有可向用户显示的任何典型信息。你知道发生了什么,但还没有可显示的任何信息。图 4 中展示了工厂的源代码。

图 4:工厂的源代码

public static class Factory
{
  public static Booking New(string name, DateTime when,
    int hour, int mins, int length)
  {
    var created = new NewBookingCreatedEvent(
      Guid.NewGuid(), name.Capitalize(), when,
      hour, mins, length);
    // Tell the aggregate to log the "received" event
    var booking = new Booking();
    booking.RaiseEvent(created);
    return booking;
  }
}

工厂中不会涉及新建的 Booking 类实例的属性,而会创建事件类,并用实际数据填充此类,以便存储在实例中,数据包括大写的客户名称,以及用于在整个系统中永久跟踪预订的唯一 ID。事件传递给 MementoFX 框架中的 RaiseEvent 方法,因为它是所有聚合的基类。RaiseEvent 将事件添加到内部列表中,存储库将在“保存”聚合实例时浏览此列表。我之所以使用“保存”一词,是因为这正是所发生的操作,而在两旁加上双引号是为了强调这种操作类型不同于典型 CRUD 中的操作类型。存储库会保存事件(即,使用指定数据创建预订)。更确切地说,存储库在业务工作流执行期间保存在聚合实例中记录的所有事件,即 saga 处理程序方法,如图 5 所示。

保存事件与保存状态
图 5:保存事件与保存状态

不过,跟踪由命令生成的业务事件还不够。

将事件去规范化到查询堆栈中

如果从保留数据历史记录的角度来看待 CRUD,你会发现创建和读取实体并不影响历史记录,而对于更新和删除实体来说,情况并非如此。事件存储仅限追加,更新和删除只是与相同聚合相关的新事件。拥有给定聚合的事件列表,可以了解与历史记录有关的一切信息,但当前状态除外。而当前状态就是你需要呈现给用户的内容。

在这种情况下,去规范化程序就有了用武之地。去规范化程序是以一系列事件处理程序的形式构建而成的类,就像保存到事件存储中的事件处理程序一样。向总线注册去规范化程序后,总线只要获取到事件,就会将事件分派给此程序。净效果是,只要事件触发,为侦听已创建的预订事件而编写的去规范化程序就会有机会反应。

去规范化程序获取事件中的数据,然后执行你所需的任何操作(例如,使易于查询的关系数据库与记录的事件保持同步)。关系数据库(或 NoSQL 存储或缓存,如果更易于或更益于使用的话)属于查询堆栈,其 API 无权访问存储的事件列表。更重要的是,可以使用多个去规范化程序创建相同原始事件的特殊视图。(我将在下一专栏中深入介绍这方面的信息。) 在图 1 中,用户从中选择时间段的日历是由普通关系数据库填充,去规范化程序使此数据库与事件保持同步。有关去规范化程序类代码,请参见图 6

图 6:去规范化程序类的结构

public class BookingDenormalizer :
  IHandleMessages<NewBookingCreatedEvent>,
  IHandleMessages<BookingMovedEvent>,
  IHandleMessages<BookingCanceledEvent>
{
  public void Handle(NewBookingCreatedEvent message)
  {
    var item = new BookingSummary()
    {
      DisplayName = message.FullName,
      BookingId = message.BookingId,
      Day = message.When,
      StartHour = message.Hour,
      StartMins = message.Mins,
      NumberOfSlots = message.Length
    };
    using (var context = new MfxbiDatabase())
    {
      context.BookingSummaries.Add(item);
      context.SaveChanges();
    }  }
  ...
}

关于图 5,去规范化程序提供的关系 CRUD 仅用于读取目的。通常,我们将去规范化程序的输出称为“读取模型”。 读取模型中的实体通常与用于生成事件的聚合不匹配,因为它们主要由 UI 的需求驱动。

更新和删除

假设现在用户想要移动先前预订的时间段。发出的命令包含新时间段的所有详细信息,由 saga 方法负责写入给定预订的 Moved 事件。saga 需要检索聚合,并需要聚合处于更新后状态。如果去规范化程序仅创建了聚合状态的关系副本(所以说,读取模型与域模型几乎一致),你可以从中获取更新后状态。否则,你需要创建全新的聚合副本,然后在其上运行所有记录的事件。重播结束时,聚合处于最新状态。重播事件不是你必须直接执行的任务。在 MementoFX 中,借助 saga 处理程序内的代码行,你将获得一个更新后的聚合:

var booking = Repository.GetById<Booking>(message.BookingId);

接下来,对此实例应用所需的任意业务逻辑。在业务逻辑生成事件后,事件通过存储库保留下来:

booking.Move(id, day, hour, mins);
Repository.Save(booking);

如果你使用域模型模式并遵循 DDD 原则,Move 方法包含所有域逻辑和事件。否则,你需要运行包含任意业务逻辑的函数,然后直接将事件提升到总线中。通过将另一个事件处理程序与去规范化程序绑定,你将有机会更新读取模型。

取消预订的方法也是一样的。取消预订属于业务事件,必须予以跟踪。也就是说,不妨在聚合中使用布尔属性来执行逻辑删除。然而,在读取模型中,删除可能是实体操作,具体取决于应用程序是否要在读取模型中查询已取消的预订。还有一个好处就是,你始终可以通过从头开始或从恢复点开始重播事件来重新生成读取模型。只需创建一个特殊工具,使用事件存储 API 读取事件和直接调用去规范化程序即可。

使用事件存储 API

让我们来看看图 2 中的下拉列表选择情况。用户希望尽可能地从开始时间延长预订。聚合中的业务逻辑必须能够实现这一点。为此,业务逻辑必须访问晚于同一天开始时间的预订列表。在典型 CRUD 中,这没有什么了不起的。了不起的是,MementoFX 也允许你查询事件:

var createdEvents = EventStore.Find<NewBookingCreatedEvent>(e =>
  e.ToDateTime() >= date).ToList();

代码片段返回指定时间之后的 NewBookingCreated 事件列表。不过,不能保证已创建的预订仍有效,并且尚未移到其他时间段。你确实需要获得这些聚合的更新后状态。算法由你自行决定。例如,可以从 Created 事件列表中筛选掉不再有效的预订,然后获取剩余预订的 ID。最后,针对要延长的时间段检查实际时间段,避免重叠。在本文的源代码中,我在命令堆栈的一个单独(域)服务中对所有这些逻辑进行了编码。

总结

CQRS 和事件源并不是只限对并发性、可缩放性和性能有高端需求的特定系统使用。借助可处理聚合和工作流的可用基础结构,你能够重写当前的所有 CRUD 系统,从而享受所带来的诸多好处。这些好处包括:

  • 保留数据历史记录
  • 更有效灵活地实现业务任务,并更改任务以反映业务变化,而工作量和回归风险都很有限
  • 由于事件是不变的,因此复制/拷贝起来很容易,甚至可以根据需要以程序化方式随意重新生成读取模型

也就是说,ECS 模式(有时亦称为 CQRS/ES)的可缩放潜力巨大。更重要的是,本文提及的 MementoFX 框架很有用,因为它简化了常见任务,并提供了方便简化编程的聚合抽象。

MementoFX 提出了一种面向 DDD 的方法,但你也可以将 ECS 模式与其他框架和范例(如功能范例)结合使用。此外,还有一个好处,这个也许是最相关的。我将在下一专栏中介绍这一好处。


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

衷心感谢以下 Microsoft 技术专家对本文的审阅: Andrea Saltarello