DDD理論學習系列(9)-- 領域事件

weixin_33751566發表於2017-07-04

DDD理論學習系列——案例及目錄


1. 引言

A domain event is a full-fledged part of the domain model, a representation of something that happened in the domain. Ignore irrelevant domain activity while making explicit the events that the domain experts want to track or be notified of, or which are associated with state change in the other model objects.
領域事件是一個領域模型中極其重要的部分,用來表示領域中發生的事件。忽略不相關的領域活動,同時明確領域專家要跟蹤或希望被通知的事情,或與其他模型物件中的狀態更改相關聯。

針對官方釋義,我們可以理出以下幾個要點:

  1. 領域事件作為領域模型的重要部分,是領域建模的工具之一。
  2. 用來捕獲領域中已經發生的事情。
  3. 並不是領域中所有發生的事情都要建模為領域事件,要忽略無業務價值的事件。
  4. 領域事件是領域專家所關心的(需要跟蹤的、希望被通知的、會引起其他模型物件改變狀態的)發生在領域中的一些事情。

簡而言之,領域事件是用來捕獲領域中發生的具有業務價值的一些事情。它的本質就是事件,不要將其複雜化。在DDD中,領域事件作為通用語言的一種,是為了清晰表述領域中產生的事件概念,幫助我們深入理解領域模型。

2. 認識領域事件

當使用者在購物車點選結算時,生成待付款訂單,若支付成功,則更新訂單狀態為已支付,扣減庫存,並推送撿貨通知資訊到撿貨中心。

在這個用例中,“訂單支付成功”就是一個領域事件。

考慮一下,在你沒有接觸領域事件或EDA(事件驅動架構)之前,你會如何實現這個用例。肯定是簡單直接的方法呼叫,在一個事務中分別去呼叫狀態更新方法、扣減庫存方法、傳送撿貨通知方法。這無可厚非,畢竟之前都是這樣乾的。

那這樣設計有什麼問題?

  1. 試想一下,若現在要求支付成功後,需要額外傳送一條付款成功通知到微信公眾號,我們怎麼實現?想必我們需要額外定義傳送微信通知的介面並封裝引數,然後再新增對方法的呼叫。這種做法雖然可以解決需求的變更,但很顯然不夠靈活耦合性強,也違反了OCP。
  2. 將多個操作放在同一個事務中,使用事務一致性可以保證多個操作要麼全部成功要麼全部失敗。在一個事務中處理多個操作,若其中一個操作失敗,則全部失敗。但是,這在業務上是不允許的。客戶成功支付了,卻發現訂單依舊為待付款,這會導致糾紛的。
  3. 違反了聚合的一大原則:在一個事務中,只對一個聚合進行修改。在這個用例中,很明顯我們在一個事務中對訂單聚合和庫存聚合進行了修改。

那如何解決這些問題?我們可以藉助領域事件的力量。

  1. 解耦,可以通過釋出訂閱模式,釋出領域事件,讓訂閱者自行訂閱;
  2. 通過領域事件來達到最終一致性,提高系統的穩定性和效能;
  3. 事件溯源;
  4. 等等。

下面我們就來一一深入。

3.建模領域事件

如何使用領域事件來解耦呢?
當然是封裝不變,應對萬變。那針對上面的用例,不變的是什麼,變的又是什麼?不變的是訂單支付成功這個事件;變化的是針對這個事件的不同處理手段。

而我們要如何封裝呢?
這時我們就要理清事件的本質,事件有因必有果,事件是由事件源和事件處理組合而成的。通過事件源我們來辨別事件的來源,事件處理來表示事件導致的下一步操作。

2799767-ecd81e1865814899.png

3.1. 抽象事件源

事件源應該至少包含事件發生的時間和觸發事件的物件。我們提取IEventData介面來封裝事件源:

/// <summary>
/// 定義事件源介面,所有的事件源都要實現該介面
/// </summary>
public interface IEventData
{
    /// <summary>
    /// 事件發生的時間
    /// </summary>
    DateTime EventTime { get; set; }

