在哪里触发依赖于持久性的领域事件 - 服务、存储库还是用户界面?

35
我的ASP.NET MVC3 / NHibernate应用程序需要触发和处理与域对象相关的各种事件。例如,一个Order对象可能有像OrderStatusChangedNoteCreatedForOrder这样的事件。在大多数情况下,这些事件会导致发送电子邮件,因此我不能只把它们留在MVC应用程序中。
我已经阅读了Udi Dahan的Domain Events以及其他许多关于如何做这种事情的想法,并决定使用基于NServiceBus的主机来处理事件消息。我已经进行了一些概念验证测试,这似乎很有效。
我的问题是实际上应该在哪个应用程序层次上引发事件。我不想在相关对象成功持久化之前触发事件(如果持久性失败,则无法发送有关创建笔记的电子邮件)。
另一个问题是,在某些情况下,事件与聚合根以下的对象绑定。在上面的例子中,通过将其添加到Order.Notes集合并保存订单来保存Note。这会带来一个问题,即当保存Order时,难以评估应该触发哪些事件。我想避免在保存更新后的副本之前拉取当前对象的副本并查找差异。
  • UI是否适合触发这些事件?它知道发生了哪些事件,并且可以只在服务层成功保存对象后才触发它们。让控制器触发域事件似乎有些不妥。

  • 存储库是否应在成功持久化后触发事件?

  • 我应该将事件分开,让存储库存储一个Event对象,然后由轮询服务接收该对象,然后将其转换为NServiceBus事件(或直接从轮询服务处理)吗?

  • 有没有更好的方法来处理这个问题?也许可以让我的领域对象排队等待事件,只有在对象被持久化后才由服务层触发事件?

  • 更新:我有一个服务层,但是它似乎很繁琐和多余,需要通过比较过程确定在保存给定聚合根时应该触发哪些事件。因为其中一些事件是粒度细的(例如“订单状态已更改”),所以我认为必须检索对象的数据库副本,比较属性以创建事件,保存新对象,然后在保存操作成功完成后将事件发送到 NServiceBus。

  • 更新

    我最终做的事情是,在我下面发布的答案之后(way below),将一个名为 EventQueueList<IDomainEvent> 属性构建到我的领域实体中。然后,根据领域的需要添加事件,这样我就可以保持逻辑处于领域内部,我认为这是合适的,因为我是基于实体内部正在发生的事情触发事件。

    然后,在我的服务层中持久化对象时,我处理该队列并实际将事件发送到服务总线。最初,我打算使用一个使用标识 PK 的旧数据库,因此我必须后处理这些事件以填充实体的 ID,但我最终决定切换到 Guid.Comb PK,这使我可以跳过该步骤。


    6个回答

    10

    我的解决方案是在领域层和服务层都引发事件。

    您的领域层:

    public class Order
    {
        public void ChangeStatus(OrderStatus status)
        {
            // change status
            this.Status = status;
            DomainEvent.Raise(new OrderStatusChanged { OrderId = Id, Status = status });
        }
    
        public void AddNote(string note)
        {
            // add note
            this.Notes.Add(note)
            DomainEvent.Raise(new NoteCreatedForOrder { OrderId = Id, Note = note });
        }
    }
    

    您的服务:

    public class OrderService
    {
        public void SubmitOrder(int orderId, OrderStatus status, string note)
        {
            OrderStatusChanged orderStatusChanged = null;
            NoteCreatedForOrder noteCreatedForOrder = null;
    
            DomainEvent.Register<OrderStatusChanged>(x => orderStatusChanged = x);
            DomainEvent.Register<NoteCreatedForOrder>(x => noteCreatedForOrder = x);
    
            using (var uow = UnitOfWork.Start())
            {
                var order = orderRepository.Load(orderId);
                order.ChangeStatus(status);
                order.AddNote(note);
                uow.Commit(); // commit to persist order
            }
    
            if (orderStatusChanged != null)
            {
                // something like this
                serviceBus.Publish(orderStatusChanged);
            }
    
            if (noteCreatedForOrder!= null)
            {
                // something like this
                serviceBus.Publish(noteCreatedForOrder);
            }
        }
    }
    

    7

    领域事件应该在领域内被触发。这就是它们被称为领域事件的原因。

    public void ExecuteCommand(MakeCustomerGoldCommand command)
    {
        if (ValidateCommand(command) == ValidationResult.OK)
        {
            Customer item = CustomerRepository.GetById(command.CustomerId);
            item.Status = CustomerStatus.Gold;
            CustomerRepository.Update(item);
        }
    }
    

    (然后在客户类中,以下内容):
    public CustomerStatus Status
    {
        ....
        set
        {
            if (_status != value)
            {
                _status = value;
                switch(_status)
                {
                    case CustomerStatus.Gold:
                        DomainEvents.Raise(CustomerIsMadeGold(this));
                        break;
                    ...
                }
            }
        }
    

    Raise方法将事件存储在事件存储中。它还可以执行本地注册的事件处理程序。

    6
    那是我开始的地方,但假设在 CustomerRepository.Update(item) 过程中发生错误,导致持久化失败。那么我不希望那个事件被处理。这就是为什么我想知道其他选项是什么。在仓储库中进行操作是一个坏主意,同样在用户界面(UI)也不是好的选择。如果没有必要绕过NH的确保两个“相同”对象完全一致的倾向,那么在服务中进行操作将是最好的选择,因为这会阻止我对服务中的更改前后对象属性进行比较。 - Josh Anderson
    谁在这里毫无理由地给那么多答案点踩了? - Roy Dictus
    @Josh 和 Roy,我已经对这个答案进行了负评,因为正如 Josh 在评论中正确指出的那样,你们的代码不是事务性的。如果 CustomerRepository.Update(item) 失败,事件已经被触发并且操作已经完成,这是不应该发生的。 - Alex Burtsev
    1
    有,但我们构建的软件系统通常处理处于转换状态的领域实体,并定期与数据存储同步(持久化,重新加载)。我们需要考虑到这一点。您有三个选项来处理此问题:将持久性注入实体并具有垃圾设计,将事件触发移动到更高级别,该级别已知持久性(服务或存储库),在实体中同时触发两个事件 - 并在那里执行与持久性无关的实体相关操作,另一个在持久性感知层中执行更多技术性操作。 - Alex Burtsev
    顺便提一下,在事件存储中的存储可以成为交易的一部分;在这种情况下,如果客户记录的更新失败,则不会存储。 - Roy Dictus
    显示剩余2条评论

    4

    看起来你需要一个服务层。服务层是另一种抽象层,位于前端或控制器和业务层或领域模型之间。它有点像应用程序的API。然后你的控制器只能访问你的服务层。

    然后,服务层就要负责与你的领域模型进行交互了。

    public Order GetOrderById(int id) {
      //...
      var order = orderRepository.get(id);
      //...
      return order;
    }
    
    public CreateOrder(Order order) {
      //...
      orderRepositroy.Add(order);
      if (orderRepository.Submitchanges()) {
        var email = emailManager.CreateNewOrderEmail(order);
        email.Send();
      }
      //...
    }
    

    常见的情况是出现“管理器”对象,例如OrderManager与订单进行交互,服务层处理POCO。

    UI是否适合引发这些事件?它知道发生了什么事件,并且只有在成功保存对象后才能触发它们。让控制器触发域事件似乎有些不妥。

    不适合。如果添加新操作并且开发人员不知道或忘记应该发送电子邮件,您将遇到问题。

    存储库是否应在成功持久化后触发事件?

    不应该。存储库的职责是提供对数据访问的抽象,而不是其他任何东西。

    有更好的方法吗?也许我的域对象可以排队事件,在对象持久化后由服务层触发?

    是的,听起来应该由您的服务层处理。


    是的,我有一个服务层正在进行保存操作,这是最初提出触发事件的候选方案。我遇到的问题是,服务层最终需要在保存修改后的版本之前获取某些对象的副本,以将新版本与旧版本进行比较,然后决定应该触发哪些事件。 - Josh Anderson
    有特定的问题吗?大多数ORM都会为您处理这个问题。您在这样做中遇到性能问题吗? - David Glenn
    不完全是性能问题,而是试图避免一个逻辑对象的两个实例(请参见此处的第一个答案:https://dev59.com/3UfRa4cB1Zd3GeqP6yuM)。NH并没有真正影响我所需的内容:获取订单、更改状态、保存订单、触发事件。如果状态的更改没有创建有关更改的元数据,那么除非我对要保存的订单和最初检索时存在的订单的新实例进行逐个属性比较,否则我无法在保存后触发事件。 - Josh Anderson

    1

    我认为,正如David Glenn所说,你应该拥有一个域服务。

    我遇到的问题是,服务层在保存修改后的对象之前,必须先获取一份副本,以比较新旧版本,并决定应该触发哪些事件。

    你的域服务应该包含清晰地说明你想要对域实体执行的操作的方法,例如:RegisterNewOrder、CreateNoteForOrder、ChangeOrderStatus等。

    public class OrderDomainService()
    {
        public void ChangeOrderStatus(Order order, OrderStatus status)
        {
            try
            {
                order.ChangeStatus(status);
                using(IUnitOfWork unitOfWork = unitOfWorkFactory.Get())
                {
                    IOrderRepository repository = unitOfWork.GetRepository<IOrderRepository>();
                    repository.Save(order);
                    unitOfWork.Commit();
                }
                DomainEvents.Publish<OrderStatusChnaged>(new OrderStatusChangedEvent(order, status));
            }
    
        }
    }
    

    我认为强烈建议服务层提供细粒度方法来更改单个实体属性有些过分了。这种方法会导致领域实体非常贫血,而服务层则变得庞大且难以维护。我必须防止实体属性通过其他方式进行更新,而只能通过相关的服务层方法进行更新,这并不是一个理想的解决方案。 - Josh Anderson
    你不会碰巧就是那个对其他回答进行投反对票的人吧?这些回答都是合理的解决问题的方法。 - Josh Anderson
    @Josh 领域服务示例中,我没有提供更改单个属性的方法。方法应该对应于业务操作。这种方法不会导致贫血模型(anemic model),请注意代码行order.ChangeStatus(status),我将所有逻辑工作委托给领域实体(Domain Entity)。 - Alex Burtsev
    @Josh 我实际上更喜欢洋葱架构(http://tonysneed.files.wordpress.com/2011/10/onion-arch.jpg)而不是层次结构。因此,我会将其称为Domain Service层,以避免与Application Service层或Web Service层混淆。 - Alex Burtsev
    问题在于我不能假设我需要触发的事件仅对应于“业务操作”。根据您提出的解决方案,除非通过服务层方法,否则我将无法触发事件。这对于一个逻辑事务触发多个事件的情况并不理想,因为它将需要为每个与事件相关的业务操作执行持久化操作(请回想一下,我的原始问题与依赖于持久性的事件有关)。 - Josh Anderson
    @Josh 你可以(也许应该)在每个层级中都有单独的事件。在数据访问、域服务和域实体中都可以。我并不是告诉你在每个操作上都要触发事件,只是那些你认为重要的操作。但是这里你正在走向基于事件的系统的道路,在某些情况下这是很好的,因为具有高可扩展性、低耦合度、高内聚度,但这些类型的设计更难以阅读,并找出何时发生了什么。我在 WEB 应用程序方面经验较少,但如果我要构建一个,我认为我会避免使用事件,并更多地转向 CQRS 设计,这是非常明确的。 - Alex Burtsev

    1

    解决方案是基于在NHibernate会话对象上实现 这些扩展方法

    我的问题表述可能有点不清楚。整个架构问题的原因是,除非您手动取消代理并进行各种操作,否则NHibernate对象始终处于相同状态。我不想这样做来确定哪些属性已更改,从而确定要触发哪些事件。

    在属性设置器中触发这些事件行不通,因为应该在持久更改后才触发事件,以避免在最终可能失败的操作上触发事件。

    所以我在我的存储库基础上添加了一些方法:

    public bool IsDirtyEntity(T entity)
    {
        // Use the extension method...
        return SessionFactory.GetCurrentSession().IsDirtyEntity(entity);
    }
    
    public bool IsDirtyEntityProperty(T entity, string propertyName)
    {
        // Use the extension method...
        return SessionFactory.GetCurrentSession().IsDirtyProperty(entity, propertyName);
    }
    

    然后,在我的服务的Save方法中,我可以像这样做(记住我在这里使用NServiceBus,但如果您使用Udi Dahan的域事件静态类,它也会类似地工作):
    var pendingEvents = new List<IMessage>();
    if (_repository.IsDirtyEntityProperty(order, "Status"))
        pendingEvents.Add(new OrderStatusChanged()); // In reality I'd set the properties of this event message object
    
    _repository.Save(order);
    _unitOfWork.Commit();
    
    // If we get here then the save operation succeeded
    foreach (var message in pendingEvents)
        Bus.Send(message);
    

    因为在某些情况下,实体的Id可能要等到保存后才能设置(我使用Identity整数列),所以在提交事务后可能需要运行一遍来检索Id以填充我的事件对象中的属性。由于这是现有数据,我不能轻易地切换到客户端分配的hilo类型的Id。


    事务问题在基于事件的系统中非常常见,解决此问题的方法也是通用的,不关心NHibernate或任何技术,只是逻辑解决方案。 - Alex Burtsev
    如果你要对一个答案进行负评(或三个),请在评论中加入一些理由。 - Josh Anderson

    0

    如果您有发送到服务层的命令,那么使用域事件可以很好地工作。然后,它可以根据命令更新实体,并在单个事务中引发相应的域事件。

    如果您正在在UI服务层之间移动实体本身,则变得非常困难(有时甚至是不可能的)确定发生了哪些域事件,因为它们没有明确表示,而是隐藏在实体状态下。


    尽管这将导致一个繁重的服务层(我想这是可以预料的),但在大多数情况下,在命令的上下文中触发事件的概念是可行的。唯一困难的时候是当实体最初被创建为具有状态或需要触发事件时。因此,也许我们保存一个订单,而不将其设置为“已提交”,然后使用服务中的SubmitOrder()命令触发OrderSubmitted事件。但我们也可以只是保存一个Submitted = true的订单...我如何处理这些命令? - Josh Anderson
    @Josh,所以你需要一个CreateOrder命令,其中包含创建订单所需的所有数据(但此时还没有创建订单!)。您可以在该命令中指示是否应在创建时立即提交订单,并在处理该命令时触发OrderSubmitted事件(如果需要)。 - driushkin
    那里的困难在于我将失去NHibernate的所有优势,特别是级联持久性能力。现在我可以创建或检索订单,添加一些注释,添加一些订单项目,然后在订单本身上执行一次保存,并使NH保留所有下属对象。顺便说一句,NH在这里也是问题之一-由于其对会话上下文的感知,不仅仅是从数据库中获取对象的副本并将其与内存中的副本进行比较那么简单。NH将同步两者,您将看不到属性值的任何差异。 - Josh Anderson
    不,你不会失去NH提供的任何东西。但你需要将所有的域逻辑从UI层移动到服务层,这当然可能是一项相当艰巨的任务。 - driushkin

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