【Go實現】實踐GoF的23種設計模式:原型模式

語言: CN / TW / HK

上一篇:【Go實現】實踐GoF的23種設計模式:抽象工廠模式

簡單的分散式應用系統(示例程式碼工程):https://github.com/ruanrunxue/Practice-Design-Pattern--Go-Implementation

簡介

原型模式(Prototype Pattern)主要解決物件複製的問題,它的核心就是 Clone() 方法,返回原型物件的複製品。

最簡單直接的物件複製方式是這樣的:重新例項化一個該物件的例項,然後遍歷原始物件的所有成員變數, 並將成員變數值複製到新例項中。但這種方式的缺點也很明顯:

  1. 客戶端程式必須清楚物件的實現細節。暴露細節往往不是件好事,它會導致程式碼耦合過深。

  2. 物件可能存在一些私有屬性,客戶端程式無法訪問它們,也就無法複製。

  3. 很難保證所有的客戶端程式都能完整不漏地把所有成員屬性複製完。

更好的方法是使用原型模式,將複製邏輯委託給物件本身,這樣,上述兩個問題也都解決了。

UML 結構

場景上下文

簡單的分散式應用系統(示例程式碼工程)中,我們設計了一個服務訊息中介(Service Mediator)服務,可以把它看成是一個訊息路由器,負責服務發現和訊息轉發:

訊息轉發也就意味著它必須將上游服務的請求原封不動地轉發給下游服務,這是一個典型的物件複製場景。不過,在我們的實現裡,服務訊息中介會先修改上行請求的 URI,之後再轉發給下游服務。因為上行請求 URI 中攜帶了下游服務的型別資訊,用來做服務發現,在轉發給下游服務時必須剔除。

比如,訂單服務(order service)要發請求給庫存服務(stock service),那麼:

  1. 訂單服務先往服務訊息中介發出 HTTP 請求,其中 URI 為 /stock-service/api/v1/stock

  2. 服務訊息中介收到上行請求後,會從 URI 中提取出下游服務型別 stock-service ,通過服務註冊中心發現庫存服務的 Endpoint。

  3. 隨後,服務訊息中介將修改後的請求轉發給庫存服務,其中 URI 為 /api/v1/stock

程式碼實現

如果按照簡單直接的物件複製方式,實現是這樣的:

 // 服務訊息中介
 type ServiceMediator struct {
     registryEndpoint network.Endpoint
     localIp          string
     server           *http.Server
     sidecarFactory   sidecar.Factory
 }
 ​
 // Forward 轉發請求,請求URL為 /{serviceType}+ServiceUri 的形式,如/serviceA/api/v1/task
 func (s *ServiceMediator) Forward(req *http.Request) *http.Response {
     // 提取上行請求URI中的服務型別
     svcType := s.svcTypeOf(req.Uri())
     // 剔除服務型別之後的請求URI
     svcUri := s.svcUriOf(req.Uri())
     // 根據服務型別做服務發現
     dest, err := s.discovery(svcType)
     if err != nil {
         ... // 異常處理
    }
     // 複製上行請求,將URI更改為剔除服務型別之後的URI
     forwardReq := http.EmptyRequest().
         AddUri(svcUri).
         AddMethod(req.Method()).
         AddHeaders(req.Headers()).
         AddQueryParams(req.QueryParams()).
         AddBody(req.Body())
 ​
     // 轉發請求給下游服務  
     client, err := http.NewClient(s.sidecarFactory.Create(), s.localIp)
     if err != nil {
         ... // 異常處理
    }
     defer client.Close()
     resp, err := client.Send(dest, forwardReq)
     if err != nil {
         ... // 異常處理
    }

     // 複製下行響應,將ReqId更改為上行請求的ReqId,其他保持不變
     return http.NewResponse(req.ReqId()).
         AddHeaders(resp.Headers()).
         AddStatusCode(resp.StatusCode()).
         AddProblemDetails(resp.ProblemDetails()).
         AddBody(resp.Body())
 }
 ...

上述實現中有 2 處進行了物件的複製:上行請求的複製和下行響應的複製。且不說直接進行物件複製具有前文提到的 3 種缺點,就程式碼可讀性上來看也是稍顯冗餘。下面,我們使用原型模式進行優化。

