Go: 事件驅動的微服務設計

我在 "微服務之間的最佳調用方式" 中講到了微服務之間的兩種調用方式。微服務剛興起時,大部分都是 RPC 的調用模式。我也寫了一個 RPC 的架構,詳情參見 "清晰架構(Clean Architecture)的 Go 微服務"。但現在事件驅動的微服務越來越流行,因爲大家覺得它是松耦合的。我會寫一個新的系列來講述如何構建事件驅動的微服務。本文是這個系列的第一篇,總體設計。

本文通過一個具體的例子來講解事件驅動微服務的設計,它包含兩個微服務,一個是訂單服務(Order),另一個是支付服務(Payment),它們各自獨立,每個服務有自己的源碼庫,數據庫,各自單獨部署。它們之間通過事件驅動的方式互相調傭。

在拿到一個新的項目時,我們有時會覺得不知道從哪下手。最通常的的思路是化繁爲簡,把一個大的項目分解爲一個個小的部分再各個擊破。把程序分層也是這個思路的具體應用。這裏我們仍然沿用 RPC 時的架構,按照清晰架構把業務邏輯分成三層,域模型(model),用例(usecase)和數據服務(dataservice)。在事件驅動模式時會增加新的層,就是事件層(Event),它包含事件和事件驅動器(Event Handler)。在本文中,你將會讀到如何對原來的架構進行擴展,增加事件處理功能。

本文從下面三個方面講解程序的設計:

它們是設計中最基本的也是最重要的東西,有了它其他的東西才能在這個基礎之上建立起來。

模塊設計:

程序設計中的很重要的一步就是把程序的功能拆分成小的模塊,並把程序分層,然後把這些模塊放入相應的層級,最後確立模塊之間的依賴關係。

程序分層

本程序基本延用了清晰架構的原則,但由於增加了事件驅動的部分,因此我引入了 "Domain-Driven Design" 的一些概念對清晰架構進行了一些改造。畢竟事件驅動的很多概念都是由 DDD(Domain-Driven Design)提出來的,它有關於事件驅動的一整套理論和實踐。對於 DDD 的理解,不同的人的解釋可能稍有差別,其中最著名的應該來自於 Eric Evans 的書 "Domain-Driven Design: Tackling Complexity in the Heart of Software"。但因爲它稍微有一點虛,有些概念並沒有明確的代碼,因此可能會有不同的解讀。由於這個原因,我採用了 "Patterns, Principles, and Practices of Domain-Driven Design" 這本書中對 DDD 的解釋,它裏面所有的概念都有具體的代碼和實例,全部都是落了地的東西,這樣至少不會產生歧義。

在 “Pattern,Principles,and Practices of Domain-Driven Design” 這本書中,它列舉了 DDD 的 8 個組成模塊,它們是值對象(Value Object),實體(Entities),域服務(Domain Service),域事件(Domain Event),聚合根(Aggregates),工廠(Factories),倉儲(Respository)和事件溯源(Even Sourcing)。其中值對象,實體和聚合根都是域模型。域服務就是清晰架構中的用例。工廠(Factories)是用來創建類的,也就相當於 Spring 裏的程序容器。倉儲(Respository)就是數據持久層。

我們來看一下怎樣把它們映射到清晰架構。首先,事件溯源(Even Sourcing)不是 DDD 的組成模塊,而是一種實現方式(你可以用它,也可以不用),我們先把它去掉。剩下的 7 個模塊是我們需要的。對於如何將 DDD 分層,大家的意見也並不統一,不過大致可以分成四層,領域層(Domain Layer),程序層 (Application Layer),基礎設施層(Infrastructure Layer) 和用戶界面層(User Interface Layer)。本文的重點在後端程序,所以我們只討論前三層(把用戶界面層去掉)。對於前三層的解釋和應該包含的模塊,大家也有不同意見。我先來講一下上面書中的解釋。對領域層(Domain Layer)的分歧較少,它主要是處理領域的業務邏輯。程序層 (Application Layer) 主要有三個功能,第一是用例,也就是有些業務邏輯涉及到多個域模型,放到那個單個模型都不合適,就放到程序層;第二是商業過程(Business Process),就是有些業務邏輯有流程,也需要涉及到多個域模型。第三是業務邏輯要調用外部的一些功能,例如發郵件,發消息。這部分又分成兩塊,一塊是接口定義,放在程序層中,另一部分是具體實現,放在基礎設施層(Infrastructure Layer)。

