close
X

在微服務中使用領網域事件


稍微回想一下計算機硬體的工作原理我們便不難發現,整個計算機的工作過程其實就是一個對事件的處理過程。當你點選滑鼠、敲擊鍵盤或者插上U盤時,計算機便以中斷的形式處理各種外部事件。在軟體開發領網域事件驅動架構(Event Driven Architecture,EDA)早已被開發者用於各種實踐,典型的應用場景比如瀏覽器對使用者輸入的處理、消息機制以及SOA。最近幾年重新進入開發者視野的響應式程式開發(Reactive Programming)更是將事件作為該程式開發模型中的一等公民。可見,「事件」這個概念一直在電腦科學領網域中扮演著重要的角色。

認識領網域事件 

領網域事件(Domain Events)是領網域驅動設計(Domain Driven Design,DDD)中的一個概念,用於捕獲我們所建模的領網域中所發生過的事情。領網域事件本身也作為通用語言(Ubiquitous Language)的一部抽成為包括領網域專家在內的所有專案成員的交流用語。比如,在使用者註冊過程中,我們可能會說「當使用者註冊成功之後,傳送一封歡迎郵件給客戶。」,此時的「使用者已經註冊」便是一個領網域事件。 

當然,並不是所有發生過的事情都可以成為領網域事件。一個領網域事件必須對業務有價值,有助於形成完整的業務閉環,也即一個領網域事件將導致進一步的業務操作。舉個咖啡廳建模的例子,當客戶來到前臺時將產生「客戶已到達」的事件,如果你關注的是客戶接待,比如需要為客戶預留位置等,那麼此時的「客戶已到達」便是一個典型的領網域事件,因為它將用於觸發下一步——「預留位置」操作;但是如果你建模的是咖啡結賬系統,那麼此時的「客戶已到達」便沒有多大存在的必要——你不可能在使用者到達時就立即向客戶要錢對吧,而」客戶已下單「才是對結賬系統有用的事件。 

在微服務(Microservices)架構實踐中,人們大量地借用了DDD中的概念和技術,比如一個微服務應該對應DDD中的一個限界上下文(Bounded Context);在微服務設計中應該首先識別出DDD中的聚合根(Aggregate Root);還有在微服務之間整合時採用DDD中的防腐層(Anti-Corruption Layer, ACL);我們甚至可以說DDD和微服務有著天生的默契。更多有關DDD的內容,請參考筆者的另一篇文章或參考《領網域驅動設計》及《實現領網域驅動設計》。 

在DDD中有一條原則:一個業務用例對應一個事務,一個事務對應一個聚合根,也即在一次事務中,只能對一個聚合根進行操作。但是在實際應用中,我們經常發現一個用例需要修改多個聚合根的情況,並且不同的聚合根還處於不同的限界上下文中。比如,當你在電商網站上買了東西之後,你的積分會相應增加。這裡的購買行為可能被建模為一個訂單(Order)對象,而積分可以建模成賬戶(Account)對象的某個屬性,訂單和賬戶均為聚合根,並且分別屬於訂單系統和賬戶系統。顯然,我們需要在訂單和積分之間維護資料一致性,然而在同一個事務中同時更新兩者又違背了DDD設計原則,並且此時需要在兩個不同的系統之間採用重量級的分散式事務(Distributed Transactioin,也叫XA事務或者全域性事務)。另外,這種方式還在訂單系統和賬戶系統之間產生了強耦合。通過引入領網域事件,我們可以很好地解決上述問題。 

總的來說,領網域事件給我們帶來以下好處: 

  1. 解耦微服務(限界上下文)
  1. 幫助我們深入理解領網域模型
  1. 提供審計和報告的資料來源
  1. 邁向事件(Event Sourcing)和CQRS等
還是以上面的電商網站為例,當使用者下單之後,訂單系統將發出一個「使用者已下單」的領網域事件,併發布到消息系統中,此時下單便完成了。賬戶系統訂閱了消息系統中的「使用者已下單」事件,當事件到達時進行處理,提取事件中的訂單資訊,再呼叫自身的積分引擎(也有可能是另一個微服務)計算積分,最後更新使用者積分。可以看到,此時的訂單系統在傳送了事件之後,整個用例操作便結束了,根本不用關心是誰收到了事件或者對事件做了什麼處理。事件的消費方可以是賬戶系統,也可以是任何一個對事件感興趣的第三方,比如物流系統。由此,各個微服務之間的耦合關係便解開了。值得注意的一點是,此時各個微服務之間不再是強一致性,而是基於事件的最終一致性。 

事件風暴(Event Storming) 

事件風暴是一項團隊活動,旨在通過領網域事件識別出聚合根,進而劃分微服務的限界上下文。在活動中,團隊先通過頭腦風暴的形式羅列出領網域中所有的領網域事件,整合之後形成最終的領網域事件集合,然後對於每一個事件,標註出導致該事件的命令(Command),再然後為每個事件標註出命令發起方的角色,命令可以是使用者發起,也可以是第三方系統呼叫或者是定時器觸發等。最後對事件進行分類整理出聚合根以及限界上下文。事件風暴還有一個額外的好處是可以加深參與人員對領網域的認識。需要注意的是,在事件風暴活動中,領網域專家是必須在場的。

