译文 Go 常见错误集锦之函数式选项模式

yudotyang · 2021年09月19日 · 107 次阅读

本文是对《100 Go Mistackes:How to Avoid Them》一书的翻译。因翻译水平有限,难免存在翻译准确性问题,敬请谅解。关注 公众号 “Go 学堂”,获取更多系列文章

本节将通过一个常见的用例来展示如何使 API 方便且友好地接受选项配置。我们将深入研究不同的选项,以达到最后展示一个在 Go 中流行的解决方案:函数式选项模式

首先,从概念上看下什么是函数式选项模式。 这个概念由两部分组成:函数式和选项。

所谓函数式,是从函数式编程中借鉴过来的概念,即函数和其他基础类型一样,可以将函数作为参数、返回值以及赋值给其他变量

选项就是配置中的参数字段。所以,函数式选项就是通过一系列的具有相同签名的函数(或匿名函数或带某个函数字段的结构体)来对选项中的字段执行相关的逻辑操作

下面我们通过一个例子来看看函数式选项模式的演化过程。

假设我们要设计一个库,并暴露一个函数接口来创建一个 HTTP 服务器。该函数将接受不同的输入:一个地址和一个端口。该函数的签名如下:

func NewServer(addr string, port int) (*http.Server, error) {
    // ...
}

调用者开始使用这个函数,并且所有人都很开心。然而,在某个时间点,调用者开始抱怨该函数有一些限制并缺少一些其他参数(例如,超时时间,连接上下文)。但是,这时我们开始注意到如果我们增加一个新的参数,它将会破坏兼容性,会强制使用者修改他们已经调用过的 NewServer 函数。

同时,我们也希望扩展与端口管理相关的逻辑,像下图展示的这样:

  • 如果端口号没有设置,则使用默认值
  • 如果端口号是负数,则返回错误
  • 如果端口号是 0,则使用随机端口
  • 否则,使用用户提供的端口号

我们该如何以友好的 API 的方式实现这个函数呢?让我们来看看所有的不同实现。

实现一:传一个配置结构体的实现(Config struct)

第一种方法是使用一个结构体(config struct)来处理不同的配置选项。我们可以把参数分成两类:基础配置和可选配置。基础配置参数可以作为函数参数,可选参数在 config 结构体中处理:

type Config struct {
    Port        int
}
func NewServer(addr string, cfg Config) {
}

这种解决方案修复了兼容性的问题。如果我们增加了新的配置选项,它也不会中断调用者的调用。然而,这种方法没有解决端口管理相关的需求。事实上,我们应该知道如果结构体的字段没有提供,那默认将会被初始化成零值:

  • int 类型的零值是 0
  • 浮点类型的零值是 0.0
  • 字符串的零值是 “”
  • slice、map、channels、指针、接口和函数的零值是 nil

因此,在下面的例子中两个结构体是相等的:

c1 := httplib.Config{
    Port:0, 
}

c2 := httplib.Config{
    
}

① Port 被初始化成 0 ② Port 字段缺失,所以初始值也是 0

在我们的例子中,我们需要找到一种方法来正确区分端口号是被设置成了 0 还是没有提供 port 字段。一种可能的方法是将结构体的字段都定义成指针类型:

type Config struct {
    Port *int
}

这种方式也会工作,但有两个缺点。

  • 首先,调用者提供整型指针并不方便。调用者必须要创建一个变量并且要以指针的形式传递:
port := 0
config := httplib.Config{
    Port: &port, 
}

① 提供一个整型指针

传递指针的话,整体 API 变得不那么方便使用。

  • 第二个缺点是使用我们库的调用者,如果是带默认配置的话,调用者必须要传递一个空结构体:
httplib.NewServer("localhost", httplib.Config{})

这段代码的可读性也不是很好。阅读者不得不思考这个 struct 结构体到底是什么意思。

实现二: 构造器模式

构建器模式为各种对象创建问题提供了灵活的解决方案。我们看看这种模式是如何帮助我们设计一个友好的 API,以满足我们所有的需求,包括端口号的管理。

type Config struct { 
    Port int
}
type ConfigBuilder struct { 
    Port *int
}
func (b *ConfigBuilder) Port(port int) { 
    b.Port = &port
}

func (b *ConfigBuilder) Build() (Config, error) { 
    cfg := Config{}
    if b.Port == nil { 
        cfg.Port = defaultHTTPPort
    } else {
        if *b.Port == 0 {
            cfg.Port = randomPort()
        } else if *b.Port < 0 {
            return Config{}, errors.New("port should be positive")
        } else {
            cfg.Port = *b.Port
        }
        }
    return cfg, nil
}

func NewServer(addr string, config Config) (*http.Server, error) {
    // ...
}

① 定义 Config 结构体 ② 定义 ConfigBuilder 结构体,包含一个可选的 port 字段 ③ 设置 port 的 public 方法 ④ Build 方法创建一个 config 结构体 ⑤ 管理 Port 的主要逻辑

