Event Sourcing-事件溯源(捕捉所有更改应用程序状态的事件序列)

又是一篇老马同志的博客,2005年写就,今天读来还有很多启发,原文链接:传送门

翻译如下:

Capture all changes to an application state as a sequence of events.
捕捉所有更改应用程序状态的事件序列。

上面这句话出自 Further Enterprise Application Architecture development ,这篇文章记录了我 2000 以来做过的一些事,然而很可惜的是我被别的事绊住了脚,所以没有精力继续研究,估计短期内也不会有空闲时间。关于这个话题我写了些零碎的草稿,等真正有时间了再进行完善和更新。

我们可以查看当前应用的最新状态,这能解答很多问题。然而有的时候,我们不仅仅想看到最新状态,我们也想知道达到这个状态所经历的过程。

事件溯源是把应用程序的所有变动都保存在一个事件序列。我们不仅可以查询这些事件,还可以通过事件日志重新构建过去任何时候的状态,并自动调整状态来应对追溯过的变化。

如何实现

Event Sourcing 的基本理念是把应用状态的改变都捕获到一个事件对象中,这些事件对象将自己按申请应用程序状态变化的生命周期的顺序保存起来。

举个简单的例子:船运通知。假设有许多船只在公海航行,我们需要知道他们的方位。最简单的办法就是使用跟踪应用程序,记录船只到达或离开港口的时间。

Event Sourcing-事件溯源(捕捉所有更改应用程序状态的事件序列)_第1张图片

在这个案例中,当 service 被调用后,相关船只的位置会被更新,ship 对象会记录该船只的最新位置。

采用事件溯源后这个流程会增加一个步骤。Service 会创建一个事件对象记录所有变动后再更新船只状态。

Event Sourcing-事件溯源(捕捉所有更改应用程序状态的事件序列)_第2张图片

看看整个流程,这一步似乎有些多余。有意思的区别在于当发生一些变化后应用程序中保存起来的东西。让我们想象一下:

  • The Ship ‘King Roy’ departs San Francisco
  • King Roy 离开 San Francisco
  • The Ship ‘Prince Trevor’ arrives at Los Angeles
  • Prince Trevor 到达 Los Angeles
  • The Ship ‘King Roy’ arrives in Hong Kong
  • King Roy 离开 San Francisco
  • Prince Trevor 到达 Los Angeles
  • King Roy 到达 Hong Kong

如果采用以前的service,我们能看到的是船只的最终状态,也就是我们所说的应用程序的状态。

Event Sourcing-事件溯源(捕捉所有更改应用程序状态的事件序列)_第3张图片

使用事件溯源后,每次事件都会被记录下来。如果我们使用的是持久化存储,那么这些事件也会像ship 对象一样被保存起来。这里被持久化存储的两项很有用:应用程序状态和事件日志。

Event Sourcing-事件溯源(捕捉所有更改应用程序状态的事件序列)_第4张图片

使用事件溯源后,最显而易见的成果就是我们掌握了船只所有活动的轨迹。我们不仅能查看船只当前的位置,还能看到它过去的航线。这个成果貌似无足轻重,我们也可以通过 ship 对象存储航线,或把船只的每次变动记录到日志文件里,这些方法都能达到同样的效果。

事件溯源的关键是要确领域对象的所有变化都由事件对象触发。这样基于事件日志可以建立很多机制:

  • 完全重建:可以完全不理会应用现在的状态,因为我们可以在空应用上运行事件日志中的事件来重现应用的当前状态。
  • 时态查询:我们可以查看任何时间点的系统状态,可以通过从空白状态重新运行事件直到特定的时间点或事件来实现。进一步可以使用多个时间线(类似于版本控制中的分支)。
  • 事件回放:如果我们发现过去某个事件有问题,我们可以回到这个事件点,然后重播正确事件和后续事件。(或者直接忽略系统状态,按顺序重放所有事件和正确事件。)类似的技术可以处理错误顺序的事件流,这是异步消息通信的系统常见的问题。

最常见的采用事件溯源的是版本控制系统。这种系统经常会发生时态查询,每当对存储库文件进行 dump 和 restore 时,Subversion 都是使用完全重建来实现。我不认为事件回放适合处理这些信息。 使用事件溯源的企业应用很少,但我也看到过某些应用程序(或部分应用程序)采用这种模式。

应用程序状态存储

