Golang 整潔架構實踐
作者:donghli,騰訊 PCG 後臺開發工程師
瞭解過 Hex 六邊形架構、Onion 洋蔥架構、Clean 整潔架構的同學可以將本篇文章介紹的實踐方法與自身項目代碼架構對比並互通有無,共同改進。沒了解過上述架構的同學可以學習一種新的架構方法,並嘗試將其應用到業務項目中,降低項目維護成本,提高效率。
本文提及的架構主要指項目組織的 “代碼架構”,注意與微服務架構等名詞中的服務架構進行區分。
1. 爲什麼要有代碼架構
歷史悠久的項目大都會有很多開發人員參與 “貢獻”,在沒有好的指導規則約束的情況下,大抵會變成一團亂麻。剪不斷,理還亂,也沒有勇士開發者願意去剪去理。被迫接手的勇士開發者如果想要增加一個小需求,可能需要花 10 倍的時間去理順業務邏輯,再花 10 倍的時間去補充測試代碼,實在是低效又痛苦。
這是一個普遍的痛點問題,也有無數開發者嘗試過去解決它。這麼多年發展累積下來,業界自然也誕生了很多軟件架構。大家耳熟能詳的就有六邊形架構(Hexagonal Architecture),洋蔥架構(Onion Architecture),整潔架構(Clean Architecture)等。這些架構在細節上肯定有所差異,但是核心目標都是一致的:致力於實現軟件系統的關注點分離(separation of concerns)。
關注點分離之後的軟件系統都具備如下特徵:
-
不依賴特定 UI。UI 可以任意替換,不會影響系統重其他組件。從 Web UI 變成桌面 UI,甚至變成控制檯 UI 都無所謂,業務邏輯不會被影響。
-
不依賴特定框架。以 JavaScript 生態舉例,不管是使用 web 框架 koa, express,還是使用桌面應用框架 electron,還是控制檯框架 commander,業務邏輯都不會被影響,被影響的只會是框架接入的那一層。
-
不依賴特定外部組件。系統可以任意使用 MySQL, MongoDB, 或 Neo4j 作爲數據庫,任意使用 Redis, Memcached, 或 etcd 作爲鍵值存儲等。業務邏輯不會因爲這些外部組件的替換而變化。
-
容易測試。核心業務邏輯可以在不需要 UI,不需要數據庫,不需要 Web 服務器等一切外界組件的情況下被測試。這種純粹的代碼邏輯意味着清晰容易的測試。
軟件系統有了這些特徵後,易於測試,更易於維護、更新,大大減輕了軟件開發人員的心智負擔。所以,好的代碼架構確實值得推崇。
2. 好的代碼架構是如何構建的
前文所述的三個架構在理念上是近似的,從下文圖 1 到圖 3 三幅架構圖中也能看出相似的圈層結構。圖中可以看到,越往外層越具體,越往內層越抽象。這也意味着,越往外越有可能發生變化,包括但不限於框架升級,中間件變更,適配新終端等等。
圖 1 整潔架構的同心圓結構中可以看見三條由外向內的黑色箭頭,它表示依賴規則(The Dependency Rule)。依賴規則規定外層的代碼可以依賴內層,但是內層的代碼不可以依賴外層。也就是說內層邏輯不可以依賴任何外層定義的變量,函數,結構體,類,模塊等等代碼實體。假如說,最外層藍色層 “Frameworks & Drivers” DB 處使用了 go 語言的 gorm 三方庫,並定義了 gorm 相關的數據庫結構體及其 tag 等。那麼內層的 Gateways,Use Cases, Entities 等處不可以引用任何外層中 gorm 相關的結構體或方法,甚至不應該感知到 gorm 的存在。
核心層的 Entities 定義表示核心業務規則的核心業務實體。這些實體既可以是帶方法的類,也可以是帶有一堆函數的結構體。但它們必須是高度抽象的,只可以隨着核心業務規則變化,不可以隨着外層組件的變化而變化。以簡單博客系統舉例的話,此層可以定義 Blog,Comment 等核心業務實體。
type Blog struct {...}
type Comment struct {...}
核心層的外層是應用業務層。
應用業務層的 Use Cases 應該包含軟件系統所有的業務邏輯。該層控制所有流向和流出核心層的數據流,並使用核心層的實體及其業務規則來完成業務需求。此層的變更不會影響核心層,更外層的變更,比如開發框架、數據庫、UI 等變化,也不會影響此層。接着博客系統的例子,此層可以定義 BlogManager 接口,並定義其中的 CreateBlog, LeaveComment 等業務邏輯方法。
type BlogManager interface {
CreateBlog(...) ...
LeaveComment(...) ...
}
應用業務層的外層是接口適配層。
接口適配層的 Controllers 將外層輸入的數據轉換成內層 Use Cases 和 Entities 方便使用的格式,然後 Presenters,Gateways 再將內層處理結果轉換成外層方便使用的格式,然後再由更外層呈現到 Web, UI 或者寫入到數據庫。假如系統選擇關係型數據庫作爲其持久化方案的話,那麼所有關於 SQL 的處理都應該在此層完成,更內層不需要感知到任何數據庫的存在。同理,假如系統與外界服務通信的話,那麼所有有關外界服務數據的轉化都在此層完成,更內層也不需要感知到外界服務的存在。外層通過此層傳遞數據一般通過 DTO(Data Transfer Object)或者 DO(Data Object)完成。接上文博客系統例子,示例代碼如下:
type BlogDTO struct { // Data Transfer Object
Content string `json:"..."`
}
// DTO 與 model.Blog 的轉化在此層完成
func CreateBlog(b *model.Blog) {
dbClient.Create(&blog{...})
...
}
接口適配層的外層是處在最外層的框架和驅動層。
該層包含具體的框架和依賴工具細節,比如系統使用的數據庫,Web 框架,消息隊列等等。此層主要幫助外部框架、工具和內層進行數據銜接。接博客系統例子,框架和驅動層如果使用 gorm 來操作數據庫,則相關的示例代碼如下:
import "gorm.io/driver/mysql"
import "gorm.io/gorm"
type blog struct { // Data Object
Content string `gorm:"..."` // 本層的數據庫 ORM 如果替換,此處的 tag 也需要隨之改變
}
type MySQLClient struct { DB *gorm.DB }
func New(...) { gorm.Open(...) ... }
func Create(...)
...
至此,整潔架構圖中的四層已介紹完成。但此圖中的四層結構僅作示意,整潔架構並不要求軟件系統必須嚴格按照此四層結構。只要軟件系統能保證 “由外向內” 的依賴規則,系統的層數多少可自由裁決。
同整潔架構齊名的洋蔥架構,與其相似,整體結構也是四層同心圓。
圖 2 中洋蔥架構最核心的 Domain Model 表示組織中核心業務的狀態及其行爲模型,與整潔架構中的 Entities 高度一致。其外層的 Domain Services 與整潔架構中的 Use Cases 職責相近。更外層的 Application Services 橋接 UI 和 Infrastructue 中的數據庫、文件、外部服務等,更是與整潔架構中的 Interface Adaptors 功能相同。最邊緣層的 User Interface 與整潔架構中的最外層 UI 部分一致,Infrastructure 則與整潔架構中的 DB, Devices, External Interfaces 作用一致,只 Tests 部分稍有差異。
同前兩者齊名的六邊形架構,雖然外形不是同心圓,但是結構上還是有很多呼應的地方。
圖 3 六邊形架構中灰色箭頭表示依賴注入(Dependency Injection),其與整潔架構中的依賴規則(The Dependency Rule)有異曲同工之妙,也限制了整個架構各組件的依賴方向必須是 “由外向內”。圖中的各種 Port 和 Adapter 是六邊形架構的重中之重,故該架構別稱 Ports and Adapters。
如圖 4 所示,在六邊形架構中,來自驅動邊(Driving Side)的用戶或外部系統輸入通過左邊的 Port & Adapter 到達應用系統,處理後,再通過右邊的 Adapter & Port 輸出到被驅動邊(Driven Side)的數據庫和文件等。
Port 是系統的一種與具體實現無關的入口,該入口定義了外界與系統通信的接口(interface)。Port 不關心接口的具體實現,就好比 USB 端口允許多種設備通過其與電腦通信,但它不關心設備與電腦之間的照片,視頻等等具體數據是如何編解碼傳輸的。
如圖 5 所示,Adapter 負責 Port 定義的接口的技術實現,並通過 Port 發起與應用系統的交互。比如,圖左 Driving Side 的 Adapter 可以是一個 REST 控制器,客戶端通過它與應用系統通信。圖右 Driven Side 的 Adapter 可以是一個數據庫驅動,應用系統的數據通過它寫入數據庫。此圖中可以看到,雖然六邊形架構看上去與整潔架構不那麼相似,但其應用系統核心層的 Domain ,邊緣層的 User Interface 和 Infrastructure 與整潔架構中的 Entities 和 Frameworks & Drivers 完全是遙相呼應。
再次回到圖 3 的六邊形架構整體圖,以 Java 生態爲例,Driving Side 的 HTTP Server In Port 可以承接來自 Jetty 或 Servlet 等 Adapter 的請求,其中 Jetty 的請求可以是來自其他服務的調用。既處在 Driving Side,又處在 Driven Sides 的 Messaging In/Out Port 可以承接來自 RabbitMQ 的事件請求,也可以將 Application Adapters 中生成的數據寫入到 RabbitMQ。Driven Side 的 Store Out Port 可以將 Application Adapters 產生的數據寫入到 MongoDB;HTTP Client Out Port 則可以將 Application Adapters 產生的數據通過 JettyHTTP 發送到外部服務。
其實,不僅國外有優秀的代碼架構,國內也有。
國內開發者在學習了六邊形架構,洋蔥架構和整潔架構之後,提出了 COLA (Clean Object-oriented and Layered Architecture)架構,其名稱含義爲 “整潔的基於面向對象和分層的架構”。它的核心理念與國外三種架構相同,都是提倡以業務爲核心,解耦外部依賴,分離業務複雜度和技術複雜度 [4]。整體架構形式如圖 6 所示。
雖然 COLA 架構不再是同心圓或者六邊形的形式,但是還是能明顯看到前文三種架構的影子。Domain 層中 model 對應整潔架構的 Entities,六邊形架構和洋蔥架構中的 Domain Model。Domain 層中 gateway 和 ability 對應整潔架構的 Use Cases,六邊形架構中的 Application Logic,以及洋蔥架構中的 Domain Services。App 層則對應整潔架構 Interface Adapters 層中的 Controllers,Gateways,和 Presenters。最上方的 Adapter 層和最下方的 Infrastructure 層合起來與整潔架構的邊緣層 Frameworks & Drivers 相呼應。
Adapter 層上方的 Driving adater 與 Infrastructure 層下方的 Driven adapter 更是與六邊形架構中的 Driving Side 和 Driven Side 高度一致。
COLA 架構在 Java 生態中落地已久,也爲開發者們提供了 Java 語言的 archetype,可方便地用於 Java 項目腳手架代碼的生成。筆者受其啓發,推出了一種符合 COLA 架構規則的 Go 語言項目腳手架實踐方案。
3. 推薦一種 Go 代碼架構實踐
項目目錄結構如下:
├── adapter // Adapter層,適配各種框架及協議的接入,比如:Gin,tRPC,Echo,Fiber 等
├── application // App層,處理Adapter層適配過後與框架、協議等無關的業務邏輯
│ ├── consumer //(可選)處理外部消息,比如來自消息隊列的事件消費
│ ├── dto // App層的數據傳輸對象,外層到達App層的數據,從App層出發到外層的數據都通過DTO傳播
│ ├── executor // 處理請求,包括command和query
│ └── scheduler //(可選)處理定時任務,比如Cron格式的定時Job
├── domain // Domain層,最核心最純粹的業務實體及其規則的抽象定義
│ ├── gateway // 領域網關,model的核心邏輯以Interface形式在此定義,交由Infra層去實現
│ └── model // 領域模型實體
├── infrastructure // Infra層,各種外部依賴,組件的銜接,以及domain/gateway的具體實現
│ ├── cache //(可選)內層所需緩存的實現,可以是Redis,Memcached等
│ ├── client //(可選)各種中間件client的初始化
│ ├── config // 配置實現
│ ├── database //(可選)內層所需持久化的實現,可以是MySQL,MongoDB,Neo4j等
│ ├── distlock //(可選)內層所需分佈式鎖的實現,可以基於Redis,ZooKeeper,etcd等
│ ├── log // 日誌實現,在此接入第三方日誌庫,避免對內層的污染
│ ├── mq //(可選)內層所需消息隊列的實現,可以是Kafka,RabbitMQ,Pulsar等
│ ├── node //(可選)服務節點一致性協調控制實現,可以基於ZooKeeper,etcd等
│ └── rpc //(可選)廣義上第三方服務的訪問實現,可以通過HTTP,gRPC,tRPC等
└── pkg // 各層可共享的公共組件代碼
由此目錄結構可以看出通過 Adapter 層屏蔽外界框架、協議的差異,Infrastructure 層囊括各種中間件和外部依賴的具體實現,App 層負責組織輸入輸出, Domain 層可以完全聚焦在最純粹也最不容易變化的核心業務規則上。
按照前文 infrastructure 中目錄結構,各子目錄中文件樣例參考如下:
├── infrastructure
│ ├── cache
│ │ └── redis.go // Redis 實現的緩存
│ ├── client
│ │ ├── kafka.go // 構建 Kafka client
│ │ ├── mysql.go // 構建 MySQL client
│ │ ├── redis.go // 構建 Redis client(cache和distlock中都會用到 Redis,統一在此構建)
│ │ └── zookeeper.go // 構建 ZooKeeper client
│ ├── config
│ │ └── config.go // 配置定義及其解析
│ ├── database
│ │ ├── dataobject.go // 數據庫操作依賴的數據對象
│ │ └── mysql.go // MySQL 實現的數據持久化
│ ├── distlock
│ │ ├── distributed_lock.go // 分佈式鎖接口,在此是因爲domain/gateway中沒有直接需要此接口
│ │ └── redis.go // Redis 實現的分佈式鎖
│ ├── log
│ │ └── log.go // 日誌封裝
│ ├── mq
│ │ ├── dataobject.go // 消息隊列操作依賴的數據對象
│ │ └── kafka.go // Kafka 實現的消息隊列
│ ├── node
│ │ └── zookeeper_client.go // ZooKeeper 實現的一致性協調節點客戶端
│ └── rpc
│ ├── dataapi.go // 第三方服務訪問功能封裝
│ └── dataobject.go // 第三方服務訪問操作依賴的數據對象
再接前文提到的博客系統例子,假設用 Gin 框架搭建博客系統 API 服務的話,架構各層相關目錄內容大致如下:
// Adapter 層 router.go,路由入口
import (
"mybusiness.com/blog-api/application/executor" // 向內依賴 App 層
"github.com/gin-gonic/gin"
)
func NewRouter(...) (*gin.Engine, error) {
r := gin.Default()
r.GET("/blog/:blog_id", getBlog)
...
}
func getBlog(...) ... {
// b's type: *executor.BlogOperator
result := b.GetBlog(blogID)
// c's type: *gin.Context
c.JSON(..., result)
}
如代碼所體現,Gin 框架的內容全部會被限制在 Adapter 層,其他層不會感知到該框架的存在。
// App 層 executor/blog_operator.go
import "mybusiness.com/blog-api/domain/gateway" // 向內依賴 Domain 層
type BlogOperator struct {
blogManager gateway.BlogManager // 字段 type 是接口類型,通過 Infra 層具體實現進行依賴注入
}
func (b *BlogOperator) GetBlog(...) ... {
blog, err := b.blogManager.Load(ctx, blogID)
...
return dto.BlogFromModel(...) // 通過 DTO 傳遞數據到外層
}
App 層會依賴 Domain 層定義的領域網關,而領域網關接口會由 Infra 層的具體實現注入。外層調用 App 層方法,通過 DTO 傳遞數據,App 層組織好輸入交給 Domain 層處理,再將得到的結果通過 DTO 傳遞到外層。
// Domain 層 gateway/blog_manager.go
import "mybusiness.com/blog-api/domain/model" // 依賴同層的 model
type BlogManager interface { //定義核心業務邏輯的接口方法
Load(...) ...
Save(...) ...
...
}
Domain 層是核心層,不會依賴任何外層組件,只能層內依賴。這也保障了 Domain 層的純粹,保障了整個軟件系統的可維護性。
// Infrastructure 層 database/mysql.go
import (
"mybusiness.com/blog-api/domain/model" // 依賴內層的 model
"mybusiness.com/blog-api/infrastructure/client" // 依賴同層的 client
)
type MySQLPersistence struct {
client client.SQLClient // client 中已構建好了所需客戶端,此處不用引入 MySQL, gorm 相關依賴
}
func (p ...) Load(...) ... { // Domain 層 gateway 中接口方法的實現
record := p.client.FindOne(...)
return record.ToModel() // 將 DO(數據對象)轉成 Domain 層 model
}
Infrastructure 層中接口方法的實現都需要將結果的數據對象轉化成 Domain 層 model 返回,因爲領域網關 gateway 中定義的接口方法的入參、出參只能包含同層的 model,不可以有外層的數據類型。
前文提及的完整調用流程如圖 7 所示。
如圖,外部請求首先抵達 Adapter 層。如果是讀請求,則攜帶簡單參數調用 App 層;如果是寫請求,則攜帶 DTO 調用 App 層。App 層將收到的 DTO 轉化成對應的 Model,調用 Domain 層 gateway 相關業務邏輯接口方法。由於系統初始化階段已經完成依賴注入,接口對應的來自 Infra 層的具體實現會處理完成並返回 Model 到 Domain 層,再由 Domain 層返回到 App 層,最終經由 Adapter 層將響應內容呈現給外部。
至此可知,參照 COLA 設計的系統分層架構可以一層一層地將業務請求剝離乾淨,分別處理後再一層一層地組裝好返回到請求方。各層之間互不干擾,職責分明,有效地降低了系統組件之間的耦合,提升了系統的可維護性。
4. 總結
無論哪種架構都不會是項目開發的銀彈,也不會有百試百靈的開發方法論。畢竟引入一種架構是有一定複雜度和較高維護成本的,所以開發者需要根據自身項目類型判斷是否需要引入架構。
不建議引入架構的項目類型:
-
軟件生命週期大概率會小於三個月的
-
項目維護人員在現在以及可見的將來只有自己的
可以考慮引入架構的項目類型:
-
軟件生命週期大概率會大於三個月的
-
項目維護人員多於 1 人的
強烈建議引入架構的項目類型:
-
軟件生命週期大概率會大於三年的
-
項目維護人員多於 5 人的
5. 參考文獻
[1] Robert C. Martin, The Clean Architecture, https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html (2012)
[2] Andrew Gordon, Clean Architecture, https://www.andrewgordon.me/posts/Clean-Architecture/ (2021)
[3] Pablo Martinez, Hexagonal Architecture, there are always two sides to every story, https://medium.com/ssense-tech/hexagonal-architecture-there-are-always-two-sides-to-every-story-bc0780ed7d9c (2021)
[4] 張建飛, COLA 4.0:應用架構的最佳實踐, https://blog.csdn.net/significantfrank/article/details/110934799 (2022)
[5] Jeffrey Palermo, The Onion Architecture, https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/ (2008)
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/I2Fx2TIrwXV2kfLj_T5g5g