戲說領域驅動設計(廿五)——領域事件

SKevin發表於2022-05-06

  任何事物都在變化著包括領域驅動設計這門學問。Evans在首次提到DDD概念後,後來出現了陸續又出現了很多的專家與學者對其理論進行了擴充比如:“領域事件”、“事件源”、“命令查詢責任分離”等。也正是由於這些補充,不僅讓DDD的適用範圍變得更大也讓後來出現的微服務架構系統受益良多,為系統落地提供了非常優秀的理論指導。這節我們主要討論領域事件,不誇張的說,在現代化的業務系統中它的應用普度度非常高,將其看成一種事實上的標準也並不為過。尤其在使用基於Saga的分散式事務時,領域事件完全是不能少的。此外,DDD中不推薦一個事務更新多個聚合,那如果有這種需要的時候要怎麼做呢?答案還是“領域事件”,所以讓我們開始今天的學習之旅。

一、概覽

  主流的基於事件的業務處理流程大概如下圖所示。為什麼說是主流呢?有些特殊情況下可能會使用多執行緒+遠端服務呼叫的方式進行事件的投遞,但這種情況大多都發生在遺留的系統中。很多系統中早已經引入了訊息佇列中介軟體或者一些訊息佇列元件,使用它們作為訊息的載體已經是主流。所以後續的內容中一旦涉及到訊息的投遞我們預設就是指使用訊息佇列 。

戲說領域驅動設計(廿五)——領域事件

 

  單體時代,想要實現模組間的交流最簡單的方式是通過程式內函式呼叫,比較直觀,程式設計師用起來也更方便。到了微服務的時代,由於業務被劃分到多個獨立部署的服務中,想要實現業務串聯方式之一是使用程式間通訊技術比如RPC或基於HTTP呼叫。但使用遠端呼叫的方式所帶來的隱患比較多,一是由於同步的呼叫會產生效能瓶頸,其實基於進行內呼叫也是一樣,單執行緒情況之下整個業務執行的時間等於其所呼叫的所有方法的執行時間之和; 二是分散式部署的服務需要通過網路連線進行協作,你不能假設網路是穩定的,而不穩定的網路所帶來的隱患也很多,比如效能、後期運維等。所以使用訊息及訊息佇列中介軟體作為服務間的資訊交換方式成為另外一種主流,不論是在微服務的內部還是在微服務之間。而且呢,由於各服務都是與訊息中介軟體進行互動也不用知道其它服務的地址,能大大減少服務間的相互依賴(即使引入了服務治理工具也不代表沒有依賴,而是服務的客戶端不再像過去一樣需要了解服務端的IP地址和埠等資訊)。引入領域事件的另一個優勢就是系統的擴充套件性被增強:在使用基於遠端呼叫的方式實現某個業務時,當業務需要進行擴充套件時很多時候你需要增加對另外的服務的呼叫;而使用事件的機制,您只需要再引入一個事件的監聽者即可,成本非常低,也符合了我們所追求的“開閉原則”。雖然訊息這種方式看起來要美好很多,但需要額外引入新的訊息中間鍵,必然會加大學習與運營的成本。不過這個賬得看你怎麼算,通過硬體與人員的投入雖然有額外的支出,但能讓系統更加穩定,吞吐量更高,實際上又節約了成本。再說了,為了應對請求的高峰有的時候你必須要引入訊息佇列進行緩衝以實現削峰填谷。事件本質上不就一種訊息嗎?大部分情況下可以複用系統中的基礎設施,反正一個羊是趕,兩個羊也是放,也不差領域事件那點消耗。

領域事件的提出其實是在Evans那本書之後,有的時候我在想:在沒有領域事件的情況下,他是如何處理多聚合的協作呢?猜測的結果有兩個:一是和當時的時代背景有關,03或04年他提出這個概念,當時單體是主流並不會有那麼多的子服務存在,因此在實踐中應該是允許一個事務更新多個聚合的,也就是通過應用服務完成聚合的協作。二是當時EJB比較流行,裡面有企業訊息匯流排的使用,可以通過它實現聚合間的協作,但作者並未給訊息賦予領域事件之名。具體原因不可考,總得來說領域事件的使用的確讓哪怕技術一般的團隊也能開發出較高吞吐量的系統。

