使用 Go 實現 GoF 的 23 種設計模式(三)

前言

代理模式(Proxy Pattern)

代理模式結構

簡介

代理模式爲一個對象提供一種代理以控制對該對象的訪問,它是一個使用率非常高的設計模式,即使在現實生活中,也是很常見,比如演唱會門票黃牛。假設你需要看一場演唱會,但是官網上門票已經售罄,於是就當天到現場通過黃牛高價買了一張。在這個例子中,黃牛就相當於演唱會門票的代理,在正式渠道無法購買門票的情況下,你通過代理完成了該目標。

從演唱會門票的例子我們也可以看出,使用代理模式的關鍵在於當 Client 不方便直接訪問一個對象時,提供一個代理對象控制該對象的訪問。Client 實際上訪問的是代理對象,代理對象會將 Client 的請求轉給本體對象去處理。

在程序設計中,代理模式也分爲好幾種:

1、遠程代理(remote proxy),遠程代理適用於提供服務的對象處在遠程的機器上,通過普通的函數調用無法使用服務,需要經過遠程代理來完成。因爲並不能直接訪問本體對象,所有遠程代理對象通常不會直接持有本體對象的引用,而是持有遠端機器的地址,通過網絡協議去訪問本體對象

2、虛擬代理(virtual proxy),在程序設計中常常會有一些重量級的服務對象,如果一直持有該對象實例會非常消耗系統資源,這時可以通過虛擬代理來對該對象進行延遲初始化。

3、保護代理(protection proxy),保護代理用於控制對本體對象的訪問,常用於需要給 Client 的訪問加上權限驗證的場景。

4、緩存代理(cache proxy),緩存代理主要在 Client 與本體對象之間加上一層緩存,用於加速本體對象的訪問,常見於連接數據庫的場景。

5、智能引用(smart reference),智能引用爲本體對象的訪問提供了額外的動作,常見的實現爲 C++ 中的智能指針,爲對象的訪問提供了計數功能,當訪問對象的計數爲 0 時銷燬該對象。

這幾種代理都是一樣的實現原理,下面我們將介紹遠程代理的 Go 語言實現。

Go 實現

考慮要將消息處理系統輸出到數據存儲到一個數據庫中,數據庫的接口如下:

package db
...
// Key-Value數據庫接口
type KvDb interface {
 // 存儲數據
 // 其中reply爲操作結果,存儲成功爲true,否則爲false
 // 當連接數據庫失敗時返回error,成功則返回nil
 Save(record Record, reply *bool) error
 // 根據key獲取value,其中value通過函數參數中指針類型返回
 // 當連接數據庫失敗時返回error,成功則返回nil
 Get(key string, value *string) error
}

type Record struct {
 Key   string
 Value string
}

數據庫是一個 Key-Value 數據庫,使用map存儲數據,下面爲數據庫的服務端實現,db.Server實現了db.KvDb接口:

package db
...
// 數據庫服務端實現
type Server struct {
 // 採用map存儲key-value數據
 data map[string]string
}

func (s *Server) Save(record Record, reply *bool) error {
 if s.data == nil{
  s.data = make(map[string]string)
 }
 s.data[record.Key] = record.Value
 *reply = true
 return nil
}

func (s *Server) Get(key string, reply *string) error {
 val, ok := s.data[key]
 if !ok {
  *reply = ""
  return errors.New("Db has no key " + key)
 }
 *reply = val
 return nil
}

消息處理系統和數據庫並不在同一臺機器上,因此消息處理系統不能直接調用db.Server的方法進行數據存儲,像這種服務提供者和服務使用者不在同一機器上的場景,使用遠程代理再適合不過了。

遠程代理中,最常見的一種實現是遠程過程調用(Remote Procedure Call,簡稱 RPC),它允許客戶端應用可以像調用本地對象一樣直接調用另一臺不同的機器上服務端應用的方法。在 Go 語言領域,除了大名鼎鼎的 gRPC,Go 標準庫net/rpc包裏也提供了 RPC 的實現。下面,我們通過net/rpc對外提供數據庫服務端的能力:

