實踐GoF的23種設計模式:裝飾者模式

2022-06-30 18:03:20
摘要:裝飾者模式通過組合的方式,提供了能夠動態地給物件/模組擴充套件新功能的能力。理論上,只要沒有限制,它可以一直把功能疊加下去,具有很高的靈活性。

本文分享自華為雲社群《【Go實現】實踐GoF的23種設計模式:裝飾者模式》,作者: 元閏子。

簡介

我們經常會遇到「給現有物件/模組新增功能」的場景,比如 http router 的開發場景下,除了最基礎的路由功能之外,我們常常還會加上如紀錄檔、鑑權、流控等 middleware。如果你檢視框架的原始碼,就會發現 middleware 功能的實現用的就是裝飾者模式(Decorator Pattern)。

GoF 給裝飾者模式的定義如下:

Decorators provide a flexible alternative to subclassing for extending functionality. Attach additional responsibilities to an object dynamically.

簡單來說,裝飾者模式通過組合的方式,提供了能夠動態地給物件/模組擴充套件新功能的能力。理論上,只要沒有限制,它可以一直把功能疊加下去,具有很高的靈活性。

如果寫過 Java,那麼一定對 I/O Stream 體系不陌生,它是裝飾者模式的經典用法,使用者端程式可以動態地為原始的輸入輸出流新增功能,比如按字串輸入輸出,加入緩衝等,使得整個 I/O Stream 體系具有很高的可延伸性和靈活性。

UML 結構

場景上下文

簡單的分散式應用系統(範例程式碼工程)中,我們設計了 Sidecar 邊車模組,它的用處主要是為了 1)方便擴充套件 network.Socket 的功能,如增加紀錄檔、流控等非業務功能;2)讓這些附加功能對業務程式隱藏起來,也即業務程式只須關心看到 network.Socket 介面即可。

程式碼實現

Sidecar 的這個功能場景,很適合使用裝飾者模式來實現,程式碼如下:

 // demo/network/socket.go
 package network
 ​
 // 關鍵點1: 定義被裝飾的抽象介面
 // Socket 網路通訊Socket介面
 type Socket interface {
     // Listen 在endpoint指向地址上起監聽
     Listen(endpoint Endpoint) error
     // Close 關閉監聽
     Close(endpoint Endpoint)
     // Send 傳送網路報文
     Send(packet *Packet) error
     // Receive 接收網路報文
     Receive(packet *Packet)
     // AddListener 增加網路報文監聽者
     AddListener(listener SocketListener)
 }
 ​
 // 關鍵點2: 提供一個預設的基礎實現
 type socketImpl struct {
     listener SocketListener
 }
 ​
 func DefaultSocket() *socketImpl {
     return &socketImpl{}
 }
 ​
 func (s *socketImpl) Listen(endpoint Endpoint) error {
     return Instance().Listen(endpoint, s)
 }
 ... // socketImpl的其他Socket實現方法
 ​
 ​
 // demo/sidecar/flowctrl_sidecar.go
 package sidecar
 ​
 // 關鍵點3: 定義裝飾器,實現被裝飾的介面
 // FlowCtrlSidecar HTTP接收端流控功能裝飾器,自動攔截Socket接收報文,實現流控功能
 type FlowCtrlSidecar struct {
   // 關鍵點4: 裝飾器持有被裝飾的抽象介面作為成員屬性
     socket network.Socket
     ctx    *flowctrl.Context
 }
 ​
 // 關鍵點5: 對於需要擴充套件功能的方法,新增擴充套件功能
 func (f *FlowCtrlSidecar) Receive(packet *network.Packet) {
     httpReq, ok := packet.Payload().(*http.Request)
     // 如果不是HTTP請求,則不做流控處理
     if !ok {
         f.socket.Receive(packet)
         return
    }
     // 流控後返回429 Too Many Request響應
     if !f.ctx.TryAccept() {
         httpResp := http.ResponseOfId(httpReq.ReqId()).
             AddStatusCode(http.StatusTooManyRequest).
             AddProblemDetails("enter flow ctrl state")
         f.socket.Send(network.NewPacket(packet.Dest(), packet.Src(), httpResp))
         return
    }
     f.socket.Receive(packet)
 }
 ​
 // 關鍵點6: 不需要擴充套件功能的方法,直接呼叫被裝飾介面的原生方法即可
 func (f *FlowCtrlSidecar) Close(endpoint network.Endpoint) {
     f.socket.Close(endpoint)
 }
 ... // FlowCtrlSidecar的其他方法