二、領域事件本質

  領域事件的本質需要從兩個維度進行說明:業務與技術。在業務方面,領域事件表達了在領域中發生的某些事件,為了表達這個事件我們對其進行了建模並使其成為通用語言的一部分。單純的構建一個領域事件其實沒什麼作用,在業務中由於某個領域物件的動作被觸發會引發與之關聯的另外的領域物件也受到影響,那麼我們要怎麼通知受波及的物件呢?答:領域事件。通過領域事件我們可以驅動業務的流向。其實您仔細想一想會發現很多的業務都是由於某個事件的發生而推動其流程前進的,所以我有的時候在想“基於事件的架構”是不是更符合業務本質或者說更有助於系統的實現。此外,在領域驅動設計中還有一種架構風格叫“事件溯源(ES)”,其也使用領域事件,雖然在架構風格和開發風格上有別於我們傳統的模式,但其本質上也是由事件進行驅動的,只不於更注重於實體驅動實體屬性的變更。

  有這樣的一個需求:“訂單支付後需要給其所屬賬戶增加10點成就值”。在使用微服務架構的系統下,您可以很明顯的看出來系統中應該包含兩個服務:“訂單服務”用於處理訂單相關的業務; “賬戶服務”用於處理成就值業務。這段需求中您也可以發現一個明顯的領域事件“訂單支付後”。在引入了領域事件後這個業務的處理流程可分解為:訂單服務在訂單支付後產生“訂單支付”事件;賬戶服務可以根據事件觸發積分邏輯。此處,為了實現事件在服務間的投遞通常會引入事件釋出與訂閱元件,具體細節後面說明。因為領域事件的引入,您可以讓微服務系統發揮出最大的效能,每個系統都專注於完成各自的責任;從技術的角度來看由於使用了訊息佇列,整個業務的執行也會由原來的同步變為非同步,效能更高。程式碼案例如下所示。

public class OrderService {
    public void pay(Long orderId, Money cost) {
        Order order = this.orderRepository.findBy(orderId);
        OrderPaid orderPaid = order.pay(cost);
        this.eventBus.post(orderPaid);
    }
}
public class AccountService {
    public void handle(OrderPaid orderPaid) {
        Account account = this.accountRepository.findBy(orderPaid.getAccountId());
        account.increaseRewardPoints();
    }
}

  讓我們再進行一個反推,如果沒有領域事件要如何處理示例業務呢?您需要在應用服務中在執行訂單的支付業務後再通過遠端呼叫的方式讓賬戶服務執行積分的增加,大致的程式碼如下所示。

