事件驱动架构设计

这是一篇译文,译文首发于 事件驱动架构设计,转载请注明出处!

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

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

可是究竟什么才是事件驱动呢?什么时候使用事件驱动?它有没有缺陷?前端

是什么、何时用、为何用(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](https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf)

删除(Deletions)

若是如今存在一个错误的状态变动(event),咱们不能简单的将其删除由于这样会改变状态的历史记录,这就与事件溯源的设计初衷背道而驰了。替代的方法是,咱们在事件流里建立一个新的事件,咱们将但愿删除的事件回退(reverses)到以前的状态。这个过程称之为事务回退,这个操做不只将实体恢复到指望的状态,还留下记录表名这个实体在给定的时间节点所处的状态。

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

Greg Young 2010, [CQRS Documents](https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf)

快照(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

相关文章
相关标签/搜索