覃宇,Android开发者/ThoughtWorks技术教练//译者,热衷于探究软件开发的方方面面,从端到云,从工具到实践。喜欢通过翻译来学习和分享知识,译作有《Kotlin实战》、《领域驱动设计精粹》、《Serverless架构:无服务器应用与AWS Lambda》和《云原生安全与DevOps保障》。
使用事件来设计应用似乎是上个世纪八十年代后期的实践。我们可以在前端后端任何地方使用事件。当按钮被按下时,当数据变化时,又或是后端操作执行时。
但事件的准确定义是什么?我们何时该使用它?又该如何使用它?它的缺点又是什么?
何物/何时/何因
和类、组件应该保持相互之间的低耦合与自身内部的高内聚一样。当组件需要协作时,比如组件“A”需要触发组件“B”中的某段逻辑,自然而然的方法就是简单地让组件 A 调用组件 B 的一个对象的方法。然而,如果 A 知道了 B 的存在,那么它们就产生了耦合,即 A 依赖 B,这让系统更难变化和维护。而事件可以用来避免耦合。
而且,由于事件的使用和解耦组件带来的副作用,如果有团队只在组件 B 上工作,他们甚至不用和负责组件 A 的团队商量就可以改变组件 B 对组件 A 中的逻辑的响应。组件可以独立地演进:我们的应用变得更有机(??)了。
即便是在同一个组件中,有时我们也会有一段需要作为操作结果执行的代码,但是不需要在同一次请求/响应回合中立即执行。最明显的例子就是发邮件了。在这种情况下,我们可以立即向用户返回一个响应,并在稍后以异步方式发送电子邮件,从而避免用户等待电子邮件的发送。
然而,这里也有不少坑。如果我们不假思索地使用事件,就会产生风险,最终概念上高度内聚的逻辑流程却使用了事件来串联,而这本该是一种解耦的机制。换句话说,本应放在一起的代码被分开了,脉络很难理清(这和goto语句很像),理解和推断都很难:代码将变成意大利面!
要防止我们的代码库变成一坨意大利面代码,我们应该只在明确识别出来的情况下使用事件。根据我的经验,有以下三种情形需要使用事件:
❉ 解耦组件
当组件 A 执行的逻辑需要触发组件 B 的逻辑时,它会触发一个事件发送给事件派发器,而不是直接调用 B 的逻辑。组件 B 会监听事件派发器中这个特殊的事件,在该事件发生时做出响应。
这意味着 A 和 B 都将依赖派发器和事件,但它们却互不知晓:它们是解耦的。
理想情况下,派发器和事件不应该属于任何组件:
然而,事件却是应用的一部分,但是为了让组件互相无感,它应该不属于任何组件。事件就是 DDD 中称为共享内核的部分。这样一来,两个组件都依赖共享内核但仍然互相无感。
但是在单体应用中,为了方便,将事件放在触发它的组件中也是可以接收的。
共享内核 […] 团队就要共享的领域模型中的子集达成一致,用一条清晰的边界将其标明。保持内核小巧。[…] 这些显式共享的东西有着特殊的状态,没有和其它团队沟通的情况下不应该修改。 Eric Evans 2014, Domain-Driven Design Reference
❉ 执行异步任务
有时候我们有一段想要执行的逻辑,但它可能需要一些时间来执行,而我们又不希望让用户等它执行完成。这种情况下,人们希望将它作为一个异步的工作执行,并立即返回一条消息给用户,通知他他的请求将在稍后异步执行。
例如,在网店上下单可以同步完成,而发送邮件通知用户可以异步完成。
这些情况下,我们能做的是触发一个事件放到队列中,事件将在队列中等待直到有程序在系统有资源的时候能接收并执行它。
这些情形下,相关逻辑是否属于同一个上下文无关紧要,逻辑是解耦的。
❉ 跟踪状态变化(审计日志)
用传统方式保存数据时,我们用实体持有某些数据。当这些实体之中的数据变化时,我们简单地将数据库表中的行更新成新的值。
这里的问题是,我们没有保存是什么发生了变化以及何时发生的变化。
我们可以用一种审计日志的结构保存包含变化的事件。
稍后介绍事件溯源时还有更多详细解释。
监听器 vs. 订阅者
在实现事件驱动架构时常见的争论就是使用事件监听器还是事件订阅者,所以在这里澄清一下我的观点:
模式
Martin Fowler 识别出了三种不同类型的事件模式:
这些模式有着同样的关键概念:
❉ 事件通知
假设我们有一个应用核心,其组件定义清晰。理想情况下,这些组件之间是完全解耦的,但是,它们的某些功能需要执行其它组件中的逻辑。
这是最典型的情况,之前已经描述过:当组件 A 执行的逻辑需要触发组件 B 的逻辑时,它会触发一个事件发送给事件派发器,而不是直接调用 B 的逻辑。组件 B 会监听事件派发器中这个特殊的事件,在该事件发生时做出响应。
有一点要特别指出,这种模式有一个特点,事件只会携带最少的数据。它只会携带足够让监听器能知道发生了什么并能执行它们的代码的数据,通常就只有实体 ID(可以是多个)以及事件发生的日期和时间。
优点:
缺点:
❉ 事件携带的状态转换
我们还是以前面这个有着清晰定义的组件的应用核心为例。这一次,它们有些功能需要其它组件的数据。获取这些数据最顺其自然的方式就是问其它的组件要,但这意味着发起查询的组件将知道被查询的组件的信息:这两个组件耦合在了一起!
另一种分享数据的方式是使用拥有该数据的组件在数据变化时所触发的事件。这些事件会携带完整的新版本数据。对该数据有兴趣的组件会监听这些事件并通过在保存该数据的本地副本来响应它们。这样,当它们需要外部数据时,它们可以在本地找到,就不用向其他组件查询了。
优点:
缺点:
如果两个组件都在同一个进程中执行(这让组件间的通信比较迅速),这种模式可能是不必要的,但即便是这样,为了追求解耦和可维护性或是为了准备好在未来某个时间点将这些组件解耦成微服务,这种模式仍然是有吸引力的。这完全取决于我们现在和未来的需要,以及我们期望/需要多大程度的解耦。
❉ 事件溯源
假设一个实体处于初始状态。作为一个实体,它有自己的身份标识,它是应用要建模的真实世界中的一个特定事物。伴随着它的生命周期,实体数据不断变化,而传统的做法是,将实体的当前状态简单地保存为数据库中一行。
事务日志
上面这种方法大多数情况下都可以工作得很好,但是如果我们想要知道实体是如何到达这个状态的呢(比如,我们想知道银行账号得贷项和借项)?这种方法就做不到了,因为我们知保存了当前状态!
如果使用事件溯源,而不是保存实体状态,我们就能专注于保存实体的状态变化并根据这些变化计算出实体状态。每一次状态变化都是一个事件,保存在事件流中(比如,关系型数据库中的一张表)。当我们需要实体的当前状态,我们将根据事件流中它的全部事件计算出来。
事件存储变成了事实的主要来源,而系统状态完全由之推导而来。对程序员来说,版本管理系统就是最好的例子。所有的提交记录就是事件存储,而源代码树的工作副本就是系统状态。 Greg Young 2010, CQRS Documents
删除
如果有一次状态变化(事件)是错误的,我们不能简单地删除该事件,因为这样做会改变状态变化的历史,会违反整个事件溯源的理念。相反地,我们应该在事件流中创建一个事件,撤销我们想要删除的事件。这个过程被称作逆转事务,它不仅要将实体带回期望的状态,还要留下展示该对象在给定时间点处于该状态的轨迹。
保留数据还会带来架构上的好处。存储系统变成了一个递增的架构,众所周知只能追加的架构并一直更新的架构更容易变成分布式,因为要处理的锁会更少。 Greg Young 2010, CQRS Documents
快照
但是,当我们有太多事件流中的事件时,计算实体状态的代价很大,性能很差。要解决这个问题,每 X 个事件,我们都会在这个节点创建一个实体状态的快照。这样的话,当我们需要实体状态时,我们只用从最后一个快照开始计算。见鬼,我们甚至可以保留一个永远更新的实体快照,鱼与熊掌兼得。
投影
在事件溯源中,我们还有一个概念叫做投影,它是对事件流中给定起始时刻之间的事件的计算。这意味着快照、或者实体的当前状态,都符合投影的定义。但是,投影概念中最有价值的理念是我们可以分析特定时间段内实体的“行为”,让我们对未来作出有根据的猜测(例如,如果在过去五年中,实体在八月的活动都有所增加,那么很能在接下来的八月中也会发生同样的事情),这种能力对公司来说非常有价值。
优点
事件溯源对业务流程和开发流程都很有帮助:
缺点
但也并不是事事顺心,要小心潜在的问题:
因此,我建议谨慎使用,只要有可能,我会遵守以下规则:
而且,当然,和其它模式一样,我们不用在所有地方使用它,我们应该在有效的地方使用它,当它可以为我们带来优势使用它,当它解决的问题比带来的问题更多时使用它。
总结
再一次,这主要关于封装、低耦合和高内聚。
事件可以为可维护性、性能和代码库的扩张带来巨大的好处,但是,要通过事件溯源,它还可以为系统数据提供的可靠性和信息带来巨大的好处。
然而,这是一条布满荆棘的道路,因为概念复杂性和技术复杂性都在增加,滥用其中任何一种都可能带来灾难性的后果。
引用来源