public class OrderService {
    public void pay(Long orderId, Money cost) {
        Order order = this.orderRepository.findBy(orderId);
        order.pay(cost);
        this.remoteAccountService.increaseRewardPoints(10L);
    }
}

  哪種程式碼更好一點?目測還是使用領域事件的方案更優秀:非同步操作,效能是槓槓的。遠端呼叫的方式就差了點意思,案例中只展示了基本的邏輯,如果想要確保“訂單支付後需要給其所屬賬戶增加10點成就值”這個業務能夠順利完成,你還得加上一個分散式事務,這可就複雜了。當然了,使用了領域事件的方式你也得做一些工作來保證訊息不丟失。但總得來看方案二要複雜一點,如果一個業務涉及到多個服務共同參與才能完成,那這個效能低得可就不是一點半點了。是不是在您的心裡已經首先把方案二給否了?我這性子已經夠急了,您這比我還急。先彆著急下結論,親!具體使用哪種方案還得看需求呢,請聽我慢慢道來。

  首要的一點,您心裡得有一個譜,我們們這個案例是基於微服務風格的,那考慮問題的時候就得站在微服務的角度而不能仍然使用單體的思維來看待問題,說白了就是需要把眼光放寬一點。分散式系統有一個重要的特性您時刻都不能忘掉的即“CAP”,大師已經證明了您只能選擇一種,要不是“AP”要不就是“CP”。不僅是那些我們常用的中介軟體如此,您所做的業務系統也需要一同考慮。為什麼很多人會忽略這一點?因為我們使用的這些中介軟體也好,工具也好,人家已經幫你決定了到底“AP”或“CP”。比如Zookeeper,雅虎幫您確認這個就是“CP”的,使用者不用操心這些事情,直接使用即可。這種問題造成了很多的軟體工程師在建設分散式系統的時候時常忽略“CAP”這個東西,也就造成了對於上述的案例先入為主的認為方案一比較好。那為什麼我說評估方案的好壞要看業務需求呢?假如業務強烈要求你必須要保證賬戶的積分必須與訂單支付保持同步,那方案二才是首選。當然,這裡所謂的“強烈要求”需要工程師做好判斷,從使用者的角度來看他們肯定要求資料需要時刻保持同步尤其是不懂技術的客戶,可是大多數的時候其實他們是容忍這種同步存在著延遲的。可以假想一下,如果沒有系統的支撐,通過手工來實現業務是不是也存在不一致呢?說到這裡您應該知道為什麼DDD強調最終一致性了吧?因為的確是大多數情況下不需要嚴格保持資料的強一致性的。我在前面的文章中曾強調過在微服務風格系統中使用Saga代替強分散式事務是一種事實上的標準,也是由於業務的特性造成的,也就是說大多數業務其實只要實現AP就足夠了。不過話又得說回來了,假如你做的系統出現長時間的資料不一致比如一天,那您也別怪使用者懟你,誰也不能容忍如此誇張的延遲,我們所說最終一致性雖然沒有一個標準規定這個最終要經歷多久,那也不能幾小時、幾天都不一致吧?

  以DDD的眼光來看,其實方案二的問題是在建模上,沒有對於需求中的“訂單支付後”這個動作進行建模,不夠純粹。而領域事件的好處是其能夠更加精確的表達通用語言。使用了領域事件後,您可以在需求中提煉出很多的領域模型,這樣會使得建模的工作做得很細緻,十分有利於挖掘到業務的本質。當然,這話就有點虛了,具體的好處是你對業務本質認識的越清楚做出的系統就會更加健壯,可擴充套件性也更強。寫了這麼多東西,其實雖然只有這一句話“領域事件能夠更加精確的表達通用語言”對應了標題,不過那些陪襯的內容也是精華,加緊找個小本本兒記下來。

三、領域事件與領域命令

  領域事件從技術的角度來看其實就是訊息,類似的還包括領域命令,說白了就是給訊息一個業務術語(使用訊息表示兩者是比較普遍的情況,我們此處只談主流的使用方式)。可就是這些術語才能對應我們的主題“領域驅動設計”,叫“訊息驅動”總是差點意思。讓我們先解釋一下這兩者的異同。

  相同方面:1)兩者都需要使用通用語言來命名;2)都是對動作的建模,只不過一個表示已經發生,一個表示未發生;3)一般都以訊息的方式來實現;4)都需要遵從相同的使用約束比如都應該放到BO層中;不應當在其中放入領域實體;5)一般都會觸發額外的業務動作;6)針對兩者的投遞方式,主流方式是使用訊息佇列。

  不同方面:1)從業務上來看兩者所表達的含義完全不同。領域事件表示某個已經發生的業務動作,是對於發生後的事件的建模;而領域命令所表示的動作還尚未發生;2)語義不同,事件所觸發的動作具備被動色彩:某些業務動作被引發是由於某個事件發生了。您稍微注意一下會發現我這裡使用了“某些業務動作”,說明一個事件可能觸發多個業務行為。此外,事件的釋出方在生成事件後並不期待事件的訂閱方給出響應。領域命令在業務上表示主動的含義。命令產生方主動的發起某個動作,它十分期待收到命令的那個接收者給出響應,比如通過訊息佇列給出一個響應事件。這裡還是需要注意一下命令的接收者數量:只能有一個。

  使用領域命令的場景以我個人的經歷沒法概括出全部,但在此列出有代表性的且經過個人實踐過的兩點:1)CQRS架構的應用,一般C端面使用非同步的領域命令。因為使用了這種架構一般是由於高併發的需要,使用非同步的訊息模式能更好的應對;2)Saga,Saga的使用模式是接收事件併傳送命令。使用事件的場景相對就會普遍很多,我覺得在使用DDD的戰術方式進行系統建設的時候幾乎多多少少的都會涉及到 ,最起碼在有事務需求的時候少不了。

  理論說得天花亂墜,那麼領域事件到底如何產生呢?我們們這不是嚴謹的學術型文章,所以我基於日常的實踐總結出兩種方式:1)領域模型或服務在做出某個動作後,將事件以返回值的形式生成;2)領域事件的組成需要的資訊相對複雜,需要在應用服務中進行構建。方式一我在前面展示過程式碼此處便不再重複說明,方式二如下列程式碼所示。“(1)”部分所使用的“ApplyFormTerminated”事件需要“OperatorInfo”資訊,而這個資訊並不參與業務邏輯,所以我們直接使用事件的建構函式在應用服務中建立。