package db
...
// 啓動數據庫,對外提供RPC接口進行數據庫的訪問
func Start() {
 rpcServer := rpc.NewServer()
 server := &Server{data: make(map[string]string)}
  // 將數據庫接口註冊到RPC服務器上
 if err := rpcServer.Register(server); err != nil {
  fmt.Printf("Register Server to rpc failed, error: %v", err)
  return
 }
 l, err := net.Listen("tcp", "127.0.0.1:1234")
 if err != nil {
  fmt.Printf("Listen tcp failed, error: %v", err)
  return
 }
 go rpcServer.Accept(l)
 time.Sleep(1 * time.Second)
 fmt.Println("Rpc server start success.")
}

到目前爲止,我們已經爲數據庫提供了對外訪問的方式。現在,我們需要一個遠程代理來連接數據庫服務端,並進行相關的數據庫操作。對消息處理系統而言,它不需要,也不應該知道遠程代理與數據庫服務端交互的底層細節,這樣可以減輕系統之間的耦合。因此,遠程代理需要實現db.KvDb

package db
...
// 數據庫服務端遠程代理,實現db.KvDb接口
type Client struct {
 // RPC客戶端
 cli *rpc.Client
}

func (c *Client) Save(record Record, reply *bool) error {
 var ret bool
 // 通過RPC調用服務端的接口
 err := c.cli.Call("Server.Save", record, &ret)
 if err != nil {
  fmt.Printf("Call db Server.Save rpc failed, error: %v", err)
  *reply = false
  return err
 }
 *reply = ret
 return nil
}

func (c *Client) Get(key string, reply *string) error {
 var ret string
 // 通過RPC調用服務端的接口
 err := c.cli.Call("Server.Get", key, &ret)
 if err != nil {
  fmt.Printf("Call db Server.Get rpc failed, error: %v", err)
  *reply = ""
  return err
 }
 *reply = ret
 return nil
}

// 工廠方法,返回遠程代理實例
func CreateClient() *Client {
 rpcCli, err := rpc.Dial("tcp", "127.0.0.1:1234")
 if err != nil {
  fmt.Printf("Create rpc client failed, error: %v.", err)
  return nil
 }
 return &Client{cli: rpcCli}
}

作爲遠程代理的db.Client並沒有直接持有db.Server的引用,而是持有了它的ip:port,通過 RPC 客戶端調用了它的方法。

數據庫遠程代理結構

接下來,我們需要爲消息處理系統實現一個新的Output插件DbOutput,調用db.Client遠程代理,將消息存儲到數據庫上。

《使用 Go 實現 GoF 的 23 種設計模式(二)》中我們爲Plugin引入生命週期的三個方法StartStopStatus之後,每新增一個新的插件,都需要實現這三個方法。但是大多數插件的這三個方法的邏輯基本一致,因此導致了一定程度的代碼冗餘。對於重複代碼問題,有什麼好的解決方法呢?組合模式

下面,我們使用組合模式將這個方法提取成一個新的對象LifeCycle,這樣新增一個插件時,只需將LifeCycle作爲匿名成員(嵌入組合),就能解決冗餘代碼問題了。

package plugin
...
type LifeCycle struct {
 name   string
 status Status
}

func (l *LifeCycle) Start() {
 l.status = Started
 fmt.Printf("%s plugin started.\n", l.name)
}

func (l *LifeCycle) Stop() {
 l.status = Stopped
 fmt.Printf("%s plugin stopped.\n", l.name)
}

func (l *LifeCycle) Status() Status {
 return l.status
}

DbOutput的實現如下,它持有一個遠程代理,通過後者將消息存儲到遠端的數據庫中。

package plugin
...
type DbOutput struct {
 LifeCycle
 // 操作數據庫的遠程代理
 proxy db.KvDb
}

func (d *DbOutput) Send(msg *msg.Message) {
 if d.status != Started {
  fmt.Printf("%s is not running, output nothing.\n", d.name)
  return
 }
 record := db.Record{
  Key:   "db",
  Value: msg.Body.Items[0],
 }
 reply := false
 err := d.proxy.Save(record, &reply)
 if err != nil || !reply {
  fmt.Println("Save msg to db server failed.")
 }
}

