Builder 模式在 Go 語言中的應用

Builder 模式是一種創建型模式,即用來創建對象。

Builder 模式,中文翻譯不太統一,有時候被翻譯爲建造者模式或構建者模式,有時候也被翻譯爲生成器模式。爲了不給讀者造成困擾,我還是直接叫它 Builder 模式好了。

《設計模式:可複用面向對象軟件的基礎》 一書中對 Builder 模式的意圖闡明如下:

將一個複雜對象的構建與它的表示分離,使得同樣的構建過程可以創建不同的表示。

經典 Builder 模式

假設我們要建造一個大 House,其結構體定義如下:

// House 代表一個由多個部分構建的複雜對象
type House struct {
 // 地基
 Foundation string
 // 牆壁
 Walls string
 // 屋頂
 Roof string
}

通常我們會定義如下構造函數:

// NewHouse 一個普通的 House 構造函數
func NewHouse(foundation, walls, roof string) *House {
 return &House{
  Foundation: foundation,
  Walls:      walls,
  Roof:       roof,
 }
}

可以使用構造函數來創建 House

house := NewHouse("Concrete Foundation""Wooden Walls""Shingle Roof")
fmt.Printf("%+v\n", house)

現在,如果我們再爲這個 House 增加一些屬性的話,NewHouse 的參數列表就會變得很長。並且,如果有些參數是帶有默認值的,有些參數是必傳的,NewHouse 用起來就會比較彆扭。

此時,有經驗的讀者應該會想到使用 Options 模式。沒錯,這是一個在 Go 語言中非常流行的設計模式,能夠很好的解決可選參數問題。