事件溯源最简单的实践是从空白状态开始播放事件序列以达到我们预期的应用程序状态。如果事件很多的话,这将是一个很缓慢的过程。

在许多程序中,查看应用程序状态是很常见的。如果是这样话,系统可以保存当前应用的状态,这样当有人想使用事件溯源的特殊功能时会成为一种优势。

应用程序状态可以存储在内存或磁盘上。由于应用程序状态可以从事件日志中推导出来,所以缓存位置不限。当天的工作可以从前一个工作日的快照开始,同时把当前应用状态存储在内存中。如果发生崩溃,重播昨夜存储的事件即可。一天工作结束后,可以创建当前状态的新快照。系统可以在任何时间并行创建新快照,而不会拖慢正在运行的程序。

官方的记录系统可以是事件日志或当前应用程序状态。如果当前应用程序状态被保存在数据库中,那么事件日志可能只是用于审计和其他处理。另外,事件日志可以作为官方记录和在需要时建立的数据库。

事件处理逻辑结构

关于处理事件的逻辑的存放有很多选择。 主流的两种是放在 Transaction Scripts(事务脚本)或 Domain Model(领域模型)中。事务脚本适用于简单逻辑,复杂逻辑使用领域模型更好。

一般来说,通过事件或命令驱动程序的系统使用 Transaction Scripts 的倾向较大。 有些人认为这是一种必然的系统结构化方式。 然而这是一种错觉。

我们需要弄懂这里面涉及的两个职责。Processing domain logic 是处理应用程序的业务逻辑。Processing selection logic 是根据传入的事件选择运行哪个processing domain logic 的选择逻辑,本质上这就是事务脚本方法,但二者也可以分开来,将 processing selection logic 放到事件处理系统中,然后由事件处理系统调用领域模型中包含 processing domain logic的方法。

一旦做出决定,接下来要考虑把 processing selection logic 放在事件对象中还是放在单独的 event processor object 中。由于处理器对象要根据事件的类型运行不同的逻辑,这种类型的 type switch 对好的 OOer(面向对象实体关系模型)来说很糟糕。如果你想把 processing selection logic 放到事件本身也会导致这个问题,因为选择处理条件变成了事件类型。

当然,事情并不总是相同的。当事件对象是一个被一些自动化方法来序列化和反序列化的 DTO 时,单独处理器还是很有用处的,这些方法会禁止将代码放入事件中。在这种情况下,你需要找到事件的选择逻辑。如果可能的话尽量避免这种情况发生,如果做不到,就将 DTO 看作是事件的隐藏数据持有者,将事件看做一个常规的多态对象。 在这种情况下,可以使用配置文件或(更好的)命名约定,将序列化事件 DTO 与实际事件进行匹配。

如果不需要反转事件,那么域模型完全可以忽略事件日志。 反向逻辑会让这个过程更复杂,因为域模型需要存储和检索之前的状态,这样才能方便查看事件日志。

事件反转

事件除了能自己回退,还可以自己反转。

当结果有变化时,反转可以最直观地展现出变化过程。 举个例子:“向 Martin 的账户增加 10 美元”,而不是“将 Martin 的账户余额设置为 110 美元”。在前一种情况下,我们可以通过减去 10 美元来推算账户以前的余额,但后一种情况却没有足够的信息。

如果事件不遵循上述第一种方法,那么输入事件应确保存储了反转所需的一切条件。你可以通过存储任何改变的值的原来值,或通过计算事件的差异并存储来实现这一点。

当处理逻辑在域模型内时,上述的存储有显着的成果,因为域模型内部状态的改变对外部事件对象的处理是不可见的。在这种情况下,最好对域模型进行改造,让其关注事件,并能够使用事件存储先前的值。

顺便提醒一下,事件反转的所有功能都可以通过还原到过去的快照并重放事件流来实现。因此,反转功能不是必需的。然而,事件反转有时对效率也会产生很大影响,毕竟反转几个事件比在一堆事件上使用快放更有效。

外部更新

事件溯源所面临的一个棘手的问题是如何实现与不使用事件溯源的外部系统(大多数都没使用)的交互。当你将修饰后的信息发送给外部系统,或从其他系统接收查询信息时就会遇到问题。

事件溯源最大的优点就是可以随意重放事件,但是如果这些事件将更新消息发送到外部系统,就可能出错,因为这些外部系统不知道如何区分实际需要处理的信息和重播产生的信息。

