事件驱动架构设计

这篇文章是 软件架构演进 一个有关 软件架构 系列文章中的一篇。这些文章,主要是我学习软件架构、对软件架构的思考及使用方法的记录。相比于这个系列的前几篇文章,本篇文章可能看来更有意义。

采用设计驱动开发应用程序的实践,可以追溯到 1980 年左右。我们可以在前端或者后端采用事件驱动模型。比如点击一个按钮、数据变更或者某些后端服务被执行。

但是究竟什么才是事件驱动呢?何时使用事件驱动?它有没有缺陷?

是什么、什么时候用、为什么用(What / When / Why)

就像类和组件一样我们应当在编码时实现高内聚低耦合。当需要组合使用组件时,比如 组件 A 需要触发 组件 B 中的某些逻辑,我们自然而然的会想到在 组件 A 中去直接调用 组件 B 实例中的方法。然而,如果 A 需要明确知道 B 的存在,那么它们之间是耦合的,A 依赖于 B,这使得系统难以维护和迭代。事件驱动可以 解决耦合 的问题。

此外,采用事件驱动的另外一个好处是,如果我们有一个独立的团队开发 组件 B,他们可以直接修改 组件 B 的业务逻辑而无需事先和研发 组件 A 的团队进行沟通。各个组件可以单独迭代:我们的系统更变得有组织性

甚至,在同一个组建内,有时我们的代码需要在一个 request 和 response 周期内,作为某个操作的结果被执行,但是又不需要立即被执行的类似处理。一个常见示例就是发送电子邮件。此时,我们可以直接响应用户结果,然后以异步方式延迟发送一个电子邮件给用户,这样就避免了用户等待发送邮件的时间。

不过,即使这样处理依然存在风险。如果我们胡乱使用事件驱动设计,我们就有可能要承担中断业务逻辑的风险,因为这些业务逻辑具有概念上的高度内聚,却采用了解耦机制将它们联系在一起。换句话说,就是将原本需要组织在一起的代码强行分离,并且这样难于定位处理流程(比如使用 goto 语句),来理解业务处理:这就变成了 面条式的代码[1]。

为了防止我们的代码变成一堆复杂的逻辑,我们应当在某些明确场景下使用事件驱动架构。就我的经验来讲,在以下 3 种场景下可以使用事件驱动开发:

  1. 实现组件的解耦
  2. 执行异步任务
  3. 跟踪状态的变化(审计日志(audit log))

1 实现组件的解耦(To decouple components)

当组件 A 需要执行组件 B 中的业务逻辑,相比于直接调用,我们可以向事件分发器中发送一个事件。组件 B 通过监听分发器中的特殊事件类型,然后当这类事件被触发时去执行它。

这意味着组件 A 和组件 B 都依赖于事件分发器和事件,而无需关注彼此实现:即完成它们的解耦。

理论上,分发器和事件应该处在不同的组件中:

  • 分发器应当是独立于应用的组件库,然后使用依赖管理工具安装到系统中。在 PHP 里,我们使用 Composer 将其安装到 vendor 目录。

  • 对于事件来说,它是我们应用的一部分,但需要独立于这两个组件之外,这样使得组件之间相互独立。并且事件在组件之间实现共享,它是应用核心的不可分割的一部分。事件就是 DDD(领域驱动设计) 调用 共享内核(Shared Kernel) 的一部分。这样,这些组件就依赖于共享内核,而无需知道彼此的存在。不过在单个系统中,为了方便我们也可以在组件内去触发事件。

共享内核 [...] 用明确的边界指定团队同意共享的域模型的某些子集。保持这个内核很小。[...] 这个拥有特殊状态的明确的共享机制,不得在未经团队协商情况下随意修改。 Eric Evans 2014, Domain-Driven Design Reference

2 执行异步任务(To perform async tasks)

有时我们会有一系列需要执行的业务逻辑,但是由于它们需要耗费相当长的执行时间,所以我们不想看到用户耗费时间去等待这些逻辑处理完成。在这种情况下,最好将它们作为异步任务来运行,并立即向用户返回一条信息,通知其稍后继续处理相关操作。

