在微服務(wù)中使用領(lǐng)域事件
作者:無(wú)知者云
最近幾年重新進(jìn)入開發(fā)者視野的響應(yīng)式編程(Reactive Programming)更是將事件作為該編程模型中的一等公民。可見,“事件”這個(gè)概念一直在計(jì)算機(jī)科學(xué)領(lǐng)域中扮演著重要的角色。
稍微回想一下計(jì)算機(jī)硬件的工作原理我們便不難發(fā)現(xiàn),整個(gè)計(jì)算機(jī)的工作過(guò)程其實(shí)就是一個(gè)對(duì)事件的處理過(guò)程。當(dāng)你點(diǎn)擊鼠標(biāo)、敲擊鍵盤或者插上U盤時(shí),計(jì)算機(jī)便以中斷的形式處理各種外部事件。在軟件開發(fā)領(lǐng)域,事件驅(qū)動(dòng)架構(gòu)(Event Driven Architecture,EDA)早已被開發(fā)者用于各種實(shí)踐,典型的應(yīng)用場(chǎng)景比如瀏覽器對(duì)用戶輸入的處理、消息機(jī)制以及SOA。最近幾年重新進(jìn)入開發(fā)者視野的響應(yīng)式編程(Reactive Programming)更是將事件作為該編程模型中的一等公民??梢姡?ldquo;事件”這個(gè)概念一直在計(jì)算機(jī)科學(xué)領(lǐng)域中扮演著重要的角色。
認(rèn)識(shí)領(lǐng)域事件
領(lǐng)域事件(Domain Events)是領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)(Domain Driven Design,DDD)中的一個(gè)概念,用于捕獲我們所建模的領(lǐng)域中所發(fā)生過(guò)的事情。領(lǐng)域事件本身也作為通用語(yǔ)言(Ubiquitous Language)的一部分成為包括領(lǐng)域?qū)<以趦?nèi)的所有項(xiàng)目成員的交流用語(yǔ)。比如,在用戶注冊(cè)過(guò)程中,我們可能會(huì)說(shuō)“當(dāng)用戶注冊(cè)成功之后,發(fā)送一封歡迎郵件給客戶。”,此時(shí)的“用戶已經(jīng)注冊(cè)”便是一個(gè)領(lǐng)域事件。
當(dāng)然,并不是所有發(fā)生過(guò)的事情都可以成為領(lǐng)域事件。一個(gè)領(lǐng)域事件必須對(duì)業(yè)務(wù)有價(jià)值,有助于形成完整的業(yè)務(wù)閉環(huán),也即一個(gè)領(lǐng)域事件將導(dǎo)致進(jìn)一步的業(yè)務(wù)操作。舉個(gè)咖啡廳建模的例子,當(dāng)客戶來(lái)到前臺(tái)時(shí)將產(chǎn)生“客戶已到達(dá)”的事件,如果你關(guān)注的是客戶接待,比如需要為客戶預(yù)留位置等,那么此時(shí)的“客戶已到達(dá)”便是一個(gè)典型的領(lǐng)域事件,因?yàn)樗鼘⒂糜谟|發(fā)下一步——“預(yù)留位置”操作;但是如果你建模的是咖啡結(jié)賬系統(tǒng),那么此時(shí)的“客戶已到達(dá)”便沒(méi)有多大存在的必要——你不可能在用戶到達(dá)時(shí)就立即向客戶要錢對(duì)吧,而”客戶已下單“才是對(duì)結(jié)賬系統(tǒng)有用的事件。
在微服務(wù)(Microservices)架構(gòu)實(shí)踐中,人們大量地借用了DDD中的概念和技術(shù),比如一個(gè)微服務(wù)應(yīng)該對(duì)應(yīng)DDD中的一個(gè)限界上下文(Bounded Context);在微服務(wù)設(shè)計(jì)中應(yīng)該首先識(shí)別出DDD中的聚合根(Aggregate Root);還有在微服務(wù)之間集成時(shí)采用DDD中的防腐層(Anti-Corruption Layer, ACL);我們甚至可以說(shuō)DDD和微服務(wù)有著天生的默契。更多有關(guān)DDD的內(nèi)容,請(qǐng)參考筆者的另一篇文章或參考《領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)》及《實(shí)現(xiàn)領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)》。
在DDD中有一條原則:一個(gè)業(yè)務(wù)用例對(duì)應(yīng)一個(gè)事務(wù),一個(gè)事務(wù)對(duì)應(yīng)一個(gè)聚合根,也即在一次事務(wù)中,只能對(duì)一個(gè)聚合根進(jìn)行操作。但是在實(shí)際應(yīng)用中,我們經(jīng)常發(fā)現(xiàn)一個(gè)用例需要修改多個(gè)聚合根的情況,并且不同的聚合根還處于不同的限界上下文中。比如,當(dāng)你在電商網(wǎng)站上買了東西之后,你的積分會(huì)相應(yīng)增加。這里的購(gòu)買行為可能被建模為一個(gè)訂單(Order)對(duì)象,而積分可以建模成賬戶(Account)對(duì)象的某個(gè)屬性,訂單和賬戶均為聚合根,并且分別屬于訂單系統(tǒng)和賬戶系統(tǒng)。顯然,我們需要在訂單和積分之間維護(hù)數(shù)據(jù)一致性,然而在同一個(gè)事務(wù)中同時(shí)更新兩者又違背了DDD設(shè)計(jì)原則,并且此時(shí)需要在兩個(gè)不同的系統(tǒng)之間采用重量級(jí)的分布式事務(wù)(Distributed Transactioin,也叫XA事務(wù)或者全局事務(wù))。另外,這種方式還在訂單系統(tǒng)和賬戶系統(tǒng)之間產(chǎn)生了強(qiáng)耦合。通過(guò)引入領(lǐng)域事件,我們可以很好地解決上述問(wèn)題。
總的來(lái)說(shuō),領(lǐng)域事件給我們帶來(lái)以下好處:
- 解耦微服務(wù)(限界上下文)
- 幫助我們深入理解領(lǐng)域模型
- 提供審計(jì)和報(bào)告的數(shù)據(jù)來(lái)源
- 邁向事件溯源(Event Sourcing)和CQRS等
還是以上面的電商網(wǎng)站為例,當(dāng)用戶下單之后,訂單系統(tǒng)將發(fā)出一個(gè)“用戶已下單”的領(lǐng)域事件,并發(fā)布到消息系統(tǒng)中,此時(shí)下單便完成了。賬戶系統(tǒng)訂閱了消息系統(tǒng)中的“用戶已下單”事件,當(dāng)事件到達(dá)時(shí)進(jìn)行處理,提取事件中的訂單信息,再調(diào)用自身的積分引擎(也有可能是另一個(gè)微服務(wù))計(jì)算積分,***更新用戶積分??梢钥吹?,此時(shí)的訂單系統(tǒng)在發(fā)送了事件之后,整個(gè)用例操作便結(jié)束了,根本不用關(guān)心是誰(shuí)收到了事件或者對(duì)事件做了什么處理。事件的消費(fèi)方可以是賬戶系統(tǒng),也可以是任何一個(gè)對(duì)事件感興趣的第三方,比如物流系統(tǒng)。由此,各個(gè)微服務(wù)之間的耦合關(guān)系便解開了。值得注意的一點(diǎn)是,此時(shí)各個(gè)微服務(wù)之間不再是強(qiáng)一致性,而是基于事件的最終一致性。
事件風(fēng)暴(Event Storming)
事件風(fēng)暴是一項(xiàng)團(tuán)隊(duì)活動(dòng),旨在通過(guò)領(lǐng)域事件識(shí)別出聚合根,進(jìn)而劃分微服務(wù)的限界上下文。在活動(dòng)中,團(tuán)隊(duì)先通過(guò)頭腦風(fēng)暴的形式羅列出領(lǐng)域中所有的領(lǐng)域事件,整合之后形成最終的領(lǐng)域事件集合,然后對(duì)于每一個(gè)事件,標(biāo)注出導(dǎo)致該事件的命令(Command),再然后為每個(gè)事件標(biāo)注出命令發(fā)起方的角色,命令可以是用戶發(fā)起,也可以是第三方系統(tǒng)調(diào)用或者是定時(shí)器觸發(fā)等。***對(duì)事件進(jìn)行分類整理出聚合根以及限界上下文。事件風(fēng)暴還有一個(gè)額外的好處是可以加深參與人員對(duì)領(lǐng)域的認(rèn)識(shí)。需要注意的是,在事件風(fēng)暴活動(dòng)中,領(lǐng)域?qū)<沂潜仨氃趫?chǎng)的。
創(chuàng)建領(lǐng)域事件
領(lǐng)域事件應(yīng)該回答“什么人什么時(shí)候做了什么事情”這樣的問(wèn)題,在實(shí)際編碼中,可以考慮采用層超類型(Layer Supertype)來(lái)包含事件的某些共有屬性:
可以看到,領(lǐng)域事件還包含了ID,但是該ID并不是實(shí)體(Entity)層面的ID概念,而是主要用于事件追溯和日志。另外,由于領(lǐng)域事件描述的是過(guò)去發(fā)生的事情,我們應(yīng)該將領(lǐng)域事件建模成不可變的(Immutable)。從DDD概念上講,領(lǐng)域事件更像一種特殊的值對(duì)象(Value Object)。對(duì)于上文中提到的咖啡廳例子,創(chuàng)建“客戶已到達(dá)”事件如下:
在這個(gè)CustomerArrivedEvent事件中,除了繼承自Event的屬性外,還自定義了一個(gè)與該事件密切關(guān)聯(lián)的業(yè)務(wù)屬性——客戶人數(shù)(customerNumber)——這樣后續(xù)操作便可預(yù)留相應(yīng)數(shù)目的座位了。另外,我們將所有屬性以及CustomerArrivedEvent本身都聲明成了final,并且不向外暴露任何可能修改這些屬性的方法,這樣便保證了事件的不變性。
發(fā)布領(lǐng)域事件
在使用領(lǐng)域事件時(shí),我們通常采用“發(fā)布-訂閱”的方式來(lái)集成不同的模塊或系統(tǒng)。在單個(gè)微服務(wù)內(nèi)部,我們可以使用領(lǐng)域事件來(lái)集成不同的功能組件,比如在上文中提到的“用戶注冊(cè)之后向用戶發(fā)送歡迎郵件”的例子中,注冊(cè)組件發(fā)出一個(gè)事件,郵件發(fā)送組件接收到該事件后向用戶發(fā)送郵件。
在微服務(wù)內(nèi)部使用領(lǐng)域事件時(shí),我們不一定非得引入消息中間件(比如ActiveMQ等)。還是以上面的“注冊(cè)后發(fā)送歡迎郵件”為例,注冊(cè)行為和發(fā)送郵件行為雖然通過(guò)領(lǐng)域事件集成,但是他們依然發(fā)生在同一個(gè)線程中,并且是同步的。另外需要注意的是,在限界上下文之內(nèi)使用領(lǐng)域事件時(shí),我們依然需要遵循“一個(gè)事務(wù)只更新一個(gè)聚合根”的原則,違反之往往意味著我們對(duì)聚合根的拆分是錯(cuò)的。即便確實(shí)存在這樣的情況,也應(yīng)該通過(guò)異步的方式(此時(shí)需要引入消息中間件)對(duì)不同的聚合根采用不同的事務(wù),此時(shí)可以考慮使用后臺(tái)任務(wù)。
除了用于微服務(wù)的內(nèi)部,領(lǐng)域事件更多的是被用于集成不同的微服務(wù),如上文中的“電商訂單”例子。
通常,領(lǐng)域事件產(chǎn)生于領(lǐng)域?qū)ο笾校蛘吒鼫?zhǔn)確的說(shuō)是產(chǎn)生于聚合根中。在具體編碼實(shí)現(xiàn)時(shí),有多種方式可用于發(fā)布領(lǐng)域事件。
一種直接的方式是在聚合根中直接調(diào)用發(fā)布事件的Service對(duì)象。以上文中的“電商訂單”為例,當(dāng)創(chuàng)建訂單時(shí),發(fā)布“訂單已創(chuàng)建”領(lǐng)域事件。此時(shí)可以考慮在訂單對(duì)象的構(gòu)造函數(shù)中發(fā)布事件:
注:為了把焦點(diǎn)集中在事件發(fā)布上,我們對(duì)Order對(duì)象做了簡(jiǎn)化,Order對(duì)象本身在實(shí)際編碼中不具備參考性。
可以看到,為了發(fā)布OrderPlacedEvent事件,我們需要將Service對(duì)象EventPublisher傳入,這顯然是一種API污染,即Order作為一個(gè)領(lǐng)域?qū)ο笾恍枰P(guān)注和業(yè)務(wù)相關(guān)的數(shù)據(jù),而不是諸如EventPublisher這樣的基礎(chǔ)設(shè)施對(duì)象。 另一種方法是由NServiceBus的創(chuàng)始人Udi Dahan提出來(lái)的,即在領(lǐng)域?qū)ο笾型ㄟ^(guò)調(diào)用EventPublisher上的靜態(tài)方法發(fā)布領(lǐng)域事件:
這種方法雖然避免了API污染,但是這里的publish()靜態(tài)方法將產(chǎn)生副作用,對(duì)Order對(duì)象的測(cè)試帶來(lái)了難處。此時(shí),我們可以采用“在聚合根中臨時(shí)保存領(lǐng)域事件”的方式予以改進(jìn):
在測(cè)試Order對(duì)象時(shí),我們便你可以通過(guò)驗(yàn)證events集合保證Order對(duì)象在創(chuàng)建時(shí)的確發(fā)布了OrderPlacedEvent事件:
在這種方式中,聚合根對(duì)領(lǐng)域事件的保存只能是臨時(shí)的,在對(duì)該聚合根操作完成之后,我們應(yīng)該將領(lǐng)域事件發(fā)布出去并及時(shí)清空events集合。可以考慮在持久化聚合根時(shí)進(jìn)行這樣的操作,在DDD中即為資源庫(kù)(Repository):
除此之外,還有一種與“臨時(shí)保存領(lǐng)域事件”相似的做法是“在聚合根方法中直接返回領(lǐng)域事件”,然后在Repository中進(jìn)行發(fā)布。這種方式依然有很好的可測(cè)性,并且開發(fā)人員不用手動(dòng)清空先前的事件集合,不過(guò)還是得記住在Repository中將事件發(fā)布出去。另外,這種方式不適合創(chuàng)建聚合根的場(chǎng)景,因?yàn)榇藭r(shí)的創(chuàng)建過(guò)程既要返回聚合根本身,又要返回領(lǐng)域事件。
這種方式也有不好的地方,比如它要求開發(fā)人員在每次更新聚合根時(shí)都必須記得清空events集合,忘記這么做將為程序帶來(lái)嚴(yán)重的bug。不過(guò)雖然如此,這依然是筆者比較推薦的方式。
業(yè)務(wù)操作和事件發(fā)布的原子性
雖然在不同聚合根之間我們采用了基于領(lǐng)域事件的最終一致性,但是在業(yè)務(wù)操作和事件發(fā)布之間我們依然需要采用強(qiáng)一致性,也即這兩者的發(fā)生應(yīng)該是原子的,要么全部成功,要么全部失敗,否則最終一致性根本無(wú)從談起。以上文中“訂單積分”為例,如果客戶下單成功,但是事件發(fā)送失敗,下游的賬戶系統(tǒng)便拿不到事件,導(dǎo)致最終客戶的積分并不增加。
要保證業(yè)務(wù)操作和事件發(fā)布之間的原子性,最直接的方法便是采用XA事務(wù),比如Java中的JTA,這種方式由于其重量級(jí)并不被人們所看好。但是,對(duì)于一些對(duì)性能要求不那么高的系統(tǒng),這種方式未嘗不是一個(gè)選擇。一些開發(fā)框架已經(jīng)能夠支持獨(dú)立于應(yīng)用服務(wù)器的XA事務(wù)管理器(如Atomikos 和Bitronix),比如Spring Boot作為一個(gè)微服務(wù)框架便提供了對(duì)Atomikos和Bitronix的支持。
如果JTA不是你的選項(xiàng),那么可以考慮采用事件表的方式。這種方式首先將事件保存到聚合根所在的數(shù)據(jù)庫(kù)中,由于事件表和聚合根表同屬一個(gè)數(shù)據(jù)庫(kù),整個(gè)過(guò)程只需要一個(gè)本地事務(wù)就能完成。然后,在一個(gè)單獨(dú)的后臺(tái)任務(wù)中讀取事件表中未發(fā)布的事件,再將事件發(fā)布到消息中間件中。
這種方式需要注意兩個(gè)問(wèn)題,***個(gè)是由于發(fā)布了事件之后需要將表中的事件標(biāo)記成“已發(fā)布”狀態(tài),即依然涉及到對(duì)數(shù)據(jù)庫(kù)的操作,因此發(fā)布事件和標(biāo)記“已發(fā)布”之間需要原子性。當(dāng)然,此時(shí)依舊可以采用XA事務(wù),但是這違背了采用事件表的初衷。一種解決方法是將事件的消費(fèi)方創(chuàng)建成冪等的,即消費(fèi)方可以多次消費(fèi)同一個(gè)事件。這個(gè)過(guò)程大致為:整個(gè)過(guò)程中事件發(fā)送和數(shù)據(jù)庫(kù)更新采用各自的事務(wù)管理,此時(shí)有可能發(fā)生的情況是事件發(fā)送成功而數(shù)據(jù)庫(kù)更新失敗,這樣在下一次事件發(fā)布操作中,由于先前發(fā)布過(guò)的事件在數(shù)據(jù)庫(kù)中依然是“未發(fā)布”狀態(tài),該事件將被重新發(fā)布到消息系統(tǒng)中,導(dǎo)致事件重復(fù),但由于事件的消費(fèi)方是冪等的,因此事件重復(fù)不會(huì)存在問(wèn)題。
另外一個(gè)需要注意的問(wèn)題是持久化機(jī)制的選擇。其實(shí)對(duì)于DDD中的聚合根來(lái)說(shuō),NoSQL是相比于關(guān)系型數(shù)據(jù)庫(kù)更合適的選擇,比如用MongoDB的Document保存聚合根便是種很自然的方式。但是多數(shù)NoSQL是不支持ACID的,也就是說(shuō)不能保證聚合更新和事件發(fā)布之間的原子性。還好,關(guān)系型數(shù)據(jù)庫(kù)也在向NoSQL方向發(fā)展,比如新版本的PostgreSQL(版本9.4)和MySQL(版本5.7)已經(jīng)能夠提供具備NoSQL特征的JSON存儲(chǔ)和基于JSON的查詢。此時(shí),我們可以考慮將聚合根序列化成JSON格式的數(shù)據(jù)進(jìn)行保存,從而避免了使用重量級(jí)的ORM工具,又可以在多個(gè)數(shù)據(jù)之間保證ACID,何樂(lè)而不為?
總結(jié)
領(lǐng)域事件主要用于解耦微服務(wù),此時(shí)各個(gè)微服務(wù)之間將形成最終一致性。事件風(fēng)暴活動(dòng)有助于我們對(duì)微服務(wù)進(jìn)行拆分,并且有助于我們深入了解某個(gè)領(lǐng)域。領(lǐng)域事件作為已經(jīng)發(fā)生過(guò)的歷史數(shù)據(jù),在建模時(shí)應(yīng)該將其創(chuàng)建為不可變的特殊值對(duì)象。存在多種方式用于發(fā)布領(lǐng)域事件,其中“在聚合中臨時(shí)保存領(lǐng)域事件”的方式是值得推崇的。另外,我們需要考慮到聚合更新和事件發(fā)布之間的原子性,可以考慮使用XA事務(wù)或者采用單獨(dú)的事件表。為了避免事件重復(fù)帶來(lái)的問(wèn)題,***的方式是將事件的消費(fèi)方創(chuàng)建為冪等的。
責(zé)任編輯:張子龍
來(lái)源:
博客園