【Go實現】實踐GoF的23種設計模式:單例模式
上一篇:【Go實現】實踐GoF的23種設計模式:SOLID原則
簡單的分布式應用系統(示例代碼工程):https://github.com/ruanrunxue/Practice-Design-Pattern–Go-Implementation
簡述
GoF 對單例模式(Singleton)的定義如下:
Ensure a class only has one instance, and provide a global point of access to it.
也即,保證一個類只有一個實例,并且為它提供一個全局訪問點。
在程序設計中,有些對象通常只需要一個共享的實例,比如線程池、全局緩存、對象池等。實現共享實例最簡單直接的方式就是全局變量。但是,使用全局變量會帶來一些問題,比如:
客戶端程序可以創建同類實例,從而無法保證在整系統上只有一個共享實例。
難以控制對象的訪問,比如想增加一個“訪問次數統計”的功能就很難,可擴展性較低。
把實現細節暴露給客戶端程序,加深了耦合,容易產生霰彈式修改。
對這種全局唯一的場景,更好的是使用單例模式去實現。單例模式能夠限制客戶端程序創建同類實例,并且可以在全局訪問點上擴展或修改功能,而不影響客戶端程序。
但是,并非所有的全局唯一都適用單例模式。比如下面這種場景:
考慮需要統計一個API調用的情況,有兩個指標,成功調用次數和失敗調用次數。這兩個指標都是全局唯一的,所以有人可能會將其建模成兩個單例SuccessApiMetric和FailApiMetric。按照這個思路,隨著指標數量的增多,你會發現代碼里類的定義會越來越多,也越來越臃腫。這也是單例模式最常見的誤用場景,更好的方法是將兩個指標設計成一個對象ApiMetric下的兩個實例ApiMetic success和ApiMetic fail。
那么,如何判斷一個對象是否應該被建模成單例?通常,被建模成單例的對象都有“中心點”的含義,比如線程池就是管理所有線程的中心。所以,在判斷一個對象是否適合單例模式時,先思考下,是一個中心點嗎?
UML結構
代碼實現
根據單例模式的定義,實現的關鍵點有兩個:
限制調用者直接實例化該對象;
為該對象的單例提供一個全局唯一的訪問方法。
對于 C++ / Java 而言,只需把對象的構造函數設計成私有的,并提供一個 static 方法去訪問該對象的唯一實例即可。但 Go 語言并沒有構造函數的概念,也沒有 static 方法,所以需要另尋出路。
我們可以利用 Go 語言 package 的訪問規則來實現,將單例對象設計成首字母小寫,這樣就能限定它的訪問范圍只在當前package下,模擬了 C++ / Java 的私有構造函數;然后,在當前 package 下實現一個首字母大寫的訪問函數,也就相當于 static 方法的作用了。
示例
在簡單的分布式應用系統(示例代碼工程)中,我們定義了一個網絡模塊 network,模擬實現了網絡報文轉發功能。network 的設計也很簡單,通過一個哈希表維持了 Endpoint 到 Socket 的映射,報文轉發時,通過 Endpoint 尋址到 Socket,再調用 Socket 的 Receive 方法完成轉發。
因為整系統只需一個 network 對象,而且它在領域模型中具有中心點的語義,所以我們很自然地使用單例模式來實現它。單例模式大致可以分成兩類,“餓漢模式”和“懶漢模式”。前者是在系統初始化期間就完成了單例對象的實例化;后者則是在調用時才進行延遲實例化,從而一定程度上節省了內存。
“餓漢模式”實現
// demo/network/network.go package network // 1、設計為小寫字母開頭,表示只在network包內可見,限制客戶端程序的實例化 type network struct { sockets sync.Mapvar instancevar instance } // 2、定義一個包內可見的實例對象,也即單例 var instance = &network{sockets: sync.Map{}} // 3、定義一個全局可見的唯一訪問方法 func Instance() *network { return instance } func (n *network) Listen(endpoint Endpoint, socket Socket) error { if _, ok := n.sockets.Load(endpoint); ok { return ErrEndpointAlreadyListened } n.sockets.Store(endpoint, socket) return nil } func (n *network) Send(packet *Packet) error { record, rOk := n.sockets.Load(packet.Dest()) socket, sOk := record.(Socket) if !rOk || !sOk { return ErrConnectionRefuse } go socket.Receive(packet) return nil }
那么,客戶端就可以通過 network.Instance() 引用該單例了:
// demo/sidecar/flowctrl_sidecar.go package sidecar type FlowCtrlSidecar struct {...} // 通過 network.Instance() 直接引用單例 func (f *FlowCtrlSidecar) Listen(endpoint network.Endpoint) error { return network.Instance().Listen(endpoint, f) } ...
“懶漢模式”實現
眾所周知,“懶漢模式”會帶來線程安全問題,可以通過普通加鎖,或者更高效的雙重檢驗加鎖來優化。不管是哪種方法,都是為了保證單例只會被初始化一次。
type network struct {...} // 單例 var instance *network // 定義互斥鎖 var mutex = sync.Mutex{} // 普通加鎖,缺點是每次調用 Instance() 都需要加鎖 func Instance() *network { mutex.Lock() if instance == nil { instance = &network{sockets: sync.Map{}} } mutex.Unlock() return instance } // 雙重檢驗后加鎖,實例化后無需加鎖 func Instance() *network { if instance == nil { mutex.Lock() if instance == nil { instance = &network{sockets: sync.Map{}} } mutex.Unlock() } return instance }
對于“懶漢模式”,Go 語言還有一個更優雅的實現方式,那就是利用 sync.Once。它有一個 Do 方法,方法聲明為 func (o *Once) Do(f func()),其中入參是 func() 的方法類型,Go 會保證該方法僅會被調用一次。利用這個特性,我們就能夠實現單例只被初始化一次了。
type network struct {...} // 單例 var instance *network // 定義 once 對象 var once = sync.Once{} // 通過once對象確保instance只被初始化一次 func Instance() *network { once.Do(func() { // 只會被調用一次 instance = &network{sockets: sync.Map{}} }) return instance }
擴展
提供多個實例
雖然單例模式從定義上表示每個對象只能有一個實例,但是我們不應該被該定義限制住,還得從模式本身的動機來去理解它。單例模式的一大動機是限制客戶端程序對對象進行實例化,至于實例有多少個其實并不重要,根據具體場景來進行建模、設計即可。
比如在前面的 network 模塊中,現在新增一個這樣的需求,將網絡拆分為互聯網和局域網。那么,我們可以這么設計:
type network struct {...} // 定義互聯網單例 var inetInstance = &network{sockets: sync.Map{}} // 定義局域網單例 var lanInstance = &network{sockets: sync.Map{}} // 定義互聯網全局可見的唯一訪問方法 func Internet() *network { return inetInstance } // 定義局域網全局可見的唯一訪問方法 func Lan() *network { return lanInstance }
雖然上述例子中,network 結構有兩個實例,但是本質上還是單例模式,因為它做到了限制客戶端實例化,以及為每個單例提供了全局唯一的訪問方法。
提供多種實現
單例模式也可以實現多態,如果你預測該單例未來可能會擴展,那么就可以將它設計成抽象的接口,讓客戶端依賴抽象,這樣,未來擴展時就無需改動客戶端程序了。
比如,我們可以 network 設計為一個抽象接口:
// network 抽象接口 type network interface { Listen(endpoint Endpoint, socket Socket) error Send(packet *Packet) error } // network 的實現1 type networkImpl1 struct { sockets sync.Map } func (n *networkImpl1) Listen(endpoint Endpoint, socket Socket) error {...} func (n *networkImpl1) Send(packet *Packet) error {...} // networkImpl1 實現的單例 var instance = &networkImpl1{sockets: sync.Map{}} // 定義全局可見的唯一訪問方法,注意返回值時network抽象接口! func Instance() network { return instance } // 客戶端使用示例 func client() { packet := network.NewPacket(srcEndpoint, destEndpoint, payload) network.Instance().Send(packet) }
如果未來需要新增一種 networkImpl2 實現,那么我們只需修改 instance 的初始化邏輯即可,客戶端程序無需改動:
// 新增network 的實現2 type networkImpl2 struct {...} func (n *networkImpl2) Listen(endpoint Endpoint, socket Socket) error {...} func (n *networkImpl2) Send(packet *Packet) error {...} // 將單例 instance 修改為 networkImpl2 實現 var instance = &networkImpl2{...} // 單例全局訪問方法無需改動 func Instance() network { return instance } // 客戶端使用也無需改動 func client() { packet := network.NewPacket(srcEndpoint, destEndpoint, payload) network.Instance().Send(packet) }
有時候,我們還可能需要通過讀取配置來決定使用哪種單例實現,那么,我們可以通過 map 來維護所有的實現,然后根據具體配置來選取對應的實現:
// network 抽象接口 type network interface { Listen(endpoint Endpoint, socket Socket) error Send(packet *Packet) error } // network 具體實現 type networkImpl1 struct {...} type networkImpl2 struct {...} type networkImpl3 struct {...} type networkImpl4 struct {...} // 單例 map var instances = make(map[string]network) // 初始化所有的單例 func init() { instances["impl1"] = &networkImpl1{...} instances["impl2"] = &networkImpl2{...} instances["impl3"] = &networkImpl3{...} instances["impl4"] = &networkImpl4{...} } // 全局單例訪問方法,通過讀取配置決定使用哪種實現 func Instance() network { impl := readConf() instance, ok := instances[impl] if !ok { panic("instance not found") } return instance }
典型應用場景
日志。每個服務通常都會需要一個全局的日志對象來記錄本服務產生的日志。
全局配置。對于一些全局的配置,可以通過定義一個單例來供客戶端使用。
唯一序列號生成。唯一序列號生成必然要求整系統只能有一個生成實例,非常合適使用單例模式。
線程池、對象池、連接池等。xxx池的本質就是共享,也是單例模式的常見場景。
全局緩存
…
優缺點
優點
在合適的場景,使用單例模式有如下的優點:
整系統只有一個或幾個實例,有效節省了內存和對象創建的開銷。
通過全局訪問點,可以方便地擴展功能,比如新增加訪問次數的統計。
對客戶端隱藏實現細節,可避免霰彈式修改。
缺點
雖然單例模式相比全局變量有諸多的優點,但它本質上還是一個“全局變量”,還是避免不了全局變量的一些缺點:
函數調用的隱式耦合。通常我們都期望從函數的聲明中就能知道該函數做了什么、依賴了什么、返回了什么。使用使用單例模式就意味著,無需通過函數傳參,就能夠在函數中使用該實例。也即將依賴/耦合隱式化了,不利于更好地理解代碼。
對測試不友好。通常對一個方法/函數進行測試,我們并不需要知道它的具體實現。但如果方法/函數中有使用單例對象,我們就不得不考慮單例狀態的變化了,也即需要考慮方法/函數的具體實現了。
并發問題。共享就意味著可能存在并發問題,我們不僅需要在初始化階段考慮并發問題,在初始化后更是要時刻注意。因此,在高并發的場景,單例模式也可能存在鎖沖突問題。
單例模式雖然簡單易用,但也是最容易被濫用的設計模式。它并不是“銀彈”,在實際使用時,還需根據具體的業務場景謹慎使用。
與其他模式的關聯
工廠方法模式、抽象工廠模式很多時候都會以單例模式來實現,因為工廠類通常是無狀態的,而且全局只需一個實例即可,能夠有效避免對象的頻繁創建和銷毀。
Go 架構設計
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。