事件溯源模式:分离事件的发生和捕获两种不同时间 - verraes


在领域事件中,使用单独的时间戳来区分事件的发生时间和捕获时间。
 
问题
一个领域事件通常有一个时间戳。一个常见的模式是让eventstore在事件被写入时添加时间戳。
例如,可以有一个名为record_at的数据库字段,其值默认为now()。该字段被认为是事件元数据的一部分(而不是特定领域的有效载荷)。该字段可以在消费该事件的用户区代码中访问,并可用于基础设施任务(如按时间顺序排列事件)或特定领域的操作、预测、分析...。

这在很多情况下是没有问题的。对于大多数目的,记录的时间与事件发生的时间相吻合。许多事件是由系统产生的,或者是用户产生的行为的直接后果。即使在用户点击一个按钮和由此产生的事件被持久化之间有一个小的延迟,其差异也往往可以忽略不计。
毕竟,很多业务流程都是以几分钟、几小时、几天、甚至几个月和几个季度为单位进行操作的。

然而,有时我们实际上关心的是其中的差别。假设每天午夜我们都会收到一份包含当天银行交易的报表。我们将每笔交易记录为一个域事件,它的record_at时间戳是在午夜之后。然而,该交易是在前一天的某个地方发生的。如果支付日期对利息的计算、财政利益、法律影响或其他时间敏感方面有影响,这就很重要。

另一个例子是在一个车队管理系统中跟踪车祸。当我们持续记录事件的时候,我们有记录的时间。但车祸是什么时候发生的,什么时候被报告的?
 
解决方案
识别领域事件类型,在这些类型中,事件发生的时间与记录的时间是不同的,并且这种差异是重要的。在领域事件的模式中,添加一个反映事件发生时间的领域特定属性。用它在领域中的用途来命名该属性。不要有一个默认的now()值,而是依靠事件生产者来填写这个属性。

事件的消费者现在可以使用该属性来做出相关的决定。

 
案例代码
在我们的银行对账单示例中,架构可能如下所示:

# (pseudocode, details omitted)
- Event
  - eventId: UUID
  - recorded_at: timestamp
  - type: BankAccountWasCredited
  - payload:
    - amount: number
    - currency: string
    - deposited_at: timestamp  

deposited_at属性表示实际事务时间,它发生在recorded_at持久时间之前。该事件是多时间的,因为它代表时间的两个时刻。

为什么我们不简单地在事务发生的地方将事件注入到事件库中的某个地方呢?这将简化事件本身的设计。
但这其实是个坏主意。
事件存储库是按时间顺序排列的,所以我们必须插入事件并转移所有后面的事件。更重要的是,在事件源中,事件是在它们被持久化后发布的。如果我们在历史的某个地方注入事件,我们就必须告诉所有的消费者,这个特别的事件是不符合顺序的。这给消费者带来了额外的复杂性。
 
良好的习惯
即使对于没有特殊时间戳要求的事件,我也推荐这种技术。这意味着每个事件类型的有效载荷将至少有1个时间戳。

- Event
  - eventId: UUID
  - recorded_at: timestamp
  - type: BankAccountWasCredited
  - payload:
    - amount: number
    - currency: string
    - deposited_at: timestamp
    - statement_received_at: timestamp

请注意,新的 statement_received_at 包含重复的信息,因为时间戳将等于 recorded_at 的时间戳。
在我看来,与这些好处相比,这只是一个很小的代价。
  • 我们现在为该属性起了一个特定领域的名字,这更好地传达了设计的意图。另一个开发者可以理解该属性在域中的含义,而不必猜测他们是否可以使用record_at字段来满足他们的需求。
  • 我们有更好的解耦:消费者只需要担心有效载荷,而不需要依赖元数据。这是有道理的,因为它将基础设施的需求与特定领域的需求解耦。由于不依赖元数据,你可以(在理论上)用不同的供应商替换事件存储,而不影响领域模型。
  • 模型本身的演进也更容易。如果我们依赖于record_at,而后来我们决定需要跟踪发生,我们就需要调整所有消费者的代码。如果我们已经有了特定领域的属性,我们只需要改变生产者。让我们看一个例子。

更多点击标题