译|Go 对象的生命周期

原文:The Go Object Lifecycle

尽管 Go 是门如此简单的语言,Go 开发者还是发现了大量创建和使用对象的方法。在这篇文章中,我们将会看到对象管理的三步法:实例化、初始化和启动。我们还会将其与其他创建和使用对象的方法进行对比,并回顾每种方法的优缺点。

目的

这似乎是个很蠢的问题,但是,我们在 Go 中创建和使用对象的目的是什么呢?为了与 Go 的风格保持一致,我优先考虑以下几点:

  1. 简单
  2. 灵活
  3. 文档友好

此外,还应该说明哪些不是我们的目标。应该假设我们的最终用户具有基本的能力,因而不需要提供过多的防护。我们的代码用户可以 RTFM(假设我们提供了质量“FM”)。我们还应该假设我们的代码用户不是敌对的 —— 例如,我们不需要保护我们的对象字段,因为我们认为开发者不会以其它方式恶意使用它们。

过程

实例化

首先,需要为对象分配内存。Go 社区的一般建议是利用零值。我觉得这对于那些原始构造函数(例如 sync.Mutex 或者 bytes.Buffer,它们的 API 受限)而言是一个很好的建议:

var mu sync.Mutex
mu.Lock()
// do things...
mu.Unlock()

然而,对于大多数应用和库开发者而言,构造函数可以提高效率,并且防止未来可能会出现的错误。

使用构造函数

Go 中的构造函数通常采用 New 后跟着类型名称的形式。我们可以在下面的代码中看到这样一个例子:

// DefaultClientTimeout is the default Client.Timeout.
const DefaultClientTimeout = 30 * time.Seconds

// Client represents a client to our server.
type Client struct {
    Host    string
    Timeout time.Duration
}

// NewClient returns a new instance of Client with default settings.
func NewClient(host string) *Client {
    return &Client{
        Host:    host,
        Timeout: DefaultClientTimeout,
    }
}

通过使用构造函数,可以获得几个好处。首先,每次使用时无需检查 Timeout 的零值,以判断我们是否应该使用默认值。因为它始终被设置为正确的值。

其次,如果将来需要初始化字段,那么可以提供无缝的升级体验。假设我们添加了一个需要在创建时初始化的缓存值 map

type Client struct {
    cache map[string]interface{}

    Host    string
    Timeout time.Duration
}

如果我们在库的未来版本中添加了一个构造函数来初始化 cache,那么所有现有使用零值的 Client 对象都将崩溃。通过从一开始使用构造函数,并记录其用法,我们避免了需要破坏未来版本的风险。

使用自然命名

另一个从构造函数获得的好处是,我们的配置字段名称不再需要受零值限制。也就是说,如果我们有一个默认情况下应该是“可编辑的”对象,那么,就不需要有一个名为 NotEditable 的布尔型字段来使之适配默认的零值(false)。我们可以简单使用自然名称,Editable,然后我们的构造函数可以将其设置为 true

初始化

一旦分配了内存并设置了默认值,你就需要为你的特定使用场景配置对象了。这是我发现大多数的 Go 开发者会复杂化的地方,但是其实实践中非常简单。

请只使用字段

一般来说,应该只对导出字段进行设置。在上面的 Client 示例中,通过 HostTimeout 字段提供配置。

为了避免与其他 goroutine 的竞争条件,该配置字段应该只设置一次,因为其他函数(例如 Open() 或者 Start())可能会启动额外的 goroutine。我们可以在结构文档上记录这个限制。

type Client struct {
    // Host and port of remote server. Must be set before Open().
    Host string

    // Time until connection is cancelled. Must be set before Open().
    Timeout time.Duration
}

此规则的一个例外是,在开始使用该对象后,有某些字段会被更新,并且需要同时改变相关的字段。在这种情况下,提供 getter & setter 函数。

type Client struct {
    mu      sync.Mutex
    timeout time.Duration

    // Host and port of remote server. Must be set before Open().
    Host string
}

// Timeout returns the duration until connection is cancelled.
func (c *Client) Timeout() time.Duration {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.timeout
}

// SetTimeout sets the duration until connection is cancelled.
func (c *Client) SetTimeout(d time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.timeout = d
}

然而,我发现在使用期间更改配置项设置通常是一种代码异味,因此通常需要避免。此时,简单地停止对象,并使用新的实例重启,通常更简洁。

启动

现在,我们已经分配了内存并配置了对象,接下来,让我们做一些有用的事情。此时,简单的对象通常已经准备好了,但是,对于像服务器这样更复杂的对象则需要启动。它们可能需要连接资源,或者启动后台 goroutine 来监控资源,例如 net.Listener

在 Go 中,我们通常以 Open() 或者 Start() 函数的形状看到这种场景。我更喜欢 Open(),因为这与 io.Closer 中的 Close() 方法在命名上更好配对。

在我们的客户端示例中,可以使用 Open() 来创建网络连接,并使用 Close() 来关闭:

