Go 博客

保持模块兼容性

Jean Barkhuysen 和 Jonathan Amsterdam
2020 年 7 月 7 日

引言

本文是本系列文章的第 5 部分。

注意:有关开发模块的文档,请参阅 开发和发布模块

您的模块会随着时间的推移而演进,您会添加新功能、改变行为以及重新考虑模块公共接口的一部分。正如 Go 模块:v2 及更高版本 中所讨论的,v1+ 模块的破坏性更改必须作为主要版本升级的一部分进行(或者通过采用新的模块路径)。

然而,发布新的主要版本对您的用户来说很麻烦。他们必须找到新版本、学习新的 API 并修改他们的代码。而且有些用户可能永远不会更新,这意味着您必须永远维护两个版本的代码。因此,通常更好的做法是以兼容的方式更改现有的软件包。

在本文中,我们将探讨一些引入非破坏性更改的技术。共同的主题是:只增加,不修改或删除。我们还将讨论如何从一开始就设计兼容的 API。

向函数添加内容

通常,破坏性更改以向函数添加新参数的形式出现。我们将介绍一些处理此类更改的方法,但首先让我们看看一种不起作用的技术。

当添加具有合理默认值的新参数时,很容易想将它们添加为可变参数。为了扩展函数

func Run(name string)

`Run(string)`

func Run(name string, size ...int)

再添加一个默认值为零的 size 参数,有人可能会建议使用

package mypkg
var runner func(string) = yourpkg.Run

理由是所有现有的调用 site 都可以继续工作。虽然这是事实,但 Run 的其他用法可能会被破坏,例如这个用法

原始的 Run 函数在这里起作用是因为它的类型是 func(string),而新的 Run 函数的类型是 func(string, ...int),因此在编译时赋值失败。

这个例子说明调用兼容性不足以实现向后兼容。实际上,您无法对函数的签名进行任何向后兼容的更改。

不要改变函数的签名,而是添加一个新函数。例如,在引入 context 包后,将 context.Context 作为第一个参数传递给函数成为一种常见做法。然而,稳定的 API 无法改变导出的函数来接受 context.Context,因为这会破坏该函数的所有用法。

func (db *DB) Query(query string, args ...interface{}) (*Rows, error)

取而代之的是添加了新函数。例如,database/sql 包的 Query 方法的签名是(并且仍然是)

func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)

`Query(query string, args ...interface{}) (*Rows, error)`

func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
    return db.QueryContext(context.Background(), query, args...)
}

创建 context 包时,Go 团队向 database/sql 添加了一个新方法

`QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)`

func Dial(network, addr string, config *Config) (*Conn, error)

为了避免复制代码,旧方法调用新方法

`func (db *DB) Query(query string, args ...interface{}) (*Rows, error) { return db.QueryContext(context.Background(), query, args...) }`

func Listen(network, address string) (Listener, error)

添加方法允许用户按照自己的节奏迁移到新的 API。由于这些方法的读取方式相似且排在一起,并且新方法的名称中包含 Context,因此 database/sql API 的此扩展并未降低包的可读性或理解性。

type ListenConfig struct {
    Control func(network, address string, c syscall.RawConn) error
}

func (*ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)

如果您预计将来函数可能需要更多参数,可以通过将可选参数作为函数签名的一部分来提前计划。最简单的方法是添加单个结构体参数,就像 crypto/tls.Dial 函数所做的那样

`Dial(network, address string, config *Config) (Conn, error)`

grpc.Dial("some-target",
  grpc.WithAuthority("some-authority"),
  grpc.WithMaxDelay(time.Second),
  grpc.WithBlock())

Dial 执行的 TLS 握手需要网络和地址,但它还有许多其他具有合理默认值的参数。为 config 传递 nil 将使用这些默认值;传递一个设置了某些字段的 Config 结构体将覆盖这些字段的默认值。将来,添加新的 TLS 配置参数只需在 Config 结构体上添加一个新字段,这是一个向后兼容的更改(几乎总是如此——参见下面的“维护结构体兼容性”)。

notgrpc.Dial("some-target", &notgrpc.Options{
  Authority: "some-authority",
  MaxDelay:  time.Second,
  Block:     true,
})

有时,可以通过将选项结构体作为方法接收者来结合添加新函数和添加选项的技术。考虑 net 包在网络地址监听能力方面的演变。在 Go 1.11 之前,net 包只提供了一个签名如下的 Listen 函数

`Listen(network, address string) (Listener, error)`

对于 Go 1.11,net 监听功能添加了两个特性:传递上下文,以及允许调用者提供一个“控制函数”以便在连接创建后但在绑定之前调整原始连接。结果本可以是一个接受上下文、网络、地址和控制函数的新函数。相反,包的作者添加了一个 ListenConfig 结构体,预料到将来可能需要更多选项。他们没有定义一个名称笨重的新顶层函数,而是向 ListenConfig 添加了一个 Listen 方法

`func (lc *ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)`

将来提供新选项的另一种方法是“Option types”模式,其中选项作为可变参数传递,每个选项都是一个改变正在构建的值状态的函数。Rob Pike 的文章 自引用函数和选项设计 更详细地描述了它们。一个广泛使用的例子是 google.golang.org/grpcDialOption

Option types 履行了与函数参数中的结构体选项相同的作用:它们是一种可扩展的方式来传递修改行为的配置。选择哪种方式很大程度上取决于风格。考虑 gRPC 的 DialOption option type 的这个简单用法

`grpc.Dial("some.address", grpc.WithBlock())`

这也可以通过结构体选项来实现

package tar

