事件溯源和有状态系统的结合应用

本文最初发布于stitcher博客,经原作者授权由InfoQ中文站编译并分享。

首先来看场景概况。这是我们曾经做过的一个比较大的项目。一旦项目完成,它将为数以十万计的用户提供服务,处理大量财务交易,并且需要即时创建独立的租户专属安装。该项目的一个关键需求是需要轻松地报告和追踪产品订购流程的历史记录,这一流程也是业务的核心。

除了这个面向前端的客户流程外,还会有一个复杂的管理面板来管理产品。这里几乎完全不需要报告或追踪管理活动的历史记录。主要目标是提供一个易于使用的产品管理系统。

希望你能理解我故意使用模糊术语的做法,因为这显然不是一个开源项目。不过,我认为“产品管理”和“订单”的概念足以让你了解我们所做的是怎样的设计决策了。

我们首先来讨论这个系统的一种设计方法,这种方法出自我之前写过的《超越CRUD的Laravel》文章系列。

在这样的系统中可能会有两个域组:Product和Order,以及两个同时使用这些域的应用程序:AdminApplicationCustomerApplication

简化版本如下所示:

在之前的项目中,我们成功应用了这一架构,所以这次也可以直接使用它了事。但它也有一些缺点,特别是对于这个新项目而言,缺陷很突出:我们必须牢记,报告和历史记录跟踪是订购流程的关键之处。我们希望能在代码中体现这一点,而不是简单地加个副效果就行。

例如:我们可以使用活动日志包来跟踪订单过程中出现的“历史消息”。我们还能在订单和历史记录表上编写自定义查询以生成报告。

但是,这些解决方案只能成为核心业务的简单的副效果,否则就没法正常工作。可是我们的情况下并没有这个条件。因此,Freek和我的任务就是为这个项目设计一个方案,让报告和历史记录跟踪成为应用程序中易于维护和易于使用的核心部分。

很自然地,我们将目光投向了事件溯源,这是一种可以满足上述要求的优秀而灵活的解决方案。但没有什么是免费的午餐:事件溯源需要编写很多额外代码才能完成其他方法下很简单的事情。在有些地方,你过去只需要简单的CRUD操作处理数据库中的数据即可,可现在你不得不操心事件调度,还要用projector和reactor处理事件,同时还要一直关注版本控制。

很明显,事件溯源系统能解决许多问题;但即使在一些没有任何收益的地方,它也会带来很多开销。

这就是我的意思:如果我们决定事件溯源Orders模块(它依赖于Products模块中的数据),那么我们还需要事件溯源后者,否则的话我们可能会撞上无效状态。如果Products没有事件溯源,并且已被删除,则我们将无法重建Orders状态,因为它缺少信息。

因此,要么我们事件溯源一切内容,要么就设法解决这个矛盾。

需要事件溯源一切内容吗?

过去,在一些业余项目中使用事件溯源的经历让我们痛苦地意识到,我们不应该低估它所增加的复杂性。此外,Greg Young表示事件溯源整个系统往往不是一个好主意——他对人们关于事件溯源的误解有完整的论述,值得一看!

很显然,我们不想事件溯源整个应用程序。这样做根本没有任何意义。唯一的选择是找到一种将有状态系统与事件溯源系统结合在一起的方法,但可惜在这个主题上我们发现没什么资源可用。

不管怎样,我们还是进行了一些费工费力的研究,并设法找到了问题的答案。但这个答案不是来自事件溯源社区,而是来自一种完善的DDD实践:限界上下文。

如果我们希望Products模块成为一个独立的,有状态的系统,则必须清晰地尊重Products和Orders之间的界限。我们不能把这两个模块视为一个单体应用程序,而必须将它们视为两个单独的上下文——也就是单独的服务,两者之间通信时必须确保Order上下文永远不会进入无效状态。

如果构建的Order上下文不直接依赖于Product上下文,那么这个Product上下文是怎么构建的就无关紧要了。

在与Freek讨论时,我提到:将Products视为可通过一个REST API访问的独立服务。当API下线或改动了自己的数据结构时,我们如何保证事件溯源应用程序仍然可以正常工作呢。

显然,我们实际上并不会构建在服务之间通信的API,因为它们将位于同一服务器上的同一代码库中。但在设计系统时有这样的思考还是很好的。