NOTE: 如果你對 Go 的 Options(選項)模式不太熟悉,可以參考我的另一篇文章 《Go 常見設計模式之選項模式》(https://jianghushinian.cn/2021/12/12/Golang - 常見設計模式之選項模式 /)。

不過,咱們今天不講如何使用 Options 模式來解決此類問題,今天來看看如何使用 Builder 模式來解決這個問題。

要使用 Builder 模式,首先我們就要定義一個 Builder 接口:

// Builder 用於構建 House 對象的接口
type Builder interface {
 BuildFoundation()
 BuildWalls()
 BuildRoof()
 GetResult() *House
}

Builder 接口聲明瞭構建 House 對象都有哪些行爲。

其包含 4 個方法,BuildFoundation 用來構建地基,BuildWalls 用來構建牆壁,BuildRoof 用來構建屋頂,最後還有一個 GetResult 方法用來獲取構建完成的 House 對象。

這是 Builder 模式的慣用法,定義若干個 BuildXxx 方法用來構建對象的屬性,最後再定義一個 GetXxx 方法用來獲取被構建的對象。

我們再定義一個 ConcreteBuilder 結構體,用來實現 Builder 接口:

// ConcreteBuilder Builder 接口的具體實現,用於構建具體的 House
type ConcreteBuilder struct {
 house *House
}

// NewConcreteBuilder 創建一個新的 ConcreteBuilder 實例
func NewConcreteBuilder() *ConcreteBuilder {
 return &ConcreteBuilder{house: &House{}}
}

// BuildFoundation 構建地基
func (b *ConcreteBuilder) BuildFoundation() {
 b.house.Foundation = "Concrete Foundation"
}

// BuildWalls 構建牆壁
func (b *ConcreteBuilder) BuildWalls() {
 b.house.Walls = "Wooden Walls"
}

// BuildRoof 構建屋頂
func (b *ConcreteBuilder) BuildRoof() {
 b.house.Roof = "Shingle Roof"
}

// GetResult 返回構建完成的 House 對象
func (b *ConcreteBuilder) GetResult() *House {
 return b.house
}

在典型的 Builder 設計模式中,我們還差一個 Director 對象,定義如下:

// Director 用於控制構建過程的指揮者
type Director struct {
 builder Builder
}

// NewDirector 創建一個新的 Director 實例
func NewDirector(builder Builder) *Director {
 return &Director{builder: builder}
}

// Construct 構建 House 的方法
func (d *Director) Construct() {
 d.builder.BuildFoundation()
 d.builder.BuildWalls()
 d.builder.BuildRoof()
}

Director 是一個用於控制構建過程的「指揮者」,它依賴一個 Builder 接口類型的對象,傳入不同的 Builder,可以實現不同的行爲,這也是依賴注入的思想。

通過 Director 對象,我們可以控制構建 House 的整個過程,Construct 方法就是用來幹這個的。

使用 Builder 模式創建一個大 House 流程如下:

// 創建具體的 Builder
builder := NewConcreteBuilder()

// 創建 Director 並傳入具體的 Builder
director := NewDirector(builder)

// 通過 Director 控制構建過程
director.Construct()

// 獲取構建的最終產品
house := builder.GetResult()

// 輸出構建好的 House
fmt.Printf("%+v\n", house)

這就是 Builder 模式的經典寫法。

這裏涉及幾個主要角色:

如果你不嫌麻煩,有人也會這麼定義它:

type ConcreteBuilder struct {
 // 地基
 Foundation string
 // 牆壁
 Walls string
 // 屋頂
 Roof string
}

此時 GetResult 方法應該怎麼寫就留給你去思考了。

可以發現,在標準的 Builder 設計模式中,Builder 的方法通常是沒有參數的,每個方法負責一步固定的構建過程。這種設計的好處是過程非常清晰,步驟明確,但它的靈活性較低。

我稱這種寫法爲經典的 Builder 設計模式。

在這種情況下,每個步驟的實現通常是固定的,比如所有使用 ConcreteBuilder 構造的的 House 都使用同樣的地基、牆壁和屋頂材料。

要想創建一個具有不同屬性的 House,我們就需要實現另外一個新的 ConcreteBuilder1

爲了增加靈活性,可以在 Builder 的方法中添加參數,以允許在構建過程中設置不同的屬性值。

具體如何實現你可以先思考下,我們在接下來的示例中會進行講解。

簡化版 Builder 模式

經典版本的 Builder 模式代碼看起來有點 “死板”,因爲這是我模仿 Java 版本“抄” 過來的。

我們接下來介紹下 Go 語言特色版本 Builder 模式該如何編寫。

這次我們以構造一個 Car 爲例,定義如下:

// Car 代表一個汽車對象
type Car struct {
 // 品牌
 Brand string
 // 型號
 Model string
 // 顏色
 Color string
 // 發動機類型
 Engine string
}

定義 CarBuilder 結構體作爲用來構建 CarBuilder 對象:

// CarBuilder 用於構建 Car 對象的構建器
type CarBuilder struct {
 car Car
}

// NewCarBuilder 創建一個新的 CarBuilder 實例
func NewCarBuilder() *CarBuilder {
 return &CarBuilder{car: Car{}}
}

接下來我們就可以爲其定義構建方法了:

// SetBrand 設置汽車的品牌
func (b *CarBuilder) SetBrand(brand string) *CarBuilder {
 b.car.Brand = brand
 return b
}

// SetModel 設置汽車的型號
func (b *CarBuilder) SetModel(model string) *CarBuilder {
 b.car.Model = model
 return b
}

// SetColor 設置汽車的顏色
func (b *CarBuilder) SetColor(color string) *CarBuilder {
 b.car.Color = color
 return b
}

// SetEngine 設置汽車的發動機類型
func (b *CarBuilder) SetEngine(engine string) *CarBuilder {
 b.car.Engine = engine
 return b
}

// Build 構建並返回最終的 Car 對象
func (b *CarBuilder) Build() Car {
 return b.car
}

這裏的 SetXxx 方法用於設置屬性,對標的是前文構建 House 示例中的 BuildXxx 方法。並且 SetXxx 系列方法都支持傳入參數,在構建過程中可以更加靈活的設置不同的屬性值。這樣僅需要定義一個 CarBuilder 對象,不必再定義 CarBuilder2CarBuilder3 ... 了。

NOTE: 這裏之所以換了一種命名方式,採用 SetXxx,是爲了給你演示兩種不同的命名風格,這兩種寫法都比較常見,看到其他人寫的代碼時你不要疑惑。

Build 方法則對標前文示例中的 GetResult 方法,作用相同。

並且,這個版本的 Builder 模式省略了 Builder 接口的定義。

NOTE: 當然有時候爲了方便編寫測試代碼,我們還是需要定義接口的。這裏只是演示 Builder 模式在 Go 語言中的最小化實現。

最後,我們還去掉了 Director 這個角色。這可以讓代碼更加簡潔,使用起來也更加靈活。

至此,Go 語言簡化版的 Builder 模式就定義完成了。

用法如下:

// 使用 CarBuilder 構建一個 Car 對象
car := NewCarBuilder().
 SetBrand("Tesla").
 SetModel("Model S").
 SetColor("Red").
 SetEngine("Electric").
 Build()

fmt.Printf("Car: %+v\n", car)

需要提醒的是,我們不應該對構建方法 SetXxx 的調用順序做任何假設,所以這樣使用也是可以的:

car := NewCarBuilder().
 SetEngine("Electric").
 SetModel("Model S").
 SetBrand("Tesla").
 SetColor("Red").
 Build()

fmt.Printf("Car: %+v\n", car)

我們只需要保證 Build 方法在最後調用即可。

我們還可以在 Build 方法內部做統一的屬性值校驗。所有通過 SetXxx 設置的屬性,都可以在 Build 方法中進行校驗。

因爲我們對用戶調用了幾個 SetXxx 方法是無法預知的,所以也就不建議在 SetXxx 方法中參數校驗。假如用戶少調用了一個方法,那麼定義在 SetXxx 方法中的校驗就不會生效。所以我們應該在 Build 方法內部做統一的屬性值校驗。

其實這也能暴露 Builder 模式的一個問題,就是將構建過程(屬性賦值操作)分爲多個步驟以後,我們構建的對象可能不完整(用戶可能少調用了哪個 SetXxx),所以一定要定義一個 Build 方法來獲取最終的構建產物(可以校驗屬性值),這個方法是不可省略的。

講解完了 Go 語言中我們如何定義和使用 Builder 模式,接下來我再介紹一個實用場景,來加深你對 Builder 模式的理解。

實用場景

我們就以一個 Web Server 爲例,演示下在中間件場景中如何使用 Builder 模式。

假設我們有這樣一個使用 Gin 框架編寫的 HTTP Server 程序:

package main

import (
 "net/http"

 "github.com/gin-gonic/gin"
)

func main() {
 r := gin.Default()

 r.GET("/admin", func(c *gin.Context) {
  c.JSON(http.StatusOK, gin.H{
   "message""Welcome Admin!",
  })
 })

 r.GET("/user", func(c *gin.Context) {
  c.JSON(http.StatusOK, gin.H{
   "message""Welcome User!",
  })
 })

 r.Run(":8000")
}

示例程序有兩個接口,分別是 /admin/user,它們都接收 GET 請求並返回一段字符串內容。

現在我們想爲這兩個接口增加 RBAC 權限控制,/admin 接口只有角色爲 admin 的用戶纔可以訪問,/user 接口則角色爲 adminuser 的用戶都可以訪問。

這個需求顯然可以使用 Gin 的中間件來實現。

我們最容易想到的方式,就是定義一個用於控制 RBAC 權限的中間件函數:

func RBACMiddleware(allowedRoles []string) gin.HandlerFunc {
 return func(c *gin.Context) {
  userRole := c.GetHeader("Role") // 從請求頭中獲取用戶角色
  for _, role := range allowedRoles {
   if role == userRole {
    c.Next()
    return
   }
  }
  c.AbortWithStatus(http.StatusForbidden)
 }
}

代碼非常簡單,我就不過多解釋了,RBACMiddleware 中間件可以這樣使用:

// 爲 /admin 路由設置只有管理員角色才能訪問
adminMiddleware := RBACMiddleware([]string{"admin"})

r.GET("/admin", adminMiddleware, func(c *gin.Context) {
 c.JSON(http.StatusOK, gin.H{
  "message""Welcome Admin!",
 })
})

// 爲 /user 路由設置普通用戶和管理員角色都能訪問
userMiddleware := RBACMiddleware([]string{"admin""user"})

r.GET("/user", userMiddleware, func(c *gin.Context) {
 c.JSON(http.StatusOK, gin.H{
  "message""Welcome User!",
 })
})

現在啓動這個 HTTP Server,來測試一下我們的中間件效果:

# 使用 admin 角色訪問 /admin 接口
$ curl -i -H "Role: admin" "http://localhost:8000/admin"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Mon, 26 Aug 2024 06:33:07 GMT
Content-Length: 28

{"message":"Welcome Admin!"}

# 使用 user 角色訪問 /admin 接口
$ curl -i -H "Role: user" "http://localhost:8000/admin"
HTTP/1.1 403 Forbidden
Date: Mon, 26 Aug 2024 06:33:03 GMT
Content-Length: 0

# 使用 admin 角色訪問 /user 接口
$ curl -i -H "Role: admin" "http://localhost:8000/user"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Mon, 26 Aug 2024 06:32:53 GMT
Content-Length: 27

{"message":"Welcome User!"}

# 使用 user 角色訪問 /user 接口
$ curl -i -H "Role: user" "http://localhost:8000/user"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Mon, 26 Aug 2024 06:32:59 GMT
Content-Length: 27

{"message":"Welcome User!"}

根據客戶端的請求日誌來看,我們的目的實現了。除了使用 user 角色訪問 /admin 接口時,接口返回 403 狀態碼,沒有返回響應體,其他請求都能得到正常響應。

我們再來看下如何使用 Builder 模式來實現 RBACMiddleware 中間件:

// RBACMiddleware RBAC 中間件結構體
type RBACMiddleware struct {
 allowedRoles []string
}

// Middleware 返回一個 Gin 中間件函數
func (r *RBACMiddleware) Middleware() gin.HandlerFunc {
 return func(c *gin.Context) {
  userRole := c.GetHeader("Role") // 從請求頭中獲取用戶角色
  for _, role := range r.allowedRoles {
   if role == userRole {
    c.Next()
    return
   }
  }
  c.AbortWithStatus(http.StatusForbidden)
 }
}

// RBACMiddlewareBuilder 用於構建 RBACMiddleware 的構建器
type RBACMiddlewareBuilder struct {
 rbacMiddleware *RBACMiddleware
}

// NewRBACMiddlewareBuilder 創建一個新的 RBACMiddlewareBuilder 實例
func NewRBACMiddlewareBuilder() *RBACMiddlewareBuilder {
 return &RBACMiddlewareBuilder{
  rbacMiddleware: &RBACMiddleware{},
 }
}

// AllowRole 添加允許訪問的角色
func (b *RBACMiddlewareBuilder) AllowRole(role string) *RBACMiddlewareBuilder {
 b.rbacMiddleware.allowedRoles = append(b.rbacMiddleware.allowedRoles, role)
 return b
}

// Build 返回構建完成的 RBACMiddleware
func (b *RBACMiddlewareBuilder) Build() *RBACMiddleware {
 return b.rbacMiddleware
}

有了前文對 Builder 模式的講解,這段代碼就很好理解了。

NOTE: 爲了增強代碼的語義,這裏的 AllowRole 方法命名並沒有定義爲 SetXxxBuildXxx,但作用相同。

使用 Builder 模式實現的 RBAC 中間件用法如下:

// 爲 /admin 路由設置只有管理員角色才能訪問
adminMiddleware := NewRBACMiddlewareBuilder().
 AllowRole("admin").
 Build().
 Middleware()

r.GET("/admin", adminMiddleware, func(c *gin.Context) {
 c.JSON(http.StatusOK, gin.H{
  "message""Welcome Admin!",
 })
})

// 爲 /user 路由設置普通用戶和管理員角色都能訪問
userMiddleware := NewRBACMiddlewareBuilder().
 AllowRole("admin").
 AllowRole("user").
 Build().
 Middleware()

r.GET("/user", userMiddleware, func(c *gin.Context) {
 c.JSON(http.StatusOK, gin.H{
  "message""Welcome User!",
 })
})

你可以再次嘗試使用 curl 命令來測試我們的中間件效果,我就不再進行演示了。

這就是一個典型的 Builder 模式在 Go 語言中的使用場景。

也許你會覺得這個示例程序中第一種中間件實現 func RBACMiddleware(allowedRoles []string) gin.HandlerFunc 更加簡單易用。

沒錯,這不是錯覺。但是,我想要說的是,如果在版本迭代的過程中,RBACMiddleware 函數需要增加參數。那麼 RBACMiddleware 的所有調用方,就都需要修改。因爲函數簽名改變了,即使新增的參數可能並不是一個必須參數。

這就增加了代碼的維護成本。解決方案就是 Builder 模式。

使用 Builder 模式來實現中間件,如果需要新增參數,我們只需要爲 RBACMiddlewareBuilder 增加一個 SetXxx 方法即可。調用方可以根據需要決定是否調用 SetXxx 方法,完全不影響現有調用方的代碼。

所以,其實這個場景下 Builder 模式並不是一種不得不用的方式,而是一種可以更爲優雅的解決未來需求變更的方式。這就爲我們留足了 “後門”,即使剛開始代碼設計的不夠合理,未來我們也可以使用對現有調用方無感知的方式更新我們的代碼。

這其實也可以作爲套路代碼,以後 Gin 框架的中間件代碼都可以參考這樣設計。

不過,且慢!其實我平常還會使用一種比這個更加 “簡陋” 的寫法來實現中間件:

// RBACMiddlewareBuilder RBAC 中間件結構體
type RBACMiddlewareBuilder struct {
 allowedRoles []string
}

// NewRBACMiddlewareBuilder 創建一個新的 RBACMiddlewareBuilder 實例
func NewRBACMiddlewareBuilder() *RBACMiddlewareBuilder {
 return &RBACMiddlewareBuilder{}
}

// Build 返回一個 Gin 中間件函數
func (b *RBACMiddlewareBuilder) Build() gin.HandlerFunc {
 return func(c *gin.Context) {
  userRole := c.GetHeader("Role") // 從請求頭中獲取用戶角色
  for _, role := range b.allowedRoles {
   if role == userRole {
    c.Next()
    return
   }
  }
  c.AbortWithStatus(http.StatusForbidden)
 }
}

// AllowRole 添加允許訪問的角色
func (b *RBACMiddlewareBuilder) AllowRole(role string) *RBACMiddlewareBuilder {
 b.allowedRoles = append(b.allowedRoles, role)
 return b
}

用法如下:

adminMiddleware := NewRBACMiddlewareBuilder().
 AllowRole("admin").
 Build()

所以,其實我們不必糾結於設計模式的具體定義。對於 Builder 模式,在 Go 語言的實踐中,我們完全可以視情況而定捨棄如 Builder 接口、Director 指揮者這樣的角色,以最小化的代碼,來實現 Builder 模式。

此外,其實使用 Options 模式也可以實現這個 RBACMiddleware。Builder 模式與 Options 模式最大的區別就是 Options 模式不能鏈式調用,不過卻可以支持任意數量的參數。你可以自行嘗試實現一下,對比下與 Builder 模式的區別。不過,如果你懶得實現,也可以參考 我實現的版本。

總結

Builder 模式是一種創建型模式,可以用來創建對象。

Builder 模式中有幾個角色,分別是 Builder 接口,ConcreteBuilder 具體實現,以及 Director 指揮者。對這幾個角色瞭解清楚,你就能理解什麼是 Builder 模式。

不過,在生產實踐中,我們也不要過於 “學院派”,可以按照自己的理解來實現 Builder 模式。畢竟設計模式是拿來用的,而不是讓我們死記硬背應付考試的。

在實用場景介紹中,我講解了如何使用 Builder 模式來實現一個 RBAC 權限控制中間件。

實現 Builder 模式時,需要注意的一點是,不能假設用戶對 SetXxx 方法的調用順序,所以代碼實現上,一定不能依賴這些 SetXxx 方法的調用順序。

你認爲適配器模式還有哪些應用場景,可以一起交流學習。

本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。

希望此文能對你有所啓發。

聯繫我

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/LRyb7tOTZmq33j9AwOd18g