type Reader struct {
  r io.Reader
}

func NewReader(r io.Reader) *Reader {
  return &Reader{r: r}
}

func (r *Reader) Read(b []byte) (int, error) {
  if rs, ok := r.r.(io.Seeker); ok {
    // Use more efficient rs.Seek.
  }
  // Use less efficient r.r.Read.
}

`type DialConfig struct { Block bool ... } grpc.Dial("some.address", &DialConfig{Block: true})`

函数式选项有一些缺点:每次调用时都需要在选项前写包名;它们增加了包命名空间的规模;而且如果多次提供相同的选项,其行为不明确。另一方面,接受选项结构体的函数需要一个可能几乎总是为 nil 的参数,这让一些人觉得不太美观。当类型的零值具有有效含义时,指定选项应具有其默认值会很笨拙,通常需要指针或额外的布尔字段。

两者都是确保模块公共 API 未来可扩展性的合理选择。

使用接口

有时,新功能需要更改公开的接口:例如,接口需要通过新方法进行扩展。然而,直接向接口添加方法是破坏性更改——那么,我们如何支持公开接口上的新方法呢?

// TB is the interface common to T and B.
type TB interface {
    Error(args ...interface{})
    Errorf(format string, args ...interface{})
    // ...

    // A private method to prevent users implementing the
    // interface and so future additions to it will not
    // violate Go 1 compatibility.
    private()
}

基本思想是定义一个包含新方法的新接口,然后在所有使用旧接口的地方,动态检查提供的类型是旧类型还是新类型。

让我们通过 archive/tar 包中的一个例子来说明这一点。tar.NewReader 接受 io.Reader,但随着时间的推移,Go 团队意识到,如果能够调用 Seek,则从一个文件头跳到下一个文件头会更高效。但是,他们不能向 io.Reader 添加 Seek 方法:那会破坏所有实现 io.Reader 的类型。

另一个排除的选项是将 tar.NewReader 改为接受 io.ReadSeeker 而不是 io.Reader,因为它同时支持 io.Reader 方法和 Seek(通过 io.Seeker)。但是,正如我们前面看到的,更改函数签名也是一种破坏性更改。

因此,他们决定保持 tar.NewReader 签名不变,但在 tar.Reader 方法中进行类型检查(并支持)io.Seeker

`func (r *Reader) next() (rawhd *Header, err error) { ... seeker, ok := r.r.(io.Seeker) if ok { ... use seeker ... } ... }`

(参阅 reader.go 查看实际代码。)

当您遇到想要向现有接口添加方法的情况时,您可以遵循此策略。首先创建一个包含新方法的新接口,或识别一个包含新方法的现有接口。接下来,确定需要支持它的相关函数,对第二个接口进行类型检查,并添加使用它的代码。

此策略仅在仍可支持不含新方法的旧接口时有效,从而限制了模块未来的可扩展性。

在可能的情况下,最好完全避免这类问题。例如,在设计构造函数时,优先返回具体类型。与接口不同,使用具体类型允许您将来添加方法而不会破坏用户。这个特性使得您的模块将来更容易扩展。

提示:如果您确实需要使用接口但不希望用户实现它,可以添加一个未导出的方法。这可以防止在您的包之外定义的类型在不嵌入的情况下满足您的接口,使您以后可以添加方法而不会破坏用户的实现。例如,请参阅 testing.TBprivate() 函数

type Point struct {
        _ [0]func()
        X int
        Y int
}

Jonathan Amsterdam 的“Detecting Incompatible API Changes”(检测不兼容的 API 更改)演讲也更详细地探讨了此主题(视频幻灯片)。

type doNotCompare [0]func()

type Point struct {
        doNotCompare
        X int
        Y int
}

添加配置方法

到目前为止,我们讨论了明显的破坏性更改,即更改类型或函数会导致用户代码停止编译。然而,行为更改也可能破坏用户,即使用户代码继续编译。例如,许多用户期望 json.Decoder 忽略 JSON 中不在参数结构体中的字段。当 Go 团队想在这种情况下返回错误时,他们必须非常谨慎。如果没有选择加入(opt-in)机制,许多依赖这些方法的用户可能会开始收到以前没有的错误。

因此,他们没有改变所有用户的行为,而是在 Decoder 结构体中添加了一个配置方法:Decoder.DisallowUnknownFields。调用此方法使用户选择加入新行为,但不调用则保留现有用户的旧行为。

维护结构体兼容性

我们前面看到,对函数签名的任何更改都是破坏性更改。结构体的情况要好得多。如果您的结构体类型是导出的,您几乎总是可以添加字段或删除未导出的字段而不会破坏兼容性。添加字段时,请确保其零值是有意义的并保留旧行为,以便现有未设置该字段的代码能够继续工作。

回想一下,net 包的作者在 Go 1.11 中添加了 ListenConfig,因为他们认为可能会有更多选项出现。事实证明他们是对的。在 Go 1.13 中,添加了 KeepAlive 字段,以允许禁用 keep-alive 或更改其周期。默认值零保留了启用默认周期 keep-alive 的原始行为。
新字段有一种微妙的方式可能意外地破坏用户代码。如果结构体中的所有字段类型都是可比较的——意味着这些类型的值可以使用 ==!= 进行比较并用作 map 键——那么整个结构体类型也是可比较的。在这种情况下,添加一个不可比较类型的新字段将使整个结构体类型变得不可比较,从而破坏任何比较该结构体类型值的代码。
为了保持结构体可比较,不要向其中添加不可比较的字段。您可以为此编写测试,或者依靠即将推出的 gorelease 工具来捕获它。