萬字長文!Go 後臺項目架構思考與重構

萬字長文!Go 後臺項目架構思考與重構

作者 | 黃雷,騰訊雲後臺工程師

責編 | 唐小引

頭圖 | CSDN 下載自東方 IC

出品 | CSDN(ID:CSDNnews)

萬字長文!Go 後臺項目架構思考與重構

引言

本文首先介紹了架構的重要性,隨後從一個實際項目的重構過程作為主線,逐步引出主流的架構設計思想以及其所解決的實際問題是什麼。通過閱讀本文,你將學習到:

  • 架構的重要性;

  • 重構的幾種模式;

  • 設計原則;

  • DDD 中領域思想;

  • 項目的可測試性;

  • 項目的可演進性。

萬字長文!Go 後臺項目架構思考與重構

實踐背景介紹

本文涉及的項目主要用於騰訊雲團隊 K8s 集群管理的項目,其核心業務包括創建、升級、刪除集群和節點、集群監控、巡檢等。

舊工程簡介

Dashboard 是該項目最早的版本,主要包含 API 請求處理和異步流程執行等核心功能,是團隊最早的核心模塊之一。但是隨著功能不斷增加,Dashboard 早期不合理的架構設計所導致的可讀性差、擴展性差,無法單測等問題逐漸暴露出來且愈發嚴重。為了讓 Dashboard 的質量往更好的方向改進,團隊決定對其進行重構。

新工程簡介

考慮到直接重寫的代價和風險過大,團隊決定採用「修繕者」策略,即重創一個工程,承載 Dashboard 新需求的實現,並逐步將舊功能遷移到新工程中,最終達到重寫 Dashboard 的效果,Skipper 就是這個新工程。在遷移過程中,團隊對 Skipper 的架構設計經過了幾次調整,逐步解決了 Dashboard 中存在的問題,最終得到一個較為合理的架構,本文記錄了重構過程中的思考,和架構演變的過程。

萬字長文!Go 後臺項目架構思考與重構

架構的重要性

架構的目標

追求好架構的目的到底是什麼呢?或者說,我們期望一個好的架構產生什麼價值呢?

一個好的架構,其終極目標應當是,用最小的人力成本滿足構建和維護該系統的需求。

也就是說,好的架構目標應當是降低人力成本,這裡包括的不僅僅是開發成本,還有構建運維成本。而增加軟件可變性就是架構達到最終目標的核心途徑,即架構主要是通過增加軟件的可變性來降低人力成本,畢竟,捏橡皮泥比你在石頭上雕刻要輕鬆得多。

行為和架構哪個重要?

一個軟件的行為固然是很重要的,因為一個不能按預定行為工作的軟件是不產生價值的,所以很多程序員認為能實現軟件行為是最重要的,根本不該關心架構,反正壞的架構也不是實現不了行為,出了 bug 修復即可。我認為,他們忽略的是隨著軟件行為的改動,壞的架構將導致他們自己的工作越來越難以進行,改動的代碼越來越大,bug 越來越多,項目最終可能不可維護。

一個軟件的架構雖然不直接表現在行為上,但其最大的特點就是良好的可變性,即使目前行為不符合預期,也能通過低成本的改動將行為改變到預期。

可運行不可變軟件,最終會因為無法改變而導致行為無法迭代或者迭代慢而變成沒有價值。可變不可運行的軟件,可通過迭代,變成可運行可變軟件,所以架構比行為重要。

惡魔小時候也可愛

一個不太好的架構,在項目初期有時難以察覺,因為此時項目模塊少,功能少,依賴關係顯而易見,一切顯得毫無惡意,甚至有點簡潔美。但是,惡魔小時候往往也很可愛。隨著項目的增長,模塊增加了,開發人員變多了,惡魔長大了。架構帶來的問題逐漸暴露了出來,混亂的層次關係,毫無章法的依賴關係,模塊權責不清等問題接踵而至。

對開發人員而言,項目理解成本不斷增加,添加小功能都要先理清好幾個模塊的調用關係,難以測試導致上線後 bug 防不勝防,組件無法複用。項目逐漸長成大家聞風喪膽,避而不及的“大惡魔”。

雖然我們也反對過度設計,但是識別,或者說猜測項目未來符合邏輯的可能變動,將架構設計考慮進項目早期是十分有必要的,架構設計和調整應該貫穿項目的整個成長過程。