type Client struct {
    conn net.Conn

    // Host and port of remote server. Must be set before Open().
    Host string
}

// Open opens the connection to the remote server.
func (c *Client) Open() error {
    conn, err := net.Dial("tcp", c.Host)
    if err != nil {
        return err
    }
    c.conn = conn

    return nil
}

// Close disconnects the underlying connection to the server.
func (c *Client) Close() error {
    if c.conn != nil {
        return c.conn.Close()
    }
    return nil
}

在上面这个简单的例子中有两个重要的事实需要注意。首先,在 Open() 中,只使用一次我们的 Host,然后就不再使用了。这可以避免在打开该对象后设置主机产生的任何竞争条件。其次,不尝试重置对象状态以重用对象。这些一次性对象避免了试图重用对象时发生的大量错误。

一次性对象

在实践中,很难正确地清理复杂的对象,然后重用它们。在我们的例子中,不尝试在 Close() 中设置 connnil。这是因为 Client 可能会有一个后台 goroutine 试图监控连接,而改变 conn 的值需要我们添加一个互斥量来保护该字段。

我们还可以使用该字段来防止双重打开:

// Open opens the connection to the remote server.
func (c *Client) Open() error {
    if c.conn != nil {
        return errors.New("myapp.Client: cannot reopen client")
    }
    ...
}

然而,我们应该假设最终用户的基本能力,并且通常要避免这些过度的保护。

替代方法

现在,我们已经看到了 实例化-初始化-启动 方法,接下来,让我们来评估 Go 社区中的其他常见方法吧。

选项 #1:函数选项

Dave Cheney 在他的文章(Functional Options for Friendly APIs)中描述了一种名为 函数选项(functional options) 的模式。其思想是,我们可以声明一个函数参数类型来更新我们未导出的字段。之后,就可以在同一个调用中启动我们的对象了,因为它已经被初始化过了。

我们使用上面的 Client 示例来描述一下该模式:

type Client struct {
    host string
}

// OpenClient returns a new, opened client.
func OpenClient(opts ...ClientOption) (*Client, error) {
    c := &Client{}
    for _, opt := range opts {
        if err := opt(c); err != nil {
            return err
        }
    }
    // open client...
    return c, nil
}

// ClientOption represents an option to initialize the Client.
type ClientOption func(*Client) error

// Host sets the host field of the client.
func Host(host string) ClientOption {
    return func(c *Client) error {
        c.host = host
        return nil
    }
}

然后就可以用一行代码使用了:

client, err := OpenClient(Host("google.com"))

虽然这种方法隐藏了配置字段,但是却是以复杂性和可读性为代价的。而随着选项数量大增长,godoc API 也会变得庞大且无法使用,这使得咋看之下很难确定哪种选项适合哪种类型。

但是,最终我们不需要隐藏配置字段。我们应该记录它们的用法,然后相信开发者会正确使用它们。保持这些字段的导出状态会将所有相关的配置字段组合在一个类型中,正如 net.Request 类型那样。

选项 #2:配置实例化

另一个常见的方法是为你的类型提供“配置”对象。这种方法试图将配置字段与类型本身分离开来。很多时候,开发者会将配置对象中的字段复制到类型中,或者直接将配置潜入到类型。

使用上面的 Client 示例来解释一下:

type Client struct {
    host string
}

type ClientConfig struct {
    Host string
}

func NewClient(config ClientConfig) *Client {
    return &Client{
        host: config.Host,
    }
}

同样,这样会隐藏 Client 类型的配置字段,除此之外,没有其他好处了。相反,我们应该简单公开我们的 Client.Host 字段,让我们的用户直接管理它。这降低了我们的 API 的复杂度,并且会提供更清晰的文档。

何时使用配置对象

配置对象很有用,但它不应该是 API 调用者和 API 作者之间的接口。当最终用户和你的软件之间存在接口的时候,才应该存在配置对象。

例如,配置对象可以通过 YAML 文件和你的代码提供一个接口。这些配置对象通常应该位于你的 main 包中,因为你的二进制文件充当着最终用户和代码之间的翻译层。

package main

func main() {
    config := NewConfig()
    if err := readConfig(path); err != nil {
        fmt.Fprintln(os.Stderr, "cannot read config file:", err)
        os.Exit(1)
    }

    client := NewClient()
    client.Host = config.Host
    if err := client.Open(); err != nil {
        fmt.Fprintln(os.Stderr, "cannot open client:", err)
        os.Exit(1)
    }

    // do stuff... 
}

type Config struct {
    Host string `yaml:"host"`
}

func NewConfig() Config {
    return &Config{
        Host: "localhost:1234"
    }
}

总结

我们研究了一种管理 Go 对象生命周期的方法,这种方法结合了简单性和灵活性,并且文档友好。首先,我们 实例化 对象以分配内存并设置默认值。接着,通过自定义导出字段来 初始化 对象。最后,_启动_ 我们的对象,这可能会启动后台 goroutine 或者连接。

这简单的 3 个步骤有助于构建易于被开发者当下使用并在未来轻松维护的代码。