    /// <summary>
    /// 觸發事件的物件
    /// </summary>
    object EventSource { get; set; }
}

通過實現IEventData我們可以根據自己的需要新增自定義的事件屬性。

3.2. 抽象事件處理

針對事件處理,我們提取一個IEventHandler介面:

 /// <summary>
 /// 定義事件處理器公共介面,所有的事件處理都要實現該介面
 /// </summary>
 public interface IEventHandler
 {
 }

事件處理要與事件源進行繫結,所以我們再來定義一個泛型介面:

 /// <summary>
 /// 泛型事件處理器介面
 /// </summary>
 /// <typeparam name="TEventData"></typeparam>
 public interface IEventHandler<TEventData> : IEventHandler where TEventData : IEventData
 {
     /// <summary>
     /// 事件處理器實現該方法來處理事件
     /// </summary>
     /// <param name="eventData"></param>
     void HandleEvent(TEventData eventData);
 }

以上,我們就完成了領域事件的抽象。在程式碼中我們通過實現一個IEventHandler<T>來表達領域事件的概念。

3.3. 領域事件的釋出和訂閱

領域事件不是無緣無故產生的,它有一個釋出方。同理,它也要有一個訂閱方。

那如何和訂閱和釋出領域事件呢?
領域事件的釋出可以使用釋出--訂閱模式來實現。而比較常見的實現方式就是事件匯流排

2799767-6f44bdefa88a23a2.png

事件匯流排是一種集中式事件處理機制,允許不同的元件之間進行彼此通訊而又不需要相互依賴,達到一種解耦的目的。Event Bus就相當於一個介於Publisher(釋出方)和Subscriber(訂閱方)中間的橋樑。它隔離了Publlisher和Subscriber之間的直接依賴,接管了所有事件的釋出和訂閱邏輯,並負責事件的中轉。

這裡就簡要說明一下事件匯流排的實現的要點:

  1. 事件匯流排維護一個事件源與事件處理的對映字典;
  2. 通過單例模式,確保事件匯流排的唯一入口;
  3. 利用反射或依賴注入完成事件源與事件處理的初始化繫結;
  4. 提供統一的事件註冊、取消註冊和觸發介面。

最後,我們看下事件匯流排的介面定義:

public interface IEventBus
 {
    void Register < TEventData > (IEventHandler eventHandler);

    void UnRegister < TEventData > (Type handlerType) where TEventData: IEventData;

    void Trigger < TEventData > (Type eventHandlerType, TEventData eventData) where TEventData: IEventData;
}

在應用服務和領域服務中,我們都可以直接呼叫Register方法來完成領域事件的註冊,呼叫Trigger方法來完成領域事件的釋出。

而關於事件匯流排的具體實現,可參考我的這篇博文——事件匯流排知多少

4. 最終一致性

說到一致性,我們要先搞明白下面幾個概念。

事務一致性
事務一致性是是資料庫事務的四個特性之一,也就是ACID特性之一:

原子性(Atomicity):事務作為一個整體被執行,包含在其中的對資料庫的操作要麼全部被執行,要麼都不執行。
一致性(Consistency):事務應確保資料庫的狀態從一個一致狀態轉變為另一個一致狀態。
隔離性(Isolation):多個事務併發執行時,一個事務的執行不應影響其他事務的執行。
永續性(Durability):已被提交的事務對資料庫的修改應該永久儲存在資料庫中。

我們用一張圖來理解一下:

2799767-37a2232be8fa3348.png
事務一致性

在事務一致性的保證下,上面的圖示只會有兩個結果:

  1. A和B兩個操作都成功了。
  2. A和B兩個操作都失敗了。

資料一致性
舉個簡單的例子,假設10個人,每人有100個虛擬幣,虛擬幣僅能在這10人內流通,不管怎麼流通,最終的虛擬幣總數都是1000個,這就是資料一致性。

領域一致性
簡單理解就是在領域中的操作要滿足領域中定義的業務規則。比如你轉賬,並不是你餘額充足就可以轉賬的,還要求賬戶的狀態為非掛失、鎖定狀態。