func (d *DbOutput) Init() {
 d.proxy = db.CreateClient()
 d.name = "db output"
}

測試代碼如下:

package test
...
func TestDbOutput(t *testing.T) {
 db.Start()
 config := pipeline.Config{
  Name: "pipeline3",
  Input: plugin.Config{
   PluginType: plugin.InputType,
   Name:       "hello",
  },
  Filter: plugin.Config{
   PluginType: plugin.FilterType,
   Name:       "upper",
  },
  Output: plugin.Config{
   PluginType: plugin.OutputType,
   Name:       "db",
  },
 }
 p := pipeline.Of(config)
 p.Start()
 p.Exec()

 // 驗證DbOutput存儲的正確性
 cli := db.CreateClient()
 var val string
 err := cli.Get("db", &val)
 if err != nil {
  t.Errorf("Get db failed, error: %v\n.", err)
 }
 if val != "HELLO WORLD" {
  t.Errorf("expect HELLO WORLD, but actual %s.", val)
 }
}
// 運行結果
=== RUN   TestDbOutput
Rpc server start success.
db output plugin started.
upper filter plugin started.
hello input plugin started.
Pipeline started.
--- PASS: TestDbOutput (1.01s)
PASS

裝飾模式(Decorator Pattern)

裝飾模式結構

簡介

在程序設計中,我們常常需要爲對象添加新的行爲,很多同學的第一個想法就是擴展本體對象,通過繼承的方式達到目的。但是使用繼承不可避免地有如下兩個弊端:(1)繼承時靜態的,在編譯期間就已經確定,無法在運行時改變對象的行爲。(2)子類只能有一個父類,當需要添加的新功能太多時,容易導致類的數量劇增。

對於這種場景,我們通常會使用裝飾模式(Decorator Pattern)來解決,它使用組合而非繼承的方式,能夠動態地爲本體對象疊加新的行爲。理論上,只要沒有限制,它可以一直把功能疊加下去。裝飾模式最經典的應用當屬 Java 的 I/O 流體系,通過裝飾模式,使用者可以動態地爲原始的輸入輸出流添加功能,比如按照字符串輸入輸出,添加緩存等,使得整個 I/O 流體系具有很高的可擴展性和靈活性。

從結構上看,裝飾模式和代理模式具有很高的相似性,但是兩種所強調的點不一樣。前者強調的是爲本體對象添加新的功能,後者強調的是對本體對象的訪問控制。當然,代理模式中的智能引用在筆者看來就跟裝飾模式完全一樣了。

Go 實現

考慮爲消息處理系統增加這樣的一個功能,統計每個消息輸入源分別產生了多少條消息,也就是分別統計每個Input產生Message的數量。最簡單的方法是在每一個InputReceive方法中進行打點統計,但是這樣會導致統計代碼與業務代碼的耦合。如果統計邏輯發生了變化,就會產生霰彈式修改,隨着Input類型的增多,相關代碼也會變得越來越難維護。

更好的方法是將統計邏輯放到一個地方,並在每次調用InputReceive方法後進行打點統計。而這恰好適合採用裝飾模式,爲Input本體對象)提供打點統計功能(新的行爲)。我們可以設計一個InputMetricDecorator作爲Input的裝飾器,在裝飾器中完成打點統計的邏輯。

首先,我們需要設計一個用於統計每個Input產生Message數量的對象,該對象應該是一個全局唯一的,因此採用單例模式進行了實現:

package metric
...
// 消息輸入源統計,設計爲單例
type input struct {
 // 存放統計結果,key爲Input類型如hello、kafka
 // value爲對應Input的消息統計
 metrics map[string]uint64
 // 統計打點時加鎖
 mu      *sync.Mutex
}

// 給名稱爲inputName的Input消息計數加1
func (i *input) Inc(inputName string) {
 i.mu.Lock()
 defer i.mu.Unlock()
 if _, ok := i.metrics[inputName]; !ok {
  i.metrics[inputName] = 0
 }
 i.metrics[inputName] = i.metrics[inputName] + 1
}

