天天看點

在微服務中使用領域事件

稍微回想一下計算機硬體的工作原理我們便不難發現,整個計算機的工作過程其實就是一個對事件的處理過程。當你點選滑鼠、敲擊鍵盤或者插上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事務或者全局事務)。另外,這種方式還在訂單系統和賬戶系統之間産生了強耦合。通過引入領域事件,我們可以很好地解決上述問題。 

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

解耦微服務(限界上下文)

幫助我們深入了解領域模型

提供審計和報告的資料來源

邁向事件溯源(event sourcing)和cqrs等

還是以上面的電商網站為例,當使用者下單之後,訂單系統将發出一個“使用者已下單”的領域事件,并釋出到消息系統中,此時下單便完成了。賬戶系統訂閱了消息系統中的“使用者已下單”事件,當事件到達時進行處理,提取事件中的訂單資訊,再調用自身的積分引擎(也有可能是另一個微服務)計算積分,最後更新使用者積分。可以看到,此時的訂單系統在發送了事件之後,整個用例操作便結束了,根本不用關心是誰收到了事件或者對事件做了什麼處理。事件的消費方可以是賬戶系統,也可以是任何一個對事件感興趣的第三方,比如物流系統。由此,各個微服務之間的耦合關系便解開了。值得注意的一點是,此時各個微服務之間不再是強一緻性,而是基于事件的最終一緻性。 

在微服務中使用領域事件

事件風暴(event storming) 

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

在微服務中使用領域事件

建立領域事件 

領域事件應該回答“什麼人什麼時候做了什麼事情”這樣的問題,在實際編碼中,可以考慮采用層超類型(layer supertype)來包含事件的某些共有屬性: 

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

可以看到,領域事件還包含了id,但是該id并不是實體(entity)層面的id概念,而是主要用于事件追溯和日志。另外,由于領域事件描述的是過去發生的事情,我們應該将領域事件模組化成不可變的(immutable)。從ddd概念上講,領域事件更像一種特殊的值對象(value

object)。對于上文中提到的咖啡廳例子,建立“客戶已到達”事件如下: 

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

在這個customerarrivedevent事件中,除了繼承自event的屬性外,還自定義了一個與該事件密切關聯的業務屬性——客戶人數(customernumber)——這樣後續操作便可預留相應數目的座位了。另外,我們将所有屬性以及customerarrivedevent本身都聲明成了final,并且不向外暴露任何可能修改這些屬性的方法,這樣便保證了事件的不變性。

釋出領域事件 

在使用領域事件時,我們通常采用“釋出-訂閱”的方式來內建不同的子產品或系統。在單個微服務内部,我們可以使用領域事件來內建不同的功能元件,比如在上文中提到的“使用者注冊之後向使用者發送歡迎郵件”的例子中,注冊元件發出一個事件,郵件發送元件接收到該事件後向使用者發送郵件。 

在微服務中使用領域事件

在微服務内部使用領域事件時,我們不一定非得引入消息中間件(比如activemq等)。還是以上面的“注冊後發送歡迎郵件”為例,注冊行為和發送郵件行為雖然通過領域事件內建,但是他們依然發生在同一個線程中,并且是同步的。另外需要注意的是,在限界上下文之内使用領域事件時,我們依然需要遵循“一個事務隻更新一個聚合根”的原則,違反之往往意味着我們對聚合根的拆分是錯的。即便确實存在這樣的情況,也應該通過異步的方式(此時需要引入消息中間件)對不同的聚合根采用不同的事務,此時可以考慮使用背景任務。

除了用于微服務的内部,領域事件更多的是被用于內建不同的微服務,如上文中的“電商訂單”例子。 

在微服務中使用領域事件

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

一種直接的方式是在聚合根中直接調用釋出事件的service對象。以上文中的“電商訂單”為例,當建立訂單時,釋出“訂單已建立”領域事件。此時可以考慮在訂單對象的構造函數中釋出事件: 

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

注:為了把焦點集中在事件釋出上,我們對order對象做了簡化,order對象本身在實際編碼中不具備參考性。 

可以看到,為了釋出orderplacedevent事件,我們需要将service對象eventpublisher傳入,這顯然是一種api污染,即order作為一個領域對象隻需要關注和業務相關的資料,而不是諸如eventpublisher這樣的基礎設施對象。 另一種方法是由nservicebus的創始人udi

dahan提出來的,即在領域對象中通過調用eventpublisher上的靜态方法釋出領域事件:

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

這種方法雖然避免了api污染,但是這裡的publish()靜态方法将産生副作用,對order對象的測試帶來了難處。此時,我們可以采用“在聚合根中臨時儲存領域事件”的方式予以改進:

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

在測試order對象時,我們便你可以通過驗證events集合保證order對象在建立時的确釋出了orderplacedevent事件:

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

在這種方式中,聚合根對領域事件的儲存隻能是臨時的,在對該聚合根操作完成之後,我們應該将領域事件釋出出去并及時清空events集合。可以考慮在持久化聚合根時進行這樣的操作,在ddd中即為資源庫(repository):

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

除此之外,還有一種與“臨時儲存領域事件”相似的做法是“在聚合根方法中直接傳回領域事件”,然後在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事務或者采用單獨的事件表。為了避免事件重複帶來的問題,最好的方式是将事件的消費方建立為幂等的。 

作者:無知者雲

來源:51cto