淺析建造者模式

2023-07-16 18:00:30

0. 前言

建造者模式是建立型設計模式的一種。本篇文章將介紹什麼是建造者模式,以及什麼時候用建造者模式,同時給出 Kubernetes:kubectl 中類似建造者模式的範例以加深理解。

1. 建造者模式

1.1 從工廠函數說起

試想構建房子類,其屬性如下:

type house struct {
	window   int
	door     int
	bed      int
	desk     int
	deskLamp int
}

其中,door, windowbed 是必須設定,deskdeskLamp 是可選設定,且 deskdeskLamp 是配套設定。

通過工廠函數建立 house 物件:

func NewHouse(window, door, bed, desk, deskLamp int) *house {
	return &house{
		window:   window,
		door:     door,
		bed:      bed,
		desk:     desk,
		deskLamp: deskLamp,
	}
}

這裡有個問題在於 deskdeskLamp 是可選設定。通過 NewHouse 建立物件需要指定 deskdeskLamp

house := NewHouse(2, 1, 1, 0, 0)

這對呼叫者來說不必要。

繼續,使用 set 結合工廠函數構造 house 物件:

func NewHouse(window, door, bed int) *house {
    return &house{
        window:     window,
        door:       door,
        bed:        bed,
    }
}

func (h *house) SetDesk(desk int) {
    h.desk = desk
}

func (h *house) SetDeskLamp(deskLamp int) {
    h.deskLamp = deskLamp
}

建立 house 物件:

house := NewHouse(2, 1, 1)

// 使用 set 設定 desk 和 deskLamp
house.SetDesk(1)
house.SetDeskLamp(1)

看起來還不錯。

不過 deskdeskLamp 要配套出現,這裡並沒有檢查配套的邏輯。並且,window, doorbed 需要檢測,如果傳入的是 0 或 負數,應該要報錯。

結合這兩點,繼續構建 house 物件。構建有兩種思路:思路一,在構造完的 house 物件上新增 validation 方法校驗屬性。思路二,在工廠函數內校驗必配屬性,新建方法檢查 deskdeskLamp 是否配套出現。

這兩種思路雖然能實現校驗功能,但是都有瑕疵。
思路一,在構造完物件後才驗證,如果物件忘了呼叫 validation,那這個物件就是個不安全的物件。
思路二,校驗分開了,物件的屬性應該放在一起校驗,試想如果引數過多,且相互有依賴關係,那又得新增方法判斷,麻煩且容易出錯。

並且,對於呼叫方來說,構造過程暴露太多了。工廠函數的優勢在於呼叫方無感知,如果暴露太多 set 方法,並且由呼叫方來呼叫驗證方法驗證物件屬性。那工廠函數的優勢將大打折扣。

1.2 工廠函數到建造者的優雅過渡

如何適配上述場景,使得呼叫方無感知呢?

試拆分上述程式碼如下:

func NewHouse() *house {
	return &house{}
}

func (h *house) SetRequisite(window, door, bed int) *house {
	h.window = window
	h.door = door
	h.bed = bed

	return h
}

func (h *house) SetDesk(desk int) *house {
	h.desk = desk
	return h
}

func (h *house) SetDeskLamp(deskLamp int) *house {
	h.deskLamp = deskLamp
	return h
}

func (h *house) Validation() (*house, error) {
	if h.window <= 0 || h.door <= 0 || h.bed <= 0 {
		return nil, errors.New("invalid [window|door|bed]")
	}

	if h.desk < 0 || h.deskLamp < 0 {
		return nil, errors.New("invalid [desk|deskLamp]")
	}

	if !(h.desk > 0 && h.deskLamp > 0) {
		return nil, errors.New("need desk and deskLamp at same time")
	}

	return h, nil
}

建立 house 物件:

house, _ := NewHouse().SetRequisite(2, 1, 1).SetDesk(1).SetDeskLamp(1).Validation()

嗯,看起來清晰了不少。不過我們細細分析下邏輯的話還是會發現那麼一點怪異的點。這一點在於,house 物件是 set 的主體,這在邏輯上好像不通。

是的,我們需要引入一個新物件叫 Builder 來建立 house,而不是讓 house 自己建立自己。

改造程式碼如下:
範例 1.1

type Builder struct {
	house
}

func NewBuilder() *Builder {
	return &Builder{}
}

func (b *Builder) SetRequisite(window, door, bed int) *Builder {
	b.window = window
	b.door = door
	b.bed = bed
	return b
}