若要处理这个问题,就需要使用网关对外部系统进行封装。这个方法很好实现,因为在任何情况下这都是一个很好的方案。网关需要稍微复杂一些,以便能处理事件溯源正在进行的重放过程。

对重建和时态查询来说,通常的做法是在重放期间禁用网关。如果你想用一种对域逻辑不可见的方式来做到这一点,可以让域逻辑调用 PaymentGateway.send 方法,这样无论你是否处于重放模式都没有关系。在将外部调用向外传达之前,网关通过持有事件处理器的引用来检查是否处于重放模式,然后做出不同反应。

对外部系统来说还有另一种策略:按时间缓冲外部通知。有时我们可能不需要立即发出外部通知,而是在月底发出即可。在这种情况下,我们可以自由地进行操作直到发布日期到来。我们可以通过网关来存储外部消息,到发布日期再进行发布;或通过一个通知域事件触发外部消息,而不是立即发出通知。

外部查询

外部查询面临的问题是:它们返回的数据对处理事件的结果有影响。如果我想知道 12 月 5 日的汇率,然后在 12 月 20 日进行查询,显示结果是 20 日的汇率,而不是我需要的 5 日的。

外部系统可以通过增加日期选项的方法提供过去的数据,如果是这样,并且我们也认为它很可靠,那么我们可以使用它来确保对历史数据查询的一致性。如果我们使用了事件协作(Event Collaboration)的话,则必须保留所有更改的历史。

如果上面这些简单的方法不奏效,那么我们不得不做一些其他的努力。一种方法是设计外部系统的网关,让网关记住对查询的响应以便在重放期间应用这些响应。更进一步,网关需要记住所有外部查询的响应。 如果外部数据变化缓慢,那么它只需记住数据变化引起的变化

外部交互

对外部系统的查询和更新都会增加事件溯源的复杂性。如果这两者之间发生交互,情况就更糟糕了。比如说,一个外部调用不仅返回一个结果(查询),还引起了外部系统的状态改变,例如提交一个配送单并返回这个配送单的配送信息。

代码变动

那我们是不是可以假设:应用程序状态在处理事件前后保持不变。很显然不会是这样。 事件处理对数据的更改,那代码呢?

这里可能会有三种类型的代码变化:新特性,bug修复和时序逻辑。

新特性就是为系统添加新功能但不会对之前发生的事情产生影响。你可以随时添加新功能,如果你想把新功能应用到旧的时间上,只需要重新处理事件新的结果就会产生。

通常情况下,当使用新功能进行重新处理时通常需要关闭外部网关。当然也有例外,如果新功能涉及这些网关就无法避免了。当这样的情况发生时,你需要对旧事件的第一次重新处理使用一些特殊手段。这将会非常混乱,但你不得这样做。

当你查看过去的处理过程并发现错误时,就需要修复 bug 了。系统内部的 bug 是很容易解决的,你需要做的是修复问题然后重新处理下后续事件即可,应用程序固定为应有的状态。在许多情况下这是很好的解决过程。

然而,外部网关的存在把过程复杂化。本质上,网关需要追踪 bug 被修复前后的区别。这个过程类似于Retroactive Events。实际上,如果再处理应用次数较多,使用追溯事件机制(用 event 本身替换 event)是有值得的,但是你需要确保无论是正确事件还是 bug 事件都可以被正确处理。

第三种情况是:逻辑本身随时间变化而变化。例如逻辑规则是 11 月 18 日之前收取 10 美元,之后则收取 15 美元。这种规则实际上需要被引入到领域模型里,领域模型应该能够在任何时间使用正确的规则处理事件。你可以通过条件逻辑来做到这一点,但如果同时存在太多临时逻辑就会变得很麻烦。较好的解决方法是将策略对象绑定一个临时属性(Temporal Property),类似chargRules.get(aDate).process(anEvent)
。这种风格的代码请参考 Agreement Dispatcher

当需要用带有 bug 的代码处理旧事件时,处理 bug 和时序逻辑的工作之间可能存在潜在的重叠。 这可能导致双时态行为:根据 8 月 1 日的规则来反转 10 月 1 日的事件,然后根据现有的 8 月 1 日的规则来替换它。很显然这十分混乱,除非没有办法,否则尽量不要采取这种方式