// 關鍵點7: 定義裝飾器的工廠方法,入參為被裝飾介面
 func NewFlowCtrlSidecar(socket network.Socket) *FlowCtrlSidecar {
     return &FlowCtrlSidecar{
         socket: socket,
         ctx:    flowctrl.NewContext(),
    }
 }
 ​
 // demo/sidecar/all_in_one_sidecar_factory.go
 // 關鍵點8: 使用時,通過裝飾器的工廠方法,把所有裝飾器和被裝飾者串聯起來
 func (a AllInOneFactory) Create() network.Socket {
     return NewAccessLogSidecar(NewFlowCtrlSidecar(network.DefaultSocket()), a.producer)
 }

總結實現裝飾者模式的幾個關鍵點:

  1. 定義需要被裝飾的抽象介面,後續的裝飾器都是基於該介面進行擴充套件。

  2. 為抽象介面提供一個基礎實現。

  3. 定義裝飾器,並實現被裝飾的抽象介面。

  4. 裝飾器持有被裝飾的抽象介面作為成員屬性。「裝飾」的意思是在原有功能的基礎上擴充套件新功能,因此必須持有原有功能的抽象介面。

  5. 在裝飾器中,對於需要擴充套件功能的方法,新增擴充套件功能。

  6. 不需要擴充套件功能的方法,直接呼叫被裝飾介面的原生方法即可

  7. 為裝飾器定義一個工廠方法,入參為被裝飾介面。

  8. 使用時,通過裝飾器的工廠方法,把所有裝飾器和被裝飾者串聯起來。

擴充套件

Go 風格的實現

在 Sidecar 的場景上下文中,被裝飾的 Socket 是一個相對複雜的介面,裝飾器通過實現 Socket 介面來進行功能擴充套件,是典型的物件導向風格。

如果被裝飾者是一個簡單的介面/方法/函數,我們可以用更具 Go 風格的實現方式,考慮前文提到的 http router 場景。如果你使用原生的 net/http 進行 http router 開發,通常會這麼實現:

 func main() {
   // 註冊/hello的router
     http.HandleFunc("/hello", hello)
   // 啟動http伺服器
     http.ListenAndServe("localhost:8080", nil)
 }
 ​
 // 具體的請求處理邏輯,型別是 http.HandlerFunc
 func hello(w http.ResponseWriter, r *http.Request) {
     w.Write([]byte("hello, world"))
 }

其中,我們通過 http.HandleFunc 來註冊具體的 router, hello 是具體的請求處理方法。現在,我們想為該 http 伺服器增加紀錄檔、鑑權等通用功能,那麼可以把 func(w http.ResponseWriter, r *http.Request) 作為被裝飾的抽象介面,通過新增紀錄檔、鑑權等裝飾器完成功能擴充套件。

 // demo/network/http/http_handle_func_decorator.go
// 關鍵點1: 確定被裝飾介面,這裡為原生的http.HandlerFunc
 type HandlerFunc func(ResponseWriter, *Request)
 ​
 // 關鍵點2: 定義裝飾器型別,是一個函數型別,入參和返回值都是 http.HandlerFunc 函數
 type HttpHandlerFuncDecorator func(http.HandlerFunc) http.HandlerFunc
 ​
 // 關鍵點3: 定義裝飾函數,入參為被裝飾的介面和裝飾器可變列表
 func Decorate(h http.HandlerFunc, decorators ...HttpHandlerFuncDecorator) http.HandlerFunc {
     // 關鍵點4: 通過for迴圈遍歷裝飾器,完成對被裝飾介面的裝飾
     for _, decorator := range decorators {
         h = decorator(h)
    }
     return h
 }
 ​
 // 關鍵點5: 實現具體的裝飾器
 func WithBasicAuth(h http.HandlerFunc) http.HandlerFunc {
     return func(w http.ResponseWriter, r *http.Request) {
         cookie, err := r.Cookie("Auth")
         if err != nil || cookie.Value != "Pass" {
             w.WriteHeader(http.StatusForbidden)
             return
        }
         // 關鍵點6: 完成功能擴充套件之後,呼叫被裝飾的方法,才能將所有裝飾器和被裝飾者串起來
         h(w, r)
    }
 }
 ​
 func WithLogger(h http.HandlerFunc) http.HandlerFunc {
     return func(w http.ResponseWriter, r *http.Request) {
         log.Println(r.Form)
         log.Printf("path %s", r.URL.Path)
         h(w, r)
    }
 }
 ​
 func hello(w http.ResponseWriter, r *http.Request) {
     w.Write([]byte("hello, world"))
 }
 ​
 func main() {
     // 關鍵點7: 通過Decorate函數完成對hello的裝飾
     http.HandleFunc("/hello", Decorate(hello, WithLogger, WithBasicAuth))
     // 啟動http伺服器
     http.ListenAndServe("localhost:8080", nil)
 }

