事件溯源:在投影中去标准化关系

7
我正在研究CQRS/ES架构。我们将多个异步投影并行运行到读取存储中,因为某些投影可能比其他投影慢得多,我们希望对于更快的投影与写入端保持更同步。
我试图了解如何生成读取模型的方法以及这可能涉及多少数据重复的问题。以订单和订单项为例,一个订单可以有多个订单项,每个订单项都有一个名称。订单和订单项是独立的聚合。
我可以尝试以更标准化的方式保存读取模型,其中为每个订单和订单项创建一个实体或文档,然后进行引用 - 或者我可能希望以更非标准化的方式保存它,其中包含订单并包含其订单项。 标准化
{
  Id: Order1,
  Items: [Item1, Item2]
}

{
  Id: Item1,
  Name: "Foosaver 9000"
}

{
  Id: Item2,
  Name: "Foosaver 7500"
}

使用更规范化的格式可以让单个投影处理影响/影响物品和订单并更新相应对象的事件。这也意味着任何对物品名称的更改都会影响所有订单。例如,客户可能会收到与相应发票所列商品不同的交货单(因此显然该模型可能不够好,会导致与非规范化相同的问题...)。 非规范化
{
  Id: Order1,
  Items: [
    {Id: Item1, Name: "Foosaver 9000"},
    {Id: Item2, Name: "Foosaver 7500"},
  ]
}

但是,反规范化需要一些源代码,可以查找当前相关数据,例如项目。这意味着我要么必须传输我可能需要的所有信息,要么必须跟踪为我的非规范化提取的数据。这也意味着我可能需要针对每个投影执行一次此操作 - 例如,我可能需要一个非规范化的ItemForOrder以及一个非规范化的ItemForSomethingElse - 它们只包含每个非规范化实体或文档所需的最少属性(无论何时创建或修改它们)。
如果我在读取存储中共享相同的Item,则可能会混合来自不同时间点的项定义,因为项目和订单的投影可能不以相同的速度运行。在最坏的情况下,项目的投影可能尚未创建我需要用于其属性的项目。
通常,在处理事件流的关系时,我有哪些方法?
更新2016-06-17
目前,我通过对非规范化读取模型及其相关数据运行单个投影来解决此问题。如果我有多个读取模型必须共享相同的相关数据,则可以将它们放入同一个投影中,以避免重复需要进行查找的相同相关数据。
这些相关模型甚至可能有点规范化,针对我必须访问它们进行优化。我的投影是唯一读取和写入它们的东西,所以我知道它们被如何读取。
// related data 
public class Item 
{
  public Guid Id {get; set;}
  public string Name {get; set;}
  /* and whatever else is needed but not provided by events */
}

// denormalised info for document
public class ItemInfo 
{
  public Guid Id {get; set;}
  public string Name {get; set;}
}

// denormalised data as document
public class ItemStockLevel
{
  public ItemInfo Item {get; set;} // when this is a document
  public decimal Quantity {get; set;}
}

// or for RDBMS
public class ItemStockLevel
{
  public Guid ItemId {get; set;}
  public string ItemName {get; set;}
  public decimal Quantity {get; set;}
}

然而,这里更隐晦的问题是何时更新相关数据。这在很大程度上取决于业务流程。例如,在订单下达后,我不想更改订单的商品描述。当投影处理事件时,我只能根据业务流程更新已更改的数据。因此,可以认为将此信息放入事件中(并使用客户端发送的数据?)。如果我们发现以后需要其他数据,则可能必须返回到从事件流中投影相关数据并从中读取的方式...对于纯CQRS架构来说,这可能看作是类似的问题:什么时候更新文档中的非规范化数据?什么时候在向用户呈现数据之前刷新数据?同样,业务流程可能驱动这个决定。
2个回答

1

首先,我认为你在关于生命周期的聚合方面需要小心。在通常的购物车领域中,购物车(订单)的生命周期跨越了商品的生命周期。Udi Dahan写了不要创建聚合根,我发现这意味着聚合保留对“创建”它们的聚合的引用,而不是相反。

因此,我期望事件历史记录应该如下所示:

// Assuming Orders come from Customers
OrderCreated(orderId: Order1, customerId: Customer1)