識別過度設計

架構設計是為了讓未來的修改更加容易,但是未來誰又能完全預測準確呢,架構設計或多或少有一定猜測成分在裡面,但是更多的是吸取 IT 行業幾十年發展過程中前輩們的經驗以及對業務特點的瞭解所作出的符合一定邏輯的猜測。

那什麼算過度設計呢?從架構的目的是降低人力來看,就是該設計目前沒有任何強有力的邏輯能推出能在未來降低修改某種行為的人力成本,或者降低某種行為修改成本的同時,大大增加了另外一種行為的修改成本。

架構的理解成本

架構是有一定理解成本的,甚至架構設計之初會增加一定的系統理解成本,但是一個好的架構理解成本一定不會很高,因為架構的理解也是人力成本。在理解架構設計的意圖之前,因為其增加系統的理解成本而否定它的必要性是不合邏輯的。

好的架構,其關鍵意義在於降低項目發展過程中整體理解成本。

也就是說,架構良好的項目隨著業務複雜度增加,項目理解成本增長也是緩慢的。架構不合理的項目隨著業務複雜度的增加,整體理解成本可能是指數增長的。

萬字長文!Go 後臺項目架構思考與重構

架構調整需要勇氣

一旦你宣佈進行項目架構調整,就是宣告現有項目架構不合理,也意味著他人將設計出比當前優秀的架構,這是一件非常需要勇氣的事。因為調整的過程中,你會犯錯,你需要進行一些猜測,你會和他人產生觀點衝突,你有時甚至需要有點固執和執著。

因為架構投資的是未來,但大部分人只著眼於當下。

萬字長文!Go 後臺項目架構思考與重構

重構方式

拆遷者模式

根據當前業務的需求對軟件架構重新設計,並組織單獨的團隊,重新開發一個全新的版本,一次性完全替代原有的遺留系統。

為什麼不適合我們?主要有如下幾項因素:

  • 人力消耗巨大,需要一邊加新需求一邊重寫舊需求;

  • 無法確保新的工程的設計比舊的好;

  • 重寫過程中可能出現業務遺漏。

絞殺者模式

保持原來的系統不變,當需要開發新功能時,重新開發一個服務,實現新功能,通過不斷構建新的服務,逐步使遺留系統失效,最終替換它。

絞殺者模式相對比較適合我們的重構需求,但是存在以下問題:

  • 不希望存在多個服務共存的問題;

  • 希望共享舊工程的 CICD,運維,監控等能力;

  • 重構顆粒度過大,我們希望細到函數級別的重構。

修繕者模式

將遺留系統的部分功能與其餘部分隔離,以新的架構進行單獨改造。

修繕者模式特別適合我們的需求。

萬字長文!Go 後臺項目架構思考與重構

Dashboard 的架構

整體架構

Dashboard 核心功能分為兩大塊,一個是作為 Web API Server,接收 HTTP 請求,另外一個是異步流程處理,用於耗時較長的功能,比如創建集群、集群升級等。

Dashboard 整體採用 MVC 架構 + Controller 模式,這裡的 Controller 模式是指通過不斷重試,最終將目標對象設置到某種目標狀態的模式,比如通過不斷重試,將創建中的集群的各部分屬性或者依賴的資源,設置到正常集群的狀態。Dashboard 的核心模塊如圖。

萬字長文!Go 後臺項目架構思考與重構
  • MVC Controller:用於接收 HTTP 請求,並調用 Service 進行業務處理;

  • MVC Service:核心業務邏輯全部落在這一層;

  • MVC DAO:DB 相關操作都在這一層;

  • MVC Models: 包含各個對象的字段,比如集群、節點等;

  • Controller 模式下的各個 Controller:每個 Controller 邏輯差異很大,但是都是調用 Service 進行對象狀態的初始化或者設置;

  • Components:調用外部服務的模塊都在這裡,比如調用計算資源服務創建虛擬機、調用網絡資源服務設置網絡等。

Dashboard 雖然有水平分層,但是每一層內部沒有組件的設計原則,也沒有代碼規範,每一層基本都是單一一個包,包內代碼質量不高,重複代碼較多。

具體實現

Dashboard 的工程目錄如下所示:

萬字長文!Go 後臺項目架構思考與重構
  • 每一層一個包