首先,為 http.Requesthttp.Response 定義 Clone 方法:

 // demo/network/http/http_request.go
 package http
 ​
 type Request struct {
     reqId       ReqId
     method      Method
     uri         Uri
     queryParams map[string]string
     headers     map[string]string
     body        interface{}
 }
 ​
 // 關鍵點1: 定義原型複製方法Clone
 func (r *Request) Clone() *Request {
   // reqId重新生成,其他都拷貝原來的值
     reqId := rand.Uint32() % 10000
     return &Request{
         reqId:       ReqId(reqId),
         method:      r.method,
         uri:         r.uri,
         queryParams: r.queryParams,
         headers:     r.headers,
         body:        r.body,
    }
 }
 ...
 ​
 // demo/network/http/http_response.go
 ​
 type Response struct {
     reqId          ReqId
     statusCode     StatusCode
     headers        map[string]string
     body           interface{}
     problemDetails string
 }
 ​
 func (r *Response) Clone() *Response {
     return &Response{
         reqId:          r.reqId,
         statusCode:     r.statusCode,
         headers:        r.headers,
         body:           r.body,
         problemDetails: r.problemDetails,
    }
 }
 ...

最後,在客戶端程式處通過 Clone 方法來完成物件的複製:

 // demo/service/mediator/service_mediator.go
 ​
 type ServiceMediator struct {...}
 ​
 func (s *ServiceMediator) Forward(req *http.Request) *http.Response {
     ...
     dest, err := s.discovery(svcType)
     if err != nil {
         ...
    }
     // 關鍵點2: 通過Clone方法完成物件的複製,然後在此基礎上進行進一步的修改
     forwardReq := req.Clone().AddUri(svcUri)
     ...
     resp, err := client.Send(dest, forwardReq)
     if err != nil {
         ...
    }
     return resp.Clone().AddReqId(req.ReqId())
 }

原型模式的實現相對簡單,可總結為 2 個關鍵點:

  1. 為原型物件定義 Clone 方法,在此方法上完成成員屬性的拷貝。

  2. 在客戶端程式中通過 Clone 來完成物件的複製。

需要注意的是,我們不一定非得遵循標準的原型模式 UML 結構定義一個原型介面,然後讓原型物件實現它,比如:

 // Cloneable 原型複製介面
 type Cloneable interface {
     Clone() Cloneable
 }
 ​
 type Response struct {...}
 // 實現原型複製介面
 func (r *Response) Clone() Cloneable {
     return &Response{
         reqId:          r.reqId,
         statusCode:     r.statusCode,
         headers:        r.headers,
         body:           r.body,
         problemDetails: r.problemDetails,
    }
 }

在當前場景下,這樣並不會給程式帶來任何好處,反而新增一次型別強轉,讓程式變得更復雜了:

 func (s *ServiceMediator) Forward(req *http.Request) *http.Response {
     ...
     resp, err := client.Send(dest, forwardReq)
     if err != nil {
         ...
    }
     // 因為Clone方法返回的是Cloneable介面,因此需要轉型為*http.Response
     return resp.Clone().(*http.Response).AddReqId(req.ReqId())
 }

所以,運用設計模式,最重要的是學得其中精髓,而不是仿照其形式,否則很容易適得其反

擴充套件

原型模式和與建造者模式的結合

原型模式和建造者模式相結合,也是常見的場景。還是以 http.Request 為例:

首先,我們先為它新增一個 requestBuilder 物件來完成物件的構造:

 // demo/network/http/http_request_builder.go
 type requestBuilder struct {
     req *Request
 }
 // 普通Builder工廠方法,新建立一個Request物件
 func NewRequestBuilder() *requestBuilder {
     return &requestBuilder{req: EmptyRequest()}
 }
 ​
 func (r *requestBuilder) AddMethod(method Method) *requestBuilder {
     r.req.method = method
     return r
 }
 ​
 func (r *requestBuilder) AddUri(uri Uri) *requestBuilder {
     r.req.uri = uri
     return r
 }
 ​
 ... // 一系列 Addxxx 方法
 ​
 func (r *requestBuilder) Builder() *Request {
     return r.req
 }

