乐趣区

关于golang: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。

退出移动版