比如,在网店下订单可以采用同步执行处理,但是发送通知邮件则采用异步任务去处理。

在这种情况下,我们所要做的是触发一个事件,将事件加入到任务队列中,直到一个 worker 进程能够获取并执行这个任务。

此时,相关的业务逻辑是否处在同一个上下文中环境中并不重要,不管怎么说,业务逻辑都是被执行了。

3. 跟踪状态的变化(审计日志(audit log))

在传统的数据存储的方式中,我们通过实体模型(entities)保存数据。当这些实体模型中的数据发生变化时,我们只需更新数据库中的行记录来表示新的值。

这里的问题是我们无法准确存储数据的变更和修改时间。

我们可以通过审计日志模型将包含修改的内容存入到事件里。

在关于事件来源的知识,我们会做进一步的阐述。

监听器 vs 订阅者(Listeners Vs Subscribers)

在实现事件驱动的架构时,一个常见的争议是究竟是使用 监听器(listener) 还是 订阅者(Subscriber),这里谈谈我的看法:

  1. 事件监听器 仅对一种事件作出响应,同时能够使用多种方法处理事件。因此,我们应该依据事件名来命令监听器,比如,假设我们定义一个「UserRegisteredEvent」事件,我们就应当实现一个「UserRegisteredEventListener」监听器,这样我们就能够很轻易的知道监听器在监听什么事件,而无需通过查看文件内的实现。然后就是对事件的处理方法(反应)应该正确反映方法的功能,比如「notifyNewUserAboutHisAccount()」和「notifyAdminThatNewUserHasRegistered()」。这种模式能够应付大多数的使用场景,因为这样不仅能够保证监听器足够小巧,而且满足专注于响应特定事件的单个职能原则。此外,如果我们是一个组合架构,每个组件(如有有必要)都需要定义一个可以在不同位置触发的事件监听器。

  2. 事件订阅者(Event Subscriber) 支持多种事件和事件处理方法。订阅者模式命名会更麻烦一点,因为它不仅仅处理一种事件,不过订阅者依然需要遵循单一职责原则,所以订阅者命名也需要能够反映其意图。使用事件订阅者并不常见,特别是在组件中,因为它能够轻易的打破单一职责原则。实现订阅者的一个非常适合的使用场景是管理事务,具体来讲我们有个名为「RequestTransactionSubscriber」订阅者,它等待诸如「RequestsReceivedEvent」、「ResponseSentEvent」和「KernelExceptionEvent」事件,并将其绑定到事务的启动、提交和回滚处理,通过在它们内部定义「startTransaction()」、「finishTransaction()」和「rollbackTransaction()」方法。这里虽然一个订阅者能够对多个事件作出响应,但依然仅关注管理请求事务中的某一个职能。

模式

Martin Fowler 定义了 3 种事件模式:

  • 事件通知
  • 事件承载状态转移
  • 事件溯源

这三种模式核心是一样的:

  1. 事件发生则表示发生了一些事情(事件发生在这些事情后);
  2. 事件被广播到它的监听代码中(多个监听程序可以共同处理一个事件)。

事件通知(Event Notification)

假设,有一个应用在内核(core)中定义了一些组件。理想情况下,这些组件是完全分离的,但是它们的一些功能需要在其他组件中去执行一些逻辑。

这是最典型的应用场景,前面已经讲过:当组件 A 执行时,需要触发组件 B 中的逻辑时,这里可以去触发一个事件将其发送到事件分发器中,而不是直接调用。组件 B 通过监听分发器中的这类事件,当有事件触发时去执行这个事件。

需要注意的是,这个模式的一个特征是 事件本身携带的数据非量常少。它只携带足够的数据,以便监听器知道发生了什么,并执行它们的代码,数据通常是实体模型的 ID,可能还有事件创建的日期和时间。

  • 优点

    • 更健壮(Greater resilience),如果加入队列的事件能够在源组件中执行,但在其它组件中由于 bug 导致其无法执行(由于将其加入到队列任务中,它们可以在 bug 修复后再执行);
    • 减少延迟,当用户无需等待所有的逻辑都执行完成时,可以将这类工作加入到事件队列;
    • 能够让组件的研发团队独立开发,加快项目进度、降低功能难度、减少问题发生并且更有组织性;
  • 缺点
    • 如果没有合理使用,可能时我们的代码变成苗条式代码。