// 輸出當前所有打點的情況
func (i *input) Show() {
 fmt.Printf("Input metric: %v\n", i.metrics)
}

// 單例
var inputInstance = &input{
 metrics: make(map[string]uint64),
 mu:      &sync.Mutex{},
}

func Input() *input {
 return inputInstance
}

接下來我們開始實現InputMetricDecorator,它實現了Input接口,並持有一個本體對象Input。在InputMetricDecoratorReceive方法中調用本體InputReceive方法,並完成統計動作。

package plugin
...
type InputMetricDecorator struct {
 input Input
}

func (i *InputMetricDecorator) Receive() *msg.Message {
 // 調用本體對象的Receive方法
 record := i.input.Receive()
 // 完成統計邏輯
 if inputName, ok := record.Header.Items["input"]; ok {
  metric.Input().Inc(inputName)
 }
 return record
}

func (i *InputMetricDecorator) Start() {
 i.input.Start()
}

func (i *InputMetricDecorator) Stop() {
 i.input.Stop()
}

func (i *InputMetricDecorator) Status() Status {
 return i.input.Status()
}

func (i *InputMetricDecorator) Init() {
 i.input.Init()
}

// 工廠方法, 完成裝飾器的創建
func CreateInputMetricDecorator(input Input) *InputMetricDecorator {
 return &InputMetricDecorator{input: input}
}

最後,我們在Pipeline的工廠方法上,爲本體 Input 加上InputMetricDecorator代理:

package 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)
 // 爲本體Input加上InputMetricDecorator裝飾器
 p.input = plugin.CreateInputMetricDecorator(p.input)
 return p
}

測試代碼如下:

package test
...
func TestInputMetricDecorator(t *testing.T) {
 p1 := pipeline.Of(pipeline.HelloConfig())
 p2 := pipeline.Of(pipeline.KafkaInputConfig())
 p1.Start()
 p2.Start()
 p1.Exec()
 p2.Exec()
 p1.Exec()

 metric.Input().Show()
}
// 運行結果
=== RUN   TestInputMetricDecorator
Console output plugin started.
Upper filter plugin started.
Hello input plugin started.
Pipeline started.
Console output plugin started.
Upper filter plugin started.
Kafka input plugin started.
Pipeline started.
Output:
 Header:map[content:text input:hello], Body:[HELLO WORLD]
Output:
 Header:map[content:text input:kafka], Body:[I AM MOCK CONSUMER.]
Output:
 Header:map[content:text input:hello], Body:[HELLO WORLD]
Input metric: map[hello:2 kafka:1]
--- PASS: TestInputMetricProxy (0.00s)
PASS

外觀模式(Facade Pattern)

外觀模式結構

簡介

從結構上看,外觀模式非常的簡單,它主要是爲子系統提供了一個更高層次的對外統一接口,使得 Client 能夠更友好地使用子系統的功能。圖中,Subsystem Class 是子系統中對象的簡稱,它可能是一個對象,也可能是數十個對象的集合。外觀模式降低了 Client 與 Subsystem 之間的耦合,只要 Facade 不變,不管 Subsystem 怎麼變化,對於 Client 而言都是無感知的。

使用外觀模式進行系統優化

外觀模式在程序設計中用的非常多,比如我們在商城上點擊購買的按鈕,對於購買者而言,只看到了購買這一統一的接口,但是對於商城系統而言,其內部則進行了一系列的業務處理,比如庫存檢查、訂單處理、支付、物流等等。外觀模式極大地提升了用戶體驗,將用戶從複雜的業務流程中解放了出來。

外觀模式經常運用於分層架構上,通常我們都會爲分層架構中的每一個層級提供一個或多個統一對外的訪問接口,這樣就能讓各個層級之間的耦合性更低,使得系統的架構更加合理。

Go 實現

外觀模式實現起來也很簡單,還是考慮前面的消息處理系統。在Pipeline中,每一條消息會依次經過 Input->Filter->Output 的處理,代碼實現起來就是這樣:

p := pipeline.Of(config)
message := p.input.Receive()
message = p.filter.Process(message)
p.output.Send(message)

