作者: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 Clean Architecture, Robert C. Martin
圖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 Onion Architecture, Jeffrey Palermo
圖2中洋蔥架構最核心的 Domain Model 表示組織中核心業務的狀态及其行為模型,與整潔架構中的 Entities 高度一緻。其外層的 Domain Services 與整潔架構中的 Use Cases 職責相近。更外層的 Application Services 橋接 UI 和 Infrastructue 中的資料庫、檔案、外部服務等,更是與整潔架構中的 Interface Adaptors 功能相同。最邊緣層的 User Interface 與整潔架構中的最外層 UI 部分一緻,Infrastructure 則與整潔架構中的 DB, Devices, External Interfaces 作用一緻,隻 Tests 部分稍有差異。
同前兩者齊名的六邊形架構,雖然外形不是同心圓,但是結構上還是有很多呼應的地方。
圖 3 Hexagon Architecture, Andrew Gordon
圖3六邊形架構中灰色箭頭表示依賴注入(Dependency Injection),其與整潔架構中的依賴規則(The Dependency Rule)有異曲同工之妙,也限制了整個架構各元件的依賴方向必須是“由外向内”。圖中的各種 Port 和 Adapter 是六邊形架構的重中之重,故該架構别稱 Ports and Adapters。
圖 3 Hexagon Architecture, Andrew Gordon
如圖4所示,在六邊形架構中,來自驅動邊(Driving Side)的使用者或外部系統輸入通過左邊的 Port & Adapter 到達應用系統,處理後,再通過右邊的 Adapter & Port 輸出到被驅動邊(Driven Side)的資料庫和檔案等。
Port 是系統的一種與具體實作無關的入口,該入口定義了外界與系統通信的接口(interface)。Port 不關心接口的具體實作,就好比 USB 端口允許多種裝置通過其與電腦通信,但它不關心裝置與電腦之間的照片,視訊等等具體資料是如何編解碼傳輸的。
圖 5 Hexagon Architecture Phase 2, Pablo Martinez
如圖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所示。
圖 6 COLA 架構, 張建飛
雖然 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所示。
圖 7 Blog 讀取過程時序示意圖
如圖,外部請求首先抵達 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)