领域驱动设计中面向经典分层架构的领域事件的设计与实现
在我开发的《Byteart Retail》案例中,已经引入了领域事件(Domain Events)的实现部分,详情请见之前我写的一篇文章:《深度剖析Byteart Retail案例:领域事件(Domain Events)》。经过一段时间的学习和思考,对于领域事件的设计与实现也有了新的认识。在本文中,首先让我们一起了解一下Byteart Retail案例中领域事件的实现有哪些弊端,然后再对领域驱动设计中领域事件的设计与实现进行讨论。由于文中有不少地方都是出自Byteart Retail案例,因此,本文仍然可以看成是《深度剖析Byteart Retail案例》的“外传”。在写这篇文章时,我已经重构了Byteart Retail中领域事件的实现,因此,读者朋友仍然可以从GitHub获取案例的最新代码进行阅读。
回顾Byteart Retail案例中领域事件的实现
在《深度剖析Byteart Retail案例:领域事件(Domain Events)》一文中,我已经详细介绍了领域事件的实现方式,因此,在这里也不打算再作更为详细的说明,尤其是领域事件的定义部分。首先需要说明的是,本文所描述的内容或许跟这篇文章的内容有些出入,但这都不要紧,没有阅读过这篇文章的朋友,也可以回顾性地看一下,了解一下问题的背景情况,这对于更深入地了解本文的主要思想还是有很大帮助的。
问题来源于这篇文章中“还有什么问题吗?”这一节的描述。在这一节中,引入了一个发送电子邮件的应用场景,对于发送电子邮件的处理,文中建议使用区别于领域事件的另一种事件类型:应用事件(Application Events),以在领域对象完成持久化操作的同时,向事件总线(Event Bus)派发一个应用事件,从而在应用事件处理器中完成电子邮件的发送任务。当然,我们遇到的问题是一致的:我们希望对象持久化和发送电子邮件都在同一个事务中完成,确保对象持久化和电子邮件发送能够同时成功或者同时失败。
然而,事实上这个应用事件的引入和实现并不是非常合理的,它需要聚合根在领域事件发生时,将这些已发生的事件记录下来,然后在仓储完成对象持久化事务提交的同时,将这些领域事件转换为应用事件,再派发到事件总线。这里有两个方面的问题:首先,就是违背了面向对象设计的“单一职责原则(Single Responsibility Principal)”,仓储的职责是负责对象生命周期的管理,但将事件提交到事件总线,并非其职责范围之内,这样做也会对仓储的设计带来一定的影响:仓储不得不依赖于事件总线而存在,即使采用了依赖注入,也不得不让仓储感知到事件总线的存在;在这里,请区别一下CQRS架构中领域仓储的设计,需要注意的是,在CQRS架构中,领域仓储负责持久化所发生的领域事件到事件存储(Event Store),同时还负责将事件派发到事件总线,但对于事件的存储和派发本身就是领域仓储的职责,因为领域仓储已经退化到不再直接负责领域对象的持久化任务了,因此在CQRS中并不存在这样的问题;第二个问题,就是让聚合根来负责保存领域对象,这对于面向领域驱动分层架构的应用程序来说,不仅多此一举(对象状态已经保存在了私有字段中),而且跨线程的操作还会带来数据不一致性的问题,即使采用了加锁机制,也会对性能造成一定的影响,同样区别一下CQRS架构,在CQRS中,对象的状态由事件溯源来描述,因此聚合根必须维护现有的事件和事件快照。
看来,我们真的需要对Byteart Retail案例进行重构了,重构的目的就是:在不引入“应用事件”的情况下,直接在领域事件处理器中完成我们所需要的功能。这样也更符合“领域事件”原本的概念和定义。
重新设计领域事件
对于领域事件的接口定义和抽象类型的实现,在《深度剖析Byteart Retail案例:领域事件(Domain Events)》一文中我已经介绍过了,就不多作说明了。在这里我们重点了解一下领域事件的派发和处理逻辑。
首先,领域事件由领域事件处理器(Domain Event Handler)完成处理,领域事件处理器是事件处理器的一种(应用程序中的事件类型不只是领域事件一种),所不同的是,它只负责处理领域事件,因此,其接口定义如下:
1
2
|
public interface IDomainEventHandler<TDomainEvent> : IEventHandler<TDomainEvent> where TDomainEvent : class , IDomainEvent { } |
其次,实现这个接口,在实现类中完成事件处理:
1
2
3
4
5
6
7
|
public class OrderDispatchedEventHandler : IDomainEventHandler<OrderDispatchedEvent> { public void Handle(OrderDispatchedEvent evnt) { // 处理事件 } } |
然后,修改DomainEvent类,在该类中增加了Publish静态方法,用来派发领域事件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
public static void Publish<TDomainEvent>(TDomainEvent domainEvent) where TDomainEvent : class , IDomainEvent { IEnumerable<IDomainEventHandler<TDomainEvent>> handlers = ServiceLocator .Instance .ResolveAll<IDomainEventHandler<TDomainEvent>>(); foreach (var handler in handlers) { if (handler.GetType().IsDefined( typeof (HandlesAsynchronouslyAttribute), false )) Task.Factory.StartNew(() => handler.Handle(domainEvent)); else handler.Handle(domainEvent); } } |
OK,领域事件的派发和处理已经完成了,就这么简单!在此需要说明几点:1、引入了HandlesAsynchronously特性,在领域事件处理器上应用这个特性,就能使得该处理器能够基于TPL以异步方式处理事件,于是,处理器可以根据实际情况来选择自己的处理模式:某些处理过程可能需要花费较长的时间,并且业务逻辑的继续执行并不需要得知其处理结果,那么就可以在这个事件处理器应用这个特性,例如:在事件发生时发送电子邮件;2、在Publish方法中,使用服务定位器(Service Locator)来解析所有已注册的针对某种领域事件的事件处理器,进而逐个调用以完成事件处理。对“控制反转/依赖注入”有深入研究的读者朋友肯定会不认同这个做法,在应用程序中直接使用Service Locator是不可取的,Service Locator只能用在向IoC容器进行类型注册的时候,不能直接使用Service Locator来解析某个类型的对象。这种反对是很有道理的,因为如果在程序中随处使用Service Locator的Resolve方法,那么程序对外部组件的依赖关系就不明显了,更糟糕的是:如果我没有在IoC容器中注册类型,那么这个程序就根本没法运行。所以,直接使用ServiceLocator.Resolve方法,不仅会增加程序对外部组件的依赖,而且还会让这种依赖体现得更不明显,这是一种非常糟糕的设计。
在这里,我想对这个第2点谈谈自己的看法。虽然理论上讲,这个设计非常糟糕,但是对于我们目前的应用场景,只有这样设计才是最简洁的,因为请注意:首先:Publish是一个静态方法,在程序中你根本无法在静态类型或者静态方法的基础上应用依赖注入,即使你不直接使用Service Locator,而是使用类似事件聚合器(Event Aggregator)的设计,你也得想办法将这个Event Aggregator的实例“注射”到Publish方法中,但这对于静态方法又显得无能为力。或许你还会觉得,我们是否可以通过DomainEvent的构造函数将这种依赖注射进来?答案当然是否定的:DomainEvent是一种消息,它只不过是数据的载体,它根本就不应该关心自己是通过什么方式派发出去的,这样做只能加强DomainEvent与事件派发机制的耦合,甚至会将这种耦合带到领域模型当中!权衡ServiceLocator.Resolve所带来的弊端,这种设计更加糟糕,甚至可以说是恐怖!其次,之所以将Publish定义为静态方法,就是因为领域事件发自领域模型,我们根本无法也不能将事件派发机制的实例注射到领域模型中,因此,领域对象是无法得到任何派发机制的实例,进而发起领域事件的,它只能通过类似下面的方式将领域事件派发出去:
1
2
3
4
5
6
7
8
9
|
public void Confirm() { DomainEvent.Publish<OrderConfirmedEvent>( new OrderConfirmedEvent( this ) { ConfirmedDate = DateTime.Now, OrderID = this .ID, UserEmailAddress = this .User.Email }); } |
另外,在这里直接使用ServiceLocator.ResolveAll还有一个好处,就是所有的事件处理器都可以直接注册成PerResolve的生命周期,只要其所使用的外部组件(比如仓储、事件总线等)使用了合理的生命周期管理器,就能在事件处理器中直接使用这些组件的实例,并能够保证在某个执行上下文中,这些实例是一致的。这一点非常重要,在本文后面的部分会讨论。
综上所述,直接使用Service Locator来获取所有事件处理器实例的做法,是合理的。这也给我们提供了一些架构设计上的启示:任何事物没有正确与否,只有合理与否,架构的过程就是取舍的过程,找到最符合当前应用场景的解决方案,就是架构的目的。
最后,在IoC容器中注册领域事件处理器,Byteart Retail使用的是Unity IoC容器,因此我就在ByteartRetail.Services项目的web.config中写入了相关的注册信息:
1
2
3
4
5
6
7
8
9
10
11
|
< container > <!--Domain Event Handlers--> < register type="ByteartRetail.Domain.Events.IDomainEventHandler`1 [[ByteartRetail.Domain.Events.OrderDispatchedEvent, ByteartRetail.Domain]], ByteartRetail.Domain" mapTo = "ByteartRetail.Domain.Events.Handlers.OrderDispatchedEventHandler, ByteartRetail.Domain" name = "OrderDispatchedEventHandler" /> </ container > </ unity > |
到目前为止,整个领域事件的产生、派发和处理逻辑已经比较清晰了。接下来,我们深入领域事件处理器,去了解一下在处理器中如何执行与仓储或者其它第三方基础结构组件相关的操作,并保证这些操作的事务性。
领域事件处理器(Domain Event Handlers)
由于领域模型在派发领域事件时,使用了Service Locator来获得所有的事件处理器,因此,我们可以在事件处理器的构造函数中直接声明我们需要使用的基础结构组件接口,然后在Handle方法中调用这些组件即可。比如,假设我们在订单已发货的事件处理器中需要用到销售订单的仓储(Sales Order Repository),那么我们就可以这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
public class OrderDispatchedEventHandler : IDomainEventHandler<OrderDispatchedEvent> { private readonly ISalesOrderRepository salesOrderRepository; public OrderDispatchedEventHandler(ISalesOrderRepository salesOrderRepository) { this .salesOrderRepository = salesOrderRepository; } public void Handle(OrderDispatchedEvent evnt) { // this.salesOrderRepository.Find(xxxx); } } |
进一步,如果我们还需要在Handle方法中使用事件总线,以便在处理完OrderDispatchedEvent后,将事件派发到事件总线,那么同理,将IEventBus添加到OrderDispatchedEventHandler接口的构造函数中即可。
在DDD的分层架构应用程序中,应用层负责各种任务的协调,因此,从Byteart Retail案例中我们也可以看到,在应用层的WCF服务实现代码中,会获取仓储、事件总线的实例,然后通过仓储获得领域模型对象,再通过这些对象完成业务操作,整个任务都是在一个WCF的操作中完成。根据设计经验,我们应该尽可能地缩小对象生命周期范围,以减少出错的几率,因此,在Byteart Retail案例中,仓储上下文(Repository Context)和事件总线(Event Bus)都是以WCF Per Operation的生命周期注册到Unity IoC容器中,也就是,只要是在同一个WCF Operation Context下,这些对象就是唯一的。由于领域对象在调用DomainEvent.Publish方法发送消息时,也存在于这个WCF Operation Context中,所以,领域事件处理器中仓储所使用的上下文(Repository Context)就会跟应用层WCF方法中所使用的上下文一致。这一点非常重要:因为这就确保了领域事件处理器中对领域对象的更改和保存,能够在应用层的WCF方法中一次提交,因为两者使用了相同的Repository Context。下图大致可以表述这样一个过程:
类似地,在IEventBus在Unity IoC容器中注册为Per WCF Operation Context的生命周期的前提下,我们还可以在事件处理器中引用IEventBus的实例,然后在应用层的代码中使用IEventBus.Commit()方法将派发事件一次提交。OrderDispatchedEventHandler的完整代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
public class OrderDispatchedEventHandler : IDomainEventHandler<OrderDispatchedEvent> { private readonly ISalesOrderRepository salesOrderRepository; private readonly IEventBus bus; public OrderDispatchedEventHandler(ISalesOrderRepository salesOrderRepository, IEventBus bus) { this .salesOrderRepository = salesOrderRepository; this .bus = bus; } public void Handle(OrderDispatchedEvent evnt) { SalesOrder salesOrder = evnt.Source as SalesOrder; salesOrder.DateDispatched = evnt.DispatchedDate; salesOrder.Status = SalesOrderStatus.Dispatched; bus.Publish<OrderDispatchedEvent>(evnt); } } // 应用层: public void Dispatch(Guid orderID) { var salesOrder = salesOrderRepository.GetByKey(orderID); salesOrder.Dispatch(); salesOrderRepository.Update(salesOrder); Context.Commit(); bus.Commit(); } |
在上面应用层的Dispatch方法中,我们使用Context.Commit()方法和bus.Commit()方法来分别对仓储操作和事件总线操作进行事务提交。在目前的这种二阶段提交(Two Phase Commit,2PC)情况下,两者之间是不具备事务性的。当然,也不是所有的应用场景都必须保证两者的事务性,比如我们的电子邮件发送功能,在数据已经被提交到数据库以后,邮件发送成功与否并不会对系统本身的数据一致性造成多大的影响,充其量也只是客户收不到电子邮件,此时可以将邮件发送失败的原因记录在日志中,再由系统维护人员人工解决。也有一些应用场合,数据一致性要求要远远大于性能或其它方面的要求,此时,我们就必须保证两者的事务性。在Byteart Retail案例中,我引入了事务协调器的概念。
事务协调器(Transaction Coordinator)
事务协调器用于协调多个组件的事务操作,它是分布式事务架构的一种实现,在Byteart Retail中,提供了两种事务协调器的实现:一种是基于微软MSDTC(Microsoft Distributed Transaction Coordinator)的实现,另一种则是忽略任何分布式事务处理的实现,以下是与事务协调器相关的接口和类:
从图中可见,Byteart Retail实现了两种类型的事务协调器:DistributedTransactionCoordinator以及SuppressedTransactionCoordinator,由TransactionCoordinatorFactory工厂类根据传入的IUnitOfWork对象来创建所需要的事务协调器实例。在IUnitOfWork接口中提供了一个属性:DistributedTransactionSupported,表示当前的Unit Of Work是否支持微软的分布式事务协调器(MSDTC),因此,在TransactionCoordinatorFactory创建事务协调器的时候,会轮询所有Unit Of Work,看是否全部都支持MSDTC,如果都支持,则返回DistributedTransactionCoordinator的实例,否则返回SuppressedTransactionCoordinator的实例,表示忽略分布式事务处理的功能。DistributedTransactionCoordinator封装了System.Transactions.TransactionScope的实现,在Commit()方法被调用时,会首先调用基类中的Commit()方法来对Unit Of Work逐一提交,然后再使用TransactionScope.Complete()方法完成分布式事务。因此,DistributedTransactionCoordinator的使用,可以确保所有支持MSDTC的基础结构组件(MS SQL Server、Oracle、MSMQ等)的事务性。
回顾上面应用层的代码,在引入了事务协调器之后,Dispatch方法可以修改成:
1
2
3
4
5
6
7
8
9
10
|
public void Dispatch(Guid orderID) { using (ITransactionCoordinator coordinator = TransactionCoordinatorFactory.Create(Context, bus)) { var salesOrder = salesOrderRepository.GetByKey(orderID); salesOrder.Dispatch(); salesOrderRepository.Update(salesOrder); coordinator.Commit(); } } |
由于我们选用的Entity Framework和SQL Local DB作为数据存储机制,因此,Context本身是支持MSDTC的,至于coordinator是否是DistributedTransactionCoordinator,就需要看bus是否支持MSDTC。为了验证此处的事务协调器的工作,我在Byteart Retail中加入了另一种事件总线(Event Bus)的实现:基于MSMQ的事件总线(代码请参考ByteartRetail.Events.Bus.MSMQBus类),当bus.Publish被调用时,MSMQBus会将事件派发到MSMQ上。经过测试,数据的保存操作和发送事件到MSMQ的操作的确是在同一个事务中完成的,在成功完成这些操作后,我们可以在MSMQ中查看到这样的消息内容:
如果选用的Event Bus不支持MSDTC,那么coordinator就会是SuppressedTransactionCoordinator,也就意味着没有任何分布式事务的保障。例如,ByteartRetail.Events.Bus.EventBus类采用事件聚合器(Event Aggregator)来实现电子邮件发送功能。“电子邮件发送”本身也是不支持MSDTC的,所以,此处的事务性是无法得到保障的。不过,在SuppressedTransactionCoordinator进行Commit的时候,会首先提交数据库事务,一旦发生异常,那么后面对Event Bus的提交也就不会进行,对于“电子邮件发送”这个应用场景来说,已经可以满足了(因为不会出现数据没有更改,却已把电子邮件发出的尴尬局面)。
如果你是一个强迫症患者(事实上我也是),你会觉得这样做仍然不保险:因为邮件发送失败了,那就没有其它的补救措施可以重发邮件了。其实很简单:那你就用MSMQBus,它可以确保事件派发和数据库持久化同时完成,当事件被派发到MSMQ后,再弄个后台服务程序,从MSMQ读取事件信息,然后尝试发送邮件,发送成功,则将事件从MSMQ中移除,否则等下一次轮询的时候,再尝试重发。
最后啰嗦几句:使用MSDTC会引起性能问题,所以在数据一致性要求不高的情况下,尽量不要使用MSDTC,就如我们的邮件发送场景一样。支持MSDTC的资源管理器种类也十分有限,所以在实际应用中应该做好技术选型,不要盲目下结论(MSDN应该有MSDTC的开发文档,但我估计也不会有人会有太多精力去为了一个项目搞这方面的开发)。当然,使用MSDTC需要在服务器上启动Distributed Transaction Coordinator服务:
领域事件的意义
事件驱动的解决方案
领域事件为企业级应用程序带来了事件驱动的解决方案,大大减少了应用程序组件之间、应用程序之间的依赖关系:当某个业务操作开始或完成时,产生事件,并将事件派发到事件总线,即可让事件的订阅方对其进行处理,甚至是转发给其它的接收方。这不仅为应用程序带来了性能上的提升(因为事件可以以异步的方式处理),而且事件发出方完全不需要了解事件是如何被路由到其它的地方,在这些地方又是如何处理这些事件的,这对业务分析、开发和测试都带来了巨大的好处。事件驱动架构(Event Driven Architecture,EDA)的优越之处我也就不具体详谈了,网上有太多这方面的文章,感兴趣的朋友不妨去了解一下。
丰富领域模型
或许这种说法并不恰当,不过在实际中的确有这样的问题。比如,Byteart Retail中有“用户”和“订单”两种聚合,“用户”本身是不应该聚合“订单”的,从领域模型的角度讲,“用户”的存在并不依赖于“订单”(“订单”并非“用户”的组成部分),因此它跟“汽车”和“车轮”之间的关系是不同的。
当然,我们有一个很正常的需求:或许某个用户的所有订单信息。那既然“用户”没有聚合“订单”,也就无法从用户聚合来导航到其下所有的订单对象,此时又应该怎么办呢?在没有领域事件之前,要实现这个需求,只能在应用层先获得用户ID,然后使用用户仓储获得用户实体,再使用订单仓储找到该用户的所有订单。现在,让我们看看,在Byteart Retail引入了领域事件之后,这部分又是如何实现的。
首先,定义一个GetUserOrdersEvent领域事件,仍然在“用户”实体中定义一个属性(因为在代码编写中,使用user.SalesOrders这种写法更为直观),在属性的getter中,写入以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
public IEnumerable<SalesOrder> SalesOrders { get { IEnumerable<SalesOrder> result = null ; DomainEvent.Publish<GetUserOrdersEvent>( new GetUserOrdersEvent( this ), (e, ret, exc) => { result = e.SalesOrders; }); return result; } } |
然后,创建一个事件处理器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
public class GetUserOrdersEventHandler : IDomainEventHandler<GetUserOrdersEvent> { private readonly ISalesOrderRepository salesOrderRepository; public GetUserOrdersEventHandler(ISalesOrderRepository salesOrderRepository) { this .salesOrderRepository = salesOrderRepository; } public void Handle(GetUserOrdersEvent evnt) { var user = evnt.Source as User; evnt.SalesOrders = this .salesOrderRepository.FindSalesOrdersByUser(user); } } |
在事件处理器完成处理之后,DomainEvent.Publish静态方法会回调SalesOrders属性中给出的那个Lambda语句,从而将获得的订单返回出去。
这里的意义远不只是在原来的基础上改了一个写法。你可以看到,这样做解耦了领域模型和仓储操作,在领域模型中,派发领域事件,所有的仓储操作都是在事件处理器中完成。领域模型完全不知道在事件派发之后会发生什么,它只管等待处理结果。有不少读者朋友曾经问过我:领域模型中如何访问仓储?我想,这就是答案。
提高领域模型性能
假设在领域模型中某聚合有一个属性,其中包含了一个很大的对象,每次从仓储读入聚合的时候都非常耗时,而往往在查询中又不需要包含这个属性的值,在这种情况下,就可以使用领域事件来解决问题。使用上面类似的方法,将该属性改为不直接从数据库读入,而是简单地派发一个领域事件然后直接返回(可以直接返回应用层),当事件处理器完成数据读取以后,以C#中的事件模型通知调用方(比如应用层),应用层在收集完所有数据之后,再返回到展现层。
这里的实现可以使用C# 5.0的async/await编程模型,我还没有来得及实践,这里只是一种想法,不过实现起来应该不难,等有了具体的案例,再具体分析。
总结
本文首先提出了Byteart Retail案例中原有领域事件模型的实现弊端,然后给出了重构之后的解决方案,并对事件处理的事务性进行了一些简单的讨论,文章最后还讨论了领域事件的意义。Byteart Retail是一个演示案例,其中当然会有很多考虑不全的地方,我也没有太多的时间能够更深入地分析其中的利弊。如果有朋友能够发现其中的问题,并拿出来跟大家探讨,我想,这不仅是对自己,而且对他人也是一种很大的帮助。真心希望本文能给大家带来一些启示,帮助大家解决实际应用中遇到的困难。
![](http://apworks.org/wp-content/uploads/ApworksToolsIcon.png)
![](http://apworks.org/wp-content/uploads/menglala.png)