func (b *Builder) SetDesk(desk int) *Builder {
	b.desk = desk
	return b
}

func (b *Builder) SetDeskLamp(deskLamp int) *Builder {
	b.deskLamp = deskLamp
	return b
}

func (b *Builder) build() (*house, error) {
	if b.window <= 0 || b.door <= 0 || b.bed <= 0 {
		return nil, errors.New("invalid [window|door|bed]")
	}

	if b.desk < 0 || b.deskLamp < 0 {
		return nil, errors.New("invalid [desk|deskLamp]")
	}

	if !(b.desk > 0 && b.deskLamp > 0) {
		return nil, errors.New("need desk and deskLamp at same time")
	}

	return &b.house, nil
}

這裡做了幾點改動:
1)新建 Builder 物件,通過 Builder 物件建立 house。並且,將 house 作為 Builder 的屬性,houseBuilder 造的,作為屬性挺合理的。
2)重新命名 Validationbuild,之所以這麼命名是想說明 build 是建立的最後一步,結束 build 之後即可獲得 house 物件。

對於呼叫方,建立物件就變成了:

house, _ := NewBuilder().SetRequisite(2, 1, 1).SetDesk(1).SetDeskLamp(1).build()

這裡 deskdeskLamp 是配套使用的,如果不需要的話。建立物件就變成:

house, _ := NewBuilder().SetRequisite(2, 1, 1).build()

要留意這種結構,它是順序不一致的。
如果順序一致的情況,即建立的流程都是一樣的。那麼可以將 build 抽象為介面,使用不同的介面建立產品,且建立的產品流程是一樣的,可以用封裝將這一過程封裝起來。

舉例,使用兩個 Builder 建立房子。villaBuilder 先建十個門,再建五十個窗,最後放五十把椅子。residenceBuilder 負責建兩個門,兩個窗,以及五把椅子。程式碼如下:

type Builder interface {
	createDoor() Builder
	createWindow() Builder
	createChair() Builder
	build() (*house, error)
}

type villaBuilder struct {
	house
}

type residenceBuilder struct {
	house
}

type house struct {
	door   int
	window int
	chair  int
}

func (vb *villaBuilder) createDoor() Builder {
	vb.door = 10
	return vb
}

func (vb *villaBuilder) createWindow() Builder {
	vb.window = 50
	return vb
}

func (vb *villaBuilder) createChair() Builder {
	vb.chair = 50
	return vb
}

func (vb *villaBuilder) validation() error {
	return nil
}

func (vb *villaBuilder) build() (*house, error) {
	// validate property of object houseBuilder, skip...
	err := vb.validation()

	vb.createDoor()
	vb.createWindow()
	vb.createChair()

	return &vb.house, err
}

func (rb *residenceBuilder) createDoor() Builder {
	rb.door = 2
	return rb
}

func (rb *residenceBuilder) createWindow() Builder {
	rb.window = 2
	return rb
}

func (rb *residenceBuilder) createChair() Builder {
	rb.chair = 1
	return rb
}

func (rb *residenceBuilder) validation() error {
	return nil
}

func (rb *residenceBuilder) build() (*house, error) {
	// validate property of object carBuilder, skip...
	err := rb.validation()

	rb.createDoor()
	rb.createWindow()
	rb.createChair()

	return &rb.house, err
}

func NewBuilder(typ string) Builder {
	switch typ {
	case "villa":
		return &villaBuilder{}
	case "residence":
		return &residenceBuilder{}
	default:
		return nil
	}
}

最後,通過不同型別的 Builder 建立房子:

house, err := NewBuilder("villa").build()

可以看到,通過 Builderbuild 方法實現了建立過程的封裝,對於呼叫方來說相當友好。

繼續往下分析,剛才的引數是固定的。如果要使用者可配,而不是內定的引數。怎麼做呢?

重新改造程式碼如下:

type villaBuilder struct {
    house
    window int
    door int
    chair int
}

func (hb *villaBuilder) createDoor(door int) Builder {
    hb.house.door = door
    return hb
}

func (hb *villaBuilder) createWindow(window int) Builder {
    hb.house.window = window
    return hb
}

func (hb *villaBuilder) createChair(chair int) Builder {
    hb.house.chair = chair
    return hb
}

func (hb *villaBuilder) build() (*house, error) {
    // validate property of object villaBuilder, skip...
    err := hb.validate()

    hb.createDoor(hb.door)
    hb.createWindwo(hb.window)
    hb.createChair(hb.chair)

    return hb.car, err
}