上述的裝飾者模式的實現,用到了類似於 Functional Options 的技巧,也是巧妙利用了 Go 的函數語言程式設計的特點,總結下來有如下幾個關鍵點:

  1. 確定被裝飾的介面,上述例子為 http.HandlerFunc

  2. 定義裝飾器型別,是一個函數型別,入參和返回值都是被裝飾介面,上述例子為 func(http.HandlerFunc) http.HandlerFunc

  3. 定義裝飾函數,入參為被裝飾的介面和裝飾器可變列表,上述例子為 Decorate 方法。

  4. 在裝飾方法中,通過for迴圈遍歷裝飾器,完成對被裝飾介面的裝飾。這裡是用來類似 Functional Options 的技巧,一定要注意裝飾器的順序

  5. 實現具體的裝飾器,上述例子為 WithBasicAuth 和 WithLogger 函數。

  6. 在裝飾器中,完成功能擴充套件之後,記得呼叫被裝飾者的介面,這樣才能將所有裝飾器和被裝飾者串起來。

  7. 在使用時,通過裝飾函數完成對被裝飾者的裝飾,上述例子為 Decorate(hello, WithLogger, WithBasicAuth)

Go 標準庫中的裝飾者模式

在 Go 標準庫中,也有一個運用了裝飾者模式的模組,就是 context,其中關鍵的介面如下:

 package context
 ​
 // 被裝飾介面
 type Context interface {
     Deadline() (deadline time.Time, ok bool)
     Done() <-chan struct{}
     Err() error
     Value(key any) any
 }
 ​
 // cancel裝飾器
 type cancelCtx struct {
     Context // 被裝飾介面
     mu       sync.Mutex
     done     atomic.Value
     children map[canceler]struct{}=
     err      error
 }
 // cancel裝飾器的工廠方法
 func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
     // ...  
     c := newCancelCtx(parent)
     propagateCancel(parent, &c)
     return &c, func() { c.cancel(true, Canceled) }
 }
 ​
 // timer裝飾器
 type timerCtx struct {
     cancelCtx // 被裝飾介面
     timer *time.Timer
 ​
     deadline time.Time
 }
 // timer裝飾器的工廠方法
 func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
   // ...
     c := &timerCtx{
         cancelCtx: newCancelCtx(parent),
         deadline:  d,
    }
     // ...
   return c, func() { c.cancel(true, Canceled) }
 }
 // timer裝飾器的工廠方法
 func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
     return WithDeadline(parent, time.Now().Add(timeout))
 }
 ​
 // value裝飾器
 type valueCtx struct {
     Context // 被裝飾介面
     key, val any
 }
 // value裝飾器的工廠方法
 func WithValue(parent Context, key, val any) Context {
     if parent == nil {
         panic("cannot create context from nil parent")
    }
   // ...
     return &valueCtx{parent, key, val}
 }

使用時,可以這樣:

 // 使用時,可以這樣
 func main() {
     ctx := context.Background()
     ctx = context.WithValue(ctx, "key1", "value1")
     ctx, _ = context.WithTimeout(ctx, time.Duration(1))
     ctx = context.WithValue(ctx, "key2", "value2")
 }

不管是 UML 結構,還是使用方法,context 模組都與傳統的裝飾者模式有一定出入,但也不妨礙 context 是裝飾者模式的典型運用。還是那句話,學習設計模式,不能只記住它的結構,而是學習其中的動機和原理

典型使用場景

  • I/O 流,比如為原始的 I/O 流增加緩衝、壓縮等功能。

  • Http Router,比如為基礎的 Http Router 能力增加紀錄檔、鑑權、Cookie等功能。

  • ......

優缺點

優點

  1. 遵循開閉原則,能夠在不修改老程式碼的情況下擴充套件新功能。

  2. 可以用多個裝飾器把多個功能組合起來,理論上可以無限組合。

缺點

  1. 一定要注意裝飾器裝飾的順序,否則容易出現不在預期內的行為。

  2. 當裝飾器越來越多之後,系統也會變得複雜。

與其他模式的關聯

裝飾者模式和代理模式具有很高的相似性,但是兩種所強調的點不一樣。前者強調的是為本體物件新增新的功能;後者強調的是對本體物件的存取控制

裝飾者模式和介面卡模式的區別是,前者只會擴充套件功能而不會修改介面;後者則會修改介面。

 

點選關注,第一時間瞭解華為雲新鮮技術~