#11 未使用函数选项模式
在设计 API 时,可能会产生一个问题:如何处理可选配置?高效解决这个问题可以提高我们 API 的便利性。本节通过一个具体示例,涵盖了处理可选配置的不同方法。
在这个例子中,假设我们需要设计一个库,该库公开了一个用于创建 HTTP 服务器的函数。这个函数将接受不同的输入:一个地址和一个端口。以下显示了该函数的框架:
func NewServer(addr string, port int) (*http.Server, error) {
// ...
}
我们的库的客户端已经开始使用这个函数,每个人都很满意。但在某个时候,我们的客户端开始抱怨这个函数有些限制,缺少其他参数(例如,写超时和连接上下文)。然而,我们注意到添加新函数参数会破坏兼容性,迫使客户端修改他们调用 NewServer
的方式。与此同时,我们希望通过以下方式丰富与端口管理相关的逻辑(见图 2.9):
- 如果没有设置端口,则使用默认端口。
- 如果端口是负数,则返回错误。
- 如果端口等于 0,则使用随机端口。
- 否则,使用客户端提供的端口。
我们如何以一种对 API 友好的方式实现这个函数呢?让我们看看不同的选项。
11.1 配置结构体
由于 Go 不支持函数签名中的可选参数,第一个可能的方法是使用配置结构体来传达哪些是必需的,哪些是可选的。例如,必需的参数可以作为函数参数存在,而可选的参数可以在 Config 结构体中处理:
type Config struct {
Port int
}
func NewServer(addr string, cfg Config) {
}
这个解决方案解决了兼容性问题。实际上,如果我们添加新的选项,它不会破坏客户端。然而,这种方法并没有解决我们对端口管理的要求。实际上,我们应该记住,如果结构体字段没有提供,它将被初始化为其零值:
- 对于整数是 0
- 对于浮点类型是 0.0
- 对于字符串是 ""
- 对于切片、映射、通道、指针、接口和函数是 nil
因此,在以下示例中,两个结构体是相等的:
c1 := httplib.Config{
Port: 0,
}
c2 := httplib.Config{
}
在我们的情况下,我们需要找到一种方法来区分故意设置为 0 的端口和缺失的端口。也许一个选项可能是以这种方式将配置结构体的所有参数处理为指针:
type Config struct {
Port *int
}
使用整数指针,在语义上,我们可以突出 0 值和缺失值(一个 nil 指针)之间的区别。
这个选项是可行的,但它有一些缺点。首先,客户端提供整数指针并不方便。客户端必须先创建一个变量,然后以这种方式传递指针:
port := 0
config := httplib.Config {
Port: &port, // Provides an integer pointer
}
这并不是一个完全阻碍的问题,但整体 API 的使用便利性会有所降低。此外,我们添加的选项越多,代码就会变得越复杂。
第二个缺点是,使用我们库且希望采用默认配置的客户端将需要以这种方式传递一个空结构体:
httplib.NewServer("localhost", httplib.Config{})
这段代码看起来并不理想。读者将不得不理解这个神奇结构体的含义。
11.2 建造者模式
建造者模式原本是GOF的一部分,它为各种对象创建问题提供了一个灵活的解决方案。Config 的构建与结构体本身分离。它需要一个额外的结构体 ConfigBuilder
,该结构体接收用于配置和构建 Config
的方法。
让我们看一个具体的例子,以及它如何帮助我们设计一个友好的 API,解决我们所有的要求,包括端口管理:
type Config struct { // Config struct
Port int
}
type ConfigBuilder struct { // Config builder struct, containing an optional port
port *int
}
func (b *ConfigBuilder) Port(port int) *ConfigBuilder { // Public method to set up the port
b.port = &port
return b
}
func (b *ConfigBuilder) Build() (Config, error) { // Build method to create the config struct
cfg := Config{}
if b.port == nil { // Main logic related to port management
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) {
// ...
}
11.3 函数式选项模式
我们要讨论的最后一个方法是函数式选项模式(见图 2.10)。尽管有不同的实现方式,存在微小的差异,但主要思想如下:
- 一个未导出的结构体持有配置:选项
- 每个选项都是一个返回相同类型
type Option
的函数:func(options *options) error
。例如,WithPort
接受一个表示端口的int
参数,并返回一个表示如何更新选项结构体的Option
类型的函数。
以下是 Go 语言中选项结构体、Option 类型以及 WithPort 选项的实现:
type options struct {
port *int
}
type Option func(options *options) error // 表示一个更新配置结构体的函数类型
func WithPort(port int) Option { // 一个更新端口的配置函数
return func(options *options) error {
if port < 0 {
return errors.New("port should be positive")
}
options.port = &port
return nil
}
}
在这里,WithPort 返回一个闭包。闭包是一个引用其外部变量的匿名函数;在这种情况下,是 port 变量。该闭包符合 Option 类型,并实现了端口验证逻辑。每个配置字段都需要创建一个公共函数(按照惯例以 With 前缀开头),包含类似的逻辑:在需要时验证输入并更新配置结构体。
让我们看看服务提供端的最后部分:NewServer 实现。我们将把选项作为可变参数传递。因此,我们必须遍历这些选项来修改选项配置结构体:
func NewServer(addr string, opts ...Option) (*http.Server, error) {
var options options
for _, opt := range opts {
err := opt(&options)
if err != nil {
return nil, err
}
}
// 在这个阶段,选项结构体已经构建并包含了配置
// 因此,我们可以实施与端口配置相关的逻辑 var port int
if options.port == nil {
port = defaultHTTPPort
} else {
if *options.port == 0 {
port = randomPort()
} else {
port = *options.port
}
}
// ...
}
我们首先创建一个空的选项结构体。然后,我们遍历每个 Option 参数并执行它们以修改选项结构体(记住,Option 类型是一个函数)。一旦选项结构体构建完成,我们就可以实施最终的端口管理逻辑。
由于 NewServer
接受可变的 Option 参数,客户端现在可以通过在强制性地址参数之后传递多个选项来调用此 API。例如:
server, err := httplib.NewServer("localhost",
httplib.WithPort(8080),
httplib.WithTimeout(time.Second))
然而,如果客户端需要默认配置,它就不必提供参数(例如,像我们在前面的方法中看到的那样,一个空结构体)。客户端的调用现在可能看起来像这样:
server, err := httplib.NewServer("localhost")
这种模式是函数式选项模式。它提供了一种方便且对 API 友好的方式来处理选项。虽然建造者模式可能是一个有效的选择,但它有一些小缺点,这使得功能性选项模式成为在 Go 中处理这个问题的惯用方式。我们还应该注意到,这种模式在不同的 Go 库中使用,比如 gRPC。
转载自:https://juejin.cn/post/7385055925349138458