go 编程模式之 Functional Options

什么是Functional Options编程模式

Functional Options 编程模式是一种在 Go 语言中构造结构体的模式,这种模式允许用户通过一系列函数来传递配置选项,而不是通过构造函数的参数列表。它在 Go 中特别有用,因为 Go 没有构造函数,通常通过定义New函数来初始化结构体。

为什么使用Functional Options模式

在 Go 中,如果一个结构体有很多配置选项,传统的方法是为每个不同的配置选项声明一个新的构造函数,或者定义一个新的配置结构体来保存配置信息。但是,这些方法都有局限性,比如增加新的配置选项时需要修改构造函数或配置结构体,这可能会导致代码的维护成本增加。Functional Options 模式提供了一种更灵活和可扩展的方式来处理这种情况。

实例初始化

在介绍 Functional Options 模式之前,先来看一下传统的 Struct 实例初始化是怎么做的。

假设我们有一个 Server 结构体如下:

type Server struct {
    host     string
    Port     int
    Timeout  time.Duration
    maxConn  int
}

为了在实例化时更好的控制结构体的初始值,通常需要编写构造函数来实现:

func NewServer() *Server {
    return &Server{
        Host:    "127.0.0.1",
        Port:    8080,
        Timeout: time.Second * 5,
        MaxConn: 1000,
    }
}

func NewCustomServer(host string, port int) *Server {
    return &Server{
        Host: host,
        Port: port,
    }
}

由于 Go 语言不支持重载函数,所以,创建两种 Server 配置就意味着构造两个不同函数名的函数,随着业务的扩展,可能会出现更多不同的 Server 配置,就需要创建更多 Server 构造函数来满足要求。

为了解决这个问题,通常有以下几种解决方法。

方法一:编写 Set 方法

func (s *Server) SetHost(host string) {
    s.Host = host
}

func (s *Server) SetPort(port int) {
    s.Port = port
}

func (s *Server) SetTimeout(timeout time.Duration) {
    s.Timeout = timeout
}

func (s *Server) SetMaxConn(maxConn int) {
    s.MaxConn = maxConn
}

为 Server 结构体的每个字段写一个 Set 方法,这样我们在初始化实例之后,可以调用对应的 Set 方法来为我们想要的字段进行赋值。

func SetupServer() {
    s := NewCustomServer("192.168.1.1", 8081)
    s.SetTimeout(time.Second * 10)
    s.SetMaxConn(2000)
    fmt.Println(s)
}

这种方式可以直接地表达我们的意图,即调用一个方法设置一个属性。但缺点是需要创建很多的 Set 方法,实例化时也需要很多的行来设置多个属性,所以当你的实例初始化参数比较复杂时,不推荐此种方式。

方法二:使用结构体来传递可选参数

我们把 Host 和 Port 定义为 Server 的必传参数,其他为可选参数,将其他的可选参数用一个新的结构体 Config 来表示,然后将 Config 嵌入到 Server 结构体中。

type Server struct {
    Host string
    Port int
    Conf *Config
}

type Config struct {
    MaxConn int
    Timeout time.Duration
}

这时候我们的构造函数为下面这样:

func NewServer(host string, port int, conf *Config) *Server {
    if conf == nil {
        conf = &Config{
            MaxConn: 1000,
            Timeout: time.Second * 5,
        }
    }
    return &Server{
        Host: host,
        Port: port,
        Conf: conf,
    }
}

在进行初始化的时候需要先构造一个 Config 对象,然后传递给构造函数。

func SetupServer() {
    conf := &Config{
        MaxConn: 2000,
        Timeout: time.Second * 10,
    }
    // 默认配置
    s1 := NewServer("192.168.1.1", 8081, nil)
    // 自定义配置
    s2 := NewServer("192.168.1.2", 8082, conf)
}

这种方式使用 Config 结构体来存放可选参数,保持了 API 简洁,提高了代码的可读性和可维护性,同时提供了良好的扩展性。但缺点是稍微复杂了一点,也不那么美观,对于默认的配置来说,需要多一个config 参数。另外,需要注意nilConfig{}的区别,虽然我们在构造函数中处理了nil的情况,但仍然存在外部代码误用的风险,例如忘记初始化 Config 或者错误地传递了nil值。

方法三 Builder 模式

学习过设计模式的人,肯定还会想到使用 Builder 模式。这种方式允许我们使用链式调用的方法来初始化实例。仿照 Builder 模式,把我们的代码修改为下面的样子:

type Server struct {
    Host    string
    Port    int
    MaxConn int
    Timeout time.Duration
}

func NewServer(host string, port int) *Server {
    return &Server{
        Host: host,
        Port: port,
    }
}

func (s *Server) WithMaxConn(maxConn int) *Server {
    s.MaxConn = maxConn
    return s
}