這樣看來,Dashboard 的分層好像還挺清晰的。確實,相對於沒有分層,Dashboard 採用 MVC 架構進行分層本身是有一定合理性的。但是在具體實施的時候,卻出現了很多問題,其中較為嚴重的是每一層只有一個包。

比如 Controller 包中,所有請求,無論哪個業務模塊的,全部放一起,根本無法區分哪些是集群相關的,哪些是監控相關的,哪些是節點相關的,哪些是網絡相關的。

如果說 Controller 包一個文件一個請求還可以理解,那 Service 層整個只有一個包,不分模塊,而且全是全局函數可維護性就很差了,由於核心業務邏輯全在 Service 層,Service 的代碼量是所有層中最多的,隨著功能的增長,未來 Service 將越來越臃腫。

其它層,如 DAO,甚至 Component 也是一個包。

  • 依賴關係混亂

Dashboard 沒有關注各個模塊之間的依賴關係,只要不產生循環依賴就可以隨意依賴別的模塊,所以模塊之間依賴十分混亂。這直接導致模塊難以複用,例如 Component 包中部分代碼依賴 DAO,依賴 Config,而 DAO 和 Config 又強依賴了配置文件和 DB。這導致如果要複用 Component 包開發一個很簡單的工具,都需要給工具準備 Dashboard 配置文件,甚至需要能連上 DB。

  • 各層之間權責不明

Dashboard 雖然進行了分層,但是各層的權責並沒有嚴格實施,導致 MVC controller 層和 dao 層也包含了大量業務邏輯,甚至有大量與 service 層重複的業務邏輯。

  • 每層內部沒有設計

Dashboard 只劃分了水平分層,但是對每一層內部,以及各層之間的通信方式沒有做出規定,各層內部可以隨意暴露公共函數。各層之間也是直接進行函數調用。

Dashboard 的架構導致了哪些問題?

上一節介紹了 Dashboard 架構的基本情況,這節更詳細的介紹在 Dashboard 的架構下所衍生出的具體問題,這些問題便是 Skipper v1 著重需要解決的。

  • 貧血模型導致 DAO 層臃腫

MVC Models 層中的對象只有數值,沒有方法,所有對象的業務邏輯,無論輕重,都在其他層,這種模型稱為貧血模型。相對的,如果對象不僅包含數值,還包含基本的方法,例如自身生命週期設置,版本設置等等,就稱為充血模型。Dashboard 是貧血模型,這導致 DAO 層比預期的要厚的多,因為包含了大量業務邏輯,比如設置默認字段,判斷字段是否是有效值等等,這些本應該是對象自身才知道的業務邏輯。厚重的 DAO 層會導致 DAO 層難以通過 Interface 進行抽象,想換一種存儲簡直是不可能的任務。

  • 無法單測

上文提到,Dashboard 中依賴關係十分混亂,而且一層只有一個包,這導致想進行單元測試是不可能的,因為對一個簡單的函數單測,你可能需要直接連 DB,哪怕你函數里根本不查 DB。Dashboard 中各層之間是直接調用全局函數的,並沒有通過 Interface 進行隔離,這就導致想進行單測就必須通過 Monkey 來進行全局函數打樁,不僅無法併發單測,還對體系結構有要求,因為 Monkey 只支持 AMD64 體系結構。

  • 模塊劃分不清

dashboard 只進行了水平分層,但是同層沒有分模塊,這導致:

(1)想複用模塊功能但是不知道對應的函數是哪個;

(2)添加新功能不知道應該把代碼寫在哪。

  • Controller 模式能力不足

Dashboard 使用 Controller 模式進行異步操作,但是 Controller 模式在持久化和異步流程控制上能力較為薄弱。

(1)流程無法暫停,無法取消;

(2)流程參數和進度沒地方存儲等.

萬字長文!Go 後臺項目架構思考與重構

Skipper 架構 v1

整體架構

基於 Dashboard 存在的問題,我們設計了 Skipper 項目架構的 v1 版本,這個版本依然使用 MVC 分層,但是針對 Dashboard 的問題,重點關注了外部依賴接口化、DB 依賴接口化、充血模型、Task 異步流程、模塊劃分等。Dashboard 到 Skipper v1 的架構變動如下圖。

萬字長文!Go 後臺項目架構思考與重構
  • 外部依賴接口化

在 Skipper 中,對外部服務的調用(Component)都用 Interface 進行抽象,任何模塊都不直接使用 Component 的具體實現,這解耦了業務邏輯和外部服務,Component 提供 fake 版本用於單元測試。