總體來說,這個分法還是比較靠譜的,但是我對它的一些細節還是有不同看法的。首先,用例裏主要還是業務邏輯,只不過是跨了多個域模型,肯定還是應該放在領域層。其次,商業過程(Business Process)業務主要還是業務邏輯,也應該放在領域層。程序層 (Application Layer) 應該只有對外服務的接口,這樣也符合書中對程序層的描述。因爲作者一直在講,程序層要儘量小。領域層纔是大頭。

如果我們把上面提到的 7 個模塊分到不同的層中,我 · 覺得應該是這樣的:

工廠(Factories)不在上述任何一層裏面,它其實就是程序容器,可以單獨列爲一層。

有一點需要說明的是我並沒有完全採用 DDD 的架構,本程序的主要框架還是清晰架構,但由於清晰架構裏沒有對事件驅動的明確指引,因此我引入了 DDD 的事件驅動部分來對清晰架構進行改造和擴充,但總的來講,本設計的底子還是清晰架構。

程序結構:

下面我們就講一下在程序中是如何實現上面講到的模塊化和分層結構。

上面就是訂單服務的目錄結構,其中 “Domain” 對應領域層, “applicationservice”對應程序層和基礎設施層,“app”對應程序容器。

領域層:

上面就是領域層的目錄結構,這層是整個程序的大頭。這層包含有命令(Command), 事件(Event),域模型(Model)和用例(Usecase)。其中命令(Command) 和事件(Event)是事件驅動模式獨有的。Event 目錄裏有事件(Event)和事件驅動器(Event Handler)。

事件會在兩個或多個微服務之間傳遞,因此是被這些微服務共享的。一個問題是,要不把這些事件抽出來放在一個單獨的模塊中,這樣不同的微服務就可以共享這些事件?這不是一個好主意,因爲它會增大微服務之間的耦合度。儘管事件是被多個微服務共享的,但實際上它們在各個微服務裏可能並不完全相同。例如,支付微服務發送一個支付完成事件給訂單微服務,支付微服務需要在事件中增加一個字段 “支付備註”,而訂單微服務並不想馬上就用這個字段,如果共享事件的話就比較麻煩。如果支付微服務和訂單微服務各自單獨立地定義事件,就保持了各自的獨立性。雖然傳遞的事件裏有“支付備註” 字段,但訂單服務可以選擇忽略它(訂單服務不必修改代碼)。這樣雖然有一些重複代碼,但維護起來更方便。

域模型設計與 RPC 的域模型基本相同,我這裏不仔細講,有興趣的請參見清晰架構(Clean Architecture)的 Go 微服務: 程序設計。稍有不同的是在事件驅動模式下,引入了 “eventbus”(是一個接口)的概念用來處理事件,在業務邏輯裏需要調用這個接口,因此需要把“eventbus” 注入到用例(usecase)裏。

程序層:

上面就是程序層的目錄結構。這層現在只有一個服務,數據庫服務,。其實還有另外三個服務,一個是日誌服務,一個是消息服務,另一個是事件總線服務(Eventbus),但由於這三個服務都是在第三方庫裏定義的,因此就沒有放在訂單服務的程序層裏。
其實程序層裏的大部分接口都是可以共享的,那麼是不是應該把他們都定義成共享庫呢?我覺得是可以的,但如果只有接口定義(沒有具體實現)的話,它應該很小,放在項目裏也沒有太大的問題。

基礎設施層:

現在,這一層只有數據庫服務的具體實現。日誌,消息和事件總線服務(Eventbus)的實現都在第三方庫中。程序層和基礎設施層雖然屬於不同層,但在現在的目錄結構中是放在一起的,並沒有把他們分開,你如果要把它們分開也沒有問題。