但是,對於Pipeline的使用者而言,他可能並不關心消息具體的處理流程,他只需知道消息已經經過Pipeline處理即可。因此,我們需要設計一個簡單的對外接口:

package pipeline
...
func (p *Pipeline) Exec() {
 msg := p.input.Receive()
 msg = p.filter.Process(msg)
 p.output.Send(msg)
}

這樣,使用者只需簡單地調用Exec方法,就能完成一次消息的處理,測試代碼如下:

package test
...
func TestPipeline(t *testing.T) {
 p := pipeline.Of(pipeline.HelloConfig())
 p.Start()
  // 調用Exec方法完成一次消息的處理
 p.Exec()
}
// 運行結果
=== RUN   TestPipeline
console output plugin started.
upper filter plugin started.
hello input plugin started.
Pipeline started.
Output:
 Header:map[content:text input:hello], Body:[HELLO WORLD]
--- PASS: TestPipeline (0.00s)
PASS

享元模式(Flyweight Pattern)

享元模式結構

簡介

在程序設計中,我們常常會碰到一些很重型的對象,它們通常擁有很多的成員屬性,當系統中充斥着大量的這些對象時,系統的內存將會承受巨大的壓力。此外,頻繁的創建這些對象也極大地消耗了系統的 CPU。很多時候,這些重型對象裏,大部分的成員屬性都是固定的,這種場景下, 可以使用享元模式進行優化,將其中固定不變的部分設計成共享對象(享元,flyweight),這樣就能節省大量的系統內存和 CPU。

享元模式摒棄了在每個對象中保存所有數據的方式, 通過共享多個對象所共有的相同狀態, 讓你能在有限的內存容量中載入更多對象

當我們決定對一個重型對象採用享元模式進行優化時,首先需要將該重型對象的屬性劃分爲兩類,能夠共享的和不能共享的。前者我們稱爲內部狀態(intrinsic state),存儲在享元中,不隨享元所處上下文的變化而變化;後者稱爲外部狀態(extrinsic state),它的值取決於享元所處的上下文,因此不能共享。比如,文章 A 和文章 B 都引用了圖片 A,由於文章 A 和文章 B 的文字內容是不一樣的,因此文字就是外部狀態,不能共享;但是它們所引用的圖片 A 是一樣的,屬於內部狀態,因此可以將圖片 A 設計爲一個享元

工廠模式通常都會和享元模式結對出現,享元工廠提供了唯一獲取享元對象的接口,這樣 Client 就感知不到享元是如何共享的,降低了模塊的耦合性。享元模式和單例模式有些類似的地方,都是在系統中共享對象,但是單例模式更關心的是對象在系統中僅僅創建一次,而享元模式更關心的是如何在多個對象中共享相同的狀態

Go 實現

假設現在需要設計一個系統,用於記錄 NBA 中的球員信息、球隊信息以及比賽結果。

球隊Team的數據結構定義如下:

package nba
...
type TeamId uint8

const (
 Warrior TeamId = iota
 Laker
)

type Team struct {
 Id      TeamId    // 球隊ID
 Name    string    // 球隊名稱
 Players []*Player // 球隊中的球員
}

球員Player的數據結構定義如下:

package nba
...
type Player struct {
 Name string // 球員名字
 Team TeamId // 球員所屬球隊ID
}

比賽結果Match的數據結構定義如下:

package nba
...
type Match struct {
 Date         time.Time // 比賽時間
 LocalTeam    *Team     // 主場球隊
 VisitorTeam  *Team     // 客場球隊
 LocalScore   uint8     // 主場球隊得分
 VisitorScore uint8     // 客場球隊得分
}

func (m *Match) ShowResult() {
 fmt.Printf("%s VS %s - %d:%d\n", m.LocalTeam.Name, m.VisitorTeam.Name,
  m.LocalScore, m.VisitorScore)
}

NBA 中的一場比賽由兩個球隊,主場球隊和客場球隊,完成比賽,對應着代碼就是,一個Match實例會持有 2 個Team實例。目前,NBA 總共由 30 支球隊,按照每個賽季每個球隊打 82 場常規賽算,一個賽季總共會有 2460 場比賽,對應地,就會有 4920 個Team實例。但是,NBA 的 30 支球隊是固定的,實際上只需 30 個Team實例就能完整地記錄一個賽季的所有比賽信息,剩下的 4890 個Team實例屬於冗餘的數據。

