使用 Go 實現 GoF 的 23 種設計模式(一)
前言
從 1995 年 GoF 提出 23 種設計模式到現在,25 年過去了,設計模式依舊是軟件領域的熱門話題。在當下,如果你不會一點設計模式,都不好意思說自己是一個合格的程序員。設計模式通常被定義爲:
設計模式(Design Pattern)是一套被反覆使用、多數人知曉的、經過分類編目的、代碼設計經驗的總結,使用設計模式是爲了可重用代碼、讓代碼更容易被他人理解並且保證代碼可靠性。
從定義上看,設計模式其實是一種經驗的總結,是針對特定問題的簡潔而優雅的解決方案。既然是經驗總結,那麼學習設計模式最直接的好處就在於可以站在巨人的肩膀上解決軟件開發過程中的一些特定問題。然而,學習設計模式的最高境界是習得其中解決問題所用到的思想,當你把它們的本質思想喫透了,也就能做到即使已經忘掉某個設計模式的名稱和結構,也能在解決特定問題時信手拈來。
好的東西有人吹捧,當然也會招黑。設計模式被抨擊主要因爲以下兩點:
1、設計模式會增加代碼量,把程序邏輯變得複雜。這一點是不可避免的,但是我們並不能僅僅只考慮開發階段的成本。最簡單的程序當然是一個函數從頭寫到尾,但是這樣後期的維護成本會變得非常大;而設計模式雖然增加了一點開發成本,但是能讓人們寫出可複用、可維護性高的程序。引用《軟件設計的哲學》裏的概念,前者就是戰術編程,後者就是戰略編程,我們應該對戰術編程 Say No!(請移步《一步步降低軟件複雜性》)
2、濫用設計模式。這是初學者最容易犯的錯誤,當學到一個模式時,恨不得在所有的代碼都用上,從而在不該使用模式的地方刻意地使用了模式,導致了程序變得異常複雜。其實每個設計模式都有幾個關鍵要素:適用場景、解決方法、優缺點。模式並不是萬能藥,它只有在特定的問題上才能顯現出效果。所以,在使用一個模式前,先問問自己,當前的這個場景適用這個模式嗎?
《設計模式》一書的副標題是 “可複用面向對象軟件的基礎”,但並不意味着只有面嚮對象語言才能使用設計模式。模式只是一種解決特定問題的思想,跟語言無關。就像 Go 語言一樣,它並非是像 C++ 和 Java 一樣的面嚮對象語言,但是設計模式同樣適用。本系列文章將使用 Go 語言來實現 GoF 提出的 23 種設計模式,按照創建型模式(Creational Pattern)、結構型模式(Structural Pattern)和行爲型模式(Behavioral Pattern)三種類別進行組織,文本主要介紹其中的創建型模式。
單例模式(Singleton Pattern)
單例模式結構
簡述
單例模式算是 23 中設計模式裏最簡單的一個了,它主要用於保證一個類僅有一個實例,並提供一個訪問它的全局訪問點。
在程序設計中,有一些對象通常我們只需要一個共享的實例,比如線程池、全局緩存、對象池等,這種場景下就適合使用單例模式。
但是,並非所有全局唯一的場景都適合使用單例模式。比如,考慮需要統計一個 API 調用的情況,有兩個指標,成功調用次數和失敗調用次數。這兩個指標都是全局唯一的,所以有人可能會將其建模成兩個單例SuccessApiMetric
和FailApiMetric
。按照這個思路,隨着指標數量的增多,你會發現代碼裏類的定義會越來越多,也越來越臃腫。這也是單例模式最常見的誤用場景,更好的方法是將兩個指標設計成一個對象ApiMetric
下的兩個實例ApiMetic success
和ApiMetic fail
。
如何判斷一個對象是否應該被建模成單例?
通常,被建模成單例的對象都有 “中心點” 的含義,比如線程池就是管理所有線程的中心。所以,在判斷一個對象是否適合單例模式時,先思考下,這個對象是一箇中心點嗎?
Go 實現
在對某個對象實現單例模式時,有兩個點必須要注意:(1)限制調用者直接實例化該對象;(2)爲該對象的單例提供一個全局唯一的訪問方法。
對於 C++/Java 而言,只需把類的構造函數設計成私有的,並提供一個static
方法去訪問該類點唯一實例即可。但對於 Go 語言來說,即沒有構造函數的概念,也沒有static
方法,所以需要另尋出路。
我們可以利用 Go 語言package
的訪問規則來實現,將單例結構體設計成首字母小寫,就能限定其訪問範圍只在當前 package 下,模擬了 C++/Java 中的私有構造函數;再在當前package
下實現一個首字母大寫的訪問函數,就相當於static
方法的作用了。
在實際開發中,我們經常會遇到需要頻繁創建和銷燬的對象。頻繁的創建和銷燬一則消耗 CPU,二則內存的利用率也不高,通常我們都會使用對象池技術來進行優化。考慮我們需要實現一個消息對象池,因爲是全局的中心點,管理所有的 Message 實例,所以將其實現成單例,實現代碼如下:
package msgpool
...
// 消息池
type messagePool struct {
pool *sync.Pool
}
// 消息池單例
var msgPool = &messagePool{
// 如果消息池裏沒有消息,則新建一個Count值爲0的Message實例
pool: &sync.Pool{New: func() interface{} { return &Message{Count: 0} }},
}
// 訪問消息池單例的唯一方法
func Instance() *messagePool {
return msgPool
}
// 往消息池裏添加消息
func (m *messagePool) AddMsg(msg *Message) {
m.pool.Put(msg)
}
// 從消息池裏獲取消息
func (m *messagePool) GetMsg() *Message {
return m.pool.Get().(*Message)
}
...
測試代碼如下:
package test
...
func TestMessagePool(t *testing.T) {
msg0 := msgpool.Instance().GetMsg()
if msg0.Count != 0 {
t.Errorf("expect msg count %d, but actual %d.", 0, msg0.Count)
}
msg0.Count = 1
msgpool.Instance().AddMsg(msg0)
msg1 := msgpool.Instance().GetMsg()
if msg1.Count != 1 {
t.Errorf("expect msg count %d, but actual %d.", 1, msg1.Count)
}
}
// 運行結果
=== RUN TestMessagePool
--- PASS: TestMessagePool (0.00s)
PASS
以上的單例模式就是典型的 “餓漢模式”,實例在系統加載的時候就已經完成了初始化。對應地,還有一種 “懶漢模式”,只有等到對象被使用的時候,纔會去初始化它,從而一定程度上節省了內存。衆所周知,“懶漢模式” 會帶來線程安全問題,可以通過普通加鎖,或者更高效的雙重檢驗鎖來優化。對於 “懶漢模式”,Go 語言有一個更優雅的實現方式,那就是利用sync.Once
,它有一個Do
方法,其入參是一個方法,Go 語言會保證僅僅只調用一次該方法。
// 單例模式的“懶漢模式”實現
package msgpool
...
var once = &sync.Once{}
// 消息池單例,在首次調用時初始化
var msgPool *messagePool
// 全局唯一獲取消息池pool到方法
func Instance() *messagePool {
// 在匿名函數中實現初始化邏輯,Go語言保證只會調用一次
once.Do(func() {
msgPool = &messagePool{
// 如果消息池裏沒有消息,則新建一個Count值爲0的Message實例
pool: &sync.Pool{New: func() interface{} { return &Message{Count: 0} }},
}
})
return msgPool
}
...
建造者模式(Builder Pattern)
建造者模式結構
簡述
在程序設計中,我們會經常遇到一些複雜的對象,其中有很多成員屬性,甚至嵌套着多個複雜的對象。這種情況下,創建這個複雜對象就會變得很繁瑣。對於 C++/Java 而言,最常見的表現就是構造函數有着長長的參數列表:
MyObject obj = new MyObject(param1, param2, param3, param4, param5, param6, ...)
而對於 Go 語言來說,最常見的表現就是多層的嵌套實例化:
obj := &MyObject{
Field1: &Field1 {
Param1: &Param1 {
Val: 0,
},
Param2: &Param2 {
Val: 1,
},
...
},
Field2: &Field2 {
Param3: &Param3 {
Val: 2,
},
...
},
...
}
上述的對象創建方法有兩個明顯的缺點:(1)對對象使用者不友好,使用者在創建對象時需要知道的細節太多;(2)代碼可讀性很差。
針對這種對象成員較多,創建對象邏輯較爲繁瑣的場景,就適合使用建造者模式來進行優化。
建造者模式的作用有如下幾個:
1、封裝複雜對象的創建過程,使對象使用者不感知複雜的創建邏輯。
2、可以一步步按照順序對成員進行賦值,或者創建嵌套對象,並最終完成目標對象的創建。
3、對多個對象複用同樣的對象創建邏輯。
其中,第 1 和第 2 點比較常用,下面對建造者模式的實現也主要是針對這兩點進行示例。
Go 實現
考慮如下的一個Message
結構體,其主要有Header
和Body
組成:
package msg
...
type Message struct {
Header *Header
Body *Body
}
type Header struct {
SrcAddr string
SrcPort uint64
DestAddr string
DestPort uint64
Items map[string]string
}
type Body struct {
Items []string
}
...
如果按照直接的對象創建方式,創建邏輯應該是這樣的:
// 多層的嵌套實例化
message := msg.Message{
Header: &msg.Header{
SrcAddr: "192.168.0.1",
SrcPort: 1234,
DestAddr: "192.168.0.2",
DestPort: 8080,
Items: make(map[string]string),
},
Body: &msg.Body{
Items: make([]string, 0),
},
}
// 需要知道對象的實現細節
message.Header.Items["contents"] = "application/json"
message.Body.Items = append(message.Body.Items, "record1")
message.Body.Items = append(message.Body.Items, "record2")
雖然Message
結構體嵌套的層次不多,但是從其創建的代碼來看,確實存在對對象使用者不友好和代碼可讀性差的缺點。下面我們引入建造者模式對代碼進行重構:
package msg
...
// Message對象的Builder對象
type builder struct {
once *sync.Once
msg *Message
}
// 返回Builder對象
func Builder() *builder {
return &builder{
once: &sync.Once{},
msg: &Message{Header: &Header{}, Body: &Body{}},
}
}
// 以下是對Message成員對構建方法
func (b *builder) WithSrcAddr(srcAddr string) *builder {
b.msg.Header.SrcAddr = srcAddr
return b
}
func (b *builder) WithSrcPort(srcPort uint64) *builder {
b.msg.Header.SrcPort = srcPort
return b
}
func (b *builder) WithDestAddr(destAddr string) *builder {
b.msg.Header.DestAddr = destAddr
return b
}
func (b *builder) WithDestPort(destPort uint64) *builder {
b.msg.Header.DestPort = destPort
return b
}
func (b *builder) WithHeaderItem(key, value string) *builder {
// 保證map只初始化一次
b.once.Do(func() {
b.msg.Header.Items = make(map[string]string)
})
b.msg.Header.Items[key] = value
return b
}
func (b *builder) WithBodyItem(record string) *builder {
b.msg.Body.Items = append(b.msg.Body.Items, record)
return b
}
// 創建Message對象,在最後一步調用
func (b *builder) Build() *Message {
return b.msg
}
測試代碼如下:
package test
...
func TestMessageBuilder(t *testing.T) {
// 使用消息建造者進行對象創建
message := msg.Builder().
WithSrcAddr("192.168.0.1").
WithSrcPort(1234).
WithDestAddr("192.168.0.2").
WithDestPort(8080).
WithHeaderItem("contents", "application/json").
WithBodyItem("record1").
WithBodyItem("record2").
Build()
if message.Header.SrcAddr != "192.168.0.1" {
t.Errorf("expect src address 192.168.0.1, but actual %s.", message.Header.SrcAddr)
}
if message.Body.Items[0] != "record1" {
t.Errorf("expect body item0 record1, but actual %s.", message.Body.Items[0])
}
}
// 運行結果
=== RUN TestMessageBuilder
--- PASS: TestMessageBuilder (0.00s)
PASS
從測試代碼可知,使用建造者模式來進行對象創建,使用者不再需要知道對象具體的實現細節,代碼可讀性也更好。
工廠方法模式(Factory Method Pattern)
工廠方法模式結構
簡述
工廠方法模式跟上一節討論的建造者模式類似,都是將對象創建的邏輯封裝起來,爲使用者提供一個簡單易用的對象創建接口。兩者在應用場景上稍有區別,建造者模式更常用於需要傳遞多個參數來進行實例化的場景。
使用工廠方法來創建對象主要有兩個好處:
1、代碼可讀性更好。相比於使用 C++/Java 中的構造函數,或者 Go 中的{}
來創建對象,工廠方法因爲可以通過函數名來表達代碼含義,從而具備更好的可讀性。比如,使用工廠方法productA := CreateProductA()
創建一個ProductA
對象,比直接使用productA := ProductA{}
的可讀性要好。
2、與使用者代碼解耦。很多情況下,對象的創建往往是一個容易變化的點,通過工廠方法來封裝對象的創建過程,可以在創建邏輯變更時,避免霰彈式修改。
工廠方法模式也有兩種實現方式:(1)提供一個工廠對象,通過調用工廠對象的工廠方法來創建產品對象;(2)將工廠方法集成到產品對象中(C++/Java 中對象的static
方法,Go 中同一package
下的函數)
Go 實現
考慮有一個事件對象Event
,分別有兩種有效的時間類型Start
和End
:
package event
...
type Type uint8
// 事件類型定義
const (
Start Type = iota
End
)
// 事件抽象接口
type Event interface {
EventType() Type
Content() string
}
// 開始事件,實現了Event接口
type StartEvent struct{
content string
}
...
// 結束事件,實現了Event接口
type EndEvent struct{
content string
}
...
1、按照第一種實現方式,爲Event
提供一個工廠對象,具體代碼如下:
package event
...
// 事件工廠對象
type Factory struct{}
// 更具事件類型創建具體事件
func (e *Factory) Create(etype Type) Event {
switch etype {
case Start:
return &StartEvent{
content: "this is start event",
}
case End:
return &EndEvent{
content: "this is end event",
}
default:
return nil
}
}
測試代碼如下:
package test
...
func TestEventFactory(t *testing.T) {
factory := event.Factory{}
e := factory.Create(event.Start)
if e.EventType() != event.Start {
t.Errorf("expect event.Start, but actual %v.", e.EventType())
}
e = factory.Create(event.End)
if e.EventType() != event.End {
t.Errorf("expect event.End, but actual %v.", e.EventType())
}
}
// 運行結果
=== RUN TestEventFactory
--- PASS: TestEventFactory (0.00s)
PASS
2、按照第二種實現方式,分別給Start
和End
類型的Event
單獨提供一個工廠方法,代碼如下:
package event
...
// Start類型Event的工廠方法
func OfStart() Event {
return &StartEvent{
content: "this is start event",
}
}
// End類型Event的工廠方法
func OfEnd() Event {
return &EndEvent{
content: "this is end event",
}
}
測試代碼如下:
package event
...
func TestEvent(t *testing.T) {
e := event.OfStart()
if e.EventType() != event.Start {
t.Errorf("expect event.Start, but actual %v.", e.EventType())
}
e = event.OfEnd()
if e.EventType() != event.End {
t.Errorf("expect event.End, but actual %v.", e.EventType())
}
}
// 運行結果
=== RUN TestEvent
--- PASS: TestEvent (0.00s)
PASS
抽象工廠模式(Abstract Factory Pattern)
抽象工廠模式結構
簡述
在工廠方法模式中,我們通過一個工廠對象來創建一個產品族,具體創建哪個產品,則通過swtich-case
的方式去判斷。這也意味着該產品組上,每新增一類產品對象,都必須修改原來工廠對象的代碼;而且隨着產品的不斷增多,工廠對象的職責也越來越重,違反了單一職責原則。
抽象工廠模式通過給工廠類新增一個抽象層解決了該問題,如上圖所示,FactoryA
和FactoryB
都實現 · 抽象工廠接口,分別用於創建ProductA
和ProductB
。如果後續新增了ProductC
,只需新增一個FactoryC
即可,無需修改原有的代碼;因爲每個工廠只負責創建一個產品,因此也遵循了單一職責原則。
Go 實現
考慮需要如下一個插件架構風格的消息處理系統,pipeline
是消息處理的管道,其中包含了input
、filter
和output
三個插件。我們需要實現根據配置來創建pipeline
,加載插件過程的實現非常適合使用工廠模式,其中input
、filter
和output
三類插件的創建使用抽象工廠模式,而pipeline
的創建則使用工廠方法模式。
抽象工廠模式示例
各類插件和pipeline
的接口定義如下:
package plugin
...
// 插件抽象接口定義
type Plugin interface {}
// 輸入插件,用於接收消息
type Input interface {
Plugin
Receive() string
}
// 過濾插件,用於處理消息
type Filter interface {
Plugin
Process(msg string) string
}
// 輸出插件,用於發送消息
type Output interface {
Plugin
Send(msg string)
}
package pipeline
...
// 消息管道的定義
type Pipeline struct {
input plugin.Input
filter plugin.Filter
output plugin.Output
}
// 一個消息的處理流程爲 input -> filter -> output
func (p *Pipeline) Exec() {
msg := p.input.Receive()
msg = p.filter.Process(msg)
p.output.Send(msg)
}
接着,我們定義input
、filter
、output
三類插件接口的具體實現:
package plugin
...
// input插件名稱與類型的映射關係,主要用於通過反射創建input對象
var inputNames = make(map[string]reflect.Type)
// Hello input插件,接收“Hello World”消息
type HelloInput struct {}
func (h *HelloInput) Receive() string {
return "Hello World"
}
// 初始化input插件映射關係表
func init() {
inputNames["hello"] = reflect.TypeOf(HelloInput{})
}
package plugin
...
// filter插件名稱與類型的映射關係,主要用於通過反射創建filter對象
var filterNames = make(map[string]reflect.Type)
// Upper filter插件,將消息全部字母轉成大寫
type UpperFilter struct {}
func (u *UpperFilter) Process(msg string) string {
return strings.ToUpper(msg)
}
// 初始化filter插件映射關係表
func init() {
filterNames["upper"] = reflect.TypeOf(UpperFilter{})
}
package plugin
...
// output插件名稱與類型的映射關係,主要用於通過反射創建output對象
var outputNames = make(map[string]reflect.Type)
// Console output插件,將消息輸出到控制檯上
type ConsoleOutput struct {}
func (c *ConsoleOutput) Send(msg string) {
fmt.Println(msg)
}
// 初始化output插件映射關係表
func init() {
outputNames["console"] = reflect.TypeOf(ConsoleOutput{})
}
然後,我們定義插件抽象工廠接口,以及對應插件的工廠實現:
package plugin
...
// 插件抽象工廠接口
type Factory interface {
Create(conf Config) Plugin
}
// input插件工廠對象,實現Factory接口
type InputFactory struct{}
// 讀取配置,通過反射機制進行對象實例化
func (i *InputFactory) Create(conf Config) Plugin {
t, _ := inputNames[conf.Name]
return reflect.New(t).Interface().(Plugin)
}
// filter和output插件工廠實現類似
type FilterFactory struct{}
func (f *FilterFactory) Create(conf Config) Plugin {
t, _ := filterNames[conf.Name]
return reflect.New(t).Interface().(Plugin)
}
type OutputFactory struct{}
func (o *OutputFactory) Create(conf Config) Plugin {
t, _ := outputNames[conf.Name]
return reflect.New(t).Interface().(Plugin)
}
最後定義pipeline
的工廠方法,調用plugin.Factory
抽象工廠完成 pipelien 對象的實例化:
package pipeline
...
// 保存用於創建Plugin的工廠實例,其中map的key爲插件類型,value爲抽象工廠接口
var pluginFactories = make(map[plugin.Type]plugin.Factory)
// 根據plugin.Type返回對應Plugin類型的工廠實例
func factoryOf(t plugin.Type) plugin.Factory {
factory, _ := pluginFactories[t]
return factory
}
// pipeline工廠方法,根據配置創建一個Pipeline實例
func Of(conf Config) *Pipeline {
p := &Pipeline{}
p.input = factoryOf(plugin.InputType).Create(conf.Input).(plugin.Input)
p.filter = factoryOf(plugin.FilterType).Create(conf.Filter).(plugin.Filter)
p.output = factoryOf(plugin.OutputType).Create(conf.Output).(plugin.Output)
return p
}
// 初始化插件工廠對象
func init() {
pluginFactories[plugin.InputType] = &plugin.InputFactory{}
pluginFactories[plugin.FilterType] = &plugin.FilterFactory{}
pluginFactories[plugin.OutputType] = &plugin.OutputFactory{}
}
測試代碼如下:
package test
...
func TestPipeline(t *testing.T) {
// 其中pipeline.DefaultConfig()的配置內容見【抽象工廠模式示例圖】
// 消息處理流程爲 HelloInput -> UpperFilter -> ConsoleOutput
p := pipeline.Of(pipeline.DefaultConfig())
p.Exec()
}
// 運行結果
=== RUN TestPipeline
HELLO WORLD
--- PASS: TestPipeline (0.00s)
PASS
原型模式(Prototype Pattern)
原型模式結構
簡述
原型模式主要解決對象複製的問題,它的核心就是clone()
方法,返回Prototype
對象的複製品。在程序設計過程中,往往會遇到有一些場景需要大量相同的對象,如果不使用原型模式,那麼我們可能會這樣進行對象的創建:新創建一個相同對象的實例,然後遍歷原始對象的所有成員變量, 並將成員變量值複製到新對象中。這種方法的缺點很明顯,那就是使用者必須知道對象的實現細節,導致代碼之間的耦合。另外,對象很有可能存在除了對象本身以外不可見的變量,這種情況下該方法就行不通了。
對於這種情況,更好的方法就是使用原型模式,將複製邏輯委託給對象本身,這樣,上述兩個問題也都迎刃而解了。
Go 實現
還是以建造者模式一節中的Message
作爲例子,現在設計一個Prototype
抽象接口:
package prototype
...
// 原型複製抽象接口
type Prototype interface {
clone() Prototype
}
type Message struct {
Header *Header
Body *Body
}
func (m *Message) clone() Prototype {
msg := *m
return &msg
}
測試代碼如下:
package test
...
func TestPrototype(t *testing.T) {
message := msg.Builder().
WithSrcAddr("192.168.0.1").
WithSrcPort(1234).
WithDestAddr("192.168.0.2").
WithDestPort(8080).
WithHeaderItem("contents", "application/json").
WithBodyItem("record1").
WithBodyItem("record2").
Build()
// 複製一份消息
newMessage := message.Clone().(*msg.Message)
if newMessage.Header.SrcAddr != message.Header.SrcAddr {
t.Errorf("Clone Message failed.")
}
if newMessage.Body.Items[0] != message.Body.Items[0] {
t.Errorf("Clone Message failed.")
}
}
// 運行結果
=== RUN TestPrototype
--- PASS: TestPrototype (0.00s)
PASS
總結
本文主要介紹了 GoF 的 23 種設計模式中的 5 種創建型模式,創建型模式的目的都是提供一個簡單的接口,讓對象的創建過程與使用者解耦。其中,單例模式主要用於保證一個類僅有一個實例,並提供一個訪問它的全局訪問點;建造者模式主要解決需要創建對象時需要傳入多個參數,或者對初始化順序有要求的場景;工廠方法模式通過提供一個工廠對象或者工廠方法,爲使用者隱藏了對象創建的細節;抽象工廠模式是對工廠方法模式的優化,通過爲工廠對象新增一個抽象層,讓工廠對象遵循單一職責原則,也避免了霰彈式修改;原型模式則讓對象複製更加簡單。
下一篇文章,將介紹 23 種設計模式中的 7 種結構型模式(Structural Pattern),及其 Go 語言的實現。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/-DqpkPFFuRlpxMLLHbX0Gg