事件驱动架构设计

起源

事件驱动程式设计(英语:Event-driven programming)是一种电脑程式设计模型。这种模型的程式执行流程是由使用者的动作(如滑鼠的按键,键盘的按键动作)或者是由其他程式的讯息来决定的。相对于批次程式设计(batch programming)而言,程式执行的流程是由程式设计师来决定。批处理(batch)的程式设计在初级程式设计教学课程上是一种方式。然而,事件驱动程式设计这种设计模型是在互动程序(Interactive program)的情况下孕育而生的。

取代传统上一次等待一个完整的指令然后再做执行的方式,事件驱动程式模型下的系统,基本上的架构是预先设计一个事件回圈所形成的程序,这个事件回圈程序不断地检查目前要处理的资讯,根据要处理的资讯执行一个触发函式进行必要的处理。其中这个外部资讯可能来自一个目录夹中的档案,可能来自键盘或滑鼠的动作,或者是一个时间事件。

对一个事件驱动(event driven)系统进行程式设计,因此可以视为改写系统预设触发函式的行为,来符合自己需要的一种动作。输入的事件可以放进事件回圈或者是经由已经注册的中断处理器来与硬体事件互动;而许多的软体系统使用了混和这两种技术的处理。预先设计好的演算法确定了一件事,那就是当他们被需要的时候,就会在适当的时机被触发。也因此提供了一个模拟中断驱动环境(interrupt driven environment)的软体抽象架构。事件驱动程式设计基本上包含了许多小程式片段,这些小程式片段被称为事件处理器并且被用来回应外部的事件与分发事件。通常尚未被事件处理器处理的事件,都会被系统放在一个称为事件伫列的资料结构中,等待被处理。

许多情况下,事件处理器可以自己触发事件,因此也可能形成一个事件串。 事件驱动程式设计的著重于弹性以及非同步化上面,并且企图尽可能的 modeless。 图形用户界面这类程式就是典型的事件驱动设计方式。

维基百科中有个很好的例子:

  • 等待执行
read a number (from the keyboard) and store it in variable A[0]
read a number (from the keyboard) and store it in variable A[1]
print A[0]+A[1]
  • 事件驱动
set counter K to 0
repeat {
   if a number has been entered (from the keyboard) {
       store in A[K] and increment K
       if K equals 2 print A[0]+A[1] and reset K to 0
   }
}

图形构件组成了图形界面的可见部分,在这些可见构件的背后,还有不可见的程序逻辑。 就好比家用电器都提供操作面板,用户通过操作面板控制、使用电器功能,在面板的背后是 实现功能的电路逻辑。

GUI 应用程序的特点是注重与用户的交互,因此程序的执行取决于与用户的实时交互情 况。例如 Word 程序启动后,并非一路执行到程序结束,而是在做了必要的初始化工作后就 停下来,等待用户的下一步动作。用户可能在文档窗口中输入文本,也可能通过菜单设置选 项,还可能点击工具栏里的存盘图标,总之是完全不确定的。Word 程序只能等到用户的交互 动作发生后,才去执行相应的处理代码。

由于 GUI 程序的执行流程由用户控制,并且不可预期,为了适应这种特点,我们需要采 用事件驱动的编程方法。普通程序的执行可概括为“启动——做事——终止”,而事件驱动的 程序的执行可概括为“启动——事件循环(即等待事件发生并处理之)”。作为特例,GUI 程 序的终止也是由特定事件(如关闭窗口事件)引起的。

事件(event)是针对应用程序所发生的事情,并且应用程序需要对这种事情做出响应。 程序对事件的响应其实就是调用预先编制好的代码来对事件进行处理,这种代码称为事件处 理程序(event handler)。GUI 中最常见的事件是用户的交互动作,如按下某个键或者点击鼠 标。当然在其他类型的应用程序中也会出现其他类型的事件,例如在各种监控系统中,传感 器采集环境数据并传给程序,就可视为发生了需要处理的事件。又如在面向对象程序中,向 某个对象发送消息,也可看成是发生了某种需要响应的事件。事件驱动编程(event-driven programming)就是针对这种“程序的执行由事件决定”的应用的一种编程范型。

事件驱动的程序一般都有一个主循环(main loop)或称事件循环,该循环不停地做两件 事:事件监测和事件处理。首先要监测是否发生了事件,如果有事件发生则调用相应的事件 处理程序,处理完毕再继续监测新事件。那么,主循环如何监测事件以及如何触发相应的事 件处理程序呢?这个问题牵涉到操作系统的低层机制,比较复杂。好在这部分代码是独立于 具体应用程序的,一般都由 GUI 工具包提供支持,应用程序员只需编写自己的事件处理程序。

