CQRS + ES中的对象应该在哪里进行完全初始化:在构造函数中,还是在应用第一个事件时?


9

在OOP社区中似乎已经达成广泛的共识,即类构造函数不应将对象部分或什至完全未初始化。

我所说的“初始化”是什么意思?粗略地讲,原子过程将新创建的对象带入其所有类不变式都成立的状态。它应该是发生在对象上的第一件事(每个对象只能运行一次),并且任何内容都不应该拥有未初始化的对象。(因此,经常建议在类构造函数中执行对象初始化。出于同样的原因,Initialize方法常常被皱眉,因为这些方法打破了原子性并使得可以持有和使用尚未使用的对象成为可能。处于定义良好的状态。)

问题:当CQRS与事件源(CQRS + ES)结合使用时,对象的所有状态变化都被捕获在一系列有序的事件(事件流)中,我想知道对象何时真正达到完全初始化的状态:在类构造函数的末尾,还是在将第一个事件应用于对象之后?

注意:我避免使用“聚合根”一词。如果愿意,在阅读“对象”时将其替换。

讨论示例:假定每个对象由某个不透明Id值唯一标识(请考虑GUID)。可以使用相同的Id值在事件存储中标识表示该对象状态变化的事件流:(不用担心正确的事件顺序。)

interface IEventStore
{
    IEnumerable<IEvent> GetEventsOfObject(Id objectId); 
}

进一步假设有两种对象类型CustomerShoppingCart。让我们关注ShoppingCart:创建时,购物车为空,必须与一个客户完全关联。最后一点是类不变式:ShoppingCart与a无关的对象Customer处于无效状态。

在传统的OOP中,可以在构造函数中对此建模:

partial class ShoppingCart
{
    public Id Id { get; private set; }
    public Customer Customer { get; private set; }

    public ShoppingCart(Id id, Customer customer)
    {
        this.Id = id;
        this.Customer = customer;
    }
}

但是,我不知如何在CQRS + ES中对此建模而不用延迟初始化结束。由于这种简单的初始化实际上是状态更改,因此是否不必将其建模为事件?:

partial class CreatedEmptyShoppingCart
{
    public ShoppingCartId { get; private set; }
    public CustomerId { get; private set; }
}
// Note: `ShoppingCartId` is not actually required, since that Id must be
// known in advance in order to fetch the event stream from the event store.

显然,这必须是任何ShoppingCart对象的事件流中的第一个事件,并且只有将事件应用到该对象后,该对象才会被初始化。

因此,如果初始化成为事件流“回放”的一部分(这是一个非常通用的过程,可能对于一个Customer对象或一个ShoppingCart对象,或与此相关的任何其他对象类型,都可以相同)…

  • 构造函数是否应该没有参数并且什么都不做,而将所有工作留给某种void Apply(CreatedEmptyShoppingCart)方法(这与皱着眉头大同小异Initialize())?
  • 还是构造函数应该接收事件流并进行回放(这使初始化再次成为原子,但是意味着每个类的构造函数都包含相同的通用“回放并应用”逻辑,即不需要的代码重复)?
  • 还是应该同时有一个传统的OOP构造函数(如上所示)正确地初始化该对象,然后除第一个事件外的所有事件都void Apply(…)对其进行处理?

我不希望答案能够提供一个完全正常的演示实现;如果有人可以解释我的推理存在缺陷的地方,或者大多数CQRS + ES实现中对象初始化确实一个“痛点”,我已经非常高兴。

Answers:


3

在执行CQRS + ES时,我宁愿完全没有公共构造函数。创建我的聚合根应该通过工厂(对于这样的足够简单的构造)或构建器(对于更复杂的聚合根)来完成。

然后如何实际初始化对象是实现细节。OOP“不使用初始化”建议对公共接口是不正确的。您不应期望使用您的代码的任何人都知道他们必须调用SecretInitializeMethod42(bool,int,string)-这是错误的公共API设计。但是,如果您的类没有提供任何公共构造函数,而是有一个ShoppingCartFactory及其CreateNewShoppingCart(string)方法,则该工厂的实现很可能会隐藏您的用户不需要知道的任何初始化/构造函数魔术关于(因此提供了一个不错的公共API,但允许您在后台进行更多高级对象创建)。