边界是下面这个样子,其中每个服务都有自己的内部设计。

如果你读过了我的《超越CRUD的Laravel》系列文章,那么肯定已经熟悉了Product上下文的工作机制。那边就没什么新东西要讲了。不过Order上下文可以多讲一些背景信息。

事件溯源一部分内容

下面我们看一下事件溯源的部分内容。我假设你之所以会阅读这篇文章,至少是因为你对事件溯源感兴趣,所以我不会详细解释所有内容。

OrderAggregateRoot将跟踪在这一上下文中发生的所有事件,并将成为与应用程序对话的入口点。它还将调度事件,事件被存储并传播到所有reactor和projector。

Reactor将处理副效果,而这些副效果将永远不会重放,并且projector将进行投影。在我们的项目中,这些都是简单的Laravel模型。尽管只能从projector内部写入这些模型,但可以从其他任何上下文中读取它们。

我们在这里做出的一个设计决策是不拆分读写模型,因为现在我们使用了口头和书面约定,要求这些模型只能通过它们的projector写入。这种投影模型的一个例子就是一个Order。

要记住的一条最重要规则是,只能从Order存储的事件中重建Order上下文的整个状态。

那么我们如何从其他上下文中提取数据呢?当与Product相关的上下文中发生某些事情时,如何通知Order上下文?可以肯定的是:与Products有关的所有信息将需要作为事件存储在Order上下文中;因为在这一上下文中,事件是唯一的真实来源。

为了做到这一点,我们引入第三种事件监听器。我们已经有了projectors和reactors;现在我们添加订阅者(subscribers)的概念。允许这些订阅者侦听来自其他上下文的事件,并在其当前上下文中进行相应的处理。看起来,它们几乎总是将外部事件转换为内部存储事件。

从事件存储在Order上下文中的那一刻起,我们就可以放心地忘记对Product上下文的任何依赖。

有些读者可能认为我们会通过在这两个上下文之间复制事件来复制数据。当然,我们将基于Product的created时间存储特定于Orders的事件,因此的确会复制一些数据。但是,这样做带来的好处比你想象的更多。

首先:Product上下文不需要知道其他哪些上下文将使用它的数据。它不必考虑事件版本控制,因为它的事件永远都不会被存储。这样我们就可以在处理Product上下文时将它视为正常的有状态应用程序,无需加入事件溯源,也就避免其复杂性。

第二:会被事件溯源的不只是Order上下文,而且所有这些上下文都能单独侦听Product上下文中触发的相关事件。

第三:我们不必存储原始Product事件的完整副本,因为每个上下文都可以挑选和存储与自己用例相关的数据。

数据迁移问题呢?

一个新问题出现了。

假设这套系统已经投入生产一年之久,我们决定添加一个新的事件溯源上下文;它还需要有关Product上下文的信息。由于上面列出的原因,原始的Product事件没有被存储下来——那么我们如何为新的上下文建立初始状态?

答案是这样的:在部署时,我们必须读取所有产品数据,并根据现有产品将相关事件发送到新添加的上下文中。这种一次性迁移的麻烦是额外的成本,但它让我们可以自由地处理Product上下文,而不必担心外部环境。对于这个项目,这是值得付出的代价。

最终整合

最后,通过使用只读模型,我们就能使用从所有上下文收集的应用程序数据。到目前为止,我们的约定依旧要求这些模型是只读的;而将来可能会改变这个约定。

从应用程序到Product上下文,就像普通的有状态应用程序一样通信即可。应用程序和事件溯源的上下文(例如Orders)之间是通过其聚合根通信的。

下面是最终成品的概述。这张图中还缺少一些箭头,但是上下文和应用程序之间与它们内部的相关流程画的应该足够清楚了。

解决我们问题的关键来自DDD的限界上下文思想。它们描述了我们代码库中的严格界限,我们不能随意跨越这些界限。当然这增加了一层复杂性,但它还让我们能够自由地以期望的方式构建每个上下文,而不必操心支持其他上下文的问题。

最后一个难题是仅依靠事件作为上下文之间的交流手段。它又增加了一层复杂性,但同时也是一种解耦和增加灵活性的方式。

第二部分则是讲述我们如何在一个Laravel项目中编写具体的代码。

英文原文:

Combining event sourcing and stateful systems

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章