ItemAdded(itemId: Item1, orderId: Order1, Name:"Foosaver 9000")

ItemAdded(itemId: Item2, orderId: Order1, Name:"Foosaver 7500")

现在,依然不能保证顺序的问题 - 这将取决于聚合在写模型中的设计方式,以及事件存储是否在不同历史记录之间线性化事件等因素。
请注意,在规范化的视图中,您可以由订单转到商品,但反过来却不行。执行我所描述的事件处理会给您带来相同的限制:您不再有具有神秘商品的订单,而是具有神秘订单的商品。任何寻找订单的人都可能看不到它,只看到空白或者看到一些商品数量,并且可以从这些商品链接到关键存储。
您的键值存储中的规范化表单不需要与示例中不同;负责编写订单规范化表单的投影需要足够聪明,以便也可以观察商品流,但一切都很好。
(另请注意:我们在这里省略了ItemRemoved)
那样做没问题,但忽略了读取频率比写入频率高的想法。 对于热查询,您将希望可用的非规范化形式:存储中的数据是要发送响应查询的DTO。 例如,如果查询支持订单报告(不允许编辑),则您也不需要发送商品ID。
{
    Title: "Your order #{Order1}",
    Items: [
        {Name: "Foosaver 9000"},
        {Name: "Foosaver 7500"}
    ]
}

您可以考虑跟踪相关聚合的版本,这样当用户从一个视图导航到另一个视图时,查询会等待新的投影追上,而不是获取过时的投影。

例如,如果您的DTO是超媒体,则可能如下所示

{
    Title: "Your order #{Order1}",
    refreshUrl: /orders/Order1?atLeastVersion=20,
    Items: [
        {Name: "Foosaver 9000", detailsUrl: /items/Item1?atLeastVersion=7},
        {Name: "Foosaver 7500", detailsUrl: /items/Item2?atLeastVersion=9}
    ]
}

因此,这将归结为在事件中拥有我可能需要的所有描述等内容 - 这让我犹豫不决,因为我可能不知道以后可能需要使用的所有字段,而且它再次紧密地耦合了读取和写入方面。 - urbanhusky
为了重新获取数据,我在我的聚合上拥有所有必要的属性 - 但是投影需要来自多个聚合的数据。项目(或者更确切地说是文章)是单独的聚合,因为它们不仅存在于单个订单的上下文中(多个订单可能是同一篇文章),所以我要么在我的读取存储中跟踪它们(带有前面提到的并发/存储问题),要么找到其他方法来获取我需要的反规范化投影数据。 - urbanhusky
是的,生成器会监听它们需要的所有事件。以事件流 [ArticleDefined(article1, "Foosaver 9000"), OrderCreated(order1, customer1), ItemAdded(order1, article1), ArticleRebranded(article1, "Supersaver 9000"] 为例 - 订单投影需要了解订单中的文章以将其反规范化到订单中。我想在读取端需要一个 ArticleRepository,在处理 ItemAdded 事件时查询文章的名称和其他细节。我的担忧是我要么需要每个生成器一个存储库,要么就会出现不一致... - urbanhusky
如果您知道所需的聚合和版本,建议在构建投影时从事件存储中重新加载它们。 - VoiceOfUnreason
那会使领域模型与读取模型耦合得太紧,并且有点违背了使用独立的读写模型实现CQRS(命令查询职责分离)的原则。这也会使投影变得极其缓慢,而且非常不一致(不仅是最终一致性,还可能存在实际错误数据,因为写入位置可能与读取完全不同)。 - urbanhusky
显示剩余4条评论

0

我也遇到了这个问题,尝试了不同的方法。我阅读了这个建议,虽然我还没有尝试过,但我认为这可能是最好的方法。在发布事件之前,只需丰富一下它们。


我确实同意事件应该包含描述它所需的所有信息。这可能对于所有情况都不足够,但那应该是个例外。有些读模型投影可能需要保留本地状态以进一步丰富读模型 - 你必须小心本地状态与读模型同步更新(即在位置X处的读模型看到位置X处的状态)。然而,当事件存储具有全局排序时,这只能起作用。 - urbanhusky

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