什么是事件溯源Event Sourcing?

本文比较全面以易懂方式阐述了什么是事件溯源以及优缺点。

什么是Event Sourcing?
“传统”保存应用程序变化数据的方式是存储当前状态。例如,您的应用程序可能是一个日历,所以您想存储约会。它可能看起来像这样:


约会ID 开始时间 结束时间 标题
1 09:30 10:45 会议
2 11:15 11:30 饭局


这是一张看起来很熟悉的数据表。它可能是关系数据库中的一个表,或一个键值存储中的一组文档,甚至一个存储在内存中的对象列表。重点是,它代表了系统的状态,因为它是现在。让我问你一个问题:这个系统是如何进入这个状态的?

审计日志audit log
要回答这个问题,您可以创建一个审计日志。除了记录创建或更新约会,在审计日志中您还将记录描述所发生的事件(一个“事件”)。对于一个单一的约会,它可能看起来像这样:


Sequence 事件Event
1 约会被创建:
时间:09:30 - 10:30
Title: 开会
2 约会重新被安排: 09:30 - 10:45
3 标题被改变: 饭局

相比如果你只有系统的当前状态,这揭示了一个大量的信息。在约会被创建一次,约会改期一次,它的标题被改变了。10:30是预约结束时间,但它后来被改期,之前的预约信息会消失。

这种信息对应用程序的用户来说是非常有用的,因为他们可以看到发生了什么事,他们应该责怪谁。开发人员也将从中获利最多;当你看到为什么导致某个特定的状态时,寻找问题的原因就变得容易得多了。

单一真理之源
除了你的当前状态以外存在一个审计日志是有用的,但有一个问题:冲突。如果你发现自己在目前的状态A,而你的审计日志说是B,你现在有两个问题。你不仅要诊断手头的问题,而且你也必须找出其中的差异原因。

问题是,你有两个“真相”的来源,陈述你系统的状态应该是什么样的。您的应用程序只能查看到当前状态,所以它没有任何问题。你作为一个开发人员面对两个来源的真相。因为他们冲突,也就都没有真正值得信任,如同一个人戴两只表,如果时间不一样,都无法信任了。

事件溯源
如果我们消除审计日志,我们只有一个真相的来源,但我们失去了所有详细的历史信息,这些信息我们非常重视。如果我们消除了当前的状态呢?

这是事件溯源的本质:它不是存储系统的当前状态,您只存储导致该状态的事件。为了获得当前状态,您可以在内存中“重播”这些事件。

当前状态只是发生了事件的“短暂”表示。它不是恒久不变的。你可以改变你的应用程序的状态,但你不能改变事件。他们已经成为不争的事实。

重播
为了得到您的系统的当前状态,您将必须重播所有的事件。所有的事件?嗯,不是真的都是。您当前的状态通常被分为几个逻辑的“对象”,这将是传统上一个表中的行。如果您唯一标识每个对象,您只需要重播该对象的事件,以获得该对象的状态。

让我们来看看一个非常简单的例子,我们的日历目前的状态。


class Appointment
{
public Guid AppointmentId { get; }
public DateTime StartTime { get; private set; }
public DateTime EndTime { get; private set; }
public string Title { get; private set; }
public bool IsCanceled { get; private set; }
}

事件看上去如下:


class AppointmentCreated
{
public Guid AppointmentId { get; }
public DateTimeOffset StartTime { get; }
public DateTimeOffset EndTime { get; }
public string Title { get; }
}

class AppointmentRescheduled
{
public DateTimeOffset StartTime { get; }
public DateTimeOffset EndTime { get; }
}

class AppointmentRenamed
{
public string Title { get; }
}

class AppointmentCanceled
{
}

AppointmentCanceled 事件没有任何属性。它的类型代表发生了一个有意义的事件。

从约会视角重播这些事件看起来像什么呢?


void ReplayEvent(AppointmentCreated @event)
{
AppointmentId = @event.AppointmentId;
StartTime = @event.StartTime;
EndTime = @event.EndTime;
Title = @event.Title;
}

void ReplayEvent(AppointmentRescheduled @event)
{
StartTime = @event.StartTime;
EndTime = @event.EndTime;
}

void ReplayEvent(AppointmentRenamed @event)
{
Title = @event.Title;
}