人们认为工厂太多,使工厂表现不佳,但如果使用得当,它们可以在易于理解的良好公共API后面隐藏很多复杂性。不要害怕使用它们,它们是一个功能强大的工具,可以帮助您简化复杂的对象构建过程-只要您可以使用更多的代码行即可。

看谁能用最少的代码行解决问题并不是一场竞赛-然而,关于谁能做出最好的公共API却是一场持续的竞赛!;)

编辑:添加一些示例,以了解如何应用这些模式

如果您只有一个具有几个必需参数的“简单”聚合构造函数,则可以使用一个非常基本的工厂实现,沿着这些思路

public class FooAggregate {
     internal FooAggregate() { }

     public int A { get; private set; }
     public int B { get; private set; }

     internal Handle(FooCreatedEvent evt) {
         this.A = a;
         this.B = b;
     }
}

public class FooFactory {
    public FooAggregate Create(int a, int b) {
        var evt = new FooCreatedEvent(a, b);
        var result = new FooAggregate();
        result.Handle(evt);
        DomainEvents.Register(result, evt);
        return result;
    }
}

当然,在这种情况下,如何划分创建FooCreatedEvent的方法完全取决于您。也可以考虑使用FooAggregate(FooCreatedEvent)构造函数,或者使用创建事件的FooAggregate(int,int)构造函数。在这里,您如何选择划分职责完全取决于您认为最干净的事情以及如何实施域事件注册。我经常选择由工厂创建事件-但是这取决于您,因为事件创建现在是内部实现细节,您可以随时更改和重构,而无需更改外部接口。这里的一个重要细节是聚合没有公共构造函数,并且所有的设置器都是私有的。您不希望任何人在外部使用它们。

当您或多或少地替换构造函数时,此模式很好用,但是如果您具有更高级的对象构造,则使用起来可能会变得过于复杂。在这种情况下,我通常会放弃工厂模式,而转而使用构建器模式-通常使用更流利的语法。

由于构建的类不是很复杂,因此此示例有点强制,但是您可以希望理解这个主意,并了解它如何减轻更复杂的构建任务

public class FooBuilder {
    private int a;
    private int b;   

    public FooBuilder WithA(int a) {
         this.a = a;
         return this;
    }

    public FooBuilder WithB(int b) {
         this.b = b;
         return this;
    }

    public FooAggregate Build() {
         if(!someChecksThatWeHaveAllState()) {
              throw new OmgException();
         }

         // Some hairy logic on how to create a FooAggregate and the creation events from our state
         var foo = new FooAggregate(....);
         foo.PlentyOfHairyInitialization(...);
         DomainEvents.Register(....);

         return foo;
    }
}

然后像

var foo = new FooBuilder().WithA(1).Build();

当然,通常,当我转向构建器模式时,它不仅是两个整数,它可能包含一些值对象的列表或某些其他可能有些毛茸茸的东西的字典。但是,如果有很多可选参数组合,它也很有用。

采取这种方式的重要要点是:

  • 您的主要目标是能够抽象化对象的构造,以便外部用户不必了解您的事件系统。
  • 在哪里或谁注册创建事件并不重要,重要的是注册事件,并且您可以保证-除此以外,它是内部实现细节。做最适合您的代码的事情,不要遵循我的示例,因为这是某种“正确的方式”。
  • 如果愿意,通过这种方式,您可以让工厂/存储库返回接口而不是具体的类-使它们更易于模拟以进行单元测试!
  • 有时,这是很多额外的代码,这使许多人回避它。但是,与替代方法相比,它通常是非常容易的代码,并且在您迟早需要进行更改时,确实可以提供价值。埃里克·埃文斯(Eric Evans)在其DDD书中将工厂/存储库视为DDD的重要部分是有原因的-它们是必要的抽象,不会将某些实现细节泄漏给用户。泄漏的抽象是不好的。

希望能有所帮助,否则请在注释中要求澄清:)


+1,我什至从未想到过将工厂作为可能的设计解决方案。但是,有一件事:听起来工厂似乎在公共接口中实际上占据了聚合构造函数(可能还有一个Initialize方法)相同的位置。这使我想到一个问题,您这样的工厂会是什么样?
stakx 2014年

3

我认为,答案更像是您对现有总量的建议2;对于新的聚合,更像#3(但按照您的建议处理事件)。

这是一些代码,希望对您有所帮助。

public abstract class Aggregate
{
    Dictionary<Type, Delegate> _handlers = new Dictionary<Type, Delegate>();

