作者 | 黃雷,騰訊云后臺工程師
責(zé)編 | 唐小引
頭圖 | CSDN 下載自東方 IC
出品 | CSDN(ID:CSDNnews)
引言
本文首先介紹了架構(gòu)的重要性,隨后從一個實際項目的重構(gòu)過程作為主線,逐步引出主流的架構(gòu)設(shè)計思想以及其所解決的實際問題是什么。通過閱讀本文,你將學(xué)習(xí)到:
-
架構(gòu)的重要性;
重構(gòu)的幾種模式;
設(shè)計原則;
DDD 中領(lǐng)域思想;
項目的可測試性;
項目的可演進性。
實踐背景介紹
本文涉及的項目主要用于騰訊云團隊 K8s 集群管理的項目,其核心業(yè)務(wù)包括創(chuàng)建、升級、刪除集群和節(jié)點、集群監(jiān)控、巡檢等。
? 舊工程簡介
Dashboard 是該項目最早的版本,主要包含 API 請求處理和異步流程執(zhí)行等核心功能,是團隊最早的核心模塊之一。但是隨著功能不斷增加,Dashboard 早期不合理的架構(gòu)設(shè)計所導(dǎo)致的可讀性差、擴展性差,無法單測等問題逐漸暴露出來且愈發(fā)嚴重。為了讓 Dashboard 的質(zhì)量往更好的方向改進,團隊決定對其進行重構(gòu)。
? 新工程簡介
考慮到直接重寫的代價和風(fēng)險過大,團隊決定采用「修繕者」策略,即重創(chuàng)一個工程,承載 Dashboard 新需求的實現(xiàn),并逐步將舊功能遷移到新工程中,最終達到重寫 Dashboard 的效果,Skipper 就是這個新工程。在遷移過程中,團隊對 Skipper 的架構(gòu)設(shè)計經(jīng)過了幾次調(diào)整,逐步解決了 Dashboard 中存在的問題,最終得到一個較為合理的架構(gòu),本文記錄了重構(gòu)過程中的思考,和架構(gòu)演變的過程。
架構(gòu)的重要性
? 架構(gòu)的目標
追求好架構(gòu)的目的到底是什么呢?或者說,我們期望一個好的架構(gòu)產(chǎn)生什么價值呢?
一個好的架構(gòu),其終極目標應(yīng)當(dāng)是,用最小的人力成本滿足構(gòu)建和維護該系統(tǒng)的需求。
也就是說,好的架構(gòu)目標應(yīng)當(dāng)是降低人力成本,這里包括的不僅僅是開發(fā)成本,還有構(gòu)建運維成本。而增加軟件可變性就是架構(gòu)達到最終目標的核心途徑,即架構(gòu)主要是通過增加軟件的可變性來降低人力成本,畢竟,捏橡皮泥比你在石頭上雕刻要輕松得多。
? 行為和架構(gòu)哪個重要?
一個軟件的行為固然是很重要的,因為一個不能按預(yù)定行為工作的軟件是不產(chǎn)生價值的,所以很多程序員認為能實現(xiàn)軟件行為是最重要的,根本不該關(guān)心架構(gòu),反正壞的架構(gòu)也不是實現(xiàn)不了行為,出了 bug 修復(fù)即可。我認為,他們忽略的是隨著軟件行為的改動,壞的架構(gòu)將導(dǎo)致他們自己的工作越來越難以進行,改動的代碼越來越大,bug 越來越多,項目最終可能不可維護。
一個軟件的架構(gòu)雖然不直接表現(xiàn)在行為上,但其最大的特點就是良好的可變性,即使目前行為不符合預(yù)期,也能通過低成本的改動將行為改變到預(yù)期。
可運行不可變軟件,最終會因為無法改變而導(dǎo)致行為無法迭代或者迭代慢而變成沒有價值。可變不可運行的軟件,可通過迭代,變成可運行可變軟件,所以架構(gòu)比行為重要。
? 惡魔小時候也可愛
一個不太好的架構(gòu),在項目初期有時難以察覺,因為此時項目模塊少,功能少,依賴關(guān)系顯而易見,一切顯得毫無惡意,甚至有點簡潔美。但是,惡魔小時候往往也很可愛。隨著項目的增長,模塊增加了,開發(fā)人員變多了,惡魔長大了。架構(gòu)帶來的問題逐漸暴露了出來,混亂的層次關(guān)系,毫無章法的依賴關(guān)系,模塊權(quán)責(zé)不清等問題接踵而至。
對開發(fā)人員而言,項目理解成本不斷增加,添加小功能都要先理清好幾個模塊的調(diào)用關(guān)系,難以測試導(dǎo)致上線后 bug 防不勝防,組件無法復(fù)用。項目逐漸長成大家聞風(fēng)喪膽,避而不及的“大惡魔”。
雖然我們也反對過度設(shè)計,但是識別,或者說猜測項目未來符合邏輯的可能變動,將架構(gòu)設(shè)計考慮進項目早期是十分有必要的,架構(gòu)設(shè)計和調(diào)整應(yīng)該貫穿項目的整個成長過程。
? 識別過度設(shè)計
架構(gòu)設(shè)計是為了讓未來的修改更加容易,但是未來誰又能完全預(yù)測準確呢,架構(gòu)設(shè)計或多或少有一定猜測成分在里面,但是更多的是吸取 IT 行業(yè)幾十年發(fā)展過程中前輩們的經(jīng)驗以及對業(yè)務(wù)特點的了解所作出的符合一定邏輯的猜測。
那什么算過度設(shè)計呢?從架構(gòu)的目的是降低人力來看,就是該設(shè)計目前沒有任何強有力的邏輯能推出能在未來降低修改某種行為的人力成本,或者降低某種行為修改成本的同時,大大增加了另外一種行為的修改成本。
? 架構(gòu)的理解成本
架構(gòu)是有一定理解成本的,甚至架構(gòu)設(shè)計之初會增加一定的系統(tǒng)理解成本,但是一個好的架構(gòu)理解成本一定不會很高,因為架構(gòu)的理解也是人力成本。在理解架構(gòu)設(shè)計的意圖之前,因為其增加系統(tǒng)的理解成本而否定它的必要性是不合邏輯的。
好的架構(gòu),其關(guān)鍵意義在于降低項目發(fā)展過程中整體理解成本。
也就是說,架構(gòu)良好的項目隨著業(yè)務(wù)復(fù)雜度增加,項目理解成本增長也是緩慢的。架構(gòu)不合理的項目隨著業(yè)務(wù)復(fù)雜度的增加,整體理解成本可能是指數(shù)增長的。
? 架構(gòu)調(diào)整需要勇氣
一旦你宣布進行項目架構(gòu)調(diào)整,就是宣告現(xiàn)有項目架構(gòu)不合理,也意味著他人將設(shè)計出比當(dāng)前優(yōu)秀的架構(gòu),這是一件非常需要勇氣的事。因為調(diào)整的過程中,你會犯錯,你需要進行一些猜測,你會和他人產(chǎn)生觀點沖突,你有時甚至需要有點固執(zhí)和執(zhí)著。
因為架構(gòu)投資的是未來,但大部分人只著眼于當(dāng)下。
重構(gòu)方式
? 拆遷者模式
根據(jù)當(dāng)前業(yè)務(wù)的需求對軟件架構(gòu)重新設(shè)計,并組織單獨的團隊,重新開發(fā)一個全新的版本,一次性完全替代原有的遺留系統(tǒng)。
為什么不適合我們?主要有如下幾項因素:
-
人力消耗巨大,需要一邊加新需求一邊重寫舊需求;
無法確保新的工程的設(shè)計比舊的好;
重寫過程中可能出現(xiàn)業(yè)務(wù)遺漏。
? 絞殺者模式
保持原來的系統(tǒng)不變,當(dāng)需要開發(fā)新功能時,重新開發(fā)一個服務(wù),實現(xiàn)新功能,通過不斷構(gòu)建新的服務(wù),逐步使遺留系統(tǒng)失效,最終替換它。
絞殺者模式相對比較適合我們的重構(gòu)需求,但是存在以下問題:
-
不希望存在多個服務(wù)共存的問題;
希望共享舊工程的 CICD,運維,監(jiān)控等能力;
重構(gòu)顆粒度過大,我們希望細到函數(shù)級別的重構(gòu)。
? 修繕者模式
將遺留系統(tǒng)的部分功能與其余部分隔離,以新的架構(gòu)進行單獨改造。
修繕者模式特別適合我們的需求。
Dashboard 的架構(gòu)
? 整體架構(gòu)
Dashboard 核心功能分為兩大塊,一個是作為 Web API Server,接收 HTTP 請求,另外一個是異步流程處理,用于耗時較長的功能,比如創(chuàng)建集群、集群升級等。
Dashboard 整體采用 MVC 架構(gòu) Controller 模式,這里的 Controller 模式是指通過不斷重試,最終將目標對象設(shè)置到某種目標狀態(tài)的模式,比如通過不斷重試,將創(chuàng)建中的集群的各部分屬性或者依賴的資源,設(shè)置到正常集群的狀態(tài)。Dashboard 的核心模塊如圖。
-
MVC Controller:用于接收 HTTP 請求,并調(diào)用 Service 進行業(yè)務(wù)處理;
MVC Service:核心業(yè)務(wù)邏輯全部落在這一層;
MVC DAO:DB 相關(guān)操作都在這一層;
MVC Models: 包含各個對象的字段,比如集群、節(jié)點等;
Controller 模式下的各個 Controller:每個 Controller 邏輯差異很大,但是都是調(diào)用 Service 進行對象狀態(tài)的初始化或者設(shè)置;
Components:調(diào)用外部服務(wù)的模塊都在這里,比如調(diào)用計算資源服務(wù)創(chuàng)建虛擬機、調(diào)用網(wǎng)絡(luò)資源服務(wù)設(shè)置網(wǎng)絡(luò)等。
Dashboard 雖然有水平分層,但是每一層內(nèi)部沒有組件的設(shè)計原則,也沒有代碼規(guī)范,每一層基本都是單一一個包,包內(nèi)代碼質(zhì)量不高,重復(fù)代碼較多。
? 具體實現(xiàn)
Dashboard 的工程目錄如下所示:
-
每一層一個包
這樣看來,Dashboard 的分層好像還挺清晰的。確實,相對于沒有分層,Dashboard 采用 MVC 架構(gòu)進行分層本身是有一定合理性的。但是在具體實施的時候,卻出現(xiàn)了很多問題,其中較為嚴重的是每一層只有一個包。
比如 Controller 包中,所有請求,無論哪個業(yè)務(wù)模塊的,全部放一起,根本無法區(qū)分哪些是集群相關(guān)的,哪些是監(jiān)控相關(guān)的,哪些是節(jié)點相關(guān)的,哪些是網(wǎng)絡(luò)相關(guān)的。
如果說 Controller 包一個文件一個請求還可以理解,那 Service 層整個只有一個包,不分模塊,而且全是全局函數(shù)可維護性就很差了,由于核心業(yè)務(wù)邏輯全在 Service 層,Service 的代碼量是所有層中最多的,隨著功能的增長,未來 Service 將越來越臃腫。
其它層,如 DAO,甚至 Component 也是一個包。
-
依賴關(guān)系混亂
Dashboard 沒有關(guān)注各個模塊之間的依賴關(guān)系,只要不產(chǎn)生循環(huán)依賴就可以隨意依賴別的模塊,所以模塊之間依賴十分混亂。這直接導(dǎo)致模塊難以復(fù)用,例如 Component 包中部分代碼依賴 DAO,依賴 Config,而 DAO 和 Config 又強依賴了配置文件和 DB。這導(dǎo)致如果要復(fù)用 Component 包開發(fā)一個很簡單的工具,都需要給工具準備 Dashboard 配置文件,甚至需要能連上 DB。
-
各層之間權(quán)責(zé)不明
Dashboard 雖然進行了分層,但是各層的權(quán)責(zé)并沒有嚴格實施,導(dǎo)致 MVC controller 層和 dao 層也包含了大量業(yè)務(wù)邏輯,甚至有大量與 service 層重復(fù)的業(yè)務(wù)邏輯。
-
每層內(nèi)部沒有設(shè)計
Dashboard 只劃分了水平分層,但是對每一層內(nèi)部,以及各層之間的通信方式?jīng)]有做出規(guī)定,各層內(nèi)部可以隨意暴露公共函數(shù)。各層之間也是直接進行函數(shù)調(diào)用。
? Dashboard 的架構(gòu)導(dǎo)致了哪些問題?
上一節(jié)介紹了 Dashboard 架構(gòu)的基本情況,這節(jié)更詳細的介紹在 Dashboard 的架構(gòu)下所衍生出的具體問題,這些問題便是 Skipper v1 著重需要解決的。
-
貧血模型導(dǎo)致 DAO 層臃腫
MVC Models 層中的對象只有數(shù)值,沒有方法,所有對象的業(yè)務(wù)邏輯,無論輕重,都在其他層,這種模型稱為貧血模型。相對的,如果對象不僅包含數(shù)值,還包含基本的方法,例如自身生命周期設(shè)置,版本設(shè)置等等,就稱為充血模型。Dashboard 是貧血模型,這導(dǎo)致 DAO 層比預(yù)期的要厚的多,因為包含了大量業(yè)務(wù)邏輯,比如設(shè)置默認字段,判斷字段是否是有效值等等,這些本應(yīng)該是對象自身才知道的業(yè)務(wù)邏輯。厚重的 DAO 層會導(dǎo)致 DAO 層難以通過 Interface 進行抽象,想換一種存儲簡直是不可能的任務(wù)。
-
無法單測
上文提到,Dashboard 中依賴關(guān)系十分混亂,而且一層只有一個包,這導(dǎo)致想進行單元測試是不可能的,因為對一個簡單的函數(shù)單測,你可能需要直接連 DB,哪怕你函數(shù)里根本不查 DB。Dashboard 中各層之間是直接調(diào)用全局函數(shù)的,并沒有通過 Interface 進行隔離,這就導(dǎo)致想進行單測就必須通過 Monkey 來進行全局函數(shù)打樁,不僅無法并發(fā)單測,還對體系結(jié)構(gòu)有要求,因為 Monkey 只支持 AMD64 體系結(jié)構(gòu)。
-
模塊劃分不清
dashboard 只進行了水平分層,但是同層沒有分模塊,這導(dǎo)致:
(1)想復(fù)用模塊功能但是不知道對應(yīng)的函數(shù)是哪個;
(2)添加新功能不知道應(yīng)該把代碼寫在哪。
-
Controller 模式能力不足
Dashboard 使用 Controller 模式進行異步操作,但是 Controller 模式在持久化和異步流程控制上能力較為薄弱。
(1)流程無法暫停,無法取消;
(2)流程參數(shù)和進度沒地方存儲等.
Skipper 架構(gòu) v1
? 整體架構(gòu)
基于 Dashboard 存在的問題,我們設(shè)計了 Skipper 項目架構(gòu)的 v1 版本,這個版本依然使用 MVC 分層,但是針對 Dashboard 的問題,重點關(guān)注了外部依賴接口化、DB 依賴接口化、充血模型、Task 異步流程、模塊劃分等。Dashboard 到 Skipper v1 的架構(gòu)變動如下圖。
-
外部依賴接口化
在 Skipper 中,對外部服務(wù)的調(diào)用(Component)都用 Interface 進行抽象,任何模塊都不直接使用 Component 的具體實現(xiàn),這解耦了業(yè)務(wù)邏輯和外部服務(wù),Component 提供 fake 版本用于單元測試。
-
充血模型
在 Skipper 中,Models 層只會被 core obj 層和 store interface 所引用,所有其它模塊都直接使用包含充血模型的 core obj 層。在 core obj 中,每個對象都是充血模型的,其不僅包含一個或多個對象數(shù)據(jù),還包含一些業(yè)務(wù)方法,比如將對象設(shè)置為升級狀態(tài),比如將對象生命周期改為 deleting 等等,也就是說,原來處于 dao 中的業(yè)務(wù)邏輯被上升到 core obj 中,使得 DAO 層薄到只有最基本的 CRUD 操作,這對后面 DB 依賴接口化有巨大幫助。
-
DB 依賴接口化
由于使用了充血模型,存儲層只有最基本的 CRUD,我們很方便得加入了 store interface 來解耦系統(tǒng)和具體存儲,store 層還提供基于 gorm 的具體實現(xiàn),以及 fake 版本的實現(xiàn)用于單元測試。
-
異步流程
為了解決 Controller 模式存在的問題,Skipper 開發(fā)一個 Task 異步流程執(zhí)行框架,用于執(zhí)行一次性的異步流程,但依舊保留 Controller 模式的存在,其中 Task Controller 是 Task 異步流程框架的引擎。
(1)Controller 模式用于需要一直運行的全局性旁路,比如節(jié)點狀態(tài)監(jiān)控,Task 執(zhí)行監(jiān)控等;
(2)Task 模式用于復(fù)雜的一次性流程,比如升級一個節(jié)點,升級一個集群等等。
-
Service 分包
Skipper 中也有 Service 層,和 Dashboard 不同的是,Skipper 的 Service 會根據(jù)業(yè)務(wù)模塊進行分包,比如一個包專門處理集群升級,一個包專門處理監(jiān)控組件,一個包專門處理巡檢等。
Skipper 的 Service 層依舊使用了全局函數(shù),沒有進行封裝,我們后續(xù)將提到,這是 Skipper v1 版本存在的一個問題。
-
可測試
由于外部服務(wù)以及 DB 都可以用 fake 的了,Service 層的代碼是可以進行單測的。
? 為什么相對 Dashboard 可以降低人力
案例:節(jié)點升級
這里以節(jié)點升級功能為例,介紹為什么 Skipper v1 相對 Dashboard 能降低人力。
功能簡介:節(jié)點升級功能是指將一批 k8s 節(jié)點上的組件版本從低版本升級至高版本,這是一個比較耗時的流程,所以不能在同步請求中直接完成,需要異步執(zhí)行,且需要展示升級進度。由于節(jié)點升級是高危操作,一批節(jié)點升級過程中,需要支持用戶隨時暫停,取消升級。
Dashboard 中開發(fā)過程:如果該功能在 Dashboard 中實現(xiàn),大概需要以下流程。
-
考慮節(jié)點升級請求參數(shù)比較復(fù)雜,沒法存在現(xiàn)有表中,需要新建一個表用于存儲節(jié)點升級的參數(shù)和進度。
編寫對應(yīng)的 Models。
編寫專門用于上述表的 DAO 層代碼。
編寫一個 Controller 異步流程,要為該 Controller 專門實現(xiàn)暫停,取消等控制機制。
編寫專門的旁路進行監(jiān)控告警。
Service 中實現(xiàn)節(jié)點升級核心流程。
由于無法單測,覺得寫得差不多了,需要等待測試環(huán)境空閑時,部署到測試環(huán)境進行調(diào)試。注意,測試環(huán)境是公共的,別人可能也需要用。
Skipper 中開發(fā)過程:如果該功能在 Skipper 中實現(xiàn),將基于 Task 異步流程實現(xiàn),大概需要以下流程:
-
由于 Task 框架已經(jīng)提供了參數(shù),進度的存儲,以及 Task 相關(guān)的 DAO 代碼,所以不需要創(chuàng)建任何新的 DB 表;
由于 Task 已經(jīng)實現(xiàn)了統(tǒng)一的暫停,取消等任務(wù)控制機制,不需要編寫相關(guān)代碼;
創(chuàng)建一個 Task Handler,實現(xiàn)節(jié)點升級;
Task 有統(tǒng)一的監(jiān)控,無需重復(fù)編寫;
由于 Skipper 是可單測的,在部署到測試環(huán)境之前,我們通過單元測試快速調(diào)通了核心邏輯;
部署到測試環(huán)境進行集成測試,這時候 Bug 已經(jīng)很少了。
? Skipper v1 存在問題
雖然 Skipper v1 解決了 Dashboard 存在的很多問題,但是其自身依然有很多不足,在新需求開發(fā)和舊代碼遷移過程中不斷暴露出來。
-
core obj 過度設(shè)計
Skipper 為了采用充血模型,在 core obj 中進行了封裝,例如 cluster 對象,隱藏了 Dashboard 中的多個 models 結(jié)構(gòu)體,隱藏了某些字段實際是 JSON 字段的,對外暴露出帶有方法的 cluster 對象,設(shè)計時候考慮了多種集群存在的可能性,所以整個對象對外不是一個實體,而是暴露了一個 Interface。而在實際使用時,發(fā)現(xiàn)為了對外暴露對象屬性,Interface 中充斥了大量的 Get 的 Set 方法,顯得很笨重,而且由于不同類型集群的差異并不體現(xiàn)在 cluster 對象本身,而是 cluster 的業(yè)務(wù)邏輯中,所以暴露 Interface 并沒有達到抽象集群的作用。
-
全局依賴
skipper v1 認為像 store, component 中的外部組件都是單例的,所以使用了全局依賴。使用全局依賴使得整個工程用的是一個 DB,這樣的方式至少存在以下幾個弊端:
(1)各模塊 DB 是耦合的,無法分開存儲,雖然目前所有模塊確實共用存儲,但是隨著模塊的成長,模塊 DB 獨立也是有可能的;
(2)Component 里聚合所有外部服務(wù)這使得使用任何一個外部服務(wù),就會依賴于所有外部服務(wù),使用 Component 的地方都需要從全局獲取對應(yīng)的 Component,重復(fù)代碼較多。
-
模塊不內(nèi)聚
雖然 Skipper v1 中,各層基本都按功能進行分包了,但是模塊并不內(nèi)聚,一些包之間依賴關(guān)系很明顯,應(yīng)該屬于一個模塊的不同部分,并且由于只使用了水平分層,模塊的內(nèi)部各層代碼分散到項目各層中并和其他模塊對應(yīng)層代碼耦合在一起。針對某一模塊,由于 Service 層依舊使用了全局函數(shù),除非有文檔說明,否則無法知道該模塊對其它模塊暴露了哪些 API,其它模塊甚至可以直接讀寫該模塊的 DB。例如集群監(jiān)控模塊,當(dāng) 1.16 版本的集群升級時,需要更新對應(yīng)集群的監(jiān)控配置,Skipper v1 中的實現(xiàn)是在集群升級代碼中顯示調(diào)用更新監(jiān)控配置的函數(shù),這就使得集群監(jiān)控開發(fā)人員必須理解集群升級的代碼并知道在哪里調(diào)用更新監(jiān)控配置的函數(shù),這使得集群生命周期模塊和監(jiān)控模塊是耦合的。
? 進一步探索
為了解決 Skipper v1 中的問題,我們決定重新審視一下設(shè)計原則相關(guān)的指導(dǎo)。我們比較警惕過度設(shè)計,也不喜歡在 Golang 中使用過多設(shè)計模式以及層層封裝,但是我們相信,設(shè)計原則是所有語言通用的,因為設(shè)計原則只是一種思考的方向,讓你對架構(gòu)的壞味道更加警覺。
-
架構(gòu)設(shè)計原則
架構(gòu)設(shè)計原則是軟件行業(yè)幾十年發(fā)展總結(jié)出的一些具有指導(dǎo)意義的思想,雖然在實踐時,很難完全遵循設(shè)計原則,但是識別其中違反原則的地方,并控制由于違反原則帶來的風(fēng)險是很有必要的。
-
SRP:單一職責(zé)原則
SRP 是最容易被誤解的原則,因為大多數(shù)人看到名字,就以為該原則指的是一個模塊只做一件事,但其實不是這樣的。SRP 較為經(jīng)典的描述是:
任何一個軟件模塊都應(yīng)該有且僅有一個原因被修改。
這里我更喜歡 Robert 大叔在其著作《架構(gòu)整潔之道》中描述的:
任何一個軟件模塊都應(yīng)該只對一類行為者負責(zé)。
這里的行為者是指一個或多個有共同需求的人。從我們的實踐背景下,集群生命周期模塊和監(jiān)控模塊是不同的小團隊在維護,而 skipper v1 中,監(jiān)控模塊想支持集群升級時更新配置,卻需要改動集群生命周期模塊代碼,這其實就違反了 SRP。
-
OCP:開閉原則
OCP 是 Bertrand Meyer 于 1988 年提出的:
設(shè)計良好的計算機軟件應(yīng)該易于擴展,同時抗拒修改。
OCP 是我們進行系統(tǒng)架構(gòu)設(shè)計的主導(dǎo)原則,其主要目的是讓系統(tǒng)易于擴展,同時限制其每次被修改所影響的范圍。實現(xiàn)方式是通過將系統(tǒng)劃分為一系列組件,并且將這些組件間的依賴關(guān)系按層次結(jié)構(gòu)進行組織,使得高層組件不會因底層組件被修改而受到影響。Skipper v1 中 Task 模式是符合開閉原則的,因為如果要添加一個新的異步流程,只要實現(xiàn)一個新的 Handler 即可,并不需要修改 Task 機制高層代碼。
-
LSP:里氏替換原則
1988 年,Barbara Liskov 在描述如何定義子類型時候?qū)懴逻@樣一段話:
這里需要一種可替換性:如果對每一個類型為 T1 的對象 o1,都有類型為 T2 的對象 o2,使得以 T1 定義的所有程序 P 在所有的對象 o1 都代換成 o2 時,程序 P 的行為沒有發(fā)生變化,那么類型 T2 是類型 T1 的子類型。
面向?qū)ο笳Z言中有另外一種解釋:
所有引用基類的地方必須能透明地使用其子類的對象。
當(dāng)然,Golang 不是面向?qū)ο笳Z言,沒有父類,子類的概念,但是里氏原則對于 Interface 的使用有著重要的指導(dǎo)意義,即:
假設(shè)存在接口 A 的實現(xiàn) Aa 和 Ab,使用接口 A 的程序在傳入的具體實現(xiàn)由 Aa 改成 Ab 時,行為不發(fā)生變化。
在 Skipper v1 中,store 層是符合里氏替換原則的,因為使用 DAO 版本的實現(xiàn)和使用 fake 版本的實現(xiàn),store 接口使用者行為是不變的。Robert 在《架構(gòu)整潔之道》給出了一個著名的反面例子,即正方形長方形問題。假設(shè) Class Rectangle 表示長方形。假設(shè) Class Square 集成了 Rectangle 表示正方形。使用 Rectangle 對象的程序并不能用 Square 對象來替換 Rectangle 對象,因為 Rectangle 長寬可以隨意設(shè)置,但是 Square 卻不行。
-
ISP:接口隔離原則
ISP 的定義十分直觀:
客戶端不應(yīng)該依賴它不需要的接口。
在 Skipper v1 中 Store 中定義的接口違反了 ISP,因為該接口包含了所有模塊的數(shù)據(jù)庫操作接口,基于 ISP 原則,我們應(yīng)該讓每個模塊自己擁有并維護自己單獨的 Store 接口。
-
DIP:依賴反轉(zhuǎn)原則
DIP 主要指導(dǎo)我們系統(tǒng)各層的依賴關(guān)系:
高層模塊不應(yīng)該依賴低層模塊,二者都應(yīng)該依賴其抽象;抽象不應(yīng)該依賴細節(jié);細節(jié)應(yīng)該依賴抽象。
從具體實現(xiàn)而言,如果想設(shè)計一個靈活的系統(tǒng),在源碼層次的依賴關(guān)系中,就應(yīng)該多引用抽象類型,而非具體實現(xiàn)。在具體實施時,《架構(gòu)整潔之道》中給出了 4 點建議:
(1)應(yīng)該避免在代碼中寫入與任何具體實現(xiàn)相關(guān)的名字,或者是其他容易變動的事物名字;
(2)應(yīng)在代碼中多使用抽象接口,盡量避免使用那些多變的具體實現(xiàn)類;
(3)不要在具體實現(xiàn)類上創(chuàng)建衍生類,Golang 語言天生就符合這一點;
(4)不要覆蓋包含具體實現(xiàn)的函數(shù),即別重寫,在 Skipper v1 的 Task 模式中違反了這一條,因為 Task 模式為了減少代碼重復(fù),所有 Task Handler 都需要內(nèi)嵌 Default Handler,并重寫其覺得需要修改的函數(shù)。
? 組件設(shè)計原則
-
CCP:共同閉包原則
應(yīng)該將那些會同時修改,并且為相同目的而修改的類放在同一個組件中,而將不會同時修改,并且不會為了相同目的的修改的那些類放在不同組件中。
其實 CCP 是 SRP 有很多相似的地方,我們可以統(tǒng)一描述它們的思想:
將由于相同原因而需改,并且需要同時修改的東西放在一起。將由于不同原因而修改,并且不同時修改的東西放在一起。
-
CRP:共同復(fù)用原則
不要強迫一個組件的用戶依賴他們不需要的東西。
這個原則實際上告訴我們應(yīng)該將那些會被同時用到的代碼放在同一個組件中。
-
ADP:無依賴環(huán)原則
組件依賴關(guān)系圖中不應(yīng)該出現(xiàn)環(huán)。
Golang 編譯器實際上已經(jīng)幫助我們避免了循環(huán)依賴。
-
SDP:穩(wěn)定依賴原則
依賴關(guān)系必須要指向更穩(wěn)定的方向。
這條原則告訴我們,一個我們預(yù)期會經(jīng)常變更的組件不該被一個難以修改的組件所依賴,否則這個多變的組件也會變得難以被修改。這里所謂的穩(wěn)定組件,就是指那些被別的組件依賴多的組件,不穩(wěn)定的組件是那些依賴很多其他組件,但被其他組件依賴少的組件。
有時候我們的穩(wěn)定組件還是需要依賴不穩(wěn)定組件,怎么辦呢?我們需要在他們中間加入一層穩(wěn)定的抽象層。
-
SAP:穩(wěn)定抽象原則
一個組件的抽象化程度應(yīng)該與其穩(wěn)定性保持一致。
SDP 中提到,穩(wěn)定的組件是不易修改的,這會導(dǎo)致整個項目的架構(gòu)難以被修改,我們需要通過高度抽象這些穩(wěn)定的組件,來讓其接受修改。
前一個原則 SDP 告訴我們,依賴應(yīng)該指向更加穩(wěn)定的方向,而 SAP 告訴我們,越穩(wěn)定,抽象化程度應(yīng)該越高,這兩個連起來就可以得出另外一個結(jié)論:
依賴關(guān)系應(yīng)該指向更加抽象的方向。
? 借鑒領(lǐng)域驅(qū)動開發(fā)
領(lǐng)域驅(qū)動開發(fā)是一種用于復(fù)雜軟件的架構(gòu)設(shè)計思想,學(xué)習(xí)門檻比較高且對團隊成員整體架構(gòu)水平要求較高,其實并不適合完全使用在 Skipper 的開發(fā)中,我們只借鑒其中一部分適合于我們項目的思想。
-
水平分層
在 Skipper v1 中,我們依舊采用了 MVC 分層。但是領(lǐng)域驅(qū)動開發(fā),以及《架構(gòu)整潔之道》都提醒我們,應(yīng)當(dāng)存在一個應(yīng)用層(《架構(gòu)整潔之道》中稱為 Use Cases 層)用于處理依賴多個組件的業(yè)務(wù)邏輯,各層之間依賴于接口而非實現(xiàn),且下層不能依賴上層。比如創(chuàng)建一個包含三個節(jié)點的集群,就同時需要操作集群模塊和節(jié)點模塊。
領(lǐng)域驅(qū)動開發(fā)中,每個領(lǐng)域稱為 Domain,每個 Domain 有自己的領(lǐng)域?qū)嶓w,并且是充血模型,每個領(lǐng)域的存儲也是內(nèi)聚在領(lǐng)域之中,綜合以上,水平分層應(yīng)當(dāng)如下。
-
領(lǐng)域劃分與邊界
在領(lǐng)域驅(qū)動開發(fā)中不僅進行了水平分層,還進行了垂直切片,將應(yīng)用層以下劃分成了不同領(lǐng)域(Domain),每個領(lǐng)域責(zé)任明確且高度內(nèi)聚。
領(lǐng)域的劃分應(yīng)該滿足單一職責(zé)原則,每個領(lǐng)域應(yīng)當(dāng)只對同一類行為者負責(zé),每次系統(tǒng)的修改都應(yīng)該分析屬于哪個領(lǐng)域,如果某些領(lǐng)域總是同時被修改,他們應(yīng)當(dāng)被合并為一個領(lǐng)域。一旦領(lǐng)域劃分后,不同領(lǐng)域之間需要制定嚴格的邊界,領(lǐng)域暴露的接口,事件,領(lǐng)域之間的依賴關(guān)系都該被嚴格把控。
-
領(lǐng)域事件
領(lǐng)域可以定義事件并發(fā)布到事件總線,如果對某個領(lǐng)域事件感興趣,就可以訂閱事件。領(lǐng)域事件可以大大降低各領(lǐng)域間的耦合,且對系統(tǒng)擴展性有巨大好處。例如在 Skipper v1 中,如果劃分出了集群監(jiān)控領(lǐng)域和集群生命周期管理領(lǐng)域,當(dāng)有一天監(jiān)控領(lǐng)域決定去掉集群升級過程中對監(jiān)控配置文件的修改,需要在集群升級代碼里找調(diào)用監(jiān)控配置文件升級的地方。而如果采用了領(lǐng)域事件,則只需要讓集群生命周期模塊發(fā)布升級完成事件,并讓監(jiān)控模塊訂閱或者取消訂閱事件進而做出配置文件修改邏輯即可。
Skipper 架構(gòu) v2
參考前兩文的探索,我們對 Skipper v1 做了一定調(diào)整。
? 整體架構(gòu)
下圖是 v1 到 v2 的轉(zhuǎn)變,其核心是加入是領(lǐng)域模型,形成高內(nèi)聚的業(yè)務(wù)領(lǐng)域組件。
-
我們將 v1 中的 service 層切成兩層,把跨多領(lǐng)域的業(yè)務(wù)邏輯上拉至 application 層中,讓剩下的業(yè)務(wù)邏輯包含明顯的業(yè)務(wù)邊界;
我們再根據(jù)各個業(yè)務(wù)模塊的依賴關(guān)系緊密程度進行重組,形成領(lǐng)域,每個領(lǐng)域只處理自己領(lǐng)域的業(yè)務(wù),每個領(lǐng)域?qū)ν獗┞兑惶?Service 接口用于描述該領(lǐng)域?qū)ν獗┞兜哪芰?,領(lǐng)域可以利用 Event Bus 對外發(fā)布事件,用于通知外部領(lǐng)域內(nèi)正在發(fā)生的事;
原來全局公用的存儲層,現(xiàn)在分散到各個領(lǐng)域自行維護,不同領(lǐng)域可以采用不同的存儲;
原來放置全局的 Controller 和 Task Handler,現(xiàn)在由每個領(lǐng)域自行管理,系統(tǒng)依然提供 Controller 和 Task 的引擎(由 Task 領(lǐng)域負責(zé))。這使得領(lǐng)域業(yè)務(wù)邏輯更加內(nèi)聚;
注意各模塊的依賴關(guān)系,我們盡量遵循穩(wěn)定依賴原則和穩(wěn)定抽象原則,不穩(wěn)定模塊盡量依賴于穩(wěn)定模塊,如果需要讓穩(wěn)定模塊依賴于不穩(wěn)定模塊,我們引入 Interface 進行抽象。
? 新領(lǐng)域孵化
我們可以肯定隨著業(yè)務(wù)的發(fā)展,會有越來越多的領(lǐng)域被加入到 Skipper 中(目前已經(jīng)出現(xiàn)”虛擬集群“領(lǐng)域)。
當(dāng)一個新的領(lǐng)域被加入到 Skipper 中時,根據(jù)上邊的架構(gòu),我們只需要借鑒其他領(lǐng)域的設(shè)計,新建一個領(lǐng)域,并在讓領(lǐng)域負責(zé)人在此領(lǐng)域中迭代需求即可,這過程中,新領(lǐng)域可以依賴其它領(lǐng)域,監(jiān)聽其它領(lǐng)域的事件等等,對其它領(lǐng)域而言都是無感的。
? 領(lǐng)域成長與獨立
隨著領(lǐng)域內(nèi)業(yè)務(wù)邏輯越來越復(fù)雜,或者因為業(yè)務(wù)調(diào)整,存在某個領(lǐng)域獨立出項目的情況(目前”集群監(jiān)控“領(lǐng)域已準備獨立),由于我們的領(lǐng)域是高內(nèi)聚的,領(lǐng)域獨立的難度并不大,對整個項目而言,也只是將剝離的領(lǐng)域從領(lǐng)域?qū)愚D(zhuǎn)移至 Infrastructure 層,作為外部服務(wù)而已。
由于領(lǐng)域之間總是依賴于接口或者依賴于領(lǐng)域事件,當(dāng)領(lǐng)域獨立時,依賴這個領(lǐng)域的業(yè)務(wù)邏輯是不需要進行修改的。
? 微服務(wù)化
可能隨著領(lǐng)域不斷剝離,項目的領(lǐng)域不斷的成為獨立的服務(wù),當(dāng)服務(wù)增多時,就需要引入更加統(tǒng)一有效的運維、監(jiān)控、部署方案,我們相信這才是項目微服務(wù)化最自然的方式,我們傾向于項目盡量是單體應(yīng)用。
? 為什么相對 v1 可以降低人力
案例:增加集群創(chuàng)建失敗通知機制
功能簡介:集群創(chuàng)建目前成功率雖然符合 SLA,但是依然不是 100% 的,我們希望當(dāng)集群創(chuàng)建失敗時能第一時間通知我們。通知本身是一個比較簡單的需求,完全可以分配給新人來做。
Skipper v1 中開發(fā):如果在 Skipper v1 中開發(fā),我們面對的最大問題是開發(fā)人員必須知道集群創(chuàng)建失敗的具體位置,這只有集群創(chuàng)建流程的開發(fā)人員才知道,為了加入通知功能,新人不得不去請教集群創(chuàng)建流程的開發(fā)人員,并且需要修改集群創(chuàng)建流程,由于修改了集群創(chuàng)建流程,還需要走測試,雖然通知功能的代碼不多,但是由于要修改集群創(chuàng)建流程,導(dǎo)致了人力成本的增加。
Skipper v2 中開發(fā):如果在 Skipper v2 中開發(fā),只需要單獨創(chuàng)建一個領(lǐng)域,專門用于系統(tǒng)各種需要觸達我們的通知,然后訂閱對應(yīng)事件即可,比如該例子中,就是訂閱集群創(chuàng)建失敗事件。這種開發(fā)模式,不需要修改集群創(chuàng)建流程代碼,一切改動都在關(guān)鍵事件通知領(lǐng)域進行,且基于這種開發(fā)方式,就不會讓事件通知代碼散落在各個領(lǐng)域中。
總結(jié)
本文是一次 Golang 項目重構(gòu)的思考與記錄,首先討論了為什么架構(gòu)是重要的,又介紹了幾種可行的重構(gòu)方式?;趯嶋H的項目,我們介紹了舊工程 Dashboard 項目的架構(gòu)和其中的問題,針對這些問題,我們嘗試著去設(shè)計一個更優(yōu)秀的架構(gòu) Skipper v1。但是,隨著遷移的進行,我們發(fā)現(xiàn) Skipper v1 中依舊存在一些如模塊不內(nèi)聚,充血模型過度設(shè)計等問題,為了更好地解決已知的架構(gòu)問題,我們參考了《架構(gòu)整潔之道》以及 DDD 的一些思想,再結(jié)合 Skipper v1 的實際情況,設(shè)計出了 Skipper v2 的架構(gòu)。
參考文獻:
[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]喬梁.持續(xù)交付 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 技術(shù)專家,系統(tǒng)可觀測性專家。擁有多年大規(guī)模 Kubernetes 集群開發(fā)運維經(jīng)驗。目前負責(zé)騰訊云 TKE 萬級規(guī)模 Kubernetes 集群治理,主導(dǎo)研發(fā)超大規(guī)模 Kubernetes 集群聯(lián)邦智能監(jiān)控系統(tǒng)與巡檢系統(tǒng)。
今日福利
遇見大咖
由 CSDN 全新專為技術(shù)人打造的高端對話欄目《大咖來了》來啦!
CSDN 創(chuàng)始人&董事長、極客幫創(chuàng)投創(chuàng)始合伙人蔣濤攜手京東集團技術(shù)副總裁、IEEE Fellow、京東人工智能研究院常務(wù)副院長、深度學(xué)習(xí)及語音和語言實驗室負責(zé)人何曉冬,來也科技 CTO 胡一川,共話中國 AI 應(yīng)用元年來了,開發(fā)者及企業(yè)的路徑及發(fā)展方向!
版權(quán)聲明:本文內(nèi)容由互聯(lián)網(wǎng)用戶自發(fā)貢獻,該文觀點僅代表作者本人。本站僅提供信息存儲空間服務(wù),不擁有所有權(quán),不承擔(dān)相關(guān)法律責(zé)任。如發(fā)現(xiàn)本站有涉嫌抄襲侵權(quán)/違法違規(guī)的內(nèi)容, 請發(fā)送郵件至 舉報,一經(jīng)查實,本站將立刻刪除。