上述有些问题可以通过将代码放进数据中来处理,一种方法是使用通过配置对象指出处理过程的自适应对象模型(Adaptive Object Models),另一种方法是将不需要编译就可以直接执行的语言脚本嵌入到数据中-例如将 JRuby 嵌入到 Java 应用程序中。然而这样做就需要保持对底层恰当的配置控制。 我更倾向于使用与其他更新相同的方式处理-通过事件来处理逻辑脚本的任何变动。 (虽然到目前为止,我都只是观察和推测而已)。

Events and Accounts

在某些财务系统中我见过一些 Event Sourcing(及其衍生模式)的优秀案例。它们在需求(审计对于财务系统非常重要)和实现间具有非常好的协同作用。其中最主要的原因是,一个领域事件(Domain Event)的所有财务结果都可以被指派去创建财务条款(Accounting Entrys)并将这些与原始事件连接起来。这为追踪更改、事务反转等提供了非常好的基础。最主要的是,它简化了各种各样的解决问题的技术。

实际上,考虑账户的一种方式是将财务条款(Accounting Entrys)看作是记录所有改变账户数值的事件的日志,所以账户(Account)本身就是事件溯源的一个例子。

什么情况下适用

将应用程序的每个变动都封装到事件保存下来,并不是每个人都能接受的风格,而且大多数人都认为这样很别扭。 因此,这不是一个自然而然的选择,使用它意味着你希望得到某种形式的返回。

一个明显的返回形式是它能很容易地将事件序列化成一个审计日志(Audit Log)。这样的审计跟踪除了对审计有用之外还有其他用途。我跟一个人聊过,他说他们的在线账户曾经出过问题,只好拨打电话寻求帮助。令他印象深刻的是,解决问题的人不仅清楚他们所有的活动,还知道如何解决问题。想要具备这种能力意味着将审计线索提供给支持部门,以便他们了解用户与系统的交所有交互。事件溯源能很好地提供这个功能,当然你还可以使用其他的日志机制,这样就不需要使用奇怪的接口。

这种完整审计日志(Audit Log)的另一个用途是协助调试。 当然,使用日志文件调试产品问题是老把戏了。但事件溯源能做到的更多,它可以提供一个测试环境并在测试环境中对事件重放来让你查看整个过程,在这个过程中你可以停止、后退和回放,就像在调试器中进行测试一样。这对产品升级之前的并发测试是很有用的。你可以在测试系统中回放特定的事件来检测是否达到了预期。

事件溯源是并行模型(Parallel Models)和事件追溯(Retroactive Events)的基础。如果你想使用两者中任何一种模式,首先都要使用事件溯源。的确,将这些模式应用到不是以事件溯源为基础的系统是很困难的。 因此,如果你认为系统在将来可能会用到这些模式,现在开始构建事件溯源绝对是明智的。千万不要将这个决定留给以后再进行重构。

事件溯源为整体架构提供了一些可能性,尤其是你正在寻找一些扩展性强的东西。现在人们对事件驱动架构(event-driven architecture)的兴趣日渐浓厚,这个术语涵盖了很多理念,但大多数都是以通过事件消息进行的系统之间的通信为中心。这种系统可以以一种松耦合的并行方式进行操作,这为系统故障提供了很好的水平可扩展性和弹性。

以一个有大量读者和少量作者的系统为例,使用事件溯源,系统可以被部署为具有内存数据库的系统集群,集群之间通过事件流进行更新。需要进行更新时,更新事件可以传递给一个主系统(或围绕单个数据库或消息队列的紧密的服务器集群),这个系统把更新应用到记录系统中,然后将产生的事件广播给更广泛的读者集群。如果记录系统把应用程序状态记录到数据库中,这将是一个非常吸引人的结构。如果记录系统记入事件日志,会有很多高性能的方法,因为事件日志只是一个需要最少锁定的附加结构。

当然,这样的架构并不是完美无缺的。由于与事件传播在时间上的差异,读者系统容易和主机(或彼此)失去同步。然而对于这种粗陋架构的使用,我听到的几乎是一面倒的好评。

这样使用事件流,新应用可以通过开发事件流并移植自己的模型来被轻松添加,这对所有系统来说不需要是相同的。这是一种非常适合与通信方法集成的方法。

译者按

后面有些代码示例,我就不翻译了,大家自己看原文吧。

你可能感兴趣的:(架构,Event,Sourcing,技术架构)