事件承载状态转移(Event-Carried State Transfer)

还是之前那个在内核中定义了一些组件的应用。这次,多于一些功能需要使用其它组件中的数据。获取数据的最自然方式是从其它组件中查询出数据,但是这也意味着这个组件知道被查询组件的存在:这样两个组件就偶合在一起了!

实现数据共享的另一种方法是,当数据在所属组件中被变更时,触发一个事件。这个事件携带新版本中的所有数据。对该数据感兴趣的组件可以监听这类事件,并依据数据存储中的数据进行处理。这样当组件之间需要外部数据时,他们也能够获取本地副本,而无需从其它组件中查询。

  • 优点

    • 更健壮(Greater resilience),因为查询组件在被查询组件不可用情况下(或者由于 bug 或远程服务器不可用时)依然可用;
    • 减少延迟,因为无需远程调用(当被查询组件为远程服务时)来获取数据;
    • 无需担心被查询组件的负载(尤其是远程组件)
  • 缺点
    • 尽管现在数据存储已经不再是问题根源,依然会保存多个只读的数据副本;
    • 增加查询组件的复杂度,即使处理逻辑符合规范它也需要额外处理和维护外部数据的本地副本业务逻辑。

如果两个组件都在同一个进程中,能够快速的实现组件间通信,那么实现这种设计模式可能就没那么必要了。不过为了实现组件分离或可维护性,或在未来的计划中将组件封装进不同的微服务中使用这种模式。所有的一切取决于现有需求和计划,以及我们希望(或需要)将系统解耦到什么程度。

事件溯源(Event-Sourcing)

假设,现在有一个刚刚初始化的实体(Entity)。作为实体,它有自己的标识(identity),它对应现实世界中的某一事物,在程序中就是模型。在整个生命周期内,数据库仅仅简单的保存实体的当前状态。

事务日志(Transaction log)

多数场景下,这种存储方式是可行的,但如果我们需要知道实体究竟如何到达当前这个状态(比如,我们想知道银行账户的贷方和借方)。这时候由于我们仅存储当前状态,可能就无法实现这种需求了。

使用事件溯源模式替代实体状态存储,我们关注实例状态的 变更依据变更计算出实体状态。每个状态的变化都是一个事件,被存储到事件流中(如 RDBMS 中的表)。当我们需要获取实体的当前状态是,我们通过计算这个事件的所有事件流来完成。

事件存储作为结果的主要来源,系统状态也单纯的转变成了它的派生结果。对程序员来说,最好的例子是版本控制系统。所有的提交日志就是事件存储,当前源代码树的工作副本就是系统的状态。 Greg Young 2010, CQRS Documents

删除(Deletions)

如果现在存在一个错误的状态变更(event),我们不能简单的将其删除因为这样会改变状态的历史记录,这就与事件溯源的设计初衷背道而驰了。替代的方法是,我们在事件流里创建一个新的事件,我们将希望删除的事件回退(reverses)到之前的状态。这个过程称之为事务回退,这个操作不仅将实体恢复到期望的状态,还留下记录表名这个实体在给定的时间节点所处的状态。

不删除数据也有架构上的收益。存储系统成为一种仅添加的架构,众所周知,仅添加的架构比起可更新架构更容易部署,因为它要处理的锁要少得多。 Greg Young 2010, CQRS Documents

快照(Snapshots)

不过,当在一个事件流中包含很多的事件时,计算实体状态则会变的代价高昂,还会严重影响性能。为了解决这个问题,每当产生 X 条事件时,我们将在那个时间点创建实体状态的快照。甚至,我们可以保存这个实体的永久更新过的快照,这样我们就能同时拥有两个最优的平行世界。

event snapshots

投影(Projections)

