2016 年 6 月

第 31 卷,第 6 期

领先技术 - 构建历史 CRUD(第 2 部分)

作者 Dino Esposito | 2016 年 6 月

Dino Esposito从概念上讲,历史 CRUD(创建、读取、更新、删除)是借助一个额外参数(日期)延伸的经典 CRUD。通过历史 CRUD,你可以添加、更新和删除数据库中的记录,而且可以查询数据库在给定时间点的状态。历史 CRUD 针对商业智能分析和高级报告功能为你的应用程序提供了内置基础结构。

在上个月的专栏 (msdn.com/magazine/mt703431) 中,我介绍了历史 CRUD 系统的理论基础。在本文中,我将给出一个实际演示。

演示示例方案

就本文而言,我将考虑一个简单的预订系统。我们以公司在内部使用以供员工预订会议室的系统为例。在本质上,此类软件是一个普通的 CRUD,可以在其中创建一个新记录以保留一个位置。如果会议移到其他时间,则对相应的记录进行更新;如果会议取消,则删除相应的记录。

如果你将这样的预订系统编码为定期 CRUD,则可以了解该系统的最新状态,但会丢失有关已更新和已删除会议的任何信息。这确实是个问题吗? 这要视情况而定。如果你只是想看一下会议对实际业务的影响,这很可能不是问题。然而,如果你正在寻找提高员工整体绩效的途径,那么跟踪记录的更新和删除的历史 CRUD 可能表明,移动或取消会议次数过多,而且可能是内部过程不佳或态度不好的迹象。

图 1 呈现了一个真实的会议室预订系统 UI。基础数据库是一个含有两个链接表的 SQL Server 数据库: Rooms 和 Bookings。

预订系统的前端 UI
图 1 预订系统的前端 UI

此示例应用程序设计为 ASP.NET MVC 应用程序。当用户单击以发出请求时,一个控制器方法会介入并处理发布的信息。下面的代码片段清晰地说明了在服务器端处理请求的代码:

[HttpPost]
public ActionResult Add(RoomRequest room)
{
  service.AddBooking(room); 
  return RedirectToAction("index", "home");
}

该方法属于 BookingController 类,而且将组织实际工作的重担委托给注入工作线程服务类。该方法的实现的一个有趣方面是,在创建预订后,它会重定向到图 1 中的首页。执行 add-booking 操作不会创建任何显式视图。这是选择命令查询职责分离 (CQRS) 架构的副作用。add-booking 命令被发布到后端;它会改变系统的状态。在示例应用程序中使用 AJAX 发布后,将不必刷新任何内容,而且该命令会成为独立操作,无需指向 UI 的可见链接。

经典 CRUD 和历史 CRUD 之间的核心区别是后者从一开始就一直跟踪改变系统状态的所有操作。若要规划历史 CRUD,你应该将业务操作视为你向系统发出的命令并考虑跟踪这些命令的机制。每个命令都可以改变系统的状态,而且历史 CRUD 会一直跟踪系统达到的每个状态。任何达到的状态都被记录为一个事件。事件是已发生事情的简单且不可改变的描述。一旦你有了事件的列表,就可以基于该列表创建多个数据投影,其中最常用的就是所涉及的业务实体的当前状态。

在应用程序中,事件直接源于用户命令的执行或间接源于其他命令或外部输入。在此示例方案中,你希望用户单击一个按钮即可发布预订请求。

处理命令

以下是应用程序控制器的 AddBooking 方法的一种可能实现:

public void AddBooking(RoomRequest request)
{
  var command = new RequestBookingCommand(request);
  var saga = new BookingSaga();
  var response = saga.AddBooking(command);
  // Do something based on the outcome of the command
}

RoomRequest 类是一个由 ASP.NET MVC 绑定层用已发布数据填充的简单的数据传输对象。相反,RequestBookingCommand 类存储执行该命令所需的输入参数。在如此简单的方案中,这两个类极为类似。你要如何处理此命令? 图 2 显示了处理命令的三个关键步骤。

处理命令的核心步骤链
图 2 处理命令的核心步骤链

处理程序是接收和处理命令的组件。处理程序可以通过直接内存中调用的方式从工作线程服务代码内部进行调用,或者你可以在中间加入 bus,如下所示:

public void AddBooking(RoomRequest request)
{
  var command = new RequestBookingCommand(request);
  // Place the command on the bus for
  // registered components to pick it up
  BookingApplication.Bus.Send(command);
}

bus 可能带来两个好处。一个好处是,你可以轻松地处理多个处理程序可能对相同的命令感兴趣的情况。另一个好处是,bus 可能被配置为一个可靠的消息传送工具,保证消息随着时间的推移不断传递,并解决可能的连接问题。此外,bus 只能作为一个提供记录命令的功能的组件。

该处理程序可能是在同一请求中开始和结束的简单一次性组件,也可以是一个长期运行的工作流程,需要数小时或数天才能完成,而且可能会在某个时间点暂停并等待人工批准。不是简单的一次性任务执行程序的处理程序通常被称为 saga。

通常,如果你对可伸缩性和可靠性有特定要求,则使用 bus 或队列。如果你只是希望构建一个历史 CRUD 来代替经典 CRUD,你很可能并不需要使用 bus。无论你是否使用 bus,在某个点该命令可能会到达其一次性或长时间运行的处理程序。该处理程序应该执行任何预期的任务。大多数任务由数据库上的核心操作组成。

记录命令

在经典 CRUD 中,将信息写入数据库意味着添加一个显示已传入值的记录。然而,从历史 CRUD 的角度看,新添加的记录代表预订的已创建事件。预订的已创建事件是一条独立且不变的信息,其中包括该事件的唯一标识符、时间戳、名称和特定于该事件的参数列表。已创建事件的参数通常包括针对在经典 Bookings 表中新添加的 Booking 记录要填充的所有列。相反,已更新事件的参数仅限于实际更新的字段。因此,所有已更新事件可能不含有相同的内容。最后,已删除事件的参数仅限于唯一标识预订的值。

历史 CRUD 的任何操作均由两个步骤组成:

  1. 记录事件及其相关数据。
  2. 确保系统的当前状态是即刻快速可查询的。

通过这种方式,系统的当前状态总是可用且最新,而且导致其处于当前状态的所有操作也可用于任何进一步分析。请注意,“系统的当前状态”是你在经典 CRUD 系统中看到的唯一状态。若要在简单 CRUD 系统的上下文中有效,记录事件和更新系统状态的步骤应该在相同事务内同时执行,如图 3 中所示。

图 3 记录事件并更新系统

using (var tx = new TransactionScope())
{
  // Create the "regular" booking in the Bookings table   
  var booking = _bookingRepository.AddBooking(
    command.RoomId, ...);
  if (booking == null)
  {
    tx.Dispose();   
    return CommandResponse.Fail;
  }
  // Track that a booking was created
  var eventToLog = command.ToEvent(booking.Id);
    eventRepository.Store(eventToLog);
  tx.Complete();
  return CommandResponse.Ok;
}

就目前情况来看,每次你添加、编辑或删除预订记录时,你会保持整个预订列表处于最新状态,同时知道导致当前状态的事件的确切序列。图 4 显示了此示例方案中涉及的两个 SQL Server 表,以及执行插入和更新操作后表中的内容。

并排显示的 Bookings 和 LoggedEvents 表
图 4 并排显示的 Bookings 和 LoggedEvents 表

Bookings 表列出了在系统中找到的所有不同预订,并为每个预订返回了当前状态。LoggedEvents 表列出了按顺序记录的各种预订的所有事件。例如,预订 54 已在给定的日期创建,并在稍后几天进行了修改。在此示例中,图片中的 Cargo 列存储了要执行的命令的 JSON 序列化流。

使用 UI 中记录的事件

我们假设一个授权用户想要查看某个待定预订的详细信息。很可能该用户会从日历列表或通过基于时间的查询开始了解该预订。在这两种情况下,预订的基本要素(时间、时长以及人物)已有所了解,而且详细信息视图可能甚至没什么用。不过,在显示预订的完整历史记录时,该视图可能非常有用,如图 5 中所示。

使用 UI 中记录的事件
图 5 使用 UI 中记录的事件

通过阅览记录的事件,你可以构建一个视图模型,其中包括相同的聚合实体(预订 54)的状态列表。在示例应用程序中,当用户单击以查看预订的详细信息时,会出现一个模式弹出窗口并在后台下载某个 JSON。下面显示了返回 JSON 的终结点:

public JsonResult BookingHistory(int id)
{
  var history = _service.History(id);
  var dto = history.ToJavaScriptSlotHistory();
  return Json(dto, JsonRequestBehavior.AllowGet);
}

工作线程服务上的 History 方法在此处执行了大部分工作。此工作的核心部分是查询与指定的预订 ID 相关的所有事件:

var events = new EventRepository().All(aggregateId);
foreach (var e in events)
{
  var slot = new SlotInfo();
  switch (e.Action)
  {
    :
  }
  history.Changelist.Add(slot);
}

在遍历记录的事件过程中,适当的对象会被附加到要返回的数据传输对象。在 ToJavaScriptSlotHistory 中执行的某种转换可使其快速、简单地显示两个连续状态之间的差异,显示形式如图 5 中所示。

很明显,虽然在 CRUD 内记录事件允许对 UI 进行美观改进,但是最大的价值在于,你现在知道系统内曾发生的一切,而且可以处理相应的数据以提取你可能在某些时候所需数据的任何自定义投影。例如,你可能会创建更新的统计数据,并让分析师得出结论:因为人们预订太过频繁,然后不断更新或删除,导致申请会议室的整个过程在公司内不能正常使用。你还可以轻松地跟踪预订在某个特定日期的情况,方法很简单,只需查询在该日期之前记录的事件,并计算事件的后续状态。简而言之,历史 CRUD 为应用程序的可能性开创了一个全新的世界。

总结

历史 CRUD 是改进普通 CRUD 应用程序的更明智的选择。然而,本次讨论浅谈了一些术语和模式,其中还有更多的潜在内容,例如 CQRS、事件源、bus 和队列,以及基于消息的业务逻辑。如果你发现本文对你有帮助,我建议你返回去阅读我的 2015 年 7 月份专栏 (msdn.com/magazine/mt238399) 和 2015 年 8 月份专栏 (msdn.com/magazine/mt185569)。鉴于此示例,你可能会发现那些文章更具启发性!


Dino Esposito是《Microsoft .NET: 构建面向企业的应用程序》(Microsoft Press,2014 年)和《使用 ASP.NET 构建新型 Web 应用程序》(Microsoft Press,2016 年)的作者。作为 JetBrains 的 .NET 和 Android 平台的技术推广人员,Esposito 经常在全球行业活动中发表演讲,并在 software2cents@wordpress.com 上以及 Twitter @despos 上的推文中分享他对于软件的愿景。

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