萬字長文!Go 後臺項目架構思考與重構
  • 充血模型

在 Skipper 中,Models 層只會被 core obj 層和 store interface 所引用,所有其它模塊都直接使用包含充血模型的 core obj 層。在 core obj 中,每個對象都是充血模型的,其不僅包含一個或多個對象數據,還包含一些業務方法,比如將對象設置為升級狀態,比如將對象生命週期改為 deleting 等等,也就是說,原來處於 dao 中的業務邏輯被上升到 core obj 中,使得 DAO 層薄到只有最基本的 CRUD 操作,這對後面 DB 依賴接口化有巨大幫助。

萬字長文!Go 後臺項目架構思考與重構萬字長文!Go 後臺項目架構思考與重構
  • DB 依賴接口化

由於使用了充血模型,存儲層只有最基本的 CRUD,我們很方便得加入了 store interface 來解耦系統和具體存儲,store 層還提供基於 gorm 的具體實現,以及 fake 版本的實現用於單元測試。

萬字長文!Go 後臺項目架構思考與重構
  • 異步流程

為了解決 Controller 模式存在的問題,Skipper 開發一個 Task 異步流程執行框架,用於執行一次性的異步流程,但依舊保留 Controller 模式的存在,其中 Task Controller 是 Task 異步流程框架的引擎。

(1)Controller 模式用於需要一直運行的全局性旁路,比如節點狀態監控,Task 執行監控等;

(2)Task 模式用於複雜的一次性流程,比如升級一個節點,升級一個集群等等。

萬字長文!Go 後臺項目架構思考與重構
  • Service 分包

Skipper 中也有 Service 層,和 Dashboard 不同的是,Skipper 的 Service 會根據業務模塊進行分包,比如一個包專門處理集群升級,一個包專門處理監控組件,一個包專門處理巡檢等。

Skipper 的 Service 層依舊使用了全局函數,沒有進行封裝,我們後續將提到,這是 Skipper v1 版本存在的一個問題。

萬字長文!Go 後臺項目架構思考與重構
  • 可測試

由於外部服務以及 DB 都可以用 fake 的了,Service 層的代碼是可以進行單測的。

為什麼相對 Dashboard 可以降低人力

案例:節點升級

這裡以節點升級功能為例,介紹為什麼 Skipper v1 相對 Dashboard 能降低人力。

功能簡介:節點升級功能是指將一批 k8s 節點上的組件版本從低版本升級至高版本,這是一個比較耗時的流程,所以不能在同步請求中直接完成,需要異步執行,且需要展示升級進度。由於節點升級是高危操作,一批節點升級過程中,需要支持用戶隨時暫停,取消升級。

Dashboard 中開發過程:如果該功能在 Dashboard 中實現,大概需要以下流程。

萬字長文!Go 後臺項目架構思考與重構
  • 考慮節點升級請求參數比較複雜,沒法存在現有表中,需要新建一個表用於存儲節點升級的參數和進度。

  • 編寫對應的 Models。

  • 編寫專門用於上述表的 DAO 層代碼。

  • 編寫一個 Controller 異步流程,要為該 Controller 專門實現暫停,取消等控制機制。

  • 編寫專門的旁路進行監控告警。

  • Service 中實現節點升級核心流程。

  • 由於無法單測,覺得寫得差不多了,需要等待測試環境空閒時,部署到測試環境進行調試。注意,測試環境是公共的,別人可能也需要用。

Skipper 中開發過程:如果該功能在 Skipper 中實現,將基於 Task 異步流程實現,大概需要以下流程:

萬字長文!Go 後臺項目架構思考與重構
  • 由於 Task 框架已經提供了參數,進度的存儲,以及 Task 相關的 DAO 代碼,所以不需要創建任何新的 DB 表;

  • 由於 Task 已經實現了統一的暫停,取消等任務控制機制,不需要編寫相關代碼;

  • 創建一個 Task Handler,實現節點升級;

  • Task 有統一的監控,無需重複編寫;

  • 由於 Skipper 是可單測的,在部署到測試環境之前,我們通過單元測試快速調通了核心邏輯;

  • 部署到測試環境進行集成測試,這時候 Bug 已經很少了。

Skipper v1 存在問題

雖然 Skipper v1 解決了 Dashboard 存在的很多問題,但是其自身依然有很多不足,在新需求開發和舊代碼遷移過程中不斷暴露出來。

  • core obj 過度設計