下面,我們為 requestBuilder 新增一個 NewRequestBuilderCopyFrom 工廠方法來達到原型複製的效果:

 // demo/network/http/http_request_builder.go
 ​
 // 實現原型模式的Builder工廠方法,複製已有的Request物件
 func NewRequestBuilderCopyFrom(req *Request) *requestBuilder {
     reqId := rand.Uint32() % 10000
     replica := &Request{
         reqId:       ReqId(reqId),
         method:      req.method,
         uri:         req.uri,
         queryParams: req.queryParams,
         headers:     req.headers,
         body:        req.body,
    }
   // 將複製後的物件賦值給requestBuilder
     return &requestBuilder{req: replica}
 }

用法如下:

 func (s *ServiceMediator) Forward(req *http.Request) *http.Response {
     ...
     dest, err := s.discovery(svcType)
     if err != nil {
         ...
    }
     // 原型模式和建造者模式相結合的實現
     forwardReq := http.NewRequestBuilderCopyFrom(req).Builder().AddUri(svcUri)
     ...
     resp, err := client.Send(dest, forwardReq)
     if err != nil {
         ...
    }
     // 普通原型模式的實現
     return resp.Clone().AddReqId(req.ReqId())
 }

淺拷貝和深拷貝

如果原型物件的成員屬性包含了指標型別,那麼就會存在淺拷貝和深拷貝兩種複製方式,比如對於原型物件 ServiceProfile,其中的 Region 屬性為指標型別:

// demo/service/registry/model/service_profile.go
package model

// ServiceProfile 服務檔案,其中服務ID唯一標識一個服務例項,一種服務型別可以有多個服務例項
type ServiceProfile struct {
    Id       string           // 服務ID
    Type     ServiceType      // 服務型別
    Status   ServiceStatus    // 服務狀態
    Endpoint network.Endpoint // 服務Endpoint
    Region   *Region          // 服務所屬region
    Priority int              // 服務優先順序,範圍0~100,值越低,優先順序越高
    Load     int              // 服務負載,負載越高表示服務處理的業務壓力越大
}

淺拷貝的做法是直接複製指標:

// 淺拷貝實現
func (s *ServiceProfile) Clone() Cloneable {
    return &ServiceProfile{
        Id:       s.Id,
        Type:     s.Type,
        Status:   s.Status,
        Endpoint: s.Endpoint,
        Region:   s.Region, // 指標複製,淺拷貝
        Priority: s.Priority,
        Load:     s.Load,
    }
}

深拷貝的做法則是建立新的 Region 物件:

// 深拷貝實現
func (s *ServiceProfile) Clone() Cloneable {
    return &ServiceProfile{
        Id:       s.Id,
        Type:     s.Type,
        Status:   s.Status,
        Endpoint: s.Endpoint,
        Region: &Region{ // 新建立一個Region物件,深拷貝
            Id:      s.Region.Id,
            Name:    s.Region.Name,
            Country: s.Region.Country,
        },
        Priority: s.Priority,
        Load:     s.Load,
    }
}

具體使用哪種方式,因不同業務場景而異。淺拷貝直接複製指標,在效能上會好點;但某些場景下,引用同一個物件例項可能會導致業務異常,這時候就必須使用深拷貝了。

典型使用場景

  1. 不管是複雜還是簡單的物件,只要存在物件複製的場景,都適合使用原型模式。

優缺點

優點

  1. 對客戶端隱藏實現細節,有利於避免程式碼耦合。

  2. 讓客戶端程式碼更簡潔,有利於提升可讀性。

  3. 可方便地複製複雜物件,有利於杜絕客戶端複製物件時的低階錯誤,比如漏複製屬性。

缺點

  1. 某些業務場景需要警惕淺拷貝問題。

與其他模式的關聯

如前文提到的,原型模式和建造者模式相結合也是一種常見的應用場景。

參考

[1] 【Go實現】實踐GoF的23種設計模式:SOLID原則, 元閏子

[2] 【Go實現】實踐GoF的23種設計模式:建造者模式, 元閏子

[3] Design Patterns, Chapter 3. Creational Patterns, GoF

更多文章請關注微信公眾號:元閏子的邀請