回到我們的案例,當支付成功後,更新訂單狀態,扣減庫存,併傳送撿貨通知。按照我們以往的做法,為了維護訂單和庫存的資料一致性,我們將這三個操作放到一個應用服務去做(因為應用服務管理事務),事務的一致性可以保證要麼全部成功要麼全部失敗。但是,複雜業務巢狀的多個操作放在一個事務中,很容易造成事務超時,而往往為了效能考慮,可能會放棄事務巢狀,這樣就又很可能會導致:客戶支付成功後,訂單依舊為待付款狀態,這會引起糾紛。另外,由於庫存沒有及時扣減,很可能會導致庫存超賣。怎麼辦呢?
將事務拆解,使用領域事件來達到最終一致性。

最終一致性
“最終一致性”是一種設計方法,可以通過將某些操作的執行延遲到稍後的時間來提高應用程式的可擴充套件性和效能。

2799767-2ade05818668d245.png
最終一致性

對於常見於分散式系統的最終一致性工作流中,客戶同樣在系統中執行一個命令,但這個系統只為維護事務中的領域一致性執行部分的操作,剩餘的操作在允許延後執行。針對上圖的結果:

  1. A操作執行成功,B操作將延後執行。
  2. A操作失敗,B操作將不會執行。

而針對我們的案例,我們如何使用領域事件來進行事務拆分呢?我們看下下面這張圖你就明白了。

2799767-c652cb896fdf6c4e.png
領域事件在最終一致性的位置

分析一下,針對我們案例,我們發現一個用例需要修改多個聚合根的情況,並且不同的聚合根還處於不同的限界上下文中。其中訂單和庫存均為聚合根,分別屬於訂單系統和庫存系統。我們可以這樣做:

  1. 在訂單所在的聚合根中更新訂單支付狀態,併發布“訂單成功支付”的領域事件;
  2. 然後庫存系統訂閱並處理庫存扣減邏輯;
  3. 通知系統訂閱並處理撿貨通知。

通過這種方式,我們即保證了聚合的原則,又保證了資料的最終一致性。

5. 事件儲存和事件溯源

關於事件儲存(Event Store)和事件溯源(Event Sourcing)是一個比較複雜的概念,我們這裡就簡單介紹下,不做過多展開,後續再設章節詳述。


2799767-0783b31ecaf12c07.jpg

事件儲存,顧名思義,即事件的持久化。那為什麼要持久化事件?

  1. 當事件釋出失敗時,可用於重新發布。
  2. 通過訊息中介軟體去分發事件,提高系統的吞吐量。
  3. 用於事件溯源。

原始碼管理工具我們都用過,如Git、TFS、SVN等,通過記錄檔案每一次的修改記錄,以便我們跟蹤每一次對原始碼的修改,從而我們可以隨時回滾到檔案的指定修改版本。

事件溯源的本質亦是如此,不過它儲存的並非聚合每次變化的結果,而是儲存應用在該聚合上的歷史領域事件。當需要恢復某個狀態時,需要把應用在聚合的領域事件按序“重放”到要恢復狀態對應的領域事件為止。

6.總結

經過上面的分析,我們知道引入領域事件的目的主要有兩個,一是解耦,二是使用領域事件進行事務的拆分,通過引入事件儲存,來實現資料的最終一致性。

最後,對於領域事件,我們可以這樣理解:
通過將領域中所發生的活動建模成一系列的離散事件,並將每個事件都用領域物件來表示,來跟蹤領域中發生的事情。
也可以簡要理解為:領域事件 = 事件釋出 + 事件儲存 + 事件分發 + 事件處理

以上,僅是個人理解,DDD水很深,剪不斷,理還亂,有問題或見解,歡迎指正交流。

參考資料:
在微服務中使用領域事件
使用聚合、事件溯源和CQRS開發事務型微服務
如何理解資料庫事務中的一致性的概念?
Eventual Consistency via Domain Events and Azure Service Bus

相關文章