为什么需要函数式选项模式?
最近看go-micro/options.go源码的时候,发现了一段关于服务注册的代码如下:
type Options struct {
Broker broker.Broker
Cmd cmd.Cmd
Client client.Client
Server server.Server
Registry registry.Registry
Transport transport.Transport
// Before and After funcs
BeforeStart []func() error
BeforeStop []func() error
AfterStart []func() error
AfterStop []func() error
// Other options for implementations of the interface
// can be stored in a context
Context context.Context
}
func newOptions(opts ...Option) Options {
opt := Options{
Broker: broker.DefaultBroker,
Cmd: cmd.DefaultCmd,
Client: client.DefaultClient,
Server: server.DefaultServer,
Registry: registry.DefaultRegistry,
Transport: transport.DefaultTransport,
Context: context.Background(),
}
for _, o := range opts {
o(&opt)
}
return opt
}
当时呢,也不是很明白newOptions
这个构造函数为什么要这么写,后来在一个交流群里看到有人对类似的代码也有同样的疑问,于是查阅了资料之后才知道了这是一种Go语言中常用的设计模式–函数式选项模式。
我们把这个问题简单提炼一下。
假设我们现在需要定义一个包含多个配置项的结构体,具体定义如下:
// DoSomethingOption 定义了一些配置项
type DoSomethingOption struct {
a string
b int
c bool
// ...
}
这个配置结构体中的字段可能有几个也可能有十几个,并且可能随着业务的发展不断增加新的字段。
现在我们需要为其编写一个构造函数,根据经验我们可能会写出类似下面那样的构造函数:
// NewDoSomethingOption 创建并返回一个 DoSomethingOption
func NewDoSomethingOption(a string, b int, c bool) *DoSomethingOption {
return &DoSomethingOption{
a: a,
b: b,
c: c,
}
}
一切看起来都完美无缺。
可是仔细想一下,这样一个构造函数在实际生产场景下不可避免会遇到一些问题。
我们现在来思考以下两个问题:
- 如果 DoSomethingOption 有十几个字段,那我们的构造函数需要定义十几个参数吗?如何为某些配置项指定默认值?
- DoSomethingOption 随着业务发展不断新增字段后,我们的构造函数是否也需要同步变更?变更了构造函数是否又会影响既有代码?
函数选项模式(functional options pattern)
函数选项模式(Functional Options Pattern)也称为选项模式(Options Pattern),是一种创造性的设计模式,允许你使用接受零个或多个函数作为参数的可变构造函数构建复杂结构。我们将这些函数称为选项,由此得名函数选项模式。
可选参数
由于Go语言中的函数不支持默认参数,所以我们想到可以使用可变长参数来实现。而这个可变长参数的具体类型则需要好好设计一下。它必须满足以下条件:
- 不同的函数参数拥有相同的类型
- 指定函数参数能为特定的配置项赋值
- 支持扩展新的配置项
我们先定义一个名为OptionFunc
的类型,它实际上一个接收 *DoSomethingOption
作为参数并且会在函数内部修改其字段的函数。
type OptionFunc func(*DoSomethingOption)
接下来,我们为DoSomethingOption
字段编写一系列WithXxx
函数,其返回值是一个修改指定字段的闭包函数。
// WithB 将 DoSomethingOption 的 b 字段设置为指定值
func WithB(b int) OptionFunc {
return func(o *DoSomethingOption) {
o.b = b
}
}
// WithC 将 DoSomethingOption 的 b 字段设置为指定值
func WithC(c bool) OptionFunc {
return func(o *DoSomethingOption) {
o.c = c
}
}
WithXxx
是函数选项模式中约定成俗的函数名称格式。
这样我们的构造函数就可以改写成如下方式了,除了必须传递a
参数外,其他的参数都是可选的。
func NewDoSomethingOption(a string, opts ...OptionFunc) *DoSomethingOption {
o := &DoSomethingOption{a: a}
for _, opt := range opts {
opt(o)
}
return o
}
只想传入a
和b
参数时,可以按如下方式。
NewDoSomethingOption("q1mi", WithB(10))
默认值
在使用函数选项模式后,我们可以很方便的为某些字段设置默认值。例如下面的示例代码中B
默认值为100
。
const defaultValueB = 100
func NewDoSomethingOption(a string, opts ...OptionFunc) *DoSomethingOption {
o := &DoSomethingOption{a: a, b: defaultValueB} // 字段b使用默认值
for _, opt := range opts {
opt(o)
}
return o
}
至此我们拥有了一个支持默认值,并且能支持任意参数的构造函数,以后要为DoSomethingOption
添加新的字段时也不会影响之前的代码,我们只需要为新字段编写对应的With
函数即可。
接口类型版本
在一些场景下,我们可能并不想对外暴露具体的配置结构体,而是仅仅对外提供一个功能函数。这时我们会将对应的结构体定义为小写字母开头,将其限制只在包内部使用。
// doSomethingOption 定义一个内部使用的配置项结构体
// 类型名称及字段的首字母小写(包内私有)
type doSomethingOption struct {
a string
b int
c bool
// ...
}
此时,同样是使用函数选项模式,但我们这一次通过使用接口类型来“隐藏”内部的逻辑。
// IOption 定义一个接口类型
type IOption interface {
apply(*doSomethingOption)
}
// funcOption 定义funcOption类型,实现 IOption 接口
type funcOption struct {
f func(*doSomethingOption)
}
func (fo funcOption) apply(o *doSomethingOption) {
fo.f(o)
}
func newFuncOption(f func(*doSomethingOption)) IOption {
return &funcOption{
f: f,
}
}
// WithB 将b字段设置为指定值的函数
func WithB(b int) IOption {
return newFuncOption(func(o *doSomethingOption) {
o.b = b
})
}
// DoSomething 包对外提供的函数
func DoSomething(a string, opts ...IOption) {
o := &doSomethingOption{a: a}
for _, opt := range opts {
opt.apply(o)
}
// 在包内部基于o实现逻辑...
fmt.Printf("o:%#v\n", o)
}
如此一来,我们只需对外提供一个DoSomething
的功能函数和一系列WithXxx
函数。对于调用方来说,使用起来也很方便。
DoSomething("q1mi")
DoSomething("q1mi", WithB(100))
grpc-go中就是采用类似的实现方式。