public CommandHandlingResult terminate(Long id, OperatorInfo operatorInfo) {    
    OprApplyForm oprApplyForm = this.oprApplyFormRepository.findBy(id);
    if (oprApplyForm == null) {
        throw new InvalidOperationException(OperationMessages.APPLY_FORM_NOT_EXIST);
    }

    oprApplyForm.terminate();

    TransactionScope tScope = TransactionScope.create(UnitOfWorkFactory.INSTANCE, oprApplyFormRepository);
    this.oprApplyFormRepository.update(oprApplyForm);
    CommitHandlingResult commitResult = tScope.commit();
    if (commitResult.isSucceed()) {
        this.localEventBus.post(new ApplyFormTerminated(operatorInfo, oprApplyForm.getId())); // (1)
    }
}

四、事件的組成

  事件本質上是一個實體物件,正常情況下不會在裡面加入業務方法,即便有也不能修改其內部的屬性。我個人在用的時候還會將其當作DTO一般來看待並讓其具備值物件的不變特性,不會將事件作為某個實體的屬性,也不會在其中嵌入任何的實體或值物件,所有的屬性皆使用基本型別。實踐中,我們一般會給事件一些公共屬性如事件源即由誰來觸發的事件、事件產生的日期、事件ID等、請參看如下示例。

public class DomainEventBase {
    private String sourceService;
private Object sourceAggreateId;
private String id; private Date occurredOn; }

  此處我多廢話兩句。針對事件的來源“sourceService”,我一般情況下會把產生事件的類的全名+服務名賦給它。有的時候我們在應用中會發布各種各樣的事件,在排查問題的時候你都不知道這個事件到底是誰發出來的,又沒有文件來作為指導,專案著急上線也沒人寫那個東西。大多數文件都是系統上線後、驗收前後補的,做過開發的人你懂的……。這個欄位可以很有效的幫助排查問題。“sourceAggreateId”表示產生這個事件的聚合的ID。注意一點,我們這裡把事件稱之為“領域事件”,表示其作用範圍在整個領域內。比較現實的情況是並不是所有的限界上下文的實現都使用物件驅動的方式,存在著大比例數量的服務使用了事件指令碼。在這種情況下雖然沒有聚合的概念但不代表不能產生事件,所以我一般也會把某個資料實體的ID賦給“sourceAggreateId”。最後要說的是“id”這個屬性,表示事件的ID,建議把它加到事件中。因為對於事件的冪等性處理幾乎是一種事實上的標準,您可以使用一些業務資訊作為冪等的判斷標準,也可以使用事件ID,比如把它放到Redis中。收到事件後可以判斷ID是否在Redis中存在來決策是否要正常的處理這個事件。

五、事件的載體

  前面我們說過事件在技術上可以等同於訊息,不過並不是一個嚴格的定義。你當然可以使用比如REST進行事件的傳輸,這種方式雖然能滿足通用語言的需要但不能享受事件所帶來的效能上的提升。既然主流的使用方式是訊息佇列 ,那我們在實踐其實有很多的選擇。可以使用基於記憶體的BlockingQueue、Guava EventBus,也可以使用大型的分散式訊息佇列如Kafka、RabbitMQ等。涉及訊息中介軟體的部署與結構不是本文的重點,所以我們只談應用。這兩種方式在實踐中我都使用過,基於記憶體的自治性很好,也就是說你不需要依賴於外部的訊息佇列,不會因為佇列出現問題而導致應用不可用。基於記憶體的優勢還在於你通常情況下只需引用一個Jar包即可,拎包入住,在不怕訊息丟失的場景這是一個很好的選擇。所以您在使用前要評估一下是否可以容忍訊息的丟失,畢竟應用一重啟訊息也就丟了。但無論如何最好別自己寫一套新的,好多的現成工具可用何必重新造輪子,你能保證你寫得一定比Guava EvenBus好?

  另外一點就是訊息佇列的可靠性需要多加思考,比如如何避免訊息的丟失就是一個很值得投入精力的地方。當然,想保障訊息不丟失,首先在訊息佇列中介軟體的選擇上就不能隨意了。你整個記憶體型的訊息佇列還要要求訊息處理的可靠性基本上沒戲。我個人經歷的專案中使用過兩種分散式MQ:RabbitMQ和Kafka,在此我們只以前者為例介紹一下如何保障訊息的不丟失。通常下我們可選擇三種方式來進行保障:1)生產者使用Confirm機制,出現投遞問題後將訊息寫入到資料庫以用於重試;2)配置訊息佇列的時候開啟“Durable”模式並將訊息在伺服器端進行儲存(注意:此處使用的是訊息佇列叢集,單例項無論你怎麼折騰都沒戲);3)消費者開啟ACK機制。這裡面的前兩點訊息佇列都可以幫忙實現,而在消費端的訊息不丟除了ACk能起到部分作用外,還需要消費者進行保障,簡單來說只要訊息到達消費者就必須保障其成功的處理,類似於“TCC”事務中的“Confirm”處理。這一點不僅是針對RabbitMQ,包括Kafka、RocketMQ等都是一樣的要求。

  還有一點需要著重說明:在訊息的傳送端僅使用“Confirm”機制是不能保障訊息完全不丟失的。比如下列程式碼。“(1)”處的程式碼提交了一個資料庫的事務,假如此刻系統掛掉,事件也就一併丟失了。這種情況比較極端但不代表不發生。據小道訊息說“本地訊息表”方案可以解決這個問題,但到底要不要真的引入還請慎重。我們在生產者、消費者和訊息佇列配置上下得功夫已經不少了,已經能大大的保障訊息不丟。而引入本地訊息表又要做很多的工作。所以在考慮人工的介入還是嚴格的系統約束間要找到平衡,儘管作為一個技術人員我不應該說這種不負責任的話,但實現本來與理想就是存在差距的。

public class orderService {
    public void pay1(Long orderId, Money cost) {
        Order order = this.orderRepository.findBy(orderId);
        OrderPaid orderPaid = order.pay(cost);
        
        this.orderRepository.update(order);
        this.uniteOfWork.commit(); // (1)
        
        this.eventBus.post(orderPaid);
    }
}

  其實我個人也經常在專案中使用記憶體型的訊息佇列Guava EvenBus,當時的使用場景是對業務告警進行接收並用於後續的處理。雖然可能面臨訊息丟失風險,但偶然丟個一條兩條其實也不會造成多大的影響。因為業務異常有一個特性:其往往是重複錯誤,丟失部分訊息並不會有多大的問題。之所要提到這個事情其實就是想提醒讀者在專案建設的時候要一定要考慮系統建設的成本,原則上我們肯定要求不能有任何訊息的丟失,但這個事情得從兩個方面看而且絕對不可以上綱上線,極左或極右都不可能把事情做好。

六、事件處理

  我們已經說過,一個事件會有多個訂閱者。 在六邊型架構中,事件的“Adapter”處在架構的左側作為事件的輸入,但您不應該在Adapter中完成事件的處理而是應該和一般的REST呼叫一樣使用應用程式服務進行業務的協調處理。這裡有一點需要特別的注意即事件的“冪等性”,實際上在基於訊息的業務場景中大部分情況下都需要考這個事情 。可能由於網路、訊息元件和消費者處理異常等原因需要進行訊息的重發;當事件有多個訂閱方的時候,如果有一個訂閱方出現失敗可能也需要進行業務補償,而最簡單的補償方式就是把事件重發一次。總之呢,同一個訊息被重複的收到多次是非常常見的場景,那您在使用的時候就必須要投入精力做好保障。前面我們曾經說過,您可以給事件一個唯一ID比如“UUID”並在消費端把ID進行儲存以達到排重的目的;您也可以通過使用業務標記進行排除,這種方式在使用Saga的時候會經常被使用以達到事務的隔離效果。下面程式碼片段來自於我曾經做過的一個專案,此處使用業務資訊來決策某個事件是否被收到過如“(1)”處。

public void handle(WorkOrderAccepted workOrderAccepted) {
    if (this.status == ResourceBuildStatusEnum.UN_START) { // (1)
        this.status = ResourceBuildStatusEnum.SAVING_WORK_ORDER;
        this.updatedDate = new Date();
        this.message = this.status.getDescription();

        SaveWorkOrder saveWorkOrder = new SaveWorkOrder();
        saveWorkOrder.processManagerId = this.getId();
        this.commands.add(saveWorkOrder);
    }
}

  針對事件的儲存,這個其實要看具體的需要。如果不是使用ES架構的服務,至少要對核心的事件進行持久化,十分有利於後續系統的運維。由於事件是隻讀的,其儲存的記錄也不會進行更改。所以不論是使用MySQL這種關係型資料還是使用MongoDB這種NoSQL,並沒有太大的限制,主要看您的系統現狀。不過在運維工作中有一點請務必要注意:請對事件記錄進行週期性轉存。一是可以方便後續的安全審計,二是可以減少其資料佔用量以避免與其它業務資料發生空間爭搶。我個人在使用的時候直接存到了MySQL中,和業務資料進行了分離,每隔一個月備份一次資料。其實也只起到了備份的作用,平常幾乎不查。對了,最好在事件生產側進行儲存,萬一丟了呢。

 七、反思

  微服務架構下的事件使用,存在這樣一個場景,我們還是以本章中的“訂單支付後需要給其所屬賬戶增加10點成就值”這個需求為例。假如訂單服務釋出了一個“OrderPaid”事件,在賬戶服務中要如何進行處理呢?我們是否需要設計一個和“OrderPaid”結構一模一樣的類且保持“OrderPaid”命名不變,簡單來說就是把這個事件的程式碼複製到賬戶服務中。另外一個選擇是我們在賬戶服務中建立一個和“OrderPaid”結構一樣但叫做“ChangeRewardPoint”的領域命令,使用命令代替原來的事件來處理“積分變更”這個業務。請發揮您的聰明才智,也期待您的回覆。

總結

  本節講解了領域事件的使用,在實踐中請您結合自身的業務需求尤其是基於“CAP”理論來決策是否應該使用,不要被先入為主的想法矇蔽雙眼。我們還講解了事件的通常結構、事件的載體和事件的儲存。您別一時用得痛快結果由於不能全面考慮造成後續運維成本的加大。我個人的工作經歷中有一段時間是作為運營運維的角色存在,相信您在我的文章中總會看到我會提及系統的運維。個人其實更中意軟體設計與研發的工作,可也正是因為這段運維經歷讓自己在考慮事情的時候不會那麼侷限,能夠站在不同的維度去思考。

  客觀來講,基於事件驅動的服務用起來的確很痛快。一是建模的粒度比較細,讓系統的擴充套件點增加了很多。很多的時候加個功能不過是增加一個事件的消費者而矣,並不會因為新加入的邏輯引發全域性BUG或效能損耗。二是系統的效能會有很多的提升,服務解耦處理做得也比較優雅。然而事情有利也有弊,請客觀的、務實的、謹慎的進行選擇。

相關文章