Skipper 為了採用充血模型,在 core obj 中進行了封裝,例如 cluster 對象,隱藏了 Dashboard 中的多個 models 結構體,隱藏了某些字段實際是 JSON 字段的,對外暴露出帶有方法的 cluster 對象,設計時候考慮了多種集群存在的可能性,所以整個對象對外不是一個實體,而是暴露了一個 Interface。而在實際使用時,發現為了對外暴露對象屬性,Interface 中充斥了大量的 Get 的 Set 方法,顯得很笨重,而且由於不同類型集群的差異並不體現在 cluster 對象本身,而是 cluster 的業務邏輯中,所以暴露 Interface 並沒有達到抽象集群的作用。

  • 全局依賴

skipper v1 認為像 store, component 中的外部組件都是單例的,所以使用了全局依賴。使用全局依賴使得整個工程用的是一個 DB,這樣的方式至少存在以下幾個弊端:

(1)各模塊 DB 是耦合的,無法分開存儲,雖然目前所有模塊確實共用存儲,但是隨著模塊的成長,模塊 DB 獨立也是有可能的;

(2)Component 裡聚合所有外部服務這使得使用任何一個外部服務,就會依賴於所有外部服務,使用 Component 的地方都需要從全局獲取對應的 Component,重複代碼較多。

  • 模塊不內聚

雖然 Skipper v1 中,各層基本都按功能進行分包了,但是模塊並不內聚,一些包之間依賴關係很明顯,應該屬於一個模塊的不同部分,並且由於只使用了水平分層,模塊的內部各層代碼分散到項目各層中並和其他模塊對應層代碼耦合在一起。針對某一模塊,由於 Service 層依舊使用了全局函數,除非有文檔說明,否則無法知道該模塊對其它模塊暴露了哪些 API,其它模塊甚至可以直接讀寫該模塊的 DB。例如集群監控模塊,當 1.16 版本的集群升級時,需要更新對應集群的監控配置,Skipper v1 中的實現是在集群升級代碼中顯示調用更新監控配置的函數,這就使得集群監控開發人員必須理解集群升級的代碼並知道在哪裡調用更新監控配置的函數,這使得集群生命週期模塊和監控模塊是耦合的。

萬字長文!Go 後臺項目架構思考與重構

進一步探索

為了解決 Skipper v1 中的問題,我們決定重新審視一下設計原則相關的指導。我們比較警惕過度設計,也不喜歡在 Golang 中使用過多設計模式以及層層封裝,但是我們相信,設計原則是所有語言通用的,因為設計原則只是一種思考的方向,讓你對架構的壞味道更加警覺。

  • 架構設計原則

架構設計原則是軟件行業幾十年發展總結出的一些具有指導意義的思想,雖然在實踐時,很難完全遵循設計原則,但是識別其中違反原則的地方,並控制由於違反原則帶來的風險是很有必要的。

  • SRP:單一職責原則

SRP 是最容易被誤解的原則,因為大多數人看到名字,就以為該原則指的是一個模塊只做一件事,但其實不是這樣的。SRP 較為經典的描述是:

任何一個軟件模塊都應該有且僅有一個原因被修改。

這裡我更喜歡 Robert 大叔在其著作《架構整潔之道》中描述的:

任何一個軟件模塊都應該只對一類行為者負責。

這裡的行為者是指一個或多個有共同需求的人。從我們的實踐背景下,集群生命週期模塊和監控模塊是不同的小團隊在維護,而 skipper v1 中,監控模塊想支持集群升級時更新配置,卻需要改動集群生命週期模塊代碼,這其實就違反了 SRP。

  • OCP:開閉原則

OCP 是 Bertrand Meyer 於 1988 年提出的:

設計良好的計算機軟件應該易於擴展,同時抗拒修改。

OCP 是我們進行系統架構設計的主導原則,其主要目的是讓系統易於擴展,同時限制其每次被修改所影響的範圍。實現方式是通過將系統劃分為一系列組件,並且將這些組件間的依賴關係按層次結構進行組織,使得高層組件不會因底層組件被修改而受到影響。Skipper v1 中 Task 模式是符合開閉原則的,因為如果要添加一個新的異步流程,只要實現一個新的 Handler 即可,並不需要修改 Task 機制高層代碼。

  • LSP:里氏替換原則