在事件溯源中我们还引入了 投影(projection) 的概念,它是一定时间范围内基于事件流计算后的事件结果。这就是快照,或者说实体的当前状态,这就是投影的定义。但是在 投影 这个概念中最有价值的是,我们可以通过分析特定时间内的实体「行为」,实现对未来的行为作出预测(比如,在过去 5 年里实体模型都在 8 月份增加了活动量,那么它很有可能在明年 8 月份产生同样的行为)。这对企业来说是一个很有价值的能力。

赞成 vs 反对(Pros and cons)

事件溯源在商业和软件开发过程这两方面非常有用:

  • 通过查询这些事件,有助于商业和开发时理解用户和系统行为(调试);
  • 我们还可以使用事件日志来重建过去的状态,这对商业和开发都很有用;
  • 自动调整状态以追溯变更情况,在商业上意义重大;
  • 在回放(replay)时,通过输入预设事件探索已有历史记录,在商业上同样有意义。

然而,并非一切都如此美好,警惕如下问题:

  • 外部更新(External updates)

当事件在外部系统中触发更新时,我们不希望在回放事件以创建投影时重新触发这些事件。此时,我们只需在 「回放模式」中禁用外部更新,可以将这个逻辑封装到网关里实现。

另一种解决方案依赖于实际的问题,可以将更新缓存(buffer)到外部系统,在一段时间后执行更新,这时可以安全地假设事件不会回放。

  • 外部查询(External Queries)

当在外部系统中使用查询来检索我们的事件时,比如获取股票债券评级,当我们回放事件来创建投影时会发生什么呢? 我们可能想要得到与事件第一次运行时相同的评分,这也许是几年前生成的。因此,远程应用可以给我们这些值,或者我们需要将它们存储在我们的系统中,这样我们就可以通过封装网关中的逻辑来模拟远程查询。

  • 代码变更(Code Changes)

Martin Fowler 定义了 3 种类型的代码变更:新特性(new features),bug 修复和临时逻辑。真正的问题出现在回放事件时,这些事件应该在不同的时间点使用不同的业务逻辑规则,比如,去年的税收计算就与今年的不同。通常情况下,可以使用条件语句,但是这回使逻辑变得混乱,所以建议使用策略模式。

我的建议是谨慎使用这个模式,一般我会尽量遵循如下原则:

  • 让事情保持沉默,仅需让它知道状态发生变化,无需使其知道如何处理业务。这样,即使业务规则同时发生了更改,我们也可以安全地回放任何事件并获取期望的结果(但是我们需要保留之前的业务规则,以便在回放过去的事件时使用它们);
  • 与外部系统的交互不应依赖于这些事件,这样我们就可以安全地回放事件,而不会导致回放时触发外部逻辑引发的危险,也无需保证外部系统的响应与事件最初回放时的响应相同。

当然,和其它模式一样,并非任何时候都可以使用它,当使用比不适用带来更多收益时,我们应该去使用这种模式。

结论

事件驱动架构核心在于封装、高内聚和低耦合。

事件驱动可以提升代码的可维护性、性能和业务增长的需求,但是,通过事件溯源模式,还能提高系统数据的可靠性。

不过,事件驱动同样存在弊端,因为无论是概念上的复杂度还是技术上的复杂度都增加了,当它被滥用时将导致灾难性的后果。

资料

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”? 中译 中译2

2017 • Martin Fowler • The Many Meanings of Event-Driven Architecture

补充资料

什么是事件溯源

浅谈命令查询职责分离 (CQRS) 模式

Command 与 Query 分离(CQS)

注解

[1] 面条式代码(Spaghetti code)是软件工程中反面模式的一种 (1),是指一个源代码的控制流程复杂、混乱而难以理解 (2),尤其是用了很多 GOTO、例外、线程、或其他无组织的分支。其命名的原因是因为程序的流向就像一盘面一样的扭曲纠结。面条式代码的产生有许多原因,例如没有经验的程序设计师,及已经过长期频繁修改的复杂程序。结构化编程可避免面条式代码的出现。这样,当我们需要获取实体状态时,只需要计算最后一个快照即可。

原文

Event-Driven Architecture

发表评论

电子邮件地址不会被公开。 必填项已用*标注