簡潔架構(gòu)設(shè)計(jì):如何設(shè)計(jì)一個(gè)合理的軟件架構(gòu)?
在開發(fā)項(xiàng)目之前,需要先設(shè)計(jì)一個(gè)合理的軟件架構(gòu)。一個(gè)好的軟件架構(gòu)不僅可以大大提高項(xiàng)目的迭代速度,還可以降低項(xiàng)目的閱讀和維護(hù)難度。目前,行業(yè)中有多種流行的軟件架構(gòu),例如:MVC 架構(gòu)、六邊形架構(gòu)、洋蔥架構(gòu)、簡潔架構(gòu)等。在 Go 項(xiàng)目開發(fā)中,用的最多的是簡潔架構(gòu)。
本節(jié)課會(huì)詳細(xì)介紹簡潔架構(gòu),以及 miniblog 項(xiàng)目的簡潔架構(gòu)設(shè)計(jì)和實(shí)現(xiàn)方法。
一、為什么需要軟件架構(gòu)?
這里引用 Robert C.Martin 在其《Clean Architecture》書中的一句話,來說明為什么需要軟件架構(gòu):軟件架構(gòu)的目標(biāo)是最大限度地減少構(gòu)建和維護(hù)系統(tǒng)所需的人力資源。
具體而言,采用一個(gè)合理的軟件架構(gòu)將帶來以下好處:
- 可測試性:良好的軟件架構(gòu)能夠提高代碼的可測性,從而增強(qiáng)軟件的穩(wěn)定性;
- 可維護(hù)性:良好的軟件架構(gòu)使系統(tǒng)的各個(gè)部分相互獨(dú)立,易于理解和修改。它提供了結(jié)構(gòu)化的方式來組織代碼,使系統(tǒng)的修改和維護(hù)變得更加簡單;
- 擴(kuò)展性:軟件架構(gòu)應(yīng)能夠很好的支持系統(tǒng)的擴(kuò)展和演變。通過合理的分層和模塊化,軟件架構(gòu)可以使系統(tǒng)的功能很容易的得到擴(kuò)展,而無需對整個(gè)系統(tǒng)進(jìn)行重構(gòu);
- 可重用性:好的軟件架構(gòu)能夠提高代碼的復(fù)用度。將通用的功能封裝為可復(fù)用的包/庫,可以使這些功能在不同的項(xiàng)目和模塊中重復(fù)使用,從而提高開發(fā)效率和代碼質(zhì)量。
二、簡潔架構(gòu)介紹
簡潔架構(gòu)(Clean Architecture)是一種軟件架構(gòu)模式(又稱整潔架構(gòu)、干凈架構(gòu)),旨在實(shí)現(xiàn)可維護(hù)、可測試和可擴(kuò)展的應(yīng)用程序。最初由 Robert C.Martin 在其文節(jié)課 The Clean Architecture 提出。之后,因?yàn)楹啙嵓軜?gòu)的諸多優(yōu)點(diǎn),在 Go 項(xiàng)目開發(fā)中被大量采用。
軟件架構(gòu)有多種形式,例如六邊形架構(gòu)、洋蔥架構(gòu)、尖叫架構(gòu)、DCI 架構(gòu)和 BCE 架構(gòu)等。這些架構(gòu)在細(xì)節(jié)上各有不同,但整體而言非常相似。它們的共同目標(biāo)是實(shí)現(xiàn)關(guān)注點(diǎn)的分離,并通過軟件的分層設(shè)計(jì)來達(dá)到這一目的,從而踐行高內(nèi)聚、低耦合的架構(gòu)理念。
采用這些軟件架構(gòu)開發(fā)的應(yīng)用都具有以下五點(diǎn)特性:
- 獨(dú)立于框架:該架構(gòu)不會(huì)依賴于某些功能強(qiáng)大的軟件庫存在。這可以讓開發(fā)者使用這樣的框架作為工具,而不是讓開發(fā)者的系統(tǒng)陷入到框架的約束中;
- 可測試性:業(yè)務(wù)規(guī)則可以在沒有 UI、數(shù)據(jù)庫、Web 服務(wù)或其他外部元素的情況下進(jìn)行測試,在實(shí)際的開發(fā)中,可以通過 Mock 來解耦這些依賴;
- 獨(dú)立于 UI:在無需改變系統(tǒng)其他部分的情況下,UI 可以輕松地改變。例如,在沒有改變業(yè)務(wù)規(guī)則的情況下,Web UI 可以替換為控制臺(tái) UI;
- 獨(dú)立于數(shù)據(jù)庫:開發(fā)者可以用 Mongo、Oracle、Etcd 或者其他數(shù)據(jù)庫來替換 MariaDB,開發(fā)者的業(yè)務(wù)規(guī)則不要綁定到數(shù)據(jù)庫;
- 獨(dú)立于外部媒介:實(shí)際上,開發(fā)者的業(yè)務(wù)規(guī)則可以簡單到根本不去了解外部世界。
上述五點(diǎn)特性,也可以看作是簡潔架構(gòu)的五點(diǎn)約束,理論上任何遵循了以上五點(diǎn)約束的軟件架構(gòu),都可以看作是簡潔架構(gòu)的一種實(shí)現(xiàn)方式。通常所說的簡潔架構(gòu)指的是洋蔥架構(gòu)。
提示: Robert C. Martin 還為簡潔架構(gòu)專門寫了一本書,如果你想了解更多簡潔架構(gòu)的知識(shí),可閱讀圖書《架構(gòu)整潔之道》。
三、miniblog 簡潔架構(gòu)實(shí)現(xiàn)
任何實(shí)現(xiàn)簡潔架構(gòu)規(guī)定的五個(gè)約束的軟件架構(gòu)均可稱為簡潔架構(gòu)。miniblog 項(xiàng)目參考業(yè)界簡潔架構(gòu)的實(shí)現(xiàn),也設(shè)計(jì)實(shí)現(xiàn)了一種簡潔架構(gòu)。與其他簡潔架構(gòu)的最大區(qū)別在于,miniblog 的簡潔架構(gòu)設(shè)計(jì)更加簡單實(shí)用,省略了一部分分層特性,僅保留了必要的分層,但帶來了更大的易讀性和可維護(hù)性。
miniblog 項(xiàng)目的簡潔架構(gòu)設(shè)計(jì)如下圖所示:
整個(gè)軟件架構(gòu)一共分為以下三層:
- Handler 層:負(fù)責(zé) API 接口請求的參數(shù)解析、參數(shù)校驗(yàn)、業(yè)務(wù)邏輯處理分發(fā)、參數(shù)返回邏輯。在 Handler 層中,還有 Default 和 Validation 模塊,分別用來給請求參數(shù)設(shè)置默認(rèn)值,并校驗(yàn)請求參數(shù)的合法性;
- Biz 層:包括了具體的業(yè)務(wù)邏輯實(shí)現(xiàn)。Biz 層根據(jù) REST 資源類型分為不同的模塊,內(nèi)部可模塊間交叉調(diào)用。在 Biz 層還有 Conversion 模塊,用來進(jìn)行結(jié)構(gòu)體類型轉(zhuǎn)換;
- Store 層:數(shù)據(jù)訪問層(包括訪問數(shù)據(jù)庫或第三方微服務(wù)),用來跟數(shù)據(jù)庫/微服務(wù)交互執(zhí)行數(shù)據(jù)的 CURD 操作。該層做了進(jìn)一步的抽象,抽象出了通用的 Store 層,Generic Store 之上 REST 資源的數(shù)據(jù)存儲(chǔ)操作,均可繼承 Generic Store 的方法實(shí)現(xiàn),而不需要自行再實(shí)現(xiàn)一套。
上圖所示的簡潔架構(gòu),還具有以下特點(diǎn):
- 簡潔架構(gòu)提供了清晰的分層結(jié)構(gòu),各層功能明確,職責(zé)分明;
- 通過接口解耦每一層,從而實(shí)現(xiàn)代碼的可測性、獨(dú)立性和擴(kuò)展性;
- 代碼依賴由上向下(圖中的有向箭頭表示依賴規(guī)則),單向單層依賴,提供了清晰的依賴關(guān)系,使代碼易于理解和維護(hù)。
上述三個(gè)特點(diǎn)也使得整個(gè)軟件代碼具有很高的易讀性和可維護(hù)性。圖 3-1 所示的簡潔架構(gòu)有三層,但這不意味著簡潔架構(gòu)只有三層。如果有需要你可以對層進(jìn)行增減。雖然層數(shù)可變,但是依賴關(guān)系是固定的,即:單向依賴。
上圖所示的簡潔架構(gòu)中,API 請求的數(shù)據(jù)流轉(zhuǎn)路徑如下圖所示。
請求到來后,先經(jīng)過 Default 模塊,用來給請求參數(shù)設(shè)置默認(rèn)值。之后,經(jīng)過 Validation 模塊,用來對請求參數(shù)進(jìn)行校驗(yàn)。校驗(yàn)通過后,會(huì)經(jīng)過 Handler 方法,Handler 方法會(huì)處理請求,并將請求轉(zhuǎn)發(fā)到 Biz 層的 Biz 方法中。在 Biz 方法中需要進(jìn)行數(shù)據(jù)轉(zhuǎn)換,在 miniblog 項(xiàng)目中,會(huì)將 Biz 層的數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)換為 Store 層的數(shù)據(jù)結(jié)構(gòu),并調(diào)用 Store 層的方法,對數(shù)據(jù)進(jìn)行 CURD 操作。Store 層的方法繼承自 Generic Store,所以最終是調(diào)用 Generic Store 完成對數(shù)據(jù)的 CURD 操作。
1. 簡潔架構(gòu)中的依賴規(guī)則
簡潔架構(gòu)能夠工作的關(guān)鍵是依賴規(guī)則,這條規(guī)則規(guī)定:代碼依賴應(yīng)該由上向下,單向依賴。這種依賴包含代碼名稱、函數(shù)/方法、變量或任何其他軟件實(shí)體。也就是說,下層不應(yīng)該感知到上層的任何對象。上層中聲明的數(shù)據(jù)格式不應(yīng)被下層使用。
除了上述層與層之間的包依賴關(guān)系外,各層還可以導(dǎo)入項(xiàng)目所需的其他 Go 包,例如內(nèi)部包、外部包或框架包等。然而,必須確保依賴關(guān)系的合理性,避免出現(xiàn)循環(huán)依賴。
上述包導(dǎo)入關(guān)系如下圖所示。
2. 簡潔架構(gòu)中的分層設(shè)計(jì)
miniblog的簡潔架構(gòu)一共分為了三層:存儲(chǔ)層(Store)、業(yè)務(wù)層(Biz)、處理器層(Handler)。每一層都承載了不同的功能。
(1) 存儲(chǔ)層(Store)
存儲(chǔ)層在某些簡潔架構(gòu)設(shè)計(jì)中也稱為 Frameworks&Drivers 層或基礎(chǔ)設(shè)施層。存儲(chǔ)層負(fù)責(zé)與數(shù)據(jù)庫、外部服務(wù)等進(jìn)行交互,作為應(yīng)用程序的數(shù)據(jù)引擎進(jìn)行數(shù)據(jù)的輸入和輸出。需要注意的是,存儲(chǔ)層僅對數(shù)據(jù)庫或外部服務(wù)執(zhí)行 CRUD 操作,不封裝任何業(yè)務(wù)邏輯。
此外,存儲(chǔ)層還承擔(dān)數(shù)據(jù)轉(zhuǎn)換的任務(wù):將從數(shù)據(jù)庫或微服務(wù)獲取的數(shù)據(jù)轉(zhuǎn)換為處理器層和業(yè)務(wù)層能夠識(shí)別的數(shù)據(jù)結(jié)構(gòu),同時(shí)將處理器層和業(yè)務(wù)層的數(shù)據(jù)格式轉(zhuǎn)換為數(shù)據(jù)庫或外部服務(wù)可識(shí)別的數(shù)據(jù)格式。
(2) 業(yè)務(wù)層(Biz)
業(yè)務(wù)層在(Biz,Business)某些簡潔架構(gòu)設(shè)計(jì)中也稱為 Usecases 層。業(yè)務(wù)層是領(lǐng)域模型的應(yīng)用層,負(fù)責(zé)協(xié)調(diào)各個(gè)實(shí)體和值對象之間的交互,以完成具體的業(yè)務(wù)需求。業(yè)務(wù)層會(huì)受到業(yè)務(wù)邏輯變更的影響,但不會(huì)被其他層所影響,例如用戶界面和數(shù)據(jù)庫等。
業(yè)務(wù)層功能如下圖所示:
在實(shí)際的企業(yè)應(yīng)用開發(fā)中,業(yè)務(wù)層是變更最頻繁的一層。
(3) 處理器層(Handler)
處理器層在某些簡潔架構(gòu)設(shè)計(jì)中也稱為控制器層。處理器層負(fù)責(zé)接收 HTTP/RPC 請求,并進(jìn)行參數(shù)解析、參數(shù)校驗(yàn)、業(yè)務(wù)邏輯處理、請求返回等操作。處理器層的核心目的是將用戶的輸入轉(zhuǎn)化為領(lǐng)域模型的操作,并將結(jié)果返回給用戶。在這一層還包括其他適配器,用于將數(shù)據(jù)從外部形式(如外部服務(wù))轉(zhuǎn)換為業(yè)務(wù)層可以使用的內(nèi)部形式。
處理器層會(huì)將請求轉(zhuǎn)發(fā)給業(yè)務(wù)層,業(yè)務(wù)層處理后返回,返回?cái)?shù)據(jù)在處理器層中被整合再加工,最終返回給請求方。處理器層相當(dāng)于實(shí)現(xiàn)了業(yè)務(wù)路由的功能。具體流程如下圖所示。
提示:
在 MVC 架構(gòu)中,處理器層通常用 Controller 來表示,而在 gRPC 服務(wù)中則用 Service。為了統(tǒng)一 MVC 架構(gòu)中的處理器層名稱與 gRPC 服務(wù)中的處理器層名稱,這里統(tǒng)一使用 Handler 來表示處理器層。在大多數(shù) Go 項(xiàng)目中,包括一些優(yōu)秀的開源項(xiàng)目(如 Kubernetes、Gin、Echo 等),處理請求的層通常被命名為 Handler,而非 Controller 或其他名稱。Handler 準(zhǔn)確地表達(dá)了其職責(zé),即負(fù)責(zé)處理(handling)請求。
3. 接口依賴關(guān)系
在簡潔架構(gòu)的設(shè)計(jì)中,各層之間通過接口進(jìn)行解耦,以便減少依賴關(guān)系,同時(shí)增強(qiáng)系統(tǒng)的擴(kuò)展性。接口依賴有以下兩種模式:
- 接口依賴方式一:外層組件聲明所需的能力,內(nèi)層組件則實(shí)現(xiàn)這些能力;
- 接口依賴方式二:內(nèi)層組件首先提供所需能力(接口),外層組件才能調(diào)用這些能力。外層組件的能力依賴于內(nèi)層組件的能力。
接口依賴模式如下圖所示。
在接口依賴方式一種,包的導(dǎo)入關(guān)系為內(nèi)層導(dǎo)入外層。在接口依賴方式二中,包的導(dǎo)入關(guān)系為外層導(dǎo)入內(nèi)層。miniblog 項(xiàng)目采用了第二種接口依賴方式,即在開發(fā)過程中優(yōu)先開發(fā)內(nèi)層組件,然后再開發(fā)外層組件。具體的開發(fā)流程為:先開發(fā) Store 層、Biz 層,最后是 Handler 層。
4. 層之間的通信
處理器層、業(yè)務(wù)層和存儲(chǔ)層之間均通過接口進(jìn)行通信。通過接口通信,一方面可以支持同一個(gè)功能有不同的實(shí)現(xiàn)(也就是說具有插件化能力)。另一方面,接口解耦了不同層的具體實(shí)現(xiàn),使得每一層變得獨(dú)立且可測試。層之間通信模式如下圖所示。
四、簡潔架構(gòu)如何測試
處理器層、業(yè)務(wù)層和存儲(chǔ)層之間通過接口進(jìn)行通信。通過接口通信的一個(gè)好處是,可以讓各層變得可測試。本節(jié)將討論如何測試各層的代碼。
1. 存儲(chǔ)層測試
存儲(chǔ)層依賴于數(shù)據(jù)庫,如果調(diào)用了其他微服務(wù),則還會(huì)依賴第三方服務(wù)。開發(fā)者可以通過 sqlmock 來模擬數(shù)據(jù)庫連接,通過 httpmock 來模擬 HTTP 請求。
2. 業(yè)務(wù)層測試
業(yè)務(wù)層依賴于存儲(chǔ)層,這意味著該層需要存儲(chǔ)層的支持才能進(jìn)行測試??梢允褂?golang/mock 來模擬存儲(chǔ)層,測試用例可以參考 Test_postBiz_Delete,單元測試用例代碼如下述代碼所示。
func Test_postBiz_Delete(t *testing.T) {
// 創(chuàng)建一個(gè)新的 gomock 控制器,用于管理 Mock 對象
ctrl := gomock.NewController(t)
defer ctrl.Finish() // 確保在測試結(jié)束時(shí)調(diào)用 Finish,以驗(yàn)證所有預(yù)期的調(diào)用
// 構(gòu)造 Mock 的 PostStore
mockPostStore := store.NewMockPostStore(ctrl)
// 設(shè)置對 Delete 方法的期望:在上下文中調(diào)用一次,傳入任意參數(shù),返回 nil(表示沒有錯(cuò)誤)
mockPostStore.EXPECT().Delete(context.Background(), gomock.Any()).Return(nil).Times(1)
// 構(gòu)造 Mock 的 IStore
mockStore := store.NewMockIStore(ctrl)
// 設(shè)置對 Posts 方法的期望:可以被調(diào)用任意次數(shù),返回 mockPostStore
mockStore.EXPECT().Post().AnyTimes().Return(mockPostStore)
// 初始化 postBiz 實(shí)例,傳入 Mock 的 IStore
biz := &postBiz{store: mockStore}
// 執(zhí)行 Delete 方法,傳入上下文和一個(gè)空的 DeletePostRequest
got, err := biz.Delete(context.Background(), &apiv1.DeletePostRequest{})
// 使用 assert 進(jìn)行斷言,檢查返回的結(jié)果是否與期望的 DeletePostResponse 相等
assert.Equal(t, &apiv1.DeletePostResponse{}, got, "Expected response does not match")
// 檢查 err 是否為 nil,確保沒有錯(cuò)誤發(fā)生
assert.Nil(t, err, "Expected no error, but got one")
}
上述代碼使用 golang/mock 工具生成了存儲(chǔ)層的 Mock 方法 NewMockPostStore 和 NewMockIStore。
3. 處理器層測試
處理器層依賴于業(yè)務(wù)層,這意味著該層需要業(yè)務(wù)層的支持進(jìn)行測試。同樣可以通過 golang/mock 來模擬業(yè)務(wù)層,測試用例可參考 TestHandler_DeletePost。
五、小結(jié)
本節(jié)課介紹了 miniblog 項(xiàng)目中使用的簡潔架構(gòu)設(shè)計(jì)。在 miniblog 項(xiàng)目中,架構(gòu)被簡化為存儲(chǔ)層、業(yè)務(wù)層和處理器層,各層通過接口解耦,職責(zé)明確,提升了代碼的清晰度和可測試性。遵循的依賴規(guī)則確保了代碼依賴由外向內(nèi)、單向傳遞。此外,接口的使用提升了層之間的通信能力,使得各層可以獨(dú)立測試,使得可以通過單元測試用例來提高代碼的穩(wěn)定性。