1988 年,Barbara Liskov 在描述如何定義子類型時候寫下這樣一段話:

這裡需要一種可替換性:如果對每一個類型為 T1 的對象 o1,都有類型為 T2 的對象 o2,使得以 T1 定義的所有程序 P 在所有的對象 o1 都代換成 o2 時,程序 P 的行為沒有發生變化,那麼類型 T2 是類型 T1 的子類型。

面嚮對象語言中有另外一種解釋:

所有引用基類的地方必須能透明地使用其子類的對象。

當然,Golang 不是面嚮對象語言,沒有父類,子類的概念,但是里氏原則對於 Interface 的使用有著重要的指導意義,即:

假設存在接口 A 的實現 Aa 和 Ab,使用接口 A 的程序在傳入的具體實現由 Aa 改成 Ab 時,行為不發生變化。

在 Skipper v1 中,store 層是符合里氏替換原則的,因為使用 DAO 版本的實現和使用 fake 版本的實現,store 接口使用者行為是不變的。Robert 在《架構整潔之道》給出了一個著名的反面例子,即正方形長方形問題。假設 Class Rectangle 表示長方形。假設 Class Square 集成了 Rectangle 表示正方形。使用 Rectangle 對象的程序並不能用 Square 對象來替換 Rectangle 對象,因為 Rectangle 長寬可以隨意設置,但是 Square 卻不行。

  • ISP:接口隔離原則

ISP 的定義十分直觀:

客戶端不應該依賴它不需要的接口。

在 Skipper v1 中 Store 中定義的接口違反了 ISP,因為該接口包含了所有模塊的數據庫操作接口,基於 ISP 原則,我們應該讓每個模塊自己擁有並維護自己單獨的 Store 接口。

  • DIP:依賴反轉原則

DIP 主要指導我們系統各層的依賴關係:

高層模塊不應該依賴低層模塊,二者都應該依賴其抽象;抽象不應該依賴細節;細節應該依賴抽象。

從具體實現而言,如果想設計一個靈活的系統,在源碼層次的依賴關係中,就應該多引用抽象類型,而非具體實現。在具體實施時,《架構整潔之道》中給出了 4 點建議:

(1)應該避免在代碼中寫入與任何具體實現相關的名字,或者是其他容易變動的事物名字;

(2)應在代碼中多使用抽象接口,儘量避免使用那些多變的具體實現類;

(3)不要在具體實現類上創建衍生類,Golang 語言天生就符合這一點;

(4)不要覆蓋包含具體實現的函數,即別重寫,在 Skipper v1 的 Task 模式中違反了這一條,因為 Task 模式為了減少代碼重複,所有 Task Handler 都需要內嵌 Default Handler,並重寫其覺得需要修改的函數。

萬字長文!Go 後臺項目架構思考與重構萬字長文!Go 後臺項目架構思考與重構

組件設計原則

  • CCP:共同閉包原則

應該將那些會同時修改,並且為相同目的而修改的類放在同一個組件中,而將不會同時修改,並且不會為了相同目的的修改的那些類放在不同組件中。

其實 CCP 是 SRP 有很多相似的地方,我們可以統一描述它們的思想:

將由於相同原因而需改,並且需要同時修改的東西放在一起。將由於不同原因而修改,並且不同時修改的東西放在一起。

  • CRP:共同複用原則

不要強迫一個組件的用戶依賴他們不需要的東西。

這個原則實際上告訴我們應該將那些會被同時用到的代碼放在同一個組件中。

  • ADP:無依賴環原則

組件依賴關係圖中不應該出現環。

Golang 編譯器實際上已經幫助我們避免了循環依賴。

  • SDP:穩定依賴原則

依賴關係必須要指向更穩定的方向。

這條原則告訴我們,一個我們預期會經常變更的組件不該被一個難以修改的組件所依賴,否則這個多變的組件也會變得難以被修改。這裡所謂的穩定組件,就是指那些被別的組件依賴多的組件,不穩定的組件是那些依賴很多其他組件,但被其他組件依賴少的組件。

有時候我們的穩定組件還是需要依賴不穩定組件,怎麼辦呢?我們需要在他們中間加入一層穩定的抽象層。

萬字長文!Go 後臺項目架構思考與重構
  • SAP:穩定抽象原則

一個組件的抽象化程度應該與其穩定性保持一致。