建立領網域事件 

領網域事件應該回答「什麼人什麼時候做了什麼事情」這樣的問題,在實際編碼中,可以考慮採用層超型別(Layer Supertype)來包含事件的某些共有屬性: 

public abstract class Event {    private final UUID id;    private final DateTime createdTime;    public Event() {        this.id = UUID.randomUUID();        this.createdTime = new DateTime();    }}
可以看到,領網域事件還包含了ID,但是該ID並不是實體(Entity)層面的ID概念,而是主要用於事件追溯和日誌。另外,由於領網域事件描述的是過去發生的事情,我們應該將領網域事件建模成不可變的(Immutable)。從DDD概念上講,領網域事件更像一種特殊的值對象(Value Object)。對於上文中提到的咖啡廳例子,建立「客戶已到達」事件如下: 

public final class CustomerArrivedEvent extends Event {    private final int customerNumber;    public CustomerArrivedEvent(int customerNumber) {        super();        this.customerNumber = customerNumber;    }}
在這個CustomerArrivedEvent事件中,除了繼承自Event的屬性外,還自定義了一個與該事件密切關聯的業務屬性——客戶人數(customerNumber)——這樣後續操作便可預留相應數目的座位了。另外,我們將所有屬性以及CustomerArrivedEvent本身都宣告成了final,並且不向外暴露任何可能修改這些屬性的方法,這樣便保證了事件的不變性。

釋出領網域事件 

在使用領網域事件時,我們通常採用「釋出-訂閱」的方式來整合不同的模組或系統。在單個微服務內部,我們可以使用領網域事件來整合不同的功能元件,比如在上文中提到的「使用者註冊之後向使用者傳送歡迎郵件」的例子中,註冊元件發出一個事件,郵件傳送元件接收到該事件後向使用者傳送郵件。 


在微服務內部使用領網域事件時,我們不一定非得引入消息中介軟體(比如ActiveMQ等)。還是以上面的「註冊後傳送歡迎郵件」為例,註冊行為和傳送郵件行為雖然通過領網域事件整合,但是他們依然發生在同一個執行緒中,並且是同步的。另外需要注意的是,在限界上下文之內使用領網域事件時,我們依然需要遵循「一個事務只更新一個聚合根」的原則,違反之往往意味著我們對聚合根的拆分是錯的。即便確實存在這樣的情況,也應該通過非同步的方式(此時需要引入消息中介軟體)對不同的聚合根採用不同的事務,此時可以考慮使用後臺任務。

除了用於微服務的內部,領網域事件更多的是被用於整合不同的微服務,如上文中的「電商訂單」例子。 

通常,領網域事件產生於領網域對象中,或者更準確的說是產生於聚合根中。在具體編碼實現時,有多種方式可用於釋出領網域事件。 

一種直接的方式是在聚合根中直接呼叫釋出事件的Service對象。以上文中的「電商訂單」為例,當建立訂單時,釋出「訂單已建立」領網域事件。此時可以考慮在訂單對象的建構函式中釋出事件: 

public class Order {    public Order(EventPublisher eventPublisher) {        //create order                //…                eventPublisher.publish(new OrderPlacedEvent());            }}
注:為了把焦點集中在事件釋出上,我們對Order對象做了簡化,Order對象本身在實際編碼中不具備參考性。 

可以看到,為了釋出OrderPlacedEvent事件,我們需要將Service對象EventPublisher傳入,這顯然是一種API汙染,即Order作為一個領網域對象只需要關注和業務相關的資料,而不是諸如EventPublisher這樣的基礎設施對象。 另一種方法是由NServiceBus的創始人Udi Dahan提出來的,即在領網域對象中通過呼叫EventPublisher上的靜態方法釋出領網域事件

public class Order {    public Order() {        //create order        //...        EventPublisher.publish(new OrderPlacedEvent());    }}
這種方法雖然避免了API汙染,但是這裡的publish()靜態方法將產生副作用,對Order對象的測試帶來了難處。此時,我們可以採用「在聚合根中臨時儲存領網域事件」的方式予以改進:

public class Order {    private List<Event> events;    public Order() {        //create order        //...        events.add(new OrderPlacedEvent());    }    public List<Event> getEvents() {        return events;    }    public void clearEvents() {        events.clear();    }} 
在測試Order對象時,我們便你可以通過驗證events集合保證Order對象在建立時的確釋出了OrderPlacedEvent事件

@Testpublic void shouldPublishEventWhenCreateOrder() {    Order order = new Order();    List<Event> events = order.getEvents();    assertEquals(1, events.size());    Event event = events.get(0);    assertTrue(event instanceof OrderPlacedEvent);} 
在這種方式中,聚合根對領網域事件的儲存只能是臨時的,在對該聚合根操作完成之後,我們應該將領網域事件釋出出去並及時清空events集合。可以考慮在持久化聚合根時進行這樣的操作,在DDD中即為資源庫(Repository):

public class OrderRepository {    private EventPublisher eventPublisher;    public void save(Order order) {        //save the order        //...        List<Event> events = order.getEvents();        events.forEach(event -> eventPublisher.publish(event));        order.clearEvents();    }}
除此之外,還有一種與「臨時儲存領網域事件」相似的做法是「在聚合根方法中直接返回領網域事件」,然後在Repository中進行釋出。這種方式依然有很好的可測性,並且開發人員不用手動清空先前的事件集合,不過還是得記住在Repository中將事件釋出出去。另外,這種方式不適合建立聚合根的場景,因為此時的建立過程既要返回聚合根本身,又要返回領網域事件