    protected Aggregate(long version = 0)
    {
        this.Version = version;
    }

    public long Version { get; private set; }

    protected void Handles<TEvent>(Action<TEvent> action)
        where TEvent : IDomainEvent            
    {
        this._handlers[typeof(TEvent)] = action;
    }

    private IList<IDomainEvent> _pendingEvents = new List<IDomainEvent>();

    // Apply a new event, and add to pending events to be committed to event store
    // when transaction completes
    protected void Apply(IDomainEvent @event)
    {
        this.Invoke(@event);
        this._pendingEvents.Add(@event);
    }

    // Invoke handler to change state of aggregate in response to event
    // Event may be an old event from the event store, or may be an event triggered
    // during the lifetime of this instance.
    protected void Invoke(IDomainEvent @event)
    {
        Delegate handler;
        if (this._handlers.TryGetValue(@event.GetType(), out handler))
            ((Action<TEvent>)handler)(@event);
    }
}

public class ShoppingCart : Aggregate
{
    private Guid _id, _customerId;

    private ShoppingCart(long version = 0)
        : base(version)
    {
         // Setup handlers for events
         Handles<ShoppingCartCreated>(OnShoppingCartCreated);
         // Handles<ItemAddedToShoppingCart>(OnItemAddedToShoppingCart);  
         // etc...
    } 

    public ShoppingCart(long version, IEnumerable<IDomainEvent> events)
        : this(version)
    {
         // Replay existing events to get current state
         foreach (var @event in events)
             this.Invoke(@event);
    }

    public ShoppingCart(Guid id, Guid customerId)
        : this()
    {
        // Process new event, changing state and storing event as pending event
        // to be saved when aggregate is committed.
        this.Apply(new ShoppingCartCreated(id, customerId));            
    }            

    private void OnShoppingCartCreated(ShoppingCartCreated @event)
    {
        this._id = @event.Id;
        this._customerId = @event.CustomerId;
    }
}

public class ShoppingCartCreated : IDomainEvent
{
    public ShoppingCartCreated(Guid id, Guid customerId)
    {
        this.Id = id;
        this.CustomerId = customerId;
    }

    public Guid Id { get; private set; }
    public Guid CustomerID { get; private set; }
}

0

好吧,第一个事件应该是客户创建了购物车,因此创建购物车时,您已经有一个客户ID作为事件的一部分。

如果状态在两个不同事件之间保持,则根据定义,它是有效状态。因此,如果您说一个有效的购物车与客户相关联,则意味着在创建购物车本身时需要有客户信息


我的问题不是关于如何对领域建模;为了问一个问题,我故意使用一个简单的(但可能是不完美的)域模型。看看CreatedEmptyShoppingCart我的问题中的事件:正如您所建议的那样,它具有客户信息。我的问题更多是关于此类事件ShoppingCart在实现过程中如何与类构造函数相关或与之竞争。
stakx 2014年

1
我对这个问题了解得更好。因此,我想说,正确的答案应该是第三个答案。我认为您的实体应始终处于有效状态。如果这要求购物车具有客户ID,则应在创建时提供该ID,因此需要一个接受客户ID的构造函数。我更习惯于Scala中的CQRS,其中域对象将由case类表示,case类是不可变的,并且具有所有可能的参数组合的构造函数,因此我想当然了
Andrea 2014年

0

注意:如果您不想使用聚合根,则“实体”涵盖了您在此处提出的大部分要求,同时避免了对事务边界的担忧。

这是另一种思考方式:实体是身份+状态。与值不同,即使状态改变,实体也是同一个人。

但是状态本身可以被视为价值对象。我的意思是,状态是不可变的。实体的历史是从一个不变状态到下一个不变状态的过渡-每个过渡都对应于事件流中的一个事件。

State nextState = currentState.onEvent(e);

onEvent()方法是一个查询,当然-currentState根本没有更改,而是currentState正在计算用于创建nextState的参数。

遵循此模型,可以将“购物车”的所有实例视为从相同的种子值开始...。

State currentState = ShoppingCart.SEED;
for (Event e : history) {
    currentState = currentState.onEvent(e);
}

ShoppingCart cart = new ShoppingCart(id, currentState);

关注点分离-ShoppingCart处理命令以确定接下来应该发生什么事件;ShoppingCart州知道如何到达下一个州。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.