GoF 设计模式在 Go 中仍然有意义

2025-06-07

GoF 设计模式在 Go 中仍然有意义

自 1994 年出版以来,《设计模式》一直是软件构建领域的开创性著作。这本书创建了一个新的共享词汇表,并命名了我们在不同代码库中随处可见的那些重复的解决方案。因此,之后出现了多本关于设计模式的书籍,记录了更多示例。您可以快速地向任何读过这本书的人解释您的解决方案是一个适配器,而无需详细说明适配器是什么。

正如预期的那样,本书的内容并非没有批评,Peter Novig分析了其中许多模式是不必要的,或者可以用动态语言中更简单的结构替代。本书主要使用 C++(以及一些 Smalltalk)编写,重点涵盖了 C++ 中可用的功能,因此动态语言会改变或消除对其中一些模式的需求。

几周前,一条推特引起了我的注意,说大家不应该再读这本书了,因为很多模式已经过时了,或者在大多数主流编程语言中已经没有多大意义了。我仍然对大学时读这本书的情景记忆犹新(2003年,好久以前的事了),当时我不禁想,这话是不是真的?语言的发展是否已经超越了这本书?还是我们仍然在使用书中定义的模式和词汇?

那么,从 Golang 的角度来看,书中哪些模式至今仍然适用呢?让我们来看看!

建造者

构建器依然活跃。它们或许有着一些花哨的名字,比如函数式选项流畅接口,但它们的目标始终如一:简化复杂对象的创建,避免最终在一个函数调用中接收数十个参数。

我们现在来看看如何设置一个生成 *http.Request 对象的构建器:

package gof_go

import (
   "context"
   "io"
   "net/http"
)

// NewBuilder creates a builder given a URL, we're going to use this so we don't leak
// the actual builder and have to worry about null/empty values on the builder itself.
// You could just use a struct directly here but it makes it a bit harder to validate
// defaults so we'll go for the simpler interface based solution.
func NewBuilder(url string) HTTPBuilder {
   return &builder{
      headers: map[string][]string{},
      url:     url,
      body:    nil,
      method:  http.MethodGet,
      ctx:     context.Background(),
      close:   false,
   }
}

// HTTPBuilder defines the fields we want to set on this builder, you could add/remove
// fields here.
type HTTPBuilder interface {
   AddHeader(name, value string) HTTPBuilder
   Body(r io.Reader) HTTPBuilder
   Method(method string) HTTPBuilder
   Close(close bool) HTTPBuilder
   Build() (*http.Request, error)
}

type builder struct {
   headers map[string][]string
   url     string
   method  string
   body    io.Reader
   close   bool
   ctx     context.Context
}

func (b *builder) Close(close bool) HTTPBuilder {
   b.close = close

   return b
}

func (b *builder) Method(method string) HTTPBuilder {
   b.method = method

   return b
}

func (b *builder) AddHeader(name, value string) HTTPBuilder {
   values, found := b.headers[name]

   if !found {
      values = make([]string, 0, 10)
   }

   b.headers[name] = append(values, value)

   return b
}

func (b *builder) Body(r io.Reader) HTTPBuilder {
   b.body = r

   return b
}

func (b *builder) Build() (*http.Request, error) {
   r, err := http.NewRequestWithContext(b.ctx, b.method, b.url, b.body)
   if err != nil {
      return nil, err
   }

   for key, values := range b.headers {
      for _, value := range values {
         r.Header.Add(key, value)
      }
   }

   r.Close = b.close

   return r, nil
}
Enter fullscreen mode Exit fullscreen mode

因此,我们有一个构建器,它设置了一些合理的默认值(主体为空,方法是 GET,有一个默认上下文)。它的使用方式如下:

func TestBuilder_Build(t *testing.T) {
   request, err := NewBuilder("https://example.com/").
      AddHeader("User-Agent", "Golang patterns").
      Build()

   assert.NoError(t, err)

   assert.Equal(t, "Golang patterns", request.Header.Get("User-Agent"))
   assert.Equal(t, http.MethodGet, request.Method)
   assert.Equal(t, "https://example.com/", request.URL.String())
}
Enter fullscreen mode Exit fullscreen mode

我们可以使用多个标头来实现复杂的功能,更改方法,设置自定义上下文,并且它仍然看起来简洁易读。这就是使用构建器实例化复杂对象的主要优势。您可以根据需要进行深入研究,但仍然可以快速创建具有合理默认值的对象。虽然此构建器遵循流畅的 API风格,但它并非生成构建器的唯一方法,只要您找到一种方法将参数化与复杂对象的创建(仍然是构建器)分开(例如使用上面链接的 opts 函数)。这里的主要目标是使正确构建复杂对象变得更容易。

抽象工厂/工厂方法

由于该语言缺乏继承而侧重于组合,因此表示工厂的主要方式是使用一个生成对象作为参数的函数,而不是抽象类或方法。

这样做的一个常见原因是为了简化单元测试。假设我正在测试一个打开与其他服务的套接字连接的类型,而你可以直接使用net.Dialer,这会使测试更加困难。如果我给该对象提供一个生成对象的方法net.Conn,我可以通过更改工厂函数来轻松替换实现。

它看起来是这样的:

package gof_go

import (
   "bytes"
   "context"
   "fmt"
   "github.com/stretchr/testify/assert"
   "github.com/stretchr/testify/require"
   "net"
   "testing"
   "time"
)

type mockAddress struct {
   network string
   address string
}

func (m *mockAddress) Network() string {
   return m.network
}

func (m *mockAddress) String() string {
   return m.address
}

// mockConnection represents a connection used for testing only
type mockConnection struct {
   closed  bool
   buffer  *bytes.Buffer
   address *mockAddress
}

func (m *mockConnection) Read(b []byte) (n int, err error) {
   return m.buffer.Read(b)
}

func (m *mockConnection) Write(b []byte) (n int, err error) {
   return m.buffer.Write(b)
}

func (m *mockConnection) LocalAddr() net.Addr {
   return m.address
}

func (m *mockConnection) RemoteAddr() net.Addr {
   return m.address
}

func (m *mockConnection) SetDeadline(t time.Time) error {
   return nil
}

func (m *mockConnection) SetReadDeadline(t time.Time) error {
   return nil
}

func (m *mockConnection) SetWriteDeadline(t time.Time) error {
   return nil
}

func (m *mockConnection) Close() error {
   m.closed = true
   return nil
}

type socketClient struct {
   address string
   factory func(ctx context.Context, network, address string) (net.Conn, error)
}

func (s *socketClient) ping(ctx context.Context) error {
   c, err := s.factory(ctx, "tcp4", s.address)
   if err != nil {
      return err
   }

   defer func() {
      if err := c.Close(); err != nil {
         fmt.Printf("failed to close socket: %v\n", err)
      }
   }()

   if _, err := c.Write([]byte("PING")); err != nil {
      return err
   }

   return nil
}

func TestSocketClient(t *testing.T) {
   connection := &mockConnection{
      buffer: bytes.NewBuffer(make([]byte, 0, 1024)),
   }

   c := &socketClient{
      address: "example.com:40",
      factory: func(ctx context.Context, network, address string) (net.Conn, error) {
         connection.address = &mockAddress{
            address: address,
            network: network,
         }

         return connection, nil
      },
   }

   require.NoError(t, c.ping(context.Background()))
   assert.True(t, connection.closed)
   assert.Equal(t, "PING", connection.buffer.String())
}
Enter fullscreen mode Exit fullscreen mode

我们需要对net.Addr和进行模拟,net.Conn因为我们想在内存中返回它们的结构体。我们的socketClient类型有一个factory类型为 的属性,它的函数签名与net.Dialer#DialContextfunc(ctx context.Context, network, address string) (net.Conn, error)完全相同,所以这里的具体实现就是该函数。net.Dialer

您不必将工厂视为您使用或扩展的类,而是可以将其视为调用来生成对象的函数。无需继承、抽象类或接口,只需一个函数签名即可。

适配器

