事件溯源:投影或投射模式 -Kacper Gunia


投影是事件源中使用的核心模式之一。ES所了解的是,作为一系列事件将应用程序中正在发生的更改持久化。然后,该事件序列(也称为流)可用于重建当前状态,以便可以处理任何后续请求。
从理论上讲,我们可以仅在事件流中停下来做所有事情。不幸的是,这很快变得非常低效。通常,读取(查询)发生的次数比写入(命令)发生的次数多。
如果我们查看传统的银行业务示例,则可以查询帐户中曾经发生的所有交易,然后从更改历史记录中得出当前余额。不幸的是,读取成百上千个事件将意味着我们将花费大量时间进行IO,然后还要花费一些时间来计算当前余额。
相反,如果我们可以预先计算当前余额并将值存储在某个地方,则可以更快地回答该查询。您可以将其视为实例化视图或缓存的一种形式。使用这种方法带来了CQRS(命令查询责任隔离)模式,该模式围绕以下思想定义:您可以使用两个不同的模型来读取(读取模型)和写入(写入模型)信息。

投影的定义
投影定义:从事件流中得出的当前状态是什么?
正如格雷格·扬(Greg Young)解释的那样,投影不过是事件序列的left-fold,这是表达定义的一种有效方式。在Scala中,foldLeft是一个高阶函数,其定义(简化)为:

trait Traversable[+A] {
    def foldLeft[B](z: B)(op: (B, A) => B): B
}

对于指定的事件A的traversable,left-fold需要一个初始状态B和一个函数,用来使用事件A运算op该更新当前状态B,**然后返回一个新的状态B **。op运算函数将在从左到右遍历的每个元素上被调用,并且更新的状态将被传递。

(原文以scala为案例,Java案例见这里)
这是一个账户进出事件集合类,计算账户余额:

/**
     * Calculates the balance by summing up the values of all activities within this window.
     */

    public Money calculateBalance(AccountId accountId) {
        Money depositBalance = activities.stream()
                .filter(a -> a.getTargetAccountId().equals(accountId))
                .map(Activity::getMoney)
                .reduce(Money.ZERO, Money::add);

        Money withdrawalBalance = activities.stream()
                .filter(a -> a.getSourceAccountId().equals(accountId))
                .map(Activity::getMoney)
                .reduce(Money.ZERO, Money::add);

        return Money.add(depositBalance, withdrawalBalance.negate());
    }

使用Java stream实现最新余额状态计算。

投射持久化

1. 投射结果保存在记忆中
维护投影状态的最简单方法是在服务启动时读取事件的整个流,然后将当前状态保存在内存中。任何获取当前状态的查询都将尽快得到答复。当事件数量很少(因此可以快速重播)并且可以承受该服务在事件失败时可以重播事件的位置时,此方法非常适合原型制作。

2. 投射结果保存SQL数据库中
一种传统的存储投影状态的方式,可能是最常用的一种方式。处理事件后,我们将最新状态存储在表中,然后在需要时可以在接收到任何后续事件时对其进行查询或更新。
用于构建投影并将其存储在SQL存储中的一种启发式方法是,应将其设计为回答我们要询问的问题。如果以这种方式实现,那么任何获取状态的查询都可以非常快速地得到答复。当涉及联接或聚合查询时,可能表明当前的投影设计不是最佳的,或者它在多个用例中使用。
需要注意的陷阱之一是投影之间的依赖性。如果两个不同的投影相互依赖,那么以后需要时就很难重建它们。不幸的是,如果使用传统的SQL存储,此陷阱通常很容易掉下来,因为联接非常容易执行。可能会很想尝试由其他投影创建的表以获取当前在主投影中不可用的数据。这种方法的主要问题是,每个预测都有其自己的生命周期,并且传播到其中一个的更改可能还没有到达另一个。然后,这可能导致比赛条件,错误或其他难以解释的行为。
使用SQL构成的另一个挑战是诱惑为其实体建立第三范式模型。这通常是由于需要能够执行系统将来可能需要回答的即席查询。相反,我们应该集中精力了解什么是我们需要支持的实际用例,然后建立一组有助于我们实现这些目标的预测。

3. 投射结果在NoSQL
市场上NoSQL解决方案数量的增加极大地提高了开发人员的意识,即没有“一刀切”的解决方案来解决查询问题。将事件流投射到任何后端的能力使我们能够改善用户体验并简化应用程序代码。一个示例可以是将投影状态存储在搜索引擎,时间序列数据库或分布式键值存储中,这些存储将能够支持查询数据的最佳方式。

4. 投射结果在文件
文件系统是另一种选择,尤其是任何云对象存储都可以被视为投影数据的存储库。可能的示例之一是将数据存储为json或xml文件,以便应用程序客户端可以直接使用它们。

保持最新状态
写入状态存储后,需要更新和存储投影的状态-它可以通过两种方式发生:

  • 同步 -与写入事件流相同的事务中。这种方法通常非常受限制,因为它假定事件与投影数据存储在同一数据库中。它还不容易扩展,并且存在其他操作问题(例如可能无法实现或难以同步的重播)。从好的方面来说,它降低了操作复杂性,并且还允许假定投影状态被立即更新。根据经验,我们可以说这种方法对于原型设计很有用,但对于系统的生产版本却没什么用。
  • 异步 -将事件写入事件存储后,事件会传递到投影。根据可用的基础结构和扩展需求,这可以以基于推或拉的方式发生。由于更新是异步的,因此我们将不得不处理由投影存储的最终数据一致性以及交付保证。从好的方面来看,现在可以将预测与主要事务写入分离,并且可以根据需要进行独立缩放,重放和监视。

在现实生活中,投影实现往往包含两个部分:

  • 一个库,允许查找或存储状态
  • 一个投影器:也就是事件处理程序,它知道如何更新或创建的状态。