SDP 中提到,穩定的組件是不易修改的,這會導致整個項目的架構難以被修改,我們需要通過高度抽象這些穩定的組件,來讓其接受修改。

前一個原則 SDP 告訴我們,依賴應該指向更加穩定的方向,而 SAP 告訴我們,越穩定,抽象化程度應該越高,這兩個連起來就可以得出另外一個結論:

依賴關係應該指向更加抽象的方向。

借鑑領域驅動開發

領域驅動開發是一種用於複雜軟件的架構設計思想,學習門檻比較高且對團隊成員整體架構水平要求較高,其實並不適合完全使用在 Skipper 的開發中,我們只借鑑其中一部分適合於我們項目的思想。

  • 水平分層

在 Skipper v1 中,我們依舊採用了 MVC 分層。但是領域驅動開發,以及《架構整潔之道》都提醒我們,應當存在一個應用層(《架構整潔之道》中稱為 Use Cases 層)用於處理依賴多個組件的業務邏輯,各層之間依賴於接口而非實現,且下層不能依賴上層。比如創建一個包含三個節點的集群,就同時需要操作集群模塊和節點模塊。

領域驅動開發中,每個領域稱為 Domain,每個 Domain 有自己的領域實體,並且是充血模型,每個領域的存儲也是內聚在領域之中,綜合以上,水平分層應當如下。

萬字長文!Go 後臺項目架構思考與重構
  • 領域劃分與邊界

在領域驅動開發中不僅進行了水平分層,還進行了垂直切片,將應用層以下劃分成了不同領域(Domain),每個領域責任明確且高度內聚。

領域的劃分應該滿足單一職責原則,每個領域應當只對同一類行為者負責,每次系統的修改都應該分析屬於哪個領域,如果某些領域總是同時被修改,他們應當被合併為一個領域。一旦領域劃分後,不同領域之間需要制定嚴格的邊界,領域暴露的接口,事件,領域之間的依賴關係都該被嚴格把控。

萬字長文!Go 後臺項目架構思考與重構
  • 領域事件

領域可以定義事件併發布到事件總線,如果對某個領域事件感興趣,就可以訂閱事件。領域事件可以大大降低各領域間的耦合,且對系統擴展性有巨大好處。例如在 Skipper v1 中,如果劃分出了集群監控領域和集群生命週期管理領域,當有一天監控領域決定去掉集群升級過程中對監控配置文件的修改,需要在集群升級代碼裡找調用監控配置文件升級的地方。而如果採用了領域事件,則只需要讓集群生命週期模塊發佈升級完成事件,並讓監控模塊訂閱或者取消訂閱事件進而做出配置文件修改邏輯即可。

萬字長文!Go 後臺項目架構思考與重構
萬字長文!Go 後臺項目架構思考與重構

Skipper 架構 v2

參考前兩文的探索,我們對 Skipper v1 做了一定調整。

整體架構

下圖是 v1 到 v2 的轉變,其核心是加入是領域模型,形成高內聚的業務領域組件。

萬字長文!Go 後臺項目架構思考與重構
萬字長文!Go 後臺項目架構思考與重構
  • 我們將 v1 中的 service 層切成兩層,把跨多領域的業務邏輯上拉至 application 層中,讓剩下的業務邏輯包含明顯的業務邊界;

  • 我們再根據各個業務模塊的依賴關係緊密程度進行重組,形成領域,每個領域只處理自己領域的業務,每個領域對外暴露一套 Service 接口用於描述該領域對外暴露的能力,領域可以利用 Event Bus 對外發布事件,用於通知外部領域內正在發生的事;

  • 原來全局公用的存儲層,現在分散到各個領域自行維護,不同領域可以採用不同的存儲;

  • 原來放置全局的 Controller 和 Task Handler,現在由每個領域自行管理,系統依然提供 Controller 和 Task 的引擎(由 Task 領域負責)。這使得領域業務邏輯更加內聚;

  • 注意各模塊的依賴關係,我們儘量遵循穩定依賴原則和穩定抽象原則,不穩定模塊儘量依賴於穩定模塊,如果需要讓穩定模塊依賴於不穩定模塊,我們引入 Interface 進行抽象。

新領域孵化

我們可以肯定隨著業務的發展,會有越來越多的領域被加入到 Skipper 中(目前已經出現”虛擬集群“領域)。