事件的定义

我们这里姑且尝试对其进行准确定义。

  • 事件是某一个行为触发后产生的。
  • 事件是需要另一个实体消费的一个消息。
  • 事件有可能是内部产生,也可能是外部产生的。
  • 事件产生后是不会改变的。
“当……”
“如果发生……,则……”
“当做完……的时候,请通知……”

事件驱动架构

事件驱动架构是通过事件的传播来实现跨越多个服务之间的业务逻辑的。事件驱动架构是一种设计应用的软件架构和模型,可以最大程度减少耦合度,很好地扩展与适配不同类型的服务组件。在这一架构里,当有重要事件发生时,比如更新业务数据,某个服务会发布事件,其它服务则订阅这些事件;当某一服务接收到事件就可以执行自己的业务流程,更新业务数据,同时发布新的事件触发下一步。

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

事件驱动主要能带来以下几个比较大的好处:

  • 实现组件的解耦

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

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

  • 执行异步任务

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

比如新用户注册成功后赠送一张优惠券。
  • 跟踪状态

在传统的数据存储的方式中,我们通过实体模型(entities)保存数据。当这些实体模型中的数据发生变化时,我们只需更新数据库中的行记录来表示新的值。当然在现代的话是完全可以以日志的方式存入数据湖中或者是时序性数据库中方便将来使用。

常见事件驱动设计类别

目前主流比较常见的是监听订阅两种模式。

  • 监听

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

  • 订阅

订阅模式下,一个订阅者处理的可能不仅仅是一个事件,但它仍然遵循单一职责设计原则,一般只实现一个业务。比如有一个「优惠券订阅者」,这个订阅者订阅了诸如「注册」、「签到」、「邀请好友成功回调」等一系列事件。

这里有个非常有意思的问题是:“使用了消息队列不代表是事件驱动设计”

无论是监听还是订阅,本质上一般事件都是做几件事情(CQRS 我认为不是很常见也不是很实用,不在本次讨论范围):

  • 通知

通知就跟普通的情况下使用 MQ 差不多,事件中只附带很少很少的信息。比如上面说的注册事件,那么可能只会附带用户的 id  在事件消息中。

  • 转移

这种情况也比较常见,某些情况下是需要附带完整的信息的。比如说在支付结算的时候,可能需要附带订单信息、用户信息、优惠券信息、库存信息等完整的信息。不过这种模式下虽然可以在原先的服务不可用情况下继续工作且减少了远程的调用,但实际上仍然是有可能出现读写污染的问题,而且耦合度太高了。

  • 溯源

这是一个很有意思的模式,比如我们同样拿一个用户在购物网站从注册到支付结算的过程来举例。如果我们假设这样的场景下每一个主要流程都一个事件,那么大概会有这样的事件流:「注册」、「登录」、「搜索」、「浏览商品」、「加入购物车」、「创建订单」、「支付」、「支付成功」。

如果我们按照转移的模式让每一块事件都保留下来当时那个事件之前的丰富的信息,那么本质上就形成在不同的流程阶段下的快照。我们就可以进行调试已经恢复到过去的任意阶段的状态。

当然这只是个理想,在当前时代其实会变成海量的数据,如何管理和存储是一个非常难的事情。

设计一个事件驱动架构应该考虑什么

首先我们要明确几个“君子协定”:

  • 事件的生产者并不需要关心自己所产生的事件的后续如何,如有没有成功更新数据库、有没有合适的锁等等问题。
  • 事件消费者(包含监听和订阅两种模式)自身是一个领域模型,它只负责接收事件并响应。

那么我们可以按照下面的几个思路来考虑:

事件更多的是来自于内部还是外部?

如果是来自于外部,那建议使用消息队列中间件(如 Kafka、Pulsar、RocketMQ 等等)进行消息解耦,用一个适合场景的消息队列能很大程度下解决以下问题。

如果是来自内部,建议参考 EventBus 的实现或者是找个现成开源的 EventBus 工具。EventBus 是设计模式中的观察者模式的优雅实现。对于事件监听和发布订阅模式,可以使用 EventBus 完美的解决。

EventBus 也是俗称的事件总线,想象一下,有一个包含大量相互交互的组件的大型应用程序,并且您想要一种方法使您的组件进行通信,同时保持松散耦合和关注点分离原则,事件总线模式可以很好地解决您的问题。简单来说 EventBus 实现持有所有 Subscribables 的列表,并在每次有新事件进入 EventBusdispatch 方法时通知所有订阅者。

