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

weixin_34127717發表於2019-01-10

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

什麼是乾淨架構

在乾淨架構出現之前,已經有一些其它架構,包括 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() {\trand.Seed(time.Now().UnixNano())}type User struct {\tID             int    `json:\u0026quot;id\u0026quot;`\tUsername       string `json:\u0026quot;username\u0026quot;`\tpassword       string\tAvatar         string    `json:\u0026quot;avatar\u0026quot;`\tMobile         string    `json:\u0026quot;mobile\u0026quot;`\tEmail          string    `json:\u0026quot;email\u0026quot;`\tGrade          int       `json:\u0026quot;grade\u0026quot;`\tExpireAt       util.Time `json:\u0026quot;expireAt\u0026quot;`\tInvitationCode string    `json:\u0026quot;invitationCode\u0026quot;`\tCreatedAt      util.Time `json:\u0026quot;createdAt\u0026quot;`\tUpdatedAt      util.Time `json:\u0026quot;updatedAt\u0026quot;`}func (e *User) RandUsername() {\t...}func (e *User) Password() string {\t...}func (e *User) SetPassword(password string, encrypt bool) (err error) {\t...}func (e *User) CheckPassword(password string) bool {\t...}GoCopy

interface/repository/user.go

package repository...type IUser interface {\tSave(user entity.User) (id int, err error)\tByID(id int) (user entity.User, err error)\tByUsername(username string) (user entity.User, err error)\tByIDs(ids []int) (es []entity.User, err error)}GoCopy

service/account.go

package service...type Account struct {\tuserRepo repository.IUser}func NewAccount(\tuserRepo repository.IUser,) *Account {\treturn \u0026amp;Account{\t\tuserRepo: userRepo,\t}}func (s *Account) SaveUser(u entity.User) (user entity.User, err error) {\t...}func (s *Account) UserByID(id int) (user entity.User, err error) {\t...}func (s *Account) UserByUsername(username string) (user entity.User, err error) {\t...}func (s *Account) UserByIDs(ids []int) (es []entity.User, err error) {\t...}GoCopy

web/controller/account.go

package controller...type Account struct {\tBase\tAccountService *service.Account}func NewAccount(\taccountService *service.Account,) *Account {\treturn \u0026amp;Account{\t\tAccountService: accountService,\t}}func (c *Account) PostRegister() {\t...}func (c *Account) PostLogin() {\t...}func (c *Account) GetLogout() {\t...}func (c *Account) GetInfo() {\t...}func (c *Account) PostEdit() {\t...}GoCopy

dependency/repository/user.go

package repository...type user struct {\tID             int\tUsername       string\tPassword       string\tAvatar         string\tMobile         sql.NullString\tEmail          sql.NullString\tGrade          int\tExpireAt       mysql.NullTime `db:\u0026quot;expire_at\u0026quot;`\tInvitationCode string         `db:\u0026quot;invitation_code\u0026quot;`\tCreatedAt      util.Time      `db:\u0026quot;created_at\u0026quot;`\tUpdatedAt      util.Time      `db:\u0026quot;updated_at\u0026quot;`}func fromUserEntity(e entity.User) (d user) {\t...}func (d *user) toUserEntity() (e entity.User) {\t...}type User struct {\t*sqlx.DB\ttable string}func NewUser(db *sqlx.DB) *User {\treturn \u0026amp;User{db, \u0026quot;user\u0026quot;}}func (r *User) Save(e entity.User) (id int, err error) {\t...}func (r *User) ByID(id int) (e entity.User, err error) {\t...}func (r *User) ByUsername(username string) (e entity.User, err error) {\t...}func (r *User) ByIDs(ids []int) (es []entity.User, err error) {\t...}GoCopy

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

更多資料

The Clean Architecture
Iris Web Framework

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

原文地址: https://blog.jaggerwang.net/clean-architecture-in-web-service/

相關文章