這種場景下就適合採用享元模式來進行優化,我們把Team設計成多個Match實例之間的享元。享元的獲取通過享元工廠來完成,享元工廠teamFactory的定義如下,Client 統一使用teamFactory.TeamOf方法來獲取球隊Team實例。其中,每個球隊Team實例只會創建一次,然後添加到球隊池中,後續獲取都是直接從池中獲取,這樣就達到了共享的目的。

package nba
...
type teamFactory struct {
 // 球隊池,緩存球隊實例
 teams map[TeamId]*Team
}

// 根據TeamId獲取Team實例,從池中獲取,如果池裏沒有,則創建
func (t *teamFactory) TeamOf(id TeamId) *Team {
 team, ok := t.teams[id]
 if !ok {
  team = createTeam(id)
  t.teams[id] = team
 }
 return team
}

// 享元工廠的單例
var factory = &teamFactory{
 teams: make(map[TeamId]*Team),
}

func Factory() *teamFactory {
 return factory
}

// 根據TeamId創建Team實例,只在TeamOf方法中調用,外部不可見
func createTeam(id TeamId) *Team {
 switch id {
 case Warrior:
  w := &Team{
   Id:      Warrior,
   Name:    "Golden State Warriors",
  }
  curry := &Player{
   Name: "Stephen Curry",
   Team: Warrior,
  }
  thompson := &Player{
   Name: "Klay Thompson",
   Team: Warrior,
  }
  w.Players = append(w.Players, curry, thompson)
  return w
 case Laker:
  l := &Team{
   Id:      Laker,
   Name:    "Los Angeles Lakers",
  }
  james := &Player{
   Name: "LeBron James",
   Team: Laker,
  }
  davis := &Player{
   Name: "Anthony Davis",
   Team: Laker,
  }
  l.Players = append(l.Players, james, davis)
  return l
 default:
  fmt.Printf("Get an invalid team id %v.\n", id)
  return nil
 }
}

測試代碼如下:

package test
...
func TestFlyweight(t *testing.T) {
 game1 := &nba.Match{
  Date:         time.Date(2020, 1, 10, 9, 30, 0, 0, time.Local),
  LocalTeam:    nba.Factory().TeamOf(nba.Warrior),
  VisitorTeam:  nba.Factory().TeamOf(nba.Laker),
  LocalScore:   102,
  VisitorScore: 99,
 }
 game1.ShowResult()
 game2 := &nba.Match{
  Date:         time.Date(2020, 1, 12, 9, 30, 0, 0, time.Local),
  LocalTeam:    nba.Factory().TeamOf(nba.Laker),
  VisitorTeam:  nba.Factory().TeamOf(nba.Warrior),
  LocalScore:   110,
  VisitorScore: 118,
 }
 game2.ShowResult()
  // 兩個Match的同一個球隊應該是同一個實例的
 if game1.LocalTeam != game2.VisitorTeam {
  t.Errorf("Warrior team do not use flyweight pattern")
 }
}
// 運行結果
=== RUN   TestFlyweight
Golden State Warriors VS Los Angeles Lakers - 102:99
Los Angeles Lakers VS Golden State Warriors - 110:118
--- PASS: TestFlyweight (0.00s)

總結

本文我們主要介紹了結構型模式中的代理模式、裝飾模式、外觀模式和享元模式。代理模式爲一個對象提供一種代理以控制對該對象的訪問,強調的是對本體對象的訪問控制;裝飾模式能夠動態地爲本體對象疊加新的行爲,強調的是爲本體對象添加新的功能;外觀模式爲子系統提供了一個更高層次的對外統一接口,強調的是分層和解耦;享元模式通過共享對象來降低系統的資源消耗,強調的是如何在多個對象中共享相同的狀態。

到目前爲止,7 種結構型模式已經全部介紹完,下一篇文章,我們開始將介紹最後一類設計模式——行爲型模式(Behavioral Pattern)。

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