程序容器:

這個也是單獨的一部分,因爲比較複雜,我會在本系列的一篇文章 “事件驅動的微服務 - 程序容器設計” 裏單獨講解。

倉儲(Respository)

一個比較有爭議的地方應該是倉儲(Respository),在 DDD 中是把他放在領域層。其實不單是 DDD,幾乎任何框架都把它放在領域層。我在寫 RPC 的微服務時也是把它放在領域層。但如果你仔細想的話,倉儲(Respository)是數據庫的具體實現,按照 DDD 的理論是應該放在基礎設施層。但由於幾乎所有的框架都是把它放在領域層,我們已經養成了習慣,自然而然這麼做了,根本沒有仔細考慮。

另外一個原因就是倉儲中的數據對域模型確實比較重要,因此把它放在離域模型近的地方可能比較方便。但我這裏還是按照規則把它放在程序層,如果以後覺得有問題再改也不遲。

依賴關係:

在程序設計中,先要把程序拆成小的相對獨立的模塊,然後就是要確定各部分之間的依賴關係。這是程序設計裏非常重要的一步。

依賴關係都是單向的,如果出現了循環依賴,Go 就會報錯。依賴關係都是從上往下的,也就是上層依賴下層。越是下層的東西越容易複用,因爲它依賴的東西少。越是上層的東西越重,因爲有太多的依賴關係。因此衡量程序的好壞,一個重要的指標就是它所依賴的的庫,依賴的越少,程序的質量越高。在 Go 語言裏,就是看 “import” 語句。“import”越少,程序越好。

依賴關係乍一看很簡單,但仔細研究的話還是有不少內容的。它分類爲,層級依賴關係,包依賴關係,接口依賴和實現依賴。下面會仔細講解。

層級依賴關係:

我們先來看大的層級。領域層和基礎設施層之間的關係,肯定是領域層依賴基礎設施層。但如果是直接依賴具體實現,那麼就把領域層和具體的基礎設施實現綁定了,因此需要解耦。就創建了一個程序層,這樣領域層和基礎設施層都依賴程序層,就解除了綁定。

在領域層內部,它裏面又有小的層次,命令,事件,域模型和用例。其中域模型不需要依賴任何一層,而別人都需要依賴他,因此他是最底層。命令和事件都有可能調用用例,因此它們是用例的上層。而命令和事件應該是互相獨立的,因此沒有依賴關係。

依賴關係性質

依賴關係有兩種,一種是接口依賴,另一種是具體實現依賴。比如容器層(“app”)和領域層(“domain”)之間的關係就是 "app" 依賴 "domain"(主要是依賴 “domain” 裏的 “model”),而 "domain" 不依賴“app”。你可能要問,爲什麼會是這樣呢?"domain" 裏要用到“app” 建立的類呀。注意 "domain" 裏用到的是接口,而不是具體的類,接口是在 "domain" 裏定義的,而不是在 “app” 裏定義的。因此,接口依賴是一種非常靈活的依賴關係,是松耦合的。

包依賴關係:

層級依賴是抽象的依賴關係,但最終還是要落實到語言層面。在 Go 語言中就是包依賴關係,這是 Go 語言的最細顆粒度的依賴關係。在 Go 語言中不能產生循環依賴,否則報錯。

包依賴關係和層級依賴關係大部分時候是一致的,但有時由於種種願因,它們也會出現錯位。例如,數據持久邏輯是放在基礎設施層裏的,它是不應該依賴域模型的。但在本程序中卻是。實際上,數據持久層裏的域模型應該被替換成 DTO,而 DTO 不是屬於域模型,這樣就不會出現錯位依賴。但如果引入 DTO 會讓程序更復雜,又沒有增加新的功能,因此就沒有引入。實際上,DTO 和域模型都是面向對象的概念,你如果用面向函數的概念來思考就順暢了。在面向函數的模式裏,就只有數據(Data)和函數(function),因此 DTO 和域模型都是數據,實際上是一個東西。

結構修改:

我在本程序裏對原來的程序結構 (參見清晰架構(Clean Architecture)的 Go 微服務: 程序結構)做了一點小的修改。原來的結構並沒有 “domain” 這個目錄,現在我增加了這個目錄,並把所有與與業務邏輯相關的目錄都放在它之下,這樣程序結構更合理。其實,我在寫上個框架(RPC)時就有了這個想法,但當時框架已經寫完了,就沒有再改。現在增加了事件驅動的功能,就更凸顯了更改的必要性。以訂單服務爲例,它的主要功能都包含在兩個目錄裏,“app”是程序容器,“domain”(領域)是業務邏輯。“domain”裏面有四個目錄,“model”是域模型,“usecase”是用例,“event”包含事件和事件驅動器(Event Handler),“command”是命令。其中 “event” 和“command”是事件驅動模式獨有的,其它的與 RPC 模式是一樣的。

框架(Framework)和庫(lib)

當完成了程序的層級劃分和模塊拆分之後,下一步就是決定程序的框架。我在寫 RPC 的時候沒有使用現成的框架,而是自己寫了一個。在做事件驅動的微服務時,我考慮了很長時間是不是要嘗試一下使用現成的框架,這樣就有機會比較外面的框架和自己的框架的區別。Go 語言有不少很好的微服務框架例如 Go kitGo Micro,它們的功能都很強大。但我最終沒有選擇他們主要是它們都包含了太多我不需要的東西,有些重,因此我還是決定在使用自己原來的框架。現在程序已經完成,我對結果還是很滿意的。

共享代碼和第三方庫

一個程序會用到許多模塊,有些需要你自己編寫,另外一些可以直接使用第三方的現成庫。例如本程序的事件驅動部分和 SQL 驅動程序都使用的是第三方庫。值得慶幸的是 Go 語言的基本庫非常強大,已經能完成許多功能,通常情況下不需要太多的第三方庫。另外就是你自己的不同程序之間會共享一些功能,如果把它們放在各自的程序裏就會有重複代碼。在寫本程序時,我把一些共享功能從程序裏抽出來,寫成了第三方庫。例如日誌功能和消息中間件接口。這樣做的一個好處就是這些庫是不依賴於框架的,任何程序都可以用它。我會在本系列的一篇文章 “事件驅動的微服務 - 創建第三方庫” 裏詳細講解。

總結:

碼農都有自己獨特的方式來判斷程序的好壞。有的嗅覺靈敏,用鼻子來聞程序是不是有 “Code Smell”。有的用眼睛來看。
經過這樣的設計之後,整個程序的結構已經很順暢了,它看起來就像一件藝術品。因此人們說程序設計是科學和藝術的結合。如果說有什麼瑕疵的話,那就是前面講到的基礎設施層中的數據庫代碼裏有對域模型的依賴,這種依賴關係是不應該出現的。如果要讓它完美就要把域模型改成 DTO(Data Transfer Object),但這樣改過之後會讓程序更復雜,又沒有增加新的功能,只是從設計角度看更漂亮了。我畢竟是碼農,最重要的是用最簡單的方法來完成需要的功能。因此,只好忍痛容忍這點瑕疵了。

最重要的是把程序的結構理順了之後,整個程序是用一個一個小的模塊搭建起來的,各個模塊之間之間的依賴關係簡潔明確,大大地簡化了以後程序升級,複用和維護的難度。就像蓋一棟大樓,如果地基牢固,整個結構設計合理,裏面再怎麼裝修,折騰也不會倒塌。

源程序:

完整的源程序鏈接:

索引:

1 微服務之間的最佳調用方式

2 清晰架構(Clean Architecture)的 Go 微服務

3 Domain-Driven Design

4 Domain-Driven Design: Tackling Complexity in the Heart of Software

5 Patterns, Principles, and Practices of Domain-Driven Design

6 清晰架構(Clean Architecture)的 Go 微服務: 程序設計

7 清晰架構(Clean Architecture)的 Go 微服務: 程序結構

8 Go kit

9 Go Micro

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://segmentfault.com/a/1190000022376280