它仍然是一个广泛使用的模式,database/sql 包就是一个绝佳的例子。每个数据库驱动程序都实现了driver.Driver 接口并将其注册database/sql包中。因此,无论您使用哪种数据库,它们看起来都一样,因为您database/sql大多数时候只会与包中的对象进行交互。

我们也在go-cloud等库上看到了同样的模式,其中云提供商的具体细节隐藏在库提供的标准接口后面。

装饰器

装饰器会在不破坏现有对象接口的情况下,为您可能无法控制的对象添加功能。您可以通过创建一个对象来构建装饰器,该对象包装另一个对象,但具有相同的方法,并将这些方法的调用转发给被装饰的对象,并在其之上添加一些功能。

典型的用例包括向 IO 类添加缓冲区(稍后我们将看到这个示例),向你无法控制的类型(例如外部库)添加指标或跟踪等等。主要目的是向调用代码隐藏某些内容已发生更改。

缺乏继承使得 Go 中的装饰器更加棘手,正如您在解析 DNS 条目时收集指标的古老问题中所见。由于调用代码的代码net.Resolver需要精确的类型,并且没有继承,我们无法像在允许继承的语言中那样包装解析器。在 Go 中,只有在处理接口或函数签名时才能使用装饰器。如果必须包装结构体,唯一的方法是将依赖于该结构体的代码更改为具有相同方法的接口。

现在我们来看看一个向对象添加缓冲区的装饰器io.Reader(这不是最有效和最优化的版本,它只是一个例子):

func NewBufferedReader(wrapped io.Reader, length int) io.Reader {
   if length <= 0 {
      length = 1024
   }

   return &bufferedReader{
      currentIndex:  0,
      lastIndex:     0,
      buffer:        make([]byte, length, length),
      wrappedReader: wrapped,
      err:           nil,
   }
}

type bufferedReader struct {
   currentIndex  int
   lastIndex     int
   buffer        []byte
   wrappedReader io.Reader
   err           error
}

func (b *bufferedReader) Read(p []byte) (n int, err error) {
   if len(p) == 0 {
      return 0, errors.New("an empty slice was provided to Read")
   }

   availableBytes := b.lastIndex - b.currentIndex

   if availableBytes == 0 {
      if b.err != nil {
         return 0, b.err
      }

      if read, err := b.wrappedReader.Read(b.buffer); err == nil || err == io.EOF {
         b.err = err
         b.currentIndex = 0
         b.lastIndex = read
         availableBytes = read

         if availableBytes == 0 {
            return 0, b.err
         }
      } else {
         b.err = err
         return 0, err
      }
   }

   expectedBytes := len(p)

   bytesToRead := availableBytes
   if availableBytes > expectedBytes {
      bytesToRead = expectedBytes
   }

   copy(p, b.buffer[b.currentIndex:b.currentIndex+bytesToRead])
   b.currentIndex += bytesToRead

   return bytesToRead, b.err
}
Enter fullscreen mode Exit fullscreen mode

我们包装所有现有的对象io.Reader,并在其上添加一个缓冲区。对于之前使用读取器的代码,没有任何变化,它仍然是一个io.Reader对象,但我们在不违反约定的情况下引入了新功能。这也是 IO 包的工作原理,装饰器在读取器和写入器对象之上添加功能。这种模式在 Go 中仍然存在,尽管不像在其他语言中那样广泛使用。

正面

还在!Facades 将复杂或繁琐的 API 隐藏在一个小型界面之后,您无需了解幕后所有细节即可与之交互。正如我们之前提到的,go-cloud是适配器和 Facade 的绝佳示例,因为它将与云提供商交互的复杂细节隐藏在一个直观的界面之后。

代理人

代理是指向其他对象的接口,创建或直接交互这些对象可能开销很大。使用代理的一个典型案例是在 ORM 工具中加载关联。想象一下,一个Userhas Posts。您不必每次加载用户时都加载用户的帖子,因此 ORM 工具会将 隐藏在Posts代理对象后面。它们会等到您尝试执行Posts需要查看对象的操作时,才会从它们所在的数据源加载它们,从而实例化Posts集合。

如果您从未尝试访问它们,则无需承担加载它们的开销,这就是使用代理的优势。您不必始终直接访问该对象。您可以应用这些延迟加载技术来编写更高效的代码并减少资源消耗。

与上面的装饰器示例一样,由于缺乏继承,代理只能用于接口或函数类型。Go 中的反射也不提供动态代理(在运行时从头创建代理对象),就像 Java 和 C# 等语言中那样;也不像method_missingRuby 那样提供动态代理功能,必须在编译时生成代理代码。

责任链和指挥

两种图案合在一起?没错!

我见过的几乎所有责任链的例子都是用命令来实现的。我甚至不确定如果没有命令对象(或函数),实现责任链是否合理。如果你用 Go 语言做过 HTTP 服务器开发,你肯定见过它们的组合:

package http

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}
Enter fullscreen mode Exit fullscreen mode

这是我们处理 HTTP 请求的命令。你如何实现你的功能并不重要,只要它符合这个接口,它就能工作。我们也将相同的接口定义为函数签名:

package http

type HandlerFunc func(ResponseWriter, *Request)
Enter fullscreen mode Exit fullscreen mode

两者应该可以互换。唯一重要的是它们具有相同的输入和输出。两者都接受 anhttp.ResponseWriter和 a http.Request,并且没有返回类型。

很好,我们有了命令。那么责任链从何而来?中间件!

这是中间件接口:

package gof_go

import (
   "fmt"
   "net/http"
)

type HTTPMiddleware func(w http.ResponseWriter, r *http.Request, next http.Handler)

func LoggingMiddleware(w http.ResponseWriter, r *http.Request, next http.Handler) {
   fmt.Printf("REQUEST %v\n", r.URL.String())
   next.ServeHTTP(w, r)
}

func OkHandler(w http.ResponseWriter, r *http.Request) {
   w.WriteHeader(http.StatusOK)
   if _, err := w.Write([]byte("OK")); err != nil {
      fmt.Printf("failed to write to body: %v", err)
   }
}

func MidddlewareToHandler(middleware HTTPMiddleware, next http.Handler) http.Handler {
   return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
      middleware(writer, request, next)
   })
}

Enter fullscreen mode Exit fullscreen mode

与 几乎相同http.Handler,唯一的区别是它还接受一个next参数,如果当前处理程序认为应该调用,该参数将被调用。让我们看看它的用法:

package gof_go

import (
   "github.com/stretchr/testify/assert"
   "github.com/stretchr/testify/require"
   "io"
   "net/http"
   "net/http/httptest"
   "testing"
)

func TestOkHandler(t *testing.T) {
   ts := httptest.NewServer(MidddlewareToHandler(LoggingMiddleware, http.HandlerFunc(OkHandler)))
   defer ts.Close()

   res, err := http.Get(ts.URL)
   require.NoError(t, err)

   ok, err := io.ReadAll(res.Body)
   require.NoError(t, err)
   require.NoError(t, res.Body.Close())

   assert.Equal(t, "OK", string(ok))
}
Enter fullscreen mode Exit fullscreen mode

这里我们使用MiddlewareToHandler方法将日志中间件添加到,OkHandler,但您可以在此处使用其他中间件添加任意数量的功能。授权、功能切换、速率限制等等,所有这些都无需更改处理请求的命令,并且顺序不受限制。由于实现者HTTPMiddleware可以完全控制请求是否继续,您可以添加功能(几乎像装饰器一样)并决定请求流是否继续。

虽然此示例仅添加了一个中间件,但实际堆栈可以进一步扩展,并通过多次调用 来添加所需的中间件MiddlewareToHandler。您甚至可以更进一步,使用构建器对象来创建实际的责任链,就像gorilla mux 项目所做的那样。

迭代器

虽然你可以在 Go 中为特定用例构建迭代器,但由于 Go 不像其他语言那样提供集合库,因此迭代器的应用并不普遍。随着 1.18 版本泛型的推出,我们可能会看到集合库的出现,并且可能会出现一个标准的迭代器,或者为语言本身不包含的对象(例如数组、切片和映射)提供一种更简单的方法,使用for ... range循环来迭代它们。

该语言中最著名的迭代器可能是sql.Rows,这是每个人在处理 Go 中的 SQL 时都会看到的对象。

观察者

鉴于通道的存在,观察者在 Go 语言中几乎是一等公民。你为支持观察者而编写的大部分代码,尤其是单生产者单消费者模式,已经融入了 Go 语言中通道的使用方式。当你需要发布一条消息并让多个消费者看到同一条消息时,情况会变得稍微复杂一些,因为你需要多个通道来实现扇出行为。

战略

策略是指实现多种算法但公开相同接口的类。你可以有多种排序方法(例如归并排序、冒泡排序、快速排序),它们都实现在不同的类中,但使用相同的接口(例如,一个接受数组参数的方法)。

虽然你可以在 Go 中使用单方法接口来构建策略,但这不太符合 Go 的惯用做法。最好定义一个函数签名,并将每个算法都实现为一个实现相同签名的函数。

package gof_go

import "testing"

type Sorter func(a []int)

func MergeSort(a []int) {
   // implementation
}

func QuickSort(a []int) {
   // implementation
}

func TestSorter(t *testing.T) {
   var sorter Sorter
   sorter = MergeSort
   sorter([]int{10, 7, 5, 2, 4})
}
Enter fullscreen mode Exit fullscreen mode

使用函数,我们就不需要为每个算法都设置类,因为它只是类中的一种方法。

模板方法

由于缺乏继承,并且函数可以作为一等公民对象,因此变成了模板函数。一个可以在子类中实现的抽象方法,变成了一个函数,可以作为参数,就像我们之前的工厂一样。

对对象进行排序是 Go 中模板方法的一个非常典型的例子:

package gof_go

import (
   "github.com/stretchr/testify/assert"
   "sort"
   "testing"
)

type Racer struct {
   Position int
   Name     string
}

func TestSortRacers(t *testing.T) {
   racers := []*Racer{
      {
         Position: 10,
         Name:     "Alonso",
      },
      {
         Position: 2,
         Name:     "Verstappen",
      },
      {
         Position: 1,
         Name:     "Hamilton",
      },
      {
         Position: 12,
         Name:     "Vettel",
      },
   }

   sort.SliceStable(racers, func(i, j int) bool {
      return racers[i].Position < racers[j].Position
   })

   assert.Equal(t, []*Racer{
      {
         Position: 1,
         Name:     "Hamilton",
      },
      {
         Position: 2,
         Name:     "Verstappen",
      },
      {
         Position: 10,
         Name:     "Alonso",
      },
      {
         Position: 12,
         Name:     "Vettel",
      },
   }, racers)
}
Enter fullscreen mode Exit fullscreen mode

与其他实现一样,我们不知道使用什么算法对对象进行排序。我们所做的只是提供一种用函数比较它们的方法。它消除了使用单独类的需要,就像在那些没有将函数作为一等公民的语言中实现模板方法那样。

已死之物永不消亡

正如我们所见,许多模式仍然存在于标准库、共享第三方库和框架中。我未提及的模式有时对于它们所要解决的问题而言过于具体(例如解释器和访问者),因此很难在实际中找到它们的用法,但这并不意味着它们没有必要。

虽然如果您没有 C++ 或 Smalltalk 经验,现在阅读这本书可能会比较困难,但其他书籍以较新的格式涵盖了相同的模式,例如《Head First 设计模式》

因此,尽管语言已经发展,但它们并没有消除对这些模式的需求,以及正确使用它们能为你的代码库带来的优势。把它们放在你的工具箱里,这样你就知道何时使用以及如何在代码中识别它们,这将提升你的编码词汇量和你将要构建的解决方案。

文章来源:https://dev.to/mauriciolinhares/gof-design-patterns-that-still-make-sense-in-go-27k5
PREV
使用 Angular 进行客户端缓存
NEXT
Matt 最喜欢的 Visual Studio Code 扩展