void ReplayEvent(AppointmentCanceled @event)
{
IsCanceled = true;
}

你开始一个空白对象。然后为每个发生的事件简单调用ReplayEvent,按照它们发生的顺序。这是所有您需要重新创建当前状态的步骤。

修改状态
因为状态是代表过去发生的事件,修改状态就是通过添加事件实现。获得当前状态是对象的一种职责责任,它方便让所有的业务逻辑放在一个地方,包括业务规则的知识,它是DDD中的哲学。

当前状态的对象(或实体对象或领域对象,不管你怎么称呼它)也会对自己负责一致性;它是唯一的创造事件的角色,也负责从过去的事件重播决定是否允许或禁止某个操作:你的约会已经取消;你就不能再取消了。


class Appointment
{
public Appointment(
Guid id,
DateTimeOffset startTime,
DateTimeOffset endTime,
string title)
{
if(endTime < startTime)
throw new EndTimeBeforeStartTimeException();

AppendEvent(
new AppointmentCreated(id, startTime, endTime, title));
}

public void Reschedule(
DateTimeOffset startTime,
DateTimeOffset endTime)
{
AppendEvent(
new AppointmentRescheduled(startTime, endTime));
}

public void Cancel()
{
if (IsCanceled)
throw new AppointmentAlreadyCanceledException();

AppendEvent(
new AppointmentCanceled());
}
}

AppendEvent 是一个方法,该方法将为指定的唯一对象也就是约会Appointment代表的对象添加一个事件到存储中。

在方法内完成参数的验证。它使得一种方法最终负责数据的有效性和一致性。为简洁起见,Reschedule 不验证参数,但Cancel根据目前的状态检查操作是否允许。

当前,如果您试图取消已被取消的约会,将引发异常。通常不会这样。我们可以跳过检查,并只是存储事件。它不会改变已被取消的事实;当重播时,我们还将设置iscanceled属性为true时。这里的要点是:你不必总是需要维护和基于状态验证。有时它会忽略多余的事件。

注意,所有的构造器和Reschedule或Cancel 的方法都是存储事件。他们并没有直接修改状态。为什么?我们已经有了一个基于事件修改状态的方法:ReplayEvent方法。所以,除了存储事件,AppendEvent也将立即重播的事件。

数据表
事件的规则特点:
1. 事件描述了已经发生的事情,因为我们不能改变过去,他们是不可变的。在他们持久后,事件不能改变。

2.扩展以前的规则:他们不能被删除。

3.每一个事件都包含表示状态变化所需的所有信息,并能够重播它。如果他们可以来自其他事件,他们不应该包括计算值。

4.事件的元数据包含它的类型,当它发生时间

5.您不能查询事件。它们只用于重放一个给定的时间内系统的状态。

对于一个事件源对象,一个更改状态的请求只会导致三个东西中的一个:
1.存储事件;
2.抛出一个例外(因为一个业务规则会被侵犯);
3.什么都不做。

其他的结果,如直接修改对象的状态或进行数据库调用,都是非常令人沮丧的,因为它们是副作用。副作用通常不会被存储的事件表示,这意味着你不能重播它们。也使测试更难。

好处
因为你只需要读取和附加事件,存储是非常容易的。你需要做的唯一的事情是添加一个事件和检索事件列表。你可以存储事件在任何地方:在一个表中,一个键值存储,一个文件目录,一个电子邮件,或几乎任何你可以想象的地方。由于事件是不可变的,他们是非常容易缓存,这使您能够获得优异的性能。

您将不再需要显式的事务,因为您只需要插入数据,而不需要更新或删除它;您也只需要一个表、集合、set,或是您的数据存储组数据。事务就像一把锁,所以不需要事务是一种像无锁代码:没有死锁和性能快得多。

您还得到一个免费的审计日志,您可以在调试时使用,看看到底发生了什么,为什么系统处于一个特定的状态。

如果您确定实体类中的操作是无副作用得,它基本上生活在隔离中,类Class将有一个非常低的耦合度量。你已经从系统的其余部分中分离出了业务逻辑。

如果你已经实现了这些分离,测试你的逻辑可以表示为“指定的这些事件发生了,当这个操作被请求,那么这些事件是否被存储?”或者,“那么这个异常会被抛出吗?”

