快來,這裡有23種設計模式的Go語言實現
摘要:設計模式(Design Pattern)是一套被反覆使用、多數人知曉的、經過分類編目的、程式碼設計經驗的總結,使用設計模式是為了可重用程式碼、讓程式碼更容易被他人理解並且保證程式碼可靠性。
本文分享自華為雲社群《快來,這裡有23種設計模式的Go語言實現》,原文作者:元閏子。
前言
從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種建立型模式,建立型模式的目的都是提供一個簡單的介面,讓物件的建立過程與使用者解耦。其中,單例模式主要用於保證一個類僅有一個例項,並提供一個訪問它的全域性訪問點;建造者模式主要解決需要建立物件時需要傳入多個引數,或者對初始化順序有要求的場景;工廠方法模式通過提供一個工廠物件或者工廠方法,為使用者隱藏了物件建立的細節;抽象工廠模式是對工廠方法模式的優化,通過為工廠物件新增一個抽象層,讓工廠物件遵循單一職責原則,也避免了霰彈式修改;原型模式則讓物件複製更加簡單。