事件溯源和读模型生成

35
假设Stack Overflow域问题和以下事件定义:
UserRegistered(UserId, Name, Email)
UserNameChanged(UserId, Name)
QuestionAsked(UserId, QuestionId, Title, Question)

假设事件存储的状态如下(按出现顺序):
1) UserRegistered(1, "John", "john@gmail.com")
2) UserNameChanged(1, "SuperJohn")
3) UserNameChanged(1, "John007")
4) QuestionAsked(1, 1, "Help!", "Please!")

假设以下是用于问题列表的非规范化读取模型(用于SO的第一页):

QuestionItem(UserId, QuestionId, QuestionTitle, Question, UserName)

以下是构建反规范化读模型的事件处理程序:

public class QuestionEventsHandler
{
    public void Handle(QuestionAsked question)
    {
        var item = new QuestionItem(
            question.UserId, 
            question.QuestionId, 
            question.Title, 
            question.Question, 
            ??? /* how should i get name of the user? */);
        ...
    }
}

我的问题是如何找到提问的用户的名称?或者更普遍的说,如果我的反规范化读模型需要额外的数据,而该数据不存在于特定事件中,我应该如何处理事件?
我已经检查了包括Greg Young的SimpleSQRS和Mark Nijhof的Fohjin在内的现有CQRS示例。但是我觉得它们只处理包含在事件中的数据。

3个回答

26

个人认为,从事件处理程序中查找用户名称并没有什么问题。但是,如果您无法从用户的读取模型中查询名称,则应向QuestionEventsHandler引入另一个事件处理程序来处理UserRegistered事件。

这样,QuestionEventsHandler就可以维护自己的用户名称存储库(您不需要存储用户电子邮件)。然后,QuestionAsked处理程序可以直接从自己的存储库查询用户姓名(正如Rinat Abdullin所说,存储便宜!)。

此外,由于QuestionItem读取模型保存了用户的姓名,因此您还需要在QuestionEventsHandler中处理UserNameChanged事件,以确保QuestionItem中的名称字段是最新的。

对我来说,这似乎比“丰富事件”要少费力,并且具有不建立对系统其他部分及其读取模型的依赖的好处。


如果你不小心处理,你的读模型中可能会出现大量重复的数据。 - Narvalex
1
真实的情况是,读取模型通常是非规范化的,因此将始终具有重复数据。 - Chris Moutray
我猜想,与事件相关的问题和用户相关的事件会被分发到不同的主题/流中。例如,当用户首次更改用户名时,将发布UsernameChanged事件,而在他创建问题并发布QuestionCreatedEvent之后,不能保证UsernameChanged事件将首先被处理。因此,QuestionCreatedEvent的处理程序可能会从其用户名的本地存储中读取旧用户名,因此我们会得到错误的读取结果。 - Teimuraz
1
@Teimuraz,我认为您的意思是UserNameChanged可以在QuestionedAsked之后进行处理;我认为QuestionEventsHandler是否维护自己的名称存储库并不重要。有两种事件顺序的情况:1)更改名称,然后提问;2)先提问,然后更改名称;无论哪种情况,如果您关心问题中的名称已过期,则QuestionEventsHandler都需要处理更改名称事件。例如,在提问之后一周或一个月甚至一年后再更改名称...... - Chris Moutray
是的,您说得对。我们可以使用双向方法:当处理QuestionAskedEvent时,我们从本地用户名存储中获取用户名(该存储在UsernameChangedEvent上进行更新)。当我们在本地用户名存储中处理UsernameChanged事件时,如果需要,我们也可以更新问题投影中的用户名。 - Teimuraz

4

只需在事件中添加所有必要的信息。

据我回忆,Greg的方法是,在创建事件时丰富它,并以这种方式存储/发布。


1
是的,我没有看到任何大的缺点。此外,存储成本如今很便宜,丰富的领域事件也有助于以后分析您的系统。例如,我经常在强调IO或CPU的操作中放置大量性能统计信息;这些信息甚至不用于读取模型。但是如果我需要优化性能,我可以使用LINQ查询领域日志以获取操作历史和精确的性能详细信息。 - Rinat Abdullin
11
这个回答建议所有必需的数据都应该在事件中。作为经验法则,我不同意这一点。事件处理程序将创建一个非规范化记录,通常包含聚合并非必要来自单个聚合的聚合和计算字段。例如,可能是“按月份的问题数量”视图模型。这就是CQRS的意义所在;它在持久化时进行这些计算,而不是在查询时进行。要进行这些计算,通常需要查询和处理数据。这些数据超出了聚合的范围,不能通过事件传递。 - David Masters
4
这似乎是一个混乱的解决方案。如果你想要将两个聚合连接起来,那么就要监听两个聚合的事件,或者投影到一个读模型中,由它来为你完成连接。 - Sebastian Good
3
我也不同意,因为一个缺点是你需要修改过去发生的所有事件,以包含额外的数据(在你从一开始就不知道哪些数据在以后可能会用到的情况下)。你肯定不想将所有未来可能感兴趣的信息都包含进去。 - Golo Roden
@SebastianGanslandt 缺点是由于最终一致性,事件处理程序可能会查询尚不存在的信息(在读模型中)。 - Narvalex
显示剩余4条评论

1
从EventStore中提取事件。
请记住 - 您的读模型需要已经具有对EventStore的只读访问权限。读模型是可丢弃的。它们只是缓存视图。您应该能够随时删除/过期您的Read Models,并自动从EventStore重建您的ReadModels。因此,您的ReadModelBuilders必须已经能够查询过去的事件。
public class QuestionEventsHandler
{
    public void Handle(QuestionAsked question)
    {
        // Get Name of User
        var nameChangedEvent = eventRepository.GetLastEventByAggregateId<UserNameChanged>(question.UserId);

        var item = new QuestionItem(
            question.UserId, 
            question.QuestionId, 
            question.Title, 
            question.Question, 

            nameChangedEvent.Name
    }
}

还要意识到 - EventStore 存储库不一定是真正的 EventStore,尽管它当然可以是。分布式系统的优点在于,如果需要,您可以轻松地将 EventStore 复制到更接近 ReadModels 的位置。

我遇到了完全相同的情况......我需要比单个事件中可用的更多数据。对于需要使用初始状态填充新的 ReadModel 的创建类型事件,这一点尤其正确。

从 Read Models 中获取其他数据:您可以从其他 Read Models 中提取数据。但我真的不建议这样做,因为这会引入一个大量依赖关系的泥潭,其中视图依赖于视图依赖于视图。

事件中的附加数据:您真的不想用所有额外的数据来膨胀事件以供视图使用。当您的域发生变化并且您需要迁移事件时,这将对您造成很大的伤害。域事件具有特定的目的-它们代表状态更改。而不是视图数据。

希望这有所帮助 -

Ryan


2
假设您可以访问事件存储库,在我看来处理程序应仅接收事件而不是拉取事件。这在高度分布式系统中可行吗? - Chris Moutray
你的读模型需要对EventStore具有只读访问权限。这对我来说是个新闻。我看不出为什么读取方需要访问事件存储。它只需要在发生事件时被通知即可。就我个人而言,如果处理程序已经存在于读取方,从读取方查询用户名并没有问题。 - David Masters
在我们当前的项目中,我们很可能会有对事件存储的读取访问权限,因为与特定聚合相关的事件流将以 UI、Facebook 时间线样式显示。是的,我们可以使用投影来生成它(如果需要一些去规范化的话,也许我们会这样做),但目前我们发现查询是最直接的方法。 - Dav

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接