到底什么是事件驱动架构EDA?这篇文章讲得比较清楚。

通过事件进行应用程序的设计是自20世纪80年代后期以来的一种实践。我们可以在前端或后端的任何地方使用事件。当按下按钮时,某些数据发生更改或执行某个后端动作。

但是事件究竟是什么呢?我们什么时候应该用它呢?缺点是什么?


What/When/Why
当类或组件之间内聚性很高,它们的耦合度应该很低,也就是说当组件需要相互协作调用时,比如我们假设一个组件“A”需要触发组件“B”中的一些逻辑,自然的方式是直接让组件A调用组件B中的一个方法。但前提是A必须知道B的存在,这样它们之间就是耦合的,A必须依赖于B了,这会使得系统更难以改变和维护。因此,这里可以使用事件来防止这种直接调用的耦合。

此外,使用事件实现组件解耦也有其另外的,如果我们有一个只负责组件B的工作团队,那么他们则可能不需要与负责组件A的团队进行交流,直接针对组件A中的逻辑改变在组件B中做出相对反应。两个组件团队可以独立发展(banq注:微服务特点之一), 我们的应用系统变得更灵活。

即使在同一个组件团队中,有时候我们不需要在同一请求/响应中立即执行一个动作的结果,只要异步执行这个动作,比如发送电子邮件。在这种情况下,我们可以立即向用户返回响应,并以异步方式发送电子邮件,并避免让用户等待发送电子邮件。

不过,如果我们不加区别地使用它,也有危险。我们会遇到逻辑流程的风险,这些逻辑流程在概念上是高度凝聚力的,但是通过采取脱钩机制的事件连接在一起。换句话说,应该在一起的代码将被分开,并且难以跟踪它的流程(类似于goto语句),不易于理解:可能是意大利面一样混在一起!

为了防止将我们的代码库变成一大堆意大利面条,我们应该将事件的使用限制在明确的情况下。根据我的经验,有三种使用事件的情况:

(1).去耦组件
(2).执行异步任务
(3).跟踪状态变化(审核日志)

1.去耦组件(微服务)

当组件A执行的逻辑需要触发组件B的逻辑时,不要直接调用它,我们可以将触发事件发送到事件分派器。组件B将侦听调度程序中的特定事件,并在事件发生时执行操作。

这意味着A和B都将取决于调度器和事件,但他们之间将不会知道对方存在,它们将被解耦。

理想情况下,调度员和事件都不应该在两个组件之间存在:

(1)调度员应该是完全独立于我们应用程序的库,因此使用依赖管理系统安装在通用位置。在PHP世界中,这是使用Composer等安装在vendor文件夹中的东西。

(2) 事件是我们的应用程序的一部分,应该在两个组件之间生存,组件之间通过事件进行通讯(结构上解耦,行为上耦合)。事件在组件之间共享,它是应用程序的核心部分。事件在DDD中属于共享内核Shared Kernel的一部分。这样,两个组件都将依赖于共享内核,但彼此不会意识到。然而在单体Monolithic应用程序中,为方便起见,可以将其放在触发事件的组件中。

DDD共享内核

[...]明确界定指定团队同意分享的领域模型的一些子集。保持这个内核很小。[...]这个明确共享的东西有特殊的地位,如果没有与其他团队协商,不应该改变。

Eric Evans 2014, 领域驱动设计参考


2.执行异步任务

有时候我们有一个我们想要执行的逻辑,但它可能需要相当长的时间来执行,我们不想让用户等待它完成。在这种情况下,希望将其作为异步工作运行,并立即返回给用户的消息,通知他请求将在以后异步执行。

例如,在网上商店下订单可以同步完成,但发送通知用户的电子邮件可以进行异步。

在这种情况下,我们可以做的是触发一个将被排队的事件,直到一个工作任务可以获得这个事件并执行它,只要系统有资源。

在这些情况下,相关联的逻辑是否在相同的有界环境中并不重要,无论哪种方式,逻辑都是去耦的。


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

以传统的数据存储方式,我们拥有一些数据的实体。当这些实体中的数据发生变化时,我们只需更新数据库表行以反映新值。

这里的问题是,我们并不存储这些值为什么改变且什么时候改变。

我们可以将这些改变的事件存储在审计日志中。

更多关于这个进一步的前景,在关于事件溯源的解释。


事件模式
Martin Fowler确定了三种不同类型的事件模式:

(1)事件通知
(2)事件执行状态转移
(3)事件溯源Event Sourcing

所有这些模式共享相同的关键概念:

(1)事件是代表发生了一些事情(发生在某事之后);
(2)事件被广播到正在监听的任何代码(代码可以对事件做出反应)。

一. 事件通知

假设我们有一个具有明确定义的组件作为应用程序核心。理想情况下,这些组件是完全相互分离的,但是它们的一些功能需要在其他组件中执行一些逻辑。

最典型的情况如前所述:当组件A执行的逻辑需要触发组件B的逻辑时,A不是直接去调用B,而是触发事件将且发送到事件调度程序。组件B将侦听调度程序中的特定事件,并在事件发生时执行操作。

重要的是,这种模式的一个特征是事件携带最少的数据。它只为听众提供足够的数据,以便知道发生了什么并执行其代码,通常只是实体ID,也可能是事件创建的日期和时间。

优点

(1)弹性更大:将事件排队后,发送方组件可以继续执行其自己逻辑,即使由于错误发生,因为它们排队等候,它们可以在错误被修复时被执行。

(2)降低延迟,如果事件排队,用户不需要等待该逻辑执行;
团队可以独立发展组件,使他们的工作更轻松,更快,更容易出现问题,更灵活;

缺点

(1)如果没有使用标准,有可能变成一堆意大利面条代码。


二. 事件执行状态转移

让我们再次看看前面例子,一个具有明确定义的组件作为应用程序核心。如果A组件一些功能需要来自其他组件的数据。获得该数据的最自然的方法是询问其他组件,但这意味着被查询组件必须提供查询方法以供查询组件使用,一次两次修改增加无所谓,如果频繁要求被查询组件提供新的查询方法,说明这两个组件彼此耦合!

在组件之间共享数据的另一种方法是:拥有数据的组件触发的更改事件时,该事件将携带全新更改后的数据。对该数据感兴趣的组件将会监听这些事件,从事件中获得数据并存储该数据的本地副本,然后进一步对这些全新数据做出反应。这样,当他们需要外部数据时,他们其实在本地已经拥有它们,它们将不需要查询其他组件,也不需要其他组件提供对应的查询方法。

优点

(1)更大的弹性,查询组件不依赖被查询组件,如果被查询组件变得不可用(因为有一个错误或远程服务器是不可达到的),查询组件自身能正常工作,因为拥有了被查询组件中主数据的本地数据;

(2)减少延迟,因为没有远程呼叫(假设被查询组件是远程的)访问数据;

(3)我们不必担心被查询组件上的负载了,不用担心它是否满足来自所有其他查询组件的查询(特别是如果它是远程组件);

缺点
(1)将有几个相同数据的副本,虽然它们是只读副本,数据存储在当下已经不是问题;

(2)更高的查询组件的复杂性,因为它将需要逻辑来维护外部数据的本地副本,尽管这是非常标准的逻辑。主从一致性。

如果两个组件在同一个进程中(同一个VM中、同一个主机内)执行,这种模式也许没有必要,但即使这样,它也可能很有趣,可以将其用于解耦和可维护性,或作为将这些组件分离到不同的微服务中工作做准备,也许在未来的某个时候我们能平滑升级到微服务。这一切都取决于我们目前的需求,未来的需求。


三. 事件溯源

我们假设一个实体处于一种初始状态。作为一个实体,它有自己的身份,代表在现实世界中一个特定的事情,应用程序将其建模为实体。在其生命周期中,实体数据会发生变化,并且传统上实体的当前状态被简单地作为表的一行记录存储在数据库中。

(1)事务日志

这在大多数情况下都是可以的,但如果我们需要知道实体是如何达到当前这个状态,即我们想知道我们的银行账户的贷方和借记发生的每笔金额,才能知道当前账户的余额来历,这在传统只保存当前状态的方式下是不可能实现的,因为我们只存储当前状态!每次都是新的余额状态覆盖了之前的状态,比如当前余额是10,覆盖了之前余额90,至于账户余额怎么剩余10元呢?如果数据库不保存往来明细,你可能认为银行系统出问题了。

存储实体发生的事件,而不是存储Entity状态,我们专注于存储实体状态更改并从这些更改中计算实体状态。每个状态变化是一个事件,存储在事件流中(即RDBMS中的一个表)。当我们需要实体的当前状态时,我们从事件流中的所有事件中计算出它。