 這種方式也有不好的地方,比如它要求開發人員在每次更新聚合根時都必須記得清空events集合,忘記這麼做將為程式帶來嚴重的bug。不過雖然如此,這依然是筆者比較推薦的方式。 

業務操作和事件釋出的原子性 

雖然在不同聚合根之間我們採用了基於領網域事件的最終一致性,但是在業務操作和事件釋出之間我們依然需要採用強一致性,也即這兩者的發生應該是原子的,要麼全部成功,要麼全部失敗,否則最終一致性根本無從談起。以上文中「訂單積分」為例,如果客戶下單成功,但是事件傳送失敗,下游的賬戶系統便拿不到事件,導致最終客戶的積分並不增加。 

要保證業務操作和事件釋出之間的原子性,最直接的方法便是採用XA事務,比如Java中的JTA,這種方式由於其重量級並不被人們所看好。但是,對於一些對效能要求不那麼高的系統,這種方式未嘗不是一個選擇。一些開發框架已經能夠支持獨立於應用伺服器的XA事務管理器(如Atomikos 和Bitronix),比如Spring Boot作為一個微服務框架便提供了對Atomikos和Bitronix的支持。 

如果JTA不是你的選項,那麼可以考慮採用事件表的方式。這種方式首先將事件儲存到聚合根所在的資料庫中,由於事件表和聚合根表同屬一個資料庫,整個過程只需要一個本地事務就能完成。然後,在一個單獨的後臺任務中讀取事件表中未釋出的事件,再將事件釋出到消息中介軟體中。 

這種方式需要注意兩個問題,第一個是由於釋出了事件之後需要將表中的事件標記成「已釋出」狀態,即依然涉及到對資料庫的操作,因此釋出事件和標記「已釋出」之間需要原子性。當然,此時依舊可以採用XA事務,但是這違背了採用事件表的初衷。一種解決方法是將事件的消費方建立成冪等的,即消費方可以多次消費同一個事件。這個過程大致為:整個過程中事件傳送和資料庫更新採用各自的事務管理,此時有可能發生的情況是事件傳送成功而資料庫更新失敗,這樣在下一次事件釋出操作中,由於先前釋出過的事件在資料庫中依然是「未釋出」狀態,該事件將被重新發布到消息系統中,導致事件重複,但由於事件的消費方是冪等的,因此事件重複不會存在問題。 

另外一個需要注意的問題是持久化機制的選擇。其實對於DDD中的聚合根來說,NoSQL是相比於關係型資料庫更合適的選擇,比如用MongoDB的Document儲存聚合根便是種很自然的方式。但是多數NoSQL是不支持ACID的,也就是說不能保證聚合更新和事件釋出之間的原子性。還好,關係型資料庫也在向NoSQL方向發展,比如新版本的PostgreSQL(版本9.4)和MySQL(版本5.7)已經能夠提供具備NoSQL特徵的JSON儲存和基於JSON的查詢。此時,我們可以考慮將聚合根序列化成JSON格式的資料進行儲存,從而避免了使用重量級的ORM工具,又可以在多個資料之間保證ACID,何樂而不為? 

總結

領網域事件主要用於解耦微服務,此時各個微服務之間將形成最終一致性。事件風暴活動有助於我們對微服務進行拆分,並且有助於我們深入瞭解某個領網域領網域事件作為已經發生過的歷史資料,在建模時應該將其建立為不可變的特殊值對象。存在多種方式用於釋出領網域事件,其中「在聚合中臨時儲存領網域事件」的方式是值得推崇的。另外,我們需要考慮到聚合更新和事件釋出之間的原子性,可以考慮使用XA事務或者採用單獨的事件表。為了避免事件重複帶來的問題,最好的方式是將事件的消費方建立為冪等的。 

  1. 微服務時代 怎麼看華為軟體開發雲實現DevOps落地
  2. 微服務架構:基於微服務和Docker容器技術的PaaS雲平臺架構設計(微服務架構實施原理)
  3. 成功備戰微服務的5個準備步驟
  4. 技術乾貨|如何在微服務架構下構建高效的運維管理平臺?



分享是一種美德,喜歡就幫我們讚一下支持吧~

為你推薦