静 2019-06-28
这是一篇译文,译文首发于 事件驱动架构设计,转载请注明出处!
这篇文章是 软件架构演进 一个有关 软件架构 系列文章中的一篇。这些文章,主要是我学习软件架构、对软件架构的思考及使用方法的记录。相比于这个系列的前几篇文章,本篇文章可能看来更有意义。
采用设计驱动开发应用程序的实践,可以追溯到 1980 年左右。我们可以在前端或者后端采用事件驱动模型。比如点击一个按钮、数据变更或者某些后端服务被执行。
但是究竟什么才是事件驱动呢?何时使用事件驱动?它有没有缺陷?
就像类和组件一样我们应当在编码时实现高内聚低耦合。当需要组合使用组件时,比如 组件 A 需要触发 组件 B 中的某些逻辑,我们自然而然的会想到在 组件 A 中去直接调用 组件 B 实例中的方法。然而,如果 A 需要明确知道 B 的存在,那么它们之间是耦合的,A 依赖于 B,这使得系统难以维护和迭代。事件驱动可以 解决耦合 的问题。
此外,采用事件驱动的另外一个好处是,如果我们有一个独立的团队开发 组件 B,他们可以直接修改 组件 B 的业务逻辑而无需事先和研发 组件 A 的团队进行沟通。各个组件可以单独迭代:我们的系统更变得有组织性。
甚至,在同一个组建内,有时我们的代码需要在一个 request 和 response 周期内,作为某个操作的结果被执行,但是又不需要立即被执行的类似处理。一个常见示例就是发送电子邮件。此时,我们可以直接响应用户结果,然后以异步方式延迟发送一个电子邮件给用户,这样就避免了用户等待发送邮件的时间。
不过,即使这样处理依然存在风险。如果我们胡乱使用事件驱动设计,我们就有可能要承担中断业务逻辑的风险,因为这些业务逻辑具有概念上的高度内聚,却采用了解耦机制将它们联系在一起。换句话说,就是将原本需要组织在一起的代码强行分离,并且这样难于定位处理流程(比如使用 goto 语句),来理解业务处理:这就变成了 面条式的代码[1]。
为了防止我们的代码变成一堆复杂的逻辑,我们应当在某些明确场景下使用事件驱动架构。就我的经验来讲,在以下 3 种场景下可以使用事件驱动开发:
当组件 A 需要执行组件 B 中的业务逻辑,相比于直接调用,我们可以向事件分发器中发送一个事件。组件 B 通过监听分发器中的特殊事件类型,然后当这类事件被触发时去执行它。
这意味着组件 A 和组件 B 都依赖于事件分发器和事件,而无需关注彼此实现:即完成它们的解耦。
理论上,分发器和事件应该处在不同的组件中:
共享内核
[...] 用明确的边界指定团队同意共享的域模型的某些子集。保持这个内核很小。[...] 这个拥有特殊状态的明确的共享机制,不得在未经团队协商情况下随意修改。
Eric Evans 2014, Domain-Driven Design Reference
有时我们会有一系列需要执行的业务逻辑,但是由于它们需要耗费相当长的执行时间,所以我们不想看到用户耗费时间去等待这些逻辑处理完成。在这种情况下,最好将它们作为异步任务来运行,并立即向用户返回一条信息,通知其稍后继续处理相关操作。
比如,在网店下订单可以采用同步执行处理,但是发送通知邮件则采用异步任务去处理。
在这种情况下,我们所要做的是触发一个事件,将事件加入到任务队列中,直到一个 worker 进程能够获取并执行这个任务。
此时,相关的业务逻辑是否处在同一个上下文中环境中并不重要,不管怎么说,业务逻辑都是被执行了。
在传统的数据存储的方式中,我们通过实体模型(entities)保存数据。当这些实体模型中的数据发生变化时,我们只需更新数据库中的行记录来表示新的值。
这里的问题是我们无法准确存储数据的变更和修改时间。
我们可以通过审计日志模型将包含修改的内容存入到事件里。
在关于事件来源的知识,我们会做进一步的阐述。
在实现事件驱动的架构时,一个常见的争议是究竟是使用 监听器(listener) 还是 订阅者(Subscriber),这里谈谈我的看法:
Martin Fowler 定义了 3 种事件模式:
这三种模式核心是一样的:
假设,有一个应用在内核(core)中定义了一些组件。理想情况下,这些组件是完全分离的,但是它们的一些功能需要在其他组件中去执行一些逻辑。
这是最典型的应用场景,前面已经讲过:当组件 A 执行时,需要触发组件 B 中的逻辑时,这里可以去触发一个事件将其发送到事件分发器中,而不是直接调用。组件 B 通过监听分发器中的这类事件,当有事件触发时去执行这个事件。
需要注意的是,这个模式的一个特征是 事件本身携带的数据非量常少。它只携带足够的数据,以便监听器知道发生了什么,并执行它们的代码,数据通常是实体模型的 ID,可能还有事件创建的日期和时间。
优点
缺点
还是之前那个在内核中定义了一些组件的应用。这次,多于一些功能需要使用其它组件中的数据。获取数据的最自然方式是从其它组件中查询出数据,但是这也意味着这个组件知道被查询组件的存在:这样两个组件就偶合在一起了!
实现数据共享的另一种方法是,当数据在所属组件中被变更时,触发一个事件。这个事件携带新版本中的所有数据。对该数据感兴趣的组件可以监听这类事件,并依据数据存储中的数据进行处理。这样当组件之间需要外部数据时,他们也能够获取本地副本,而无需从其它组件中查询。
优点
缺点
如果两个组件都在同一个进程中,能够快速的实现组件间通信,那么实现这种设计模式可能就没那么必要了。不过为了实现组件分离或可维护性,或在未来的计划中将组件封装进不同的微服务中使用这种模式。所有的一切取决于现有需求和计划,以及我们希望(或需要)将系统解耦到什么程度。
假设,现在有一个刚刚初始化的实体(Entity)。作为实体,它有自己的标识(identity),它对应现实世界中的某一事物,在程序中就是模型。在整个生命周期内,数据库仅仅简单的保存实体的当前状态。
多数场景下,这种存储方式是可行的,但如果我们需要知道实体究竟如何到达当前这个状态(比如,我们想知道银行账户的贷方和借方)。这时候由于我们仅存储当前状态,可能就无法实现这种需求了。
使用事件溯源模式替代实体状态存储,我们关注实例状态的 变更 并 依据变更计算出实体状态。每个状态的变化都是一个事件,被存储到事件流中(如 RDBMS 中的表)。当我们需要获取实体的当前状态是,我们通过计算这个事件的所有事件流来完成。
事件存储作为结果的主要来源,系统状态也单纯的转变成了它的派生结果。对程序员来说,最好的例子是版本控制系统。所有的提交日志就是事件存储,当前源代码树的工作副本就是系统的状态。
Greg Young 2010, [CQRS Documents](https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf)
如果现在存在一个错误的状态变更(event),我们不能简单的将其删除因为这样会改变状态的历史记录,这就与事件溯源的设计初衷背道而驰了。替代的方法是,我们在事件流里创建一个新的事件,我们将希望删除的事件回退(reverses)到之前的状态。这个过程称之为事务回退,这个操作不仅将实体恢复到期望的状态,还留下记录表名这个实体在给定的时间节点所处的状态。
不删除数据也有架构上的收益。存储系统成为一种仅添加的架构,众所周知,仅添加的架构比起可更新架构更容易部署,因为它要处理的锁要少得多。
Greg Young 2010, [CQRS Documents](https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf)
不过,当在一个事件流中包含很多的事件时,计算实体状态则会变的代价高昂,还会严重影响性能。为了解决这个问题,每当产生 X 条事件时,我们将在那个时间点创建实体状态的快照。甚至,我们可以保存这个实体的永久更新过的快照,这样我们就能同时拥有两个最优的平行世界。
在事件溯源中我们还引入了 投影(projection) 的概念,它是一定时间范围内基于事件流计算后的事件结果。这就是快照,或者说实体的当前状态,这就是投影的定义。但是在 投影 这个概念中最有价值的是,我们可以通过分析特定时间内的实体「行为」,实现对未来的行为作出预测(比如,在过去 5 年里实体模型都在 8 月份增加了活动量,那么它很有可能在明年 8 月份产生同样的行为)。这对企业来说是一个很有价值的能力。
事件溯源在商业和软件开发过程这两方面非常有用:
然而,并非一切都如此美好,警惕如下问题:
当事件在外部系统中触发更新时,我们不希望在回放事件以创建投影时重新触发这些事件。此时,我们只需在 「回放模式」中禁用外部更新,可以将这个逻辑封装到网关里实现。
另一种解决方案依赖于实际的问题,可以将更新缓存(buffer)到外部系统,在一段时间后执行更新,这时可以安全地假设事件不会回放。
当在外部系统中使用查询来检索我们的事件时,比如获取股票债券评级,当我们回放事件来创建投影时会发生什么呢? 我们可能想要得到与事件第一次运行时相同的评分,这也许是几年前生成的。因此,远程应用可以给我们这些值,或者我们需要将它们存储在我们的系统中,这样我们就可以通过封装网关中的逻辑来模拟远程查询。
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
[1] 面条式代码(Spaghetti code)是软件工程中反面模式的一种 (1),是指一个源代码的控制流程复杂、混乱而难以理解 (2),尤其是用了很多 GOTO、例外、线程、或其他无组织的分支。其命名的原因是因为程序的流向就像一盘面一样的扭曲纠结。面条式代码的产生有许多原因,例如没有经验的程序设计师,及已经过长期频繁修改的复杂程序。结构化编程可避免面条式代码的出现。这样,当我们需要获取实体状态时,只需要计算最后一个快照即可。