事件是否是必须保证顺序的?

日常思维中,顺序大部分情况会和时间关联起来,即时间的先后表示事件的顺序关系。比如事件 A 发生在下午 3 点一刻,而事件 B 发生在下午 4 点,那么我们认为事件 A 发生在事件 B 之前,他们的顺序关系为先 A 后 B。但实际上我们在架构设计上讨论的顺序是有两种情况:

  • 同样时间维度下的发生先后关系。
  • 两个事件之间的因果关系。

比如我们在前面溯源那里举例的,「支付成功」事件是依赖于「支付」的,那么 「支付」是 「支付成功」的因,「支付成功」是 「支付」的果,两者的顺序是不能相反的。

所以某些场景下是需要支持必须保证顺序这一特性的。

常见实现顺序又分三种:

  • 同个 Topic 严格按照先进先出原则进行消息发布和消费。
  • 同个 Topic 下同一分区消息保证顺序。

对于指定的一个 Topic,所有消息根据 Sharding Key 进行区块分区,同一个分区内的消息按照严格的先进先出(FIFO)原则进行发布和消费。同一分区内的消息保证顺序,不同分区之间的消息顺序不做要求。比如用户注册需要发送验证码,以用户 ID 作为 Sharding Key,那么同一个用户发送的消息都会按照发布的先后顺序来消费。

  • 对于同个 Topic,全局顺序。

对于指定的一个Topic,所有消息按照严格的先入先出(FIFO)的顺序来发布和消费。比如在证券处理中,以人民币兑换美元为 Topic,在价格相同的情况下,先出价者优先处理,则可以按照先进先出(FIFO) 的方式发布和消费全局顺序消息。

事件是否必须要处理成功?

这是一个非常难保障的事情,如果要求必须消费成功的话,那么消息最少要被消费一次或者是最多且最少被消费一次。一般可以使用基于二阶段提交的事务消息来实现。

二阶段提交,分别有协调者和参与者两个角色,二阶段分别是准备阶段和提交阶段。准备阶段就是协调者向各参与者发送准备命令,这个阶段参与者除了事务的提交啥都做了,而提交阶段就是协调者看看各个参与者准备阶段都 o 不 ok,如果有 ok 那么就向各个参与者发送提交命令,如果有一个不 ok 那么就发送回滚命令。

事件是否是唯一处理?

这个问题与上面那个问题相关,比如说一个订单已经完成了扣费,那个这个订单的事件就不应该再次被处理。

事件是否需要聚合处理?

某些情况下是需要多个事件按照某个聚合方式合并在一起处理的。比如我们希望记录用户的登录日志,那么这种登录日志完全不需要立即记录下来,为了性能的考虑我们可以每隔五分钟一口气处理完。那么这样的场景下我们需要将五分钟的事件聚合一起处理,而不是一个个事件进行处理。这种方式可以非常有效的降低负载提高并发。

事件的主要主要序列化和反序列化方式是什么?

序列化:就是将对象转化成字节序列的过程。比如 Json 对象转为 Json 字符串。
反序列化:就是讲字节序列转化成对象的过程。比如 Json 字符串转为 Json 对象。

这个要根据你喜欢传递的事件更适合用什么格式来决定,比如 Json、XML 等等再进行挑选。比如要做回溯,那肯定不能挑 ProtoBuffer 之类的序列化和反序列化方式,需要有个更方便明文传输存储的。

事件是否有可能阻塞排队?

这个跟很多原因都有关,像是传输速度、序列化反序列化速度、事件每秒产生的量等等。这个也是需要考虑进去的,比如某些场景下事件是需要处理得越快越好的,那肯定要进行更深层次的优化来保障尽可能在最快速度被消费;有些场景下比如前面举例的用户登录日志就不太需要理解处理,完全可以排队慢慢处理或者进行聚合处理。

案例

单机版本的也可以参考我在 Next 框架上的实现:https://www.yuque.com/cloudopt/next/dv04n1
其实现在  iOS、Android、JS 等等客户端的所谓的 发生了什么 -> 要做什么本质上都是事件驱动设计思想的衍生物。看看这些平台的事件相关的设计也挺好的。比如 Android 的广播输入事件

VIVO 内容平台

在当今社会,内容“横行”的时代,内容平台企业需要有极强的灵活性和应变能力。特别是在中国这样一个内容行业(如视频)飞速发展的市场里,企业要求平台能够快速地对内容业务需求做出应对,否则就会丧失先发优势。这有点类似于现代战争条件下,各国都要求部队具备快速反应能力,这种能力主要体现在平台能够通过快速开发或者重用 / 整合现有资源来达到快速响应业务需求。