你不必处理建立在代数上的关系数据库,因为你的对象不是。事件溯源回避了对象关系阻抗匹配。

你的真理之源是一个数据库中的一堆事件。在你存储它们之后,你也可以通过一个消息系统广播到世界各地。它很容易让其他应用程序知道在你的系统中发生了什么。您不再需要到另一个系统存储中查询数据,然后复制以找出发生了什么变化。所有您需要存储的是:一个唯一对象标识符的列表和他们的最新已知的版本号。

缺点
当然,每一个方法的缺点,事件溯源也不例外。最重要的实际缺点是:没有简单的方法来查看您的应用程序的持久状态是什么,你不能通过查询得知。只有当你只能看到运行时发生了什么时。一个解决方案,是使用一个单独的模型读取发生在您的系统中的事件,由此需要CQRS。

事件溯源是一个完全不同于大多数人都习惯的方法。您需要定义可以在您的系统中发生的每一个事件,所以添加新的功能可能会比过去习惯的慢。为了弥补这一差距,你的数据变得更珍贵。

事件溯源听起来像是一个非常低效的机制来存储状态。它比当前状态直接存储需要更多的存储空间。它还需要更多的处理时间,仅仅是因为需要检索更多的数据才能到达当前状态。因为你现在拥有几乎无限量的存储和处理能力,克服这些不足是很容易的。其中之一是使用快照。

快照
你可能会认为,当一个事件流包含数千或数万的事件时,您的系统必须变得缓慢,因为每一个操作需要重播所有这些事件。嗯,不一定。

记住,唯一做回放事件是由于要改变有状态。它应该是幂等;无论你重播事件一次或一百次,它应该有相同的结局。

我们重播以后,比如一万事件,我们可以得到结果状态的快照并存储,以最后一个重播事件为标识。现在,当我们想要当前状态时,我们只需加载快照和回放快照以后任何发生的事件,如果你已经正确创建了你的快照,为了重放所有的事件加载快照和重放新的事件应该是相同。

测试
前面提到过测试变得容易多了。让我们更具体一点,展示如何测试一个事件溯源对象。


private AppointmentCreated CreateAppointmentCreatedEvent()
{
return new AppointmentCreated(
appointmentId: Guid.NewGuid(),
startTime: DateTimeOffset.Now.AddHours(3),
endTime: DateTimeOffset.Now.AddHours(4),
title: "Appointment");
}

private AppointmentRenamed CreateAppointmentRenamedEvent()
{
return new AppointmentRenamed(title:
"Renamed appointment");
}

private Appointment CreateSut(IEnumerable<IEvent> events)
{
var sut = new Appointment();

foreach (var @event in events)
sut.ReplayEvent(@event);

return sut;
}

[Fact]
public void Reschedule_AppendsAppointmentRescheduled()
{
// GIVEN these events have happened
var events = new IEvent[]
{
CreateAppointmentCreatedEvent(),
CreateAppointmentRenamedEvent()
};
var sut = CreateSut(events);

// WHEN we ask to reschedule
var newStartTime = DateTimeOffset.Now.AddHours(5);
var newEndTime = DateTimeOffset.Now.AddHours(6);
sut.Reschedule(newStartTime, newEndTime);

// THEN does the AppointmentRescheduledEvent get published?
sut.AppendedEvents.ShouldAllBeEquivalentTo(
new[]
{
new AppointmentRescheduled(newStartTime, newEndTime)
},
config => config.RespectingRuntimeTypes());
}

请注意,测试可以分为三个逻辑部分:给定的一些状态,什么时候一个动作执行,那么我们可以观察到这种行为吗?这种测试正式的语言为:Cucumber 。

类比
事件溯源是非常强大的思维方式,但它不真的是新的。我们大多数人都是熟悉的系统中都存在它。一些例子:
1.您的银行帐户可能存储使用事件溯源。当前账户余额只是你所做的所有存款和取款的最终结果。

2.版本控制系统。每个提交或更改到文件都是一个事件。如果您重播所有的事件,您将得到源代码的当前状态。

3.大多数大型RDBM关系数据库内部使用事件溯源。简单地说,只有三个事件:插入,更新和删除。RDBMS存储事件在事务日志中并将其运用到数据表操作上。


Event Sourcing: Awesome, powerful & different: