使用事件来设计应用似乎是上个世纪八十年代后期的实践。我们可以在前端后端任何地方使用事件。当按钮被按下时,当数据变化时,又或是后端操作执行时。
但事件的准确定义是什么?我们何时该使用它?又该如何使用它?它的缺点又是什么?
何物/何时/何因
和类、组件应该保持相互之间的低耦合与自身内部的高内聚一样。当组件需要协作时,比如组件“A”需要触发组件“B”中的某段逻辑,自然而然的方法就是简单地让组件 A 调用组件 B 的一个对象的方法。然而,如果 A 知道了 B 的存在,那么它们就产生了耦合,即 A 依赖 B,这让系统更难变化和维护。而事件可以用来避免耦合。
而且,由于事件的使用和解耦组件带来的副作用,如果有团队只在组件 B 上工作,他们甚至不用和负责组件 A 的团队商量就可以改变组件 B 对组件 A 中的逻辑的响应。组件可以独立地演进:我们的应用变得更有机(??)了。
即便是在同一个组件中,有时我们也会有一段需要作为操作结果执行的代码,但是不需要在同一次请求/响应回合中立即执行。最明显的例子就是发邮件了。在这种情况下,我们可以立即向用户返回一个响应,并在稍后以异步方式发送电子邮件,从而避免用户等待电子邮件的发送。
然而,这里也有不少坑。如果我们不假思索地使用事件,就会产生风险,最终概念上高度内聚的逻辑流程却使用了事件来串联,而这本该是一种解耦的机制。换句话说,本应放在一起的代码被分开了,脉络很难理清(这和goto语句很像),理解和推断都很难:代码将变成意大利面!
要防止我们的代码库变成一坨意大利面代码,我们应该只在明确识别出来的情况下使用事件。根据我的经验,有以下三种情形需要使用事件:
- 解耦组件
- 执行异步任务
- 跟踪状态变化(审计日志)
❉ 解耦组件
当组件 A 执行的逻辑需要触发组件 B 的逻辑时,它会触发一个事件发送给事件派发器,而不是直接调用 B 的逻辑。组件 B 会监听事件派发器中这个特殊的事件,在该事件发生时做出响应。
这意味着 A 和 B 都将依赖派发器和事件,但它们却互不知晓:它们是解耦的。
理想情况下,派发器和事件不应该属于任何组件:
- 派发器应该是一个完全独立于应用的库,因此使用依赖管理系统安装在
- 一个通用的地址。在 PHP 中,它使用 Composer 安装在 vendor 目录中的东西。
然而,事件却是应用的一部分,但是为了让组件互相无感,它应该不属于任何组件。事件就是 DDD 中称为共享内核的部分。这样一来,两个组件都依赖共享内核但仍然互相无感。
但是在单体应用中,为了方便,将事件放在触发它的组件中也是可以接收的。
共享内核 […] 团队就要共享的领域模型中的子集达成一致,用一条清晰的边界将其标明。保持内核小巧。[…] 这些显式共享的东西有着特殊的状态,没有和其它团队沟通的情况下不应该修改。
Eric Evans 2014, Domain-Driven Design Reference
❉ 执行异步任务
有时候我们有一段想要执行的逻辑,但它可能需要一些时间来执行,而我们又不希望让用户等它执行完成。这种情况下,人们希望将它作为一个异步的工作执行,并立即返回一条消息给用户,通知他他的请求将在稍后异步执行。
例如,在网店上下单可以同步完成,而发送邮件通知用户可以异步完成。
这些情况下,我们能做的是触发一个事件放到队列中,事件将在队列中等待直到有程序在系统有资源的时候能接收并执行它。
这些情形下,相关逻辑是否属于同一个上下文无关紧要,逻辑是解耦的。
❉ 跟踪状态变化(审计日志)
用传统方式保存数据时,我们用实体持有某些数据。当这些实体之中的数据变化时,我们简单地将数据库表中的行更新成新的值。
这里的问题是,我们没有保存是什么发生了变化以及何时发生的变化。
我们可以用一种审计日志的结构保存包含变化的事件。
稍后介绍事件溯源时还有更多详细解释。
监听器 vs. 订阅者
在实现事件驱动架构时常见的争论就是使用事件监听器还是事件订阅者,所以在这里澄清一下我的观点:
- 事件监听器只会响应一种事件,并有多个方法来响应它。因此我们应该根据事件名称来命名监听器,例如,如果我们有一个“UserRegisteredEvent”事件,那么就会一个“UserRegisteredEventListener”监听器。这样,即使不查看文件内部,也可以很容易地知道监听器正在监听什么事件。响应事件的方法(反应)应该体现出该方法做了什么,例如,“notifyNewUserAboutHisAccount()”和“notifyAdminThatNewUserHasRegistered()”。大多数情况下这应该是常规做法,因为它保持监听器的小巧并让监听器专注于单一职责,即响应特定的事件。还有,如果我们采用了组件化架构,每个组件可以拥有自己的监听器,它监听的事件可能从多个地方触发。
- 事件订阅者可以响应多种事件,并有多个方法来响应它。订阅者的命名更加困难,因为它不能特有所指,然而订阅者仍然需要遵循单一职责原则,订阅者的名字需要体现出它的单一意图。事件订阅者的使用应该是更少见的方式,尤其是在组件内部,因为单一职责原则很容易被破坏。正确使用事件订阅者的一个例子是事务管理,具体点说就是我们可以采用一个名为“RequestTransactionSubscriber”的事件订阅者,它要响应“RequestReceivedEvent”、“ResponseSentEvent”和“KernelExceptionEvent”三个事件,分别绑定到事务的开始、结束和回滚,每个操作都有各自的方法,如“startTransaction()”, “finishTransaction()”和“rollbackTransaction()”。这个事件订阅者可以响应多种事件但仍然专注于管理请求事务的单一职责。
模式
Martin Fowler 识别出了三种不同类型的事件模式:
- 事件通知
- 事件携带的状态转换
- 事件溯源
这些模式有着同样的关键概念:
- 事件表达了某事已经发生(它们在某事后发生);
- 事件被广播给监听它的任意代码(一个事件能被多个代码单元响应)。
❉ 事件通知
假设我们有一个应用核心,其组件定义清晰。理想情况下,这些组件之间是完全解耦的,但是,它们的某些功能需要执行其它组件中的逻辑。
这是最典型的情况,之前已经描述过:当组件 A 执行的逻辑需要触发组件 B 的逻辑时,它会触发一个事件发送给事件派发器,而不是直接调用 B 的逻辑。组件 B 会监听事件派发器中这个特殊的事件,在该事件发生时做出响应。
有一点要特别指出,这种模式有一个特点,事件只会携带最少的数据。它只会携带足够让监听器能知道发生了什么并能执行它们的代码的数据,通常就只有实体 ID(可以是多个)以及事件发生的日期和时间。
优点:
- 更好的可恢复性,如果事件被放入了队列,即便第二段逻辑因为出现问题不能在当下执行,来源组件依然可以执行它自己的逻辑(因为事件被放入队列,它们可以稍后在问题修复后再执行)。
- 低延迟,如果事件被放入了队列,用户就不用等着逻辑执行完成;
- 团队可以独立地演进组件,他们的工作更简单、完成得更快、问题更少、更有机(??)。
缺点:
- 如果不能规范地使用,有可能把代码库变成一堆意大利面代码。
❉ 事件携带的状态转换
我们还是以前面这个有着清晰定义的组件的应用核心为例。这一次,它们有些功能需要其它组件的数据。获取这些数据最顺其自然的方式就是问其它的组件要,但这意味着发起查询的组件将知道被查询的组件的信息:这两个组件耦合在了一起!
另一种分享数据的方式是使用拥有该数据的组件在数据变化时所触发的事件。这些事件会携带完整的新版本数据。对该数据有兴趣的组件会监听这些事件并通过在保存该数据的本地副本来响应它们。这样,当它们需要外部数据时,它们可以在本地找到,就不用向其他组件查询了。
优点:
- 更好的可恢复性,因为即便被查询的组件不可用(不管是出现问题还是远程服务器无法访问),发起查询的组件依然可以工作。
- 低延迟,因为没有远程调用(如果被查询的组件是远程组件);
- 我们不用担心被查询组件的负载是否可以支撑全部发起查询的组件的查询(特别是这些组件是远程组件的话)。
缺点:
- 同样的数据存在多个副本,即便都是只读的,即便数据存储现在不再是问题了。
- 发起查询的组件复杂性更高,因为它需要逻辑来维的护外部数据在本地的拷贝,尽管这些逻辑相当的标准。
如果两个组件都在同一个进程中执行(这让组件间的通信比较迅速),这种模式可能是不必要的,但即便是这样,为了追求解耦和可维护性或是为了准备好在未来某个时间点将这些组件解耦成微服务,这种模式仍然是有吸引力的。这完全取决于我们现在和未来的需要,以及我们期望/需要多大程度的解耦。
❉ 事件溯源
假设一个实体处于初始状态。作为一个实体,它有自己的身份标识,它是应用要建模的真实世界中的一个特定事物。伴随着它的生命周期,实体数据不断变化,而传统的做法是,将实体的当前状态简单地保存为数据库中一行。
事务日志
上面这种方法大多数情况下都可以工作得很好,但是如果我们想要知道实体是如何到达这个状态的呢(比如,我们想知道银行账号得贷项和借项)?这种方法就做不到了,因为我们知保存了当前状态!
如果使用事件溯源,而不是保存实体状态,我们就能专注于保存实体的状态变化并根据这些变化计算出实体状态。每一次状态变化都是一个事件,保存在事件流中(比如,关系型数据库中的一张表)。当我们需要实体的当前状态,我们将根据事件流中它的全部事件计算出来。
事件存储变成了事实的主要来源,而系统状态完全由之推导而来。对程序员来说,版本管理系统就是最好的例子。所有的提交记录就是事件存储,而源代码树的工作副本就是系统状态。 Greg Young 2010, CQRS Documents
删除
如果有一次状态变化(事件)是错误的,我们不能简单地删除该事件,因为这样做会改变状态变化的历史,会违反整个事件溯源的理念。相反地,我们应该在事件流中创建一个事件,撤销我们想要删除的事件。这个过程被称作逆转事务,它不仅要将实体带回期望的状态,还要留下展示该对象在给定时间点处于该状态的轨迹。
保留数据还会带来架构上的好处。存储系统变成了一个递增的架构,众所周知只能追加的架构并一直更新的架构更容易变成分布式,因为要处理的锁会更少。 Greg Young 2010, CQRS Documents
快照
但是,当我们有太多事件流中的事件时,计算实体状态的代价很大,性能很差。要解决这个问题,每 X 个事件,我们都会在这个节点创建一个实体状态的快照。这样的话,当我们需要实体状态时,我们只用从最后一个快照开始计算。见鬼,我们甚至可以保留一个永远更新的实体快照,鱼与熊掌兼得。
投影
在事件溯源中,我们还有一个概念叫做投影,它是对事件流中给定起始时刻之间的事件的计算。这意味着快照、或者实体的当前状态,都符合投影的定义。但是,投影概念中最有价值的理念是我们可以分析特定时间段内实体的“行为”,让我们对未来作出有根据的猜测(例如,如果在过去五年中,实体在八月的活动都有所增加,那么很能在接下来的八月中也会发生同样的事情),这种能力对公司来说非常有价值。
优点
事件溯源对业务流程和开发流程都很有帮助:
- 我们查询这些事件,有助于从业务侧和开发侧两方面理解用户和系统的行为(调试);
- 我们还可以使用事件日志来重建过去的状态,对业务和开发也都很有帮助;
- 自动调整状态,以应对追溯性变化,对业务大有裨益;
- 在重放时注入假想事件来探索另一种的历史,对业务来说太棒了。
缺点
但也并不是事事顺心,要小心潜在的问题:
- 外部的更新:如果我们的事件要触发外部系统中的更新,当我们为了创建投影而重放事件时我们不希望重新触发这些事件。这时,我们可以在“重放模式”中简单地禁用外部更新,可能会将这段逻辑封装在网关中。还有一种解决方法,要依实际问题而定,可以将对外部系统的更新缓冲起来,在一段时间后当可以肯定这些事件不会被重放时在、再执行它们。
- 外部的查询:如果我们的事件要使用对外部系统的查询,例如,获得股票债券评级,当我们为了创建投影而重放事件时会发生什么?我们可能期望得到和第一次执行这些事件时(可能是几年之前)一样的评级。所以,要么远程应用可以将这些值给我们,要么我们需要在自己的系统中保存它们,这样我们就能模拟远程查询,同样地,这段逻辑将被封装在网关里。
- 代码变化:Martin Fowler 识别出了三种类型的代码变化:新特性、问题修复以及*临时逻辑。当应该用不同的业务逻辑规则在不同的时间点播放的事件被重放时,真正的问题就出现了。例如,去年的税收计算与今年不同。通常情况下,可以使用条件逻辑,但它会变得混乱,因此建议使用策略模式。
因此,我建议谨慎使用,只要有可能,我会遵守以下规则:
- 保持事件简单,只和状态变化有关,和变化如何决策无关。这样的话我们可以安全地重放任何事件,即使业务规则同一时间内发生了变化我们也可以期望同样的结果(尽管我们要保留遗留的业务规则,我们才能在重放过去的事件时应用它们);
- 与外部系统的交互不应该依赖这些事件,这样我们就可以安全地重放事件,不存在重新触发外部逻辑的危险,而且我们不需要保证外部系统的回复和一开始播放这些事件时是一样的。
而且,当然,和其它模式一样,我们不用在所有地方使用它,我们应该在有效的地方使用它,当它可以为我们带来优势使用它,当它解决的问题比带来的问题更多时使用它。
总结
再一次,这主要关于封装、低耦合和高内聚。
事件可以为可维护性、性能和代码库的扩张带来巨大的好处,但是,要通过事件溯源,它还可以为系统数据提供的可靠性和信息带来巨大的好处。
然而,这是一条布满荆棘的道路,因为概念复杂性和技术复杂性都在增加,滥用其中任何一种都可能带来灾难性的后果。
引用来源
- 2005 – Martin Fowler – Event Sourcing
- 2006 – Martin Fowler – Focusing on Events
- 2010 – Greg Young – CQRS Documents
- 2014 – Greg Young – CQRS and Event Sourcing – Code on the Beach 2014
- 2014 – Eric Evans – Domain-Driven Design Reference
- 2017 – Martin Fowler – What do you mean by “Event-Driven”?
- 2017 – Martin Fowler – The Many Meanings of Event-Driven Architecture