func (s *Server) WithTimeout(timeout time.Duration) *Server {
    s.Timeout = timeout
    return s
}

这样,我们就可以使用下面的方式来初始化代码了:

func SetupServer() {
    s := NewServer("192.168.1.1", 8081).
        WithMaxConn(1000).
        WithTimeout(time.Second * 10)
}

注意,这里没有考虑错误处理的情况。如果自定义参数不多,参数也都相对比较明确的情况下是可以不考虑错误处理的。但如果参数比较复杂,推荐使用一个包装类,这样最后在处理参数错误的时候会方便很多。大概代码像下面这样:

package options

import (
    "crypto/tls"
    "errors"
    "fmt"
    "time"
)

// Server 定义了服务器的结构。
type Server struct {
    Addr     string
    Port     int
    Protocol string
    MaxConns int
    Timeout  time.Duration
    TLS      *tls.Config // 假设这里有一个 tls.Config 类型,用于配置 TLS
}

// ServerBuilder 是用于构建 Server 的建造者。
type ServerBuilder struct {
    server *Server
    err    error
}

// NewServerBuilder 创建一个新的 ServerBuilder 实例。
func NewServerBuilder() *ServerBuilder {
    return &ServerBuilder{}
}

// WithAddr 设置地址,并返回建造者本身以便链式调用。
func (sb *ServerBuilder) WithAddr(addr string) *ServerBuilder {
    if addr == "" {
        sb.err = errors.New("address is required")
        return sb
    }
    sb.server.Addr = addr
    return sb
}

// WithPort 设置端口,并返回建造者本身以便链式调用。
func (sb *ServerBuilder) WithPort(port int) *ServerBuilder {
    if port <= 0 || port > 65535 {
        sb.err = errors.New("invalid port number, must be between 1 and 65535")
        return sb
    }
    sb.server.Port = port
    return sb
}

// WithProtocol 设置协议,并返回建造者本身以便链式调用。
func (sb *ServerBuilder) WithProtocol(protocol string) *ServerBuilder {
    if protocol != "tcp" && protocol != "udp" {
        sb.err = errors.New("invalid protocol, must be 'tcp' or 'udp'")
        return sb
    }
    sb.server.Protocol = protocol
    return sb
}

// WithMaxConns 设置最大连接数,并返回建造者本身以便链式调用。
func (sb *ServerBuilder) WithMaxConns(maxConns int) *ServerBuilder {
    if maxConns < 0 {
        sb.err = errors.New("max connections cannot be negative")
        return sb
    }
    sb.server.MaxConns = maxConns
    return sb
}

// WithTimeout 设置超时时间,并返回建造者本身以便链式调用。
func (sb *ServerBuilder) WithTimeout(timeout time.Duration) *ServerBuilder {
    if timeout < 0 {
        sb.err = errors.New("timeout cannot be negative")
        return sb
    }
    sb.server.Timeout = timeout
    return sb
}

// WithTLS 设置 TLS 配置,并返回建造者本身以便链式调用。
func (sb *ServerBuilder) WithTLS(tlsConfig *tls.Config) *ServerBuilder {
    sb.server.TLS = tlsConfig
    return sb
}

// Build 尝试构建 Server 实例。
func (sb *ServerBuilder) Build() (*Server, error) {
    if sb.err != nil {
        return nil, sb.err
    }
    return sb.server, nil
}

func SetupServer() {
    builder := NewServerBuilder()
    server, err := builder.
        WithAddr("127.0.0.1").
        WithPort(8080).
        WithProtocol("udp").
        WithMaxConns(1024).
        WithTimeout(30 * time.Second).
        Build()

    if err != nil {
        fmt.Printf("Error creating server: %v\n", err)
        return
    }

    fmt.Printf("Server created: %+v\n", server)
}

Functional Options模式实现

介绍完上面三中实现方式,终于轮到主角 Functional Options 登场了。基础结构体还是不变:

type Server struct {
    host     string
    port     int
    timeout  time.Duration
    maxConn  int
}

创建选项类型和选项函数

为了支持 Functional Options 模式,我们需要定义一个接受*Server指针的函数类型Option,并创建设置Server属性的选项函数:

type Option func(*Server)

func WithHost(host string) Option {
    return func(s *Server) {
        s.host = host
    }
}

func WithPort(port int) Option {
    return func(s *Server) {
        s.port = port
    }
}

func WithTimeout(timeout time.Duration) Option {
    return func(s *Server) {
        s.timeout = timeout
    }
}

func WithMaxConn(maxConn int) Option {
    return func(s *Server) {
        s.maxConn = maxConn
    }
}

定义构造函数和实例化

默认构造函数,使用可选参数,参数类型为我们自定义的 Option。接下来是关键点,在构造函数中,使用一个 for 循环,遍历传入的选项函数,完成实例化构造:

func NewServer(options ...Option) *Server {
    svr := &Server{
        host:   "localhost",
        port:   8080,
        timeout: time.Minute,
        maxConn: 100,
    }
    for _, opt := range options {
        opt(svr)
    }
    return svr
}

这样在实例化的时候,就可以这样使用:

func SetupServer() {
    svr := NewServer(
        WithHost("localhost"),
        WithPort(8080),
        WithTimeout(time.Minute),
        WithMaxConn(120),
    )
}

Functional Options 模式在处理复杂配置选项时是非常有用的,尤其是在这些选项可能来自不同来源(如文件、环境变量等)的情况下,或者更具外部情况决定要开启什么配置的时候。

Options 模式最佳实践

  1. 默认值初始化:New 方法中设置默认值,用户可以仅覆盖需要的部分。
func NewServer(opts ...Option) *Server {
    server := &Server{
        host:          "localhost", // 默认值
        port:          80,
        timeout:       30,
        maxConnections: 100,
    }
    for _, opt := range opts {
        opt(server)
    }
    return server
}

// 使用方式
server := NewServer(WithPort(8080))

好处:开发者不需要显式传递所有参数,默认值会自动填充未指定的字段。

  1. 组合 Options:将多个逻辑相关的配置合并为一个复合选项。
func WithHighPerformanceSettings() Option {
        return func(s *Server) {
            s.timeout = 10
            s.maxConnections = 1000
        }
}
// 使用方式
server := NewServer(WithPort(8080), WithHighPerformanceSettings())

好处:提高代码复用性,让复杂配置简单化。

  1. 条件选项:根据某些条件动态设置参数。
func WithConditionalTimeout(condition bool, timeout int) Option {
    return func(s *Server) {
        if condition {
            s.timeout = timeout
        }
    }
}

// 使用方式
isTest := true
server := NewServer(WithConditionalTimeout(isTest, 5))

好处:通过条件选项可以处理上下文敏感的逻辑。

  1. 链式 Option:支持链式调用,用更简洁的语法配置对象。
func (s *Server) Apply(opts ...Option) *Server {
    for _, opt := range opts {
        opt(s)
    }
    return s
}

// 使用方式
server := (&Server{}).Apply(WithPort(8080), WithTimeout(20))

好处:允许对已经创建的对象动态调整配置。

  1. 类型安全的泛型 Option:通过泛型封装更类型安全的选项:
type Configurable[T any] struct {
    value T
}

type Option[T any] func(*Configurable[T])

func WithValue[T any](value T) Option[T] {
    return func(c *Configurable[T]) {
        c.value = value
    }
}

func NewConfigurable[T any](opts ...Option[T]) *Configurable[T] {
    c := &Configurable[T]{}
    for _, opt := range opts {
        opt(c)
    }
    return c
}

// 使用方式
config := NewConfigurable[int](WithValue(42))
fmt.Println(config.value) // 输出 42

好处:让 Option 模式支持更加复杂的类型和配置。

  1. 调试友好的 Option:为每个 Option 添加日志或注释,便于排查问题。
func WithLoggedPort(port int) Option {
    return func(s *Server) {
        fmt.Printf("Setting port to %d\n", port)
        s.port = port
    }
}

好处:对复杂配置进行跟踪和调试。

  1. 互斥或依赖的 Option:Option 中处理参数间的依赖关系或互斥逻辑。
func WithSecureMode(enable bool) Option {
    return func(s *Server) {
        if enable {
            s.port = 443
            s.host = "https"
        }
    }
}

// 使用方式
server := NewServer(WithSecureMode(true))

好处:避免调用者在使用时混淆配置规则。

  1. 延迟计算的 Option:某些参数可以在构造对象时动态计算,而不是直接传递。
func WithDynamicPort(getPort func() int) Option {
    return func(s *Server) {
        s.port = getPort()
    }
}

// 使用方式
server := NewServer(WithDynamicPort(func() int { return 8080 }))

好处:支持运行时动态设置值,适合依赖外部条件的场景。

  1. Option 校验:New 方法中校验 Option 的合法性,避免不一致的配置。
func NewServer(opts ...Option) (*Server, error) {
    server := &Server{}
    for _, opt := range opts {
        opt(server)
    }
    if server.port == 0 {
        return nil, fmt.Errorf("port must be specified")
    }
    return server, nil
}

好处:在初始化时捕获潜在错误。

总结

Functional Options 模式增强了代码的灵活性和可维护性,尤其在处理复杂的配置选项时表现尤为突出。本文旨在介绍 Functional Options 模式的概念及其应用,并讨论了几种常见的实例初始化方法。需强调的是,本文无意贬低或推崇任一特定方法,只是建议在面对复杂的实例化需求时,可以考虑采用 Functional Options 模式作为解决方案之一。选择最适合当前场景的方法,才是正确的打开方式

go
本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!