事件存储成为真相的主要来源,系统状态纯粹源于它。对于程序员来说,最好的例子是版本控制系统。所有提交的日志是事件存储,源树的工作副本是系统状态。---2010年Greg Young, CQRS文件


(2)如何删除?

如果我们发现一个状态改变(事件)是一个错误,我们不能简单地删除该事件,因为这会改变状态更改历史记录,这将违反整个事情溯源的想法。相反,我们在事件流中创建一个事件,以反转我们要删除的事件。这个过程称为反转事务,不仅使实体返回到所需的状态,而且留下了一个跟踪,显示对象在给定时间点处于该状态。

不删除数据也具有架构优势。存储系统成为只添加一个体系结构,众所周知,仅附加体系结构比更新架构更容易分发,因为要处理的锁少得多。---2010年Greg Young, CQRS文件


(3)快照

但是,当事件流中有许多事件时,计算实体状态将是非常昂贵的,因此为了避免出现这种情况。每X个事件我们将在该时刻创建一个实体状态的快照。这样,当我们需要实体状态时,我们只需要计算它到最后一个快照。我们甚至可以永久保存更新实体的快照,这样我们平衡了两种世界(只保存状态和只保存事件)。


(4)投影预测Projections

在事件采集中,我们也有一个投影的概念,即事件流中的事件的计算,从特定时刻开始。这意味着快照或实体的当前状态符合预测的定义。但是在预测概念中最有价值的想法是,我们可以在特定时期分析实体的“行为”,这使我们能够对未来作出有根据的猜测(即如果在过去的5年中,实体有8月份的活动增加,明年8月份可能会发生同样的事情),这对业务来说可能是非常有价值的。

(5)利弊

事件溯源对于业务和开发过程都是非常有用的:

1.我们查询这些事件,用于业务和开发,以了解用户和系统行为(调试);

2.我们还可以使用事件日志重建过去的状态,对于业务和开发来说都是有用的;

3.自动调整状态以应对追溯变化,非常适合企业需要频繁变化;

4.通过在重播时注入假想事件来探索其他历史,令人敬畏。

但不是一切都是好消息,要注意隐藏的问题:

1.外部更新

当我们的事件在外部系统中触发更新时,但是我们又在重播事件以便创建投影,因此我们不想重新触发这些事件。在这一点上,当我们处于“重播模式”时,我们可以简单地禁用外部更新,也可以将该逻辑封装在网关中。

另一个解决方案取决于实际问题,可能是将外部系统更新放入缓冲,在一段时间后执行,保证事件不会被重播时再进行。

2.外部查询

当我们的事件使用对外部系统的查询,如获得股票评级,当我们重播事件以创建投影时会发生什么?我们可能希望获得与事件在第一次运行时所使用的相同的等级,也许是在几年前。因此,远程应用程序可以给我们这些值,或者我们需要将它们存储在系统中,所以我们可以通过在网关中封装该逻辑来模拟远程查询。

3.代码更改

Martin Fowler发现3种类型的代码更改:新功能,错误修复和时间逻辑。当在不同的时间点播放应该使用不同业务逻辑规则的事件时,真正的问题就出现。去年的税收计算与今年不同。像往常一样,条件逻辑可以使用,但它会变得凌乱,所以建议使用策略模式。

所以,我建议谨慎对待,尽可能遵循这些规则:

1.保持事态愚蠢,只关心状态的变化,而不是如何被改变的。这样我们可以安全地重播任何事件,并期望结果是一样的,即使业务规则同时发生变化(尽管我们需要保留旧的业务规则,以便我们可以在重播过去的事件时应用它们);

2.与外部系统的交互不应该依赖于这些事件,这样我们可以安全地重播事件,而不会重新触发外部逻辑,我们不需要确保来自外部系统的回复与最初的事件相同。

当然,像其他任何模式一样,我们不需要在任何地方使用它,我们应该使用它在哪里是有意义的,它给我们带来了一个优势,并解决了比创建更多的问题。

结论
这又是关于封装,低耦合和高凝聚的问题。

事件可以平衡代码的可维护性、性能和扩展性,事件溯源也是系统数据可提供的可靠性和信息。

然而,这是一条存在自身危险的道路,因为概念和技术的复杂性都会增加,而且任何一种的滥用都会产生灾难性的后果。

Event-Driven Architecture – @herbertograca