在我開發的《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)完成處理,領域事件處理器是事件處理器的一種(應用程序中的事件類型不只是領域事件一種),所不同的是,它只負責處理領域事件,因此,其接口定義如下:
public interface IDomainEventHandler<TDomainEvent> : IEventHandler<TDomainEvent>
where TDomainEvent : class, IDomainEvent { }
其次,實現這個接口,在實現類中完成事件處理:
public class OrderDispatchedEventHandler : IDomainEventHandler<OrderDispatchedEvent>
{
public void Handle(OrderDispatchedEvent evnt)
{
// 處理事件
}
}
然后,修改DomainEvent類,在該類中增加了Publish靜態方法,用來派發領域事件:
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定義為靜態方法,就是因為領域事件發自領域模型,我們根本無法也不能將事件派發機制的實例注射到領域模型中,因此,領域對象是無法得到任何派發機制的實例,進而發起領域事件的,它只能通過類似下面的方式將領域事件派發出去:
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中寫入了相關的注冊信息:
<unity xmlns="http://schemas.microsoft.com/practices/2010/unity">
<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),那么我們就可以這樣:
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的完整代碼如下:
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方法可以修改成:
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中,寫入以下代碼:
public IEnumerable<SalesOrder> SalesOrders
{
get
{
IEnumerable<SalesOrder> result = null;
DomainEvent.Publish<GetUserOrdersEvent>(new GetUserOrdersEvent(this),
(e, ret, exc) =>
{
result = e.SalesOrders;
});
return result;
}
}
然后,創建一個事件處理器:
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是一個演示案例,其中當然會有很多考慮不全的地方,我也沒有太多的時間能夠更深入地分析其中的利弊。如果有朋友能夠發現其中的問題,並拿出來跟大家探討,我想,這不僅是對自己,而且對他人也是一種很大的幫助。真心希望本文能給大家帶來一些啟示,幫助大家解決實際應用中遇到的困難。