随着内容行业业务越来越庞大复杂,所涉及的存储类型、处理器、账号体系、效率工具、数据和结算系统等非常多,这就要求平台有很强的整合能力以及对异构环境的适配能力。

最后,由于内容行业的发展日新月异,特定类型的内容业务(如小视频)都会在其初中期发展后迎来一个快速膨胀期,业务量和业务类型会急剧增加,这也要求平台有很好的可扩展性。相关平台架构见下图:

事件其实是 DDD(领域驱动设计)中的一个概念,表示的是在一个领域中所发生的一次对业务有价值的事情,落到技术层面就是任何影响业务流程或者状态的改变。事件具有自己的属性,比如发生的时间、发生了什么、事件之间的关系、状态以及变化,事件也可以生成新的事件,根据不同的事件生成新的业务事件。在创建事件时,首先需要记录事件的一些通用信息,比如唯一标识 ID 和创建时间等,为此创建事件基类 ContentEvent:

public abstract class AbstractContentEvent {
    private String eventId;
    private String publisher;
    private String receiver;
    private Long publishTime;      
}public abstract class AbstractContentEvent {    private String eventId;    private String publisher;    private String receiver;    private Long publishTime;      }

在一般场景下,事件一般随着聚合根(也是 DDD 的一个概念,这里泛指视频 id )状态的更新而产生,另外,在事件的消费方,有时我们希望监听发生在某个聚合根下的所有事件,为此建议为每一个聚合根对象创建相应的事件基类,其中包含聚合根 videoId,比如对于视频(Video)类,创建 VideoEvent:

public class VideoEvent extends AbstractContentEvent {
    private final String videoId;
}

然后对于实际的视频事件,统一继承自 VideoEvent,比如对于视频引入的 VideoInputEvent 事件;

public class VideoInputEvent extends VideoEvent {
    private Article article; // 视频基本信息
}

视频域事件的继承链见下图;

在创建事件时,需要注意两点:

  1. 事件本身应该是不变的;
  2. 事件应该携带与事件发生时相关的上下文数据信息,但是并不是整个聚合根的状态数据。例如,在视频引入时可以携带视频的基本信息 article,而对于视频状态更新的 VideoStatusChangeEvent 事件,则应该同时包含更新前后的状态 status:
public class VideoStatusChangeEvent extends VideoEvent {
    private String preStatus; //更新前的状态
    private String status; // 更新后的状态
}

发布事件有多种方式,比如可以在应用程序中发布。通常的业务处理过程都会更新数据库然后发布事件,这里一个比较常见的场景是:需要保证数据库更新和事件发布之间的原子性,也即要么二者都成功,要么都失败;当然也有不需要保证原子性的场景。如果需要保证原子性,以“内容引入”的业务流程为例,见下图:

  • 接收内容;
  • 写入内容表;
  • 写入事件表,且和内容表的更新在同一个本地数据库事务中;
  • 事务完成后,触发事件的发送;
  • 读取事件表;
  • 将事件发送到消息队列;
  • 发送成功后,将记录标注为“已发送”;

在消费事件时,除了完成基本的消息处理逻辑外,我们需要重点关注以下三点:

  • 消费方的幂等性;
  • 消费方有可能进一步产生事件;
  • 消费方的数据一致性;

对于“幂等性”,事件的发送机制保证的是“至少一次投递”,这是有消息中间件保证,技术选型时需要注意。为了能够正确地处理重复消息,要求消费方是幂等的,即多次消费事件与单次消费该事件的效果相同。保证“消费幂等性”的方法有很多,这里介绍一种。在消费方创建一个事件表,用于记录已经消费过的事件,在处理事件时,首先检查该事件是否已经被消费过,如果是则不做任何消费处理。

对于第二点,依然沿用前文讲到的“事件表”的方式。事实上,无论是处理服务请求,还是作为消息的消费方,对于聚合根(videoId)来讲都是无感知的,事件由聚合根产生进而由事件库持久化,这些过程都与具体的业务操作源头无关。

对于“数据一致性”,本质上是由第二点引出,事件驱动架构在业务对象之间通过异步的消息来同步状态,有些消息也可以同时发布给多个服务,在“消息引起了一个服务的同步”后可能会引起另外的消息,事件会扩散开。严格意义上的事件驱动是没有同步调用的,如何保证一致性,就要比非事件驱动架构要复杂,通常采用 “cache aside” 模式和“分布式锁”来保证一致性。

综上,在消费事件的过程中,应用程序需要更新业务表、事件记录表,此时整个事件的发布和消费过程见下图;

参考文献