當一個新的領域被加入到 Skipper 中時,根據上邊的架構,我們只需要借鑑其他領域的設計,新建一個領域,並在讓領域負責人在此領域中迭代需求即可,這過程中,新領域可以依賴其它領域,監聽其它領域的事件等等,對其它領域而言都是無感的。

領域成長與獨立

隨著領域內業務邏輯越來越複雜,或者因為業務調整,存在某個領域獨立出項目的情況(目前”集群監控“領域已準備獨立),由於我們的領域是高內聚的,領域獨立的難度並不大,對整個項目而言,也只是將剝離的領域從領域層轉移至 Infrastructure 層,作為外部服務而已。

由於領域之間總是依賴於接口或者依賴於領域事件,當領域獨立時,依賴這個領域的業務邏輯是不需要進行修改的。

萬字長文!Go 後臺項目架構思考與重構

微服務化

可能隨著領域不斷剝離,項目的領域不斷的成為獨立的服務,當服務增多時,就需要引入更加統一有效的運維、監控、部署方案,我們相信這才是項目微服務化最自然的方式,我們傾向於項目儘量是單體應用。

為什麼相對 v1 可以降低人力

案例:增加集群創建失敗通知機制

功能簡介:集群創建目前成功率雖然符合 SLA,但是依然不是 100% 的,我們希望當集群創建失敗時能第一時間通知我們。通知本身是一個比較簡單的需求,完全可以分配給新人來做。

Skipper v1 中開發:如果在 Skipper v1 中開發,我們面對的最大問題是開發人員必須知道集群創建失敗的具體位置,這隻有集群創建流程的開發人員才知道,為了加入通知功能,新人不得不去請教集群創建流程的開發人員,並且需要修改集群創建流程,由於修改了集群創建流程,還需要走測試,雖然通知功能的代碼不多,但是由於要修改集群創建流程,導致了人力成本的增加。

Skipper v2 中開發:如果在 Skipper v2 中開發,只需要單獨創建一個領域,專門用於系統各種需要觸達我們的通知,然後訂閱對應事件即可,比如該例子中,就是訂閱集群創建失敗事件。這種開發模式,不需要修改集群創建流程代碼,一切改動都在關鍵事件通知領域進行,且基於這種開發方式,就不會讓事件通知代碼散落在各個領域中。

萬字長文!Go 後臺項目架構思考與重構

總結

本文是一次 Golang 項目重構的思考與記錄,首先討論了為什麼架構是重要的,又介紹了幾種可行的重構方式。基於實際的項目,我們介紹了舊工程 Dashboard 項目的架構和其中的問題,針對這些問題,我們嘗試著去設計一個更優秀的架構 Skipper v1。但是,隨著遷移的進行,我們發現 Skipper v1 中依舊存在一些如模塊不內聚,充血模型過度設計等問題,為了更好地解決已知的架構問題,我們參考了《架構整潔之道》以及 DDD 的一些思想,再結合 Skipper v1 的實際情況,設計出了 Skipper v2 的架構。

參考文獻:

[1]Robert C. Martin.Clean Architecture[M].Prentice Hall:,September 20, 2017

[2]Eric Evans.Domain-Driven Design[M].Addison-Wesley Professional:,August 30, 2003

[3]喬梁.持續交付 2.0[M].人民郵電出版社:,2018-12-25

[4]https://github.com/bxcodec/go-clean-arch

[5]https://github.com/marcusolsson/goddd

[6]https://engineering.grab.com/domain-driven-development-in-golang

作者簡介:黃雷,騰訊雲後臺工程師,Kubernetes 技術專家,系統可觀測性專家。擁有多年大規模 Kubernetes 集群開發運維經驗。目前負責騰訊雲 TKE 萬級規模 Kubernetes 集群治理,主導研發超大規模 Kubernetes 集群聯邦智能監控系統與巡檢系統。

萬字長文!Go 後臺項目架構思考與重構
萬字長文!Go 後臺項目架構思考與重構

今日福利

遇見大咖

由 CSDN 全新專為技術人打造的高端對話欄目《大咖來了》來啦!

CSDN 創始人&董事長、極客幫創投創始合夥人蔣濤攜手京東集團技術副總裁、IEEE Fellow、京東人工智能研究院常務副院長、深度學習及語音和語言實驗室負責人何曉冬,來也科技 CTO 胡一川,共話中國 AI 應用元年來了,開發者及企業的路徑及發展方向!


分享到:


相關文章: