乾淨架構在 Web 服務開發中的實踐

乾淨架構(The Clean Architecture)是 Bob 大叔在 2012 年的一篇博文 The Clean Architecture 中,提出的一種適用於複雜業務系統的軟件架構方式。乾淨架構的理念非常精煉,其中最核心的就是向內依賴原則。由於其並沒有規定實施細節,因此各種採用不同語言、框架和庫的軟件系統都可以採用這種架構方式。這帶來了很大的靈活性,但同時也增加了開發人員的實踐難度。本文以一個 Go 語言開發的 Web 後端服務(湯博樂交友 應用後端服務)爲例,來闡述乾淨架構的一些實踐細節,期望對大家理解乾淨架構有所幫助。

什麼是乾淨架構

在乾淨架構出現之前,已經有一些其它架構,包括 Hexagonal ArchitectureOnion ArchitectureScreaming ArchitectureDCIBCE。這些架構本質都是類似的,它們都採用分層的方式來達到一個共同的目標,分離關注。乾淨架構將這些架構的核心理念提取了出來,形成了一種更加通用和靈活的架構。

乾淨架構的設計理念如下圖所示:

採用乾淨架構的系統,可以達成以下目標:

  1. 框架無關性。乾淨架構不依賴於具體的框架和庫,而僅把它們當作工具,因此不會受限於任何具體的框架和庫。
  2. 可測試性。業務規則可以在沒有 UI、數據庫、Web 服務器等外部依賴的情況下進行測試。
  3. UI 無關性。UI 改變可以在不改動系統其它部分的情況下完成,比如把 Web UI 替換成控制檯 UI。
  4. 數據庫無關性。可以很容易地切換數據庫類型,比如從關係型數據庫 MySQL 切換到文檔型數據庫 MongoDB,因爲業務規則並沒有綁定到某種特定的數據庫類型。
  5. 外部代理無關性。業務規則對外部世界一無所知,因此外部代理的變動不會影響到業務代碼。

可以看到乾淨架構是圍繞業務規則來設計的,核心就是保證業務代碼的穩定性。

向內依賴原則(Inward Dependency Rule)

乾淨架構最核心的原則就是代碼依賴關係只能從外向內,而不能反之。乾淨架構的每一圈層代表軟件系統的不同部分,越往裏抽象程度越高。外層爲機制,內層爲策略。這裏說的依賴關係,具體指的是內層代碼不能引用外層代碼的命名軟件實體,包括類、方法、函數和數據類型等。

實體(Entities)

實體用於封裝企業範圍的業務規則。實體可以是擁有方法的對象,也可以是數據結構和函數的集合。如果沒有企業,只是單個應用,那麼實體就是應用裏的業務對象。這些對象封裝了最通用和高層的業務規則,極少會受到外部變化的影響。任何操作層面的改動都不會影響到這一層。

用例(Use Cases)

用例是特定於應用的業務邏輯,一般用來完成用戶的某個操作。用例協調數據流向或者流出實體層,並且在此過程中通過執行實體的業務規則來達成用例的目標。用例層的改動不會影響到內部的實體層,同時也不會受外層的改動影響,比如數據庫、UI 和框架的變動。只有而且應當應用的操作發生變化的時候,用例層的代碼才隨之修改。

接口適配器(Interface Adapters)

接口適配器層的主要作用是轉換數據,數據從最適合內部用例層和實體層的結構轉換成適合外層(比如數據持久化框架)的結構。反之,來自於外部服務的數據也會在這層轉換爲內層需要的結構。

框架和驅動(Frameworks and Drivers)

最外層由各種框架和工具組成,比如 Web 框架、數據庫訪問工具等。通常在這層不需要寫太多代碼,大多是一些用來跟內層通信的膠水代碼。這一層包含了所有實現細節,把實現細節鎖定在這一層能夠減少它們的改動對整個系統造成的傷害。

關於層數

乾淨架構並沒有定死圖中的四層,可以按需增加或減少層數。前提是保證向內依賴原則,並且抽象的層級越往內越高。

跨層訪問

依賴反轉原則

向內依賴原則限定內層代碼不能依賴外層代碼,但如果內層代碼確實需要調用外層代碼代碼怎麼辦?這個時候可以採用 依賴反轉原則(Dependency Inversion Principle)。內層代碼將其所依賴的外層代碼定義爲接口(Interface),外層代碼實現該接口。這樣依賴就反轉了過來,變成了外層代碼依賴內層代碼。

傳遞數據

跨層傳遞的數據結構通常應比較簡單。可以是語言提供的基本數據類型,簡單的數據傳輸對象,函數參數,哈希表等。重要的是保證數據結構的隔離性和簡單性,不要違反向內依賴原則。

採用乾淨架構來組織 Web 服務代碼

我們要開發的 Web 服務提供 HTTP 接口給移動客戶端,業務領域是 2C 領域,複雜程度不如 2B 業務。但同樣有框架無關性、可測試性、UI 無關性、數據庫無關性、外部代理無關性這些要求,因此也可以使用乾淨架構,同時按照自身特點做一些改動。

該 Web 服務使用了對高併發場景支持良好的 Go 語言來開發,爲了不從零開始構造輪子,使用了 Iris 這個 Web 框架。不過得益於乾淨架構,使用什麼語言和框架並不重要,切換它們並不會影響到核心業務邏輯代碼,因此對代碼結構影響不大。

具體的代碼目錄結構如下:

.
├── cmd # 控制檯應用
├── config.yml # 配置文件
├── dependency # 外部依賴實現
│   ├── cache
│   ├── pay
│   ├── repository
│   ├── sms
│   └── util
├── entity # 實體
├── interface # 外部依賴接口
│   ├── cache
│   ├── pay
│   ├── repository
│   ├── sms
│   └── util
├── main.go # Main 程序
├── service # 業務邏輯
├── util # 項目內用到的一些工具類和函數
└── web # Web 應用
    ├── app.go
    ├── controller
    ├── factory # 對象工廠,用來構造 Web 應用裏需要的各種對象,主要是業務對象
    ├── middleware
    ├── model
    └── view

目錄結構大致與乾淨架構對齊,其中 entity 目錄對應實體層,service 目錄對應用例層,web 目錄和 cmd 目錄對應接口適配器層,分別面向 Web 和控制檯,dependency 目錄對應框架和驅動層。

用圖形來描述如下:

上圖跟乾淨架構的圈層圖有幾點不同:

  1. Dependency 層雖然處於最外層,但它並不依賴於內層,所以跟內層之間有空白間隙。
  2. Dependency 層需要實現 Interface 層定義的接口。

依賴反轉

Service 層需要調用外層 Dependency 的接口,比如從數據庫讀取和保存數據、支付、發送短信等,但又不能直接依賴外層接口,因爲這會違反向內依賴原則。不過可以按照依賴反轉原則,將這些依賴抽象成爲Interface 層,對應 interface 目錄。Service 層和 Dependency 層都依賴於 Interface 層,這樣就避免了內層 Service 依賴外層 Dependency。

Interface 層能夠避免業務代碼依賴於具體技術,比如使用什麼類型的數據庫、使用 ORM 還是 Raw SQL、使用哪種支付方式、使用哪家短信發送服務等。只要外部依賴接口保持不變,就可以任意替換外部依賴的實現。Dependency 層的代碼不多,大多是使用第三方 SDK 來完成某個功能,但最容易發生變化。通過 Interface 層能夠將這種變化的影響範圍縮到最小。

可測試性

整個應用代碼裏,最重要的部分就是業務邏輯相關的代碼,因此需要重點關注這部分的代碼的可測試性。由於 Service 層所有的外部依賴都通過依賴反轉轉換成了對 Interface 層的依賴,因此可以在測試的時候注入實現了指定 Interface 的模擬對象來替換外部服務,這樣業務代碼就可以在脫離外部服務的情況下進行單元測試。當然最終還是需要跟實際的外部服務一起進行系統測試。

跨層數據傳遞

乾淨架構原文裏說不要跨層傳遞實體,但這樣的話在強類型語言(比如 Go)裏面需要在每層定義許多額外的數據類型,並且還要在各層之間進行數據類型轉換。這會增加很多額外且繁瑣的代碼,因此在我們的實踐中並沒有遵循這一規定,允許跨層傳遞實體。由於實體位於最內層,其它所有層都可以依賴,所以並沒有違反向內依賴原則。

代碼示例

下面以幾乎每個應用都有的用戶註冊和登錄功能爲例,來演示上述架構如何落地爲代碼。代碼來自於 湯博樂交友 這款社交 APP 的後端服務。爲了減少代碼篇幅,只保留了結構體定義和方法簽名,去掉了方法的具體實現代碼。

相關代碼從內層到外層依次爲:

entity/user.go

package entity

...

func init() {
	rand.Seed(time.Now().UnixNano())
}

type User struct {
	ID             int    `json:"id"`
	Username       string `json:"username"`
	password       string
	Avatar         string    `json:"avatar"`
	Mobile         string    `json:"mobile"`
	Email          string    `json:"email"`
	Grade          int       `json:"grade"`
	ExpireAt       util.Time `json:"expireAt"`
	InvitationCode string    `json:"invitationCode"`
	CreatedAt      util.Time `json:"createdAt"`
	UpdatedAt      util.Time `json:"updatedAt"`
}

func (e *User) RandUsername() {
	...
}

func (e *User) Password() string {
	...
}

func (e *User) SetPassword(password string, encrypt bool) (err error) {
	...
}

func (e *User) CheckPassword(password string) bool {
	...
}

GoCopy

interface/repository/user.go

package repository

...

type IUser interface {
	Save(user entity.User) (id int, err error)
	ByID(id int) (user entity.User, err error)
	ByUsername(username string) (user entity.User, err error)
	ByIDs(ids []int) (es []entity.User, err error)
}

GoCopy

service/account.go

package service

...

type Account struct {
	userRepo repository.IUser
}

func NewAccount(
	userRepo repository.IUser,
) *Account {
	return &Account{
		userRepo: userRepo,
	}
}

func (s *Account) SaveUser(u entity.User) (user entity.User, err error) {
	...
}

func (s *Account) UserByID(id int) (user entity.User, err error) {
	...
}

func (s *Account) UserByUsername(username string) (user entity.User, err error) {
	...
}

func (s *Account) UserByIDs(ids []int) (es []entity.User, err error) {
	...
}

GoCopy

web/controller/account.go

package controller

...

type Account struct {
	Base
	AccountService *service.Account
}

func NewAccount(
	accountService *service.Account,
) *Account {
	return &Account{
		AccountService: accountService,
	}
}

func (c *Account) PostRegister() {
	...
}

func (c *Account) PostLogin() {
	...
}

func (c *Account) GetLogout() {
	...
}

func (c *Account) GetInfo() {
	...
}

func (c *Account) PostEdit() {
	...
}

GoCopy

dependency/repository/user.go

package repository

...

type user struct {
	ID             int
	Username       string
	Password       string
	Avatar         string
	Mobile         sql.NullString
	Email          sql.NullString
	Grade          int
	ExpireAt       mysql.NullTime `db:"expire_at"`
	InvitationCode string         `db:"invitation_code"`
	CreatedAt      util.Time      `db:"created_at"`
	UpdatedAt      util.Time      `db:"updated_at"`
}

func fromUserEntity(e entity.User) (d user) {
	...
}

func (d *user) toUserEntity() (e entity.User) {
	...
}

type User struct {
	*sqlx.DB
	table string
}

func NewUser(db *sqlx.DB) *User {
	return &User{db, "user"}
}

func (r *User) Save(e entity.User) (id int, err error) {
	...
}

func (r *User) ByID(id int) (e entity.User, err error) {
	...
}

func (r *User) ByUsername(username string) (e entity.User, err error) {
	...
}

func (r *User) ByIDs(ids []int) (es []entity.User, err error) {
	...
}

GoCopy

注意,上述代碼裏的各個結構體裏的成員都是用的 Interface 類型,這樣就允許在創建結構體對象的時候注入任意實現了指定 Interface 的對象,包括模擬外部服務的對象,以便後續進行單元測試。

更多資料

The Clean Architecture

本文所提出的 Web 服務架構來自於個人對乾淨架構的理解和實踐,這裏拋磚引玉,歡迎大家一起討論和指正錯誤。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章