使用事件和 CQRS 重写 CRUD 系统
使用事件和 CQRS 重写 CRUD 系统
https://msdn.microsoft.com/zh-cn/magazine/mt790196.aspx
https://github.com/demiray/IBuyStuff-dm
https://github.com/p1p3/IBuyStuff
https://github.com/wanglong/EquinoxProject
https://github.com/dotnet-architecture/eShopOnWeb
到处都是围绕关系数据库构建而成的典型“创建、读取、更新、删除 (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 类实现期间触发的所有事件。
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 中展示了工厂的源代码。
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。
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.上分享了他的软件构想。
使用事件和 CQRS 重写 CRUD 系统的更多相关文章
- 使用Struts2和jQuery EasyUI实现简单CRUD系统(转载汇总)
使用Struts2和jQuery EasyUI实现简单CRUD系统(一)——从零开始,ajax与Servlet的交互 使用Struts2和jQuery EasyUI实现简单CRUD系统(二)——aja ...
- 使用kubernetes-event-exporter将k8s的事件导出到elasticsearch日志系统中
使用kubernetes-event-exporter将k8s的事件导出到elasticsearch日志系统中 前提 版本 kubernetes v1.17.9 kubernetes-event-ex ...
- cocos2d-x游戏引擎核心(3.x)----事件分发机制之事件从(android,ios,desktop)系统传到cocos2dx的过程浅析
(一) Android平台下: cocos2dx 版本3.2,先导入一个android工程,然后看下AndroidManifest.xml <application android:label= ...
- JAVA GUI学习 - 窗口【x】按钮关闭事件触发器:重写processWindowEvent(WindowEvent e)方法
public class WindowListenerKnow extends JFrame { public WindowListenerKnow() { this.setBounds(300, 1 ...
- PyQt4 的事件与信号 -- 重写事件处理方法
# PyQt中的事件处理主要依赖重写事件处理函数来实现 import sys from PyQt4 import QtCore, QtGui class MainWindow(QtGui.QWidge ...
- 数组练习:各种数组方法的使用&&事件练习:封装兼容性添加、删除事件的函数&&星级评分系统
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/ ...
- QDialog之屏蔽Esc键(简单深刻,要么重写keyPressEvent然后break忽略此事件,要么重写eventFilter然后return,都是为了忽略此事件)
简述 Qt中Esc键会在一些控件中默认的进行一些事件的触发,比如:QDialog,按下Esc键窗口消失.大多数情况下,我们不需要这么做,那么就需要对默认事件进行屏蔽. 简述 源码分析 事件过滤器 事件 ...
- Android学习笔记之 SimpleAdapter 中添加按钮响应事件,getView的重写
Andriod 里面的ListView是一个显示列表数据的控件,常用适配器SimpleAdapter进行绑定,绑定代码如下: ListView lstView = (ListView) this.fi ...
- 使用Struts2和jQuery EasyUI实现简单CRUD系统(五)——jsp,json,EasyUI的结合
这部分比較复杂,之前看过自己的同学开发一个选课系统的时候用到了JSON,可是一直不知道有什么用.写东西也没用到.所以没去学他.然后如今以这样的怀着好奇心,这是做什么用的,这是怎么用的.这是怎么结合的心 ...
随机推荐
- sleep、yield、wait、join的区别(阿里)
只有runnable到running时才会占用cpu时间片,其他都会出让cpu时间片.线程的资源有不少,但应该包含CPU资源和锁资源这两类.sleep(long mills):让出CPU资源,但是不会 ...
- 学习:STL_vector容器
vector基本概念: 功能: vector数据结构和数组非常相似,也称为单端数组 vector与普通数组区别: 不同之处在于数组是静态空间,而vector可以动态扩展 动态扩展: 并不是在原空间之后 ...
- ### Error updating database. Cause: java.sql.SQLIntegrityConstraintViolationException: ORA-02291: 违反完整约束条件 (SSM.SYS_C0011830) - 未找到父项关键字
在向Oracle数据库里面插入数据时发生了以下错误 ; ]; ORA-: 违反完整约束条件 (SSM.SYS_C0011830) - 未找到父项关键字 ; nested exception : 违反完 ...
- (知识点4)C++ 中vector
1.定义vector<vector<int>> M; 2.添加元素这里是vector的嵌套使用,本质是vector元素里的每个元素也是vector类型,所以抓住本质来添加元素就 ...
- iOS 逆向工程(工具介绍)- 学习整理(转)
一.class-dump 简介:顾名思义,就是用来导出目标对象的class信息的工具,私有方法声明也能导出来. 原理:利用 Objective-C语言的 runtime 特性,将存 在Mach-O 文 ...
- js实现延迟加载
defer async.await 动态创建DOM jQ的getScript()方法 window.onload().$(document).ready() Promise setTimeout.se ...
- AttributeError: module ‘select’ has no attribute 'epoll’
场景:mac 下导入的 ‘select’ 包 import select,然后在 主函数 中创建的 epoll 对象 epl = select.epoll(),运行报错如下 Traceback (mo ...
- Gradle系列教程之依赖管理
这一章我将介绍Gradle对依赖管理的强大支持,学习依赖分组和定位不同类型仓库.依赖管理看起来很容易,但是当出现依赖解析冲突时就会很棘手,复杂的依赖关系可能导致构建中依赖一个库的多个版本.Gradle ...
- SpringBoot之文件上传体积过大问题(解决方案)
错误信息如下(关键): org.apache.tomcat.util.http.fileupload.FileUploadBase$SizeLimitExceededException: the re ...
- 设计模式概要 & 六原则一法则
参考文章 http://blog.csdn.net/sinat_26342009/article/details/46419873 继承vs组合:http://www.cnblogs.com/feic ...