下面是调用者如何使用我们基于构建器的 API(我们假设已经把我们的代码放在了 httplib 包中):

builder := httplib.ConfigBuilder{} 
builder.Port(8080) 
cfg, err := builder.Build() 
if err != nil {
    return err 
}
server, err := httplib.NewServer("localhost", cfg) 
if err != nil {
    return err 
}

① 创建一个 ConfigBuilder 结构体 ② 设置 port ③ 构建 config 结构体 ④ 给函数传递 config 结构体

这种方法使端口管理更方便。由于该 Port 方法接受的是一个整型参数,所有没有必要传递一个整型指针。然而,如果调用者只需要默认的配置情况下,依然需要传递一个空的 config 结构体

注意:该方法有不同的变体。例如,一种变体是 NewServer 接收一个 ConfigBuilder 结构体,然后在函数内部构建 config。然而,不管怎样,都必须要传递一个 config 对象的问题。

在某些场景下,另外一个缺点是和错误管理相关的。在 builder 的 Port 方法中,如果输入的参数是非法的,就会抛出异常。但在 Go 中,我们不能让构建方法返回错误。因为构造器模式一般被认为是用组合的模式进行调用的(例如:builder.Port(8080).Timeout(time.Second).Certificate(cert))。我们不想让调用者每次都检查错误。因此,在 Build 方法中我们把校验逻辑推迟了。在一些场景中,这对调用者来说可能不具备表现力。

现在我们来看另一个模式,叫做函数选项模式,它依赖于变量参数。

实现 3: 函数选项模式

我们要深入研究的最后一种方法是函数选项模式。虽然在不同的实现中有一些小的变化,但其主要思想下面介绍的相同,如下图:

每一个选项(例如 WithPort)都返回一个 Option 接口的具体实现,该实现将会更新 options 结构体中的某个字段。该结构体是私有的:

type options struct {
    port *int
}

同时,我们需要创建一个公有的 Option 接口和一个私有的 applyOptions 方法:

type Option interface {
    apply(*opitons) error 
}

type applyOptions struct {
    f func(*options) error 
}
func (ao *applyOptions) apply(opts *options) error {
    return ao.f(opts) 
}

① Option 接口由一个 apply 方法构成 ② f 字段是一个函数引用,该函数包含了如何更新 config 结构体的逻辑 ③ applyOptions 结构体实现了 apply 方法,该方法中调用了内部的 f 函数

整个逻辑的实现在内部的 f 函数字段上。该 f 字段是被调用者使用公开的方法创建的。例如,WithPort 方法:

func WithPort(port int) Option {
    return &applyOptions{
            f: func(options *options) error { 
            if port < 0 {
            return errors.New("port should be positive")
            }
            options.port = &port
                return nil 
            },
    }
}

① 初始化 f 字段,该 f 字段提供了一段校验输入并且更新 config 结构体的逻辑

每一个配置字段都需要创建一个包含简单逻辑的公开方法(为了方便一般以 With 前缀开头):如需要,则要验证输入参数的合法性以及说明如何更新 config 结构体。

现在,让我们深入研究供给侧的最后一部分,如何使用这些可选项:

func NewServer(addr string, opts ...Option) (*http.Server, error) { 
    var options options 
    for _, opt := range opts { 
        err := opt.apply(&options) 
        if err != nil {
            return nil, err
        }
        }

    // 程序这行到这里,options结构体已经构建完毕并包含了相关的配置
    // 因此,我们就可以实现和端口管理相关的逻辑了
        var port int
        if options.port == nil {
                port = defaultHTTPPort
        } else {
        if *options.port == 0 {
            port = randomPort()
        } else {
            port = *options.port
                }
        }
        // ... 
}

① 接受一个可变的 Options 参数 ② 创建一个空 options 结构体 ③ 循环迭代所有的输入 options ④ Apply 每一个 option,该函数将会修改普通的 options 结构体

因为 NewServer 接受一个可变的 Option 参数,调用者可以通过传递 0 个或多个 options 来使用该 API:

// No options
server, err := httplib.NewServer("localhost")
// Multiple options
server, err := httplib.NewServer("localhost", httplib.WithPort(8080),
httplib.WithTimeout(time.Second))

多亏有可变参数,如果调用者需要默认配置,它不需要提供一个空结构体,就像我们在前面看到那样调用:

server, err := httplib.NewServer("localhost")

这就是函数式选项模式,即通过一些列的具有相同签名的匿名函数来对配置选项进行更新。它给 API 接口提供了一种方便且友好的方式来处理可选参数。虽然构建模式也是一个有效的方式,但还是存在一些缺点,所以使用函数选项模式才是最理想的处理方式。这种模式在很多库中都被应用,例如 gRPC。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册