func NewBuilder(typ string) Builder {
	switch typ {
	case "villa":
		return &villaBuilder{}
	case "residence":
		return &residenceBuilder{}
	default:
		return nil
	}
}

呼叫方建立 house

house, err := NewBuilder("house", 2, 2, 2).build()

這裡最大的改變在於 Builder 物件中新增可設定屬性 window, doorchair。通過 Builer 內的屬性將引數傳給內嵌產品物件,實現有序建立。

引數可配帶來的問題在於,可以整合 villaBuilderresidenceBuilder 為一個 Builder。通過該 Builder 實現根據不同設定建立 house
那就蛻化為前面的 範例 1.1 的實現了。

試想,這時候在新增冰箱和飲料兩個屬性,且這兩個屬性是可選的,配套的。那麼怎麼建立 housecar 呢?

同樣的道理,將可選項賦值給 Builder 中的屬性。程式碼如下:
範例 1.2

type villaBuilder struct {
    house
    window int
    door int
    chair int
    icer int
    drink int
}

func (vb *villaBuilder) createIcer() Builder {
    vb.house.icer = vb.icer
    return vb 
}

func (vb *villaBuilder) createDrink() Builder {
    vb.house.drink = vb.drink
    return vb
}

func (vb *villaBuilder) setIcer(icer int) Builder {
    vb.icer = icer
    return vb
}

func (hb villaBuilder) setDrink(drink int) Builder {
    vb.drink = drink
    return vb
}

呼叫方建立過程為:

house, err := NewBuilder("house", 2, 2, 2).setIcer(1).setDrink(1).build()

呼叫方一直在和 Builder 打交道。可選設定傳遞給 Builder,最後通過 build 建立出 house,做到了表達和實現分離。

1.3 建造者模式

講到這裡基本也差不多了,在建造者模式中還有個 Director 物件作為更上層的封裝。

從上面程式碼範例中,Builder 負責整體的順序建立,可以把這塊邏輯向上提給 DirectorBuilder 只關心部件的建立,而不需要關心整體。做到邏輯的進一步拆分。程式碼範例如下:

type Director struct {
    builder Builder
}

func (d *Director) createHouse() (*house, error) {
    if err := d.builder.validation(); err != nil {
        return nil, err
    }

    d.builder.createDoor()
    d.builder.createWindwo()
    d.builder.createChair()

    return *hb.house, nil
}

呼叫方只需要建立 BuilderDirector 而不需要關心實現細節。

畫建造者模式的 UML 圖,最後感受下:

1.4 建造者模式在 Kubernetes:kubectl 的應用

kubectl 上找到了建造者模式的應用,雖然不是「完全體」,不過沒有關係。程式碼如下:

// https://github.com/kubernetes/kubectl/blob/master/pkg/cmd/get/get.go

r := f.NewBuilder().
    Unstructured().
    NamespaceParam(o.Namespace).DefaultNamespace().AllNamespaces(o.AllNamespaces).
    FilenameParam(o.ExplicitNamespace, &o.FilenameOptions).
    LabelSelectorParam(o.LabelSelector).
    FieldSelectorParam(o.FieldSelector).
    Subresource(o.Subresource).
    RequestChunksOf(chunkSize).
    ResourceTypeOrNameArgs(true, args...).
    ContinueOnError().
    Latest().
    Flatten().
    TransformRequests(o.transformRequests).
    Do()

這段程式碼是不是和我們的範例 1.2 非常像。通過 factoryNewBuilder 建立 Builder,接著通過一系列建造者方法構造 Builder,最後構建完成的 Builder 呼叫 Do 方法建立 resouce.Result 物件。

2. 小結

從上述分析可以做個建造者模式的小結:
1) 建造者模式是表達和實現分離,對於呼叫方來說不需要關注細節實現。
2) 建造者模式其內部物件建造順序是穩定的,實現是複雜的。摘錄《設計模式之美》的一段話表明什麼時候該用建造者模式:

顧客走進一家餐館點餐,我們利用工廠模式,根據顧客不同的選擇,製作不同的食物,如比薩、漢堡和沙拉等。對於比薩,顧客又有各種配料可以選擇,如乳酪、西紅柿和培根等。我們通過建造者模式,根據顧客選擇的不同配料,製作不同口味的比薩。  

3) 建造者模式建造的物件是可用的,安全的。