Go 博客

保持模块兼容

Jean de Klerk 和 Jonathan Amsterdam
2020 年 7 月 7 日

简介

这篇文章是系列文章的第 5 部分。

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

随着您添加新功能、更改行为以及重新考虑模块公共表面的部分内容,您的模块将随着时间推移而发展。如 Go 模块:v2 及更高版本 中所述,对 v1+ 模块的重大更改必须作为主要版本更新的一部分进行(或通过采用新的模块路径)。

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

在这篇文章中,我们将探讨一些引入非破坏性更改的技术。共同的主题是:添加,而不是更改或删除。我们还将讨论如何在一开始就设计 API 以实现兼容性。

向函数添加内容

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

在添加具有合理默认值的新参数时,将其作为可变参数添加非常诱人。要扩展函数

func Run(name string)

添加一个额外的 size 参数,其默认值为零,有人可能会建议

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

因为所有现有的调用站点将继续工作。虽然这是真的,但 Run 的其他用法可能会中断,例如此用法

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

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

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

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

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

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

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

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

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

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

添加方法允许用户根据自己的节奏迁移到新 API。由于方法读起来很相似并且按顺序排列,并且 Context 在新方法的名称中,因此 database/sql API 的此扩展不会降低包的可读性或理解力。

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

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

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

有时,可以通过使选项结构体成为方法接收器来组合添加新函数和添加选项的技术。考虑一下 net 包在网络地址上侦听功能的演变。在 Go 1.11 之前,net 包仅提供了一个具有以下签名的 Listen 函数

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

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

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

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

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

选项类型与函数参数中的结构体选项具有相同的作用:它们是传递修改行为配置的可扩展方式。决定选择哪一个主要是一个风格问题。考虑一下 gRPC 的 DialOption 选项类型的这个简单用法

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

这也可以作为结构体选项实现

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

函数选项有一些缺点:每次调用都需要在选项之前编写包名称;它们增加了包命名空间的大小;并且不清楚如果提供相同的选项两次,行为应该是什么。另一方面,接受选项结构体的函数需要一个参数,该参数几乎总是 nil,有些人认为这不太好看。并且当类型的零值具有有效含义时,指定选项应具有其默认值很笨拙,通常需要一个指针或一个额外的布尔字段。

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

使用接口

有时,新功能需要更改公开的接口:例如,需要使用新方法扩展接口。但是,直接向接口添加内容是一个破坏性更改 - 那么,我们如何支持公开接口上的新方法呢?

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

让我们以 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 进行类型检查(并支持)

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.
}

(有关实际代码,请参阅 reader.go。)

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

这种策略仅在没有新方法的旧接口仍然可以被支持时才有效,这限制了您模块未来的扩展性。

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

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

// 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()
}

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

添加配置方法

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

因此,与其更改所有用户的行为,不如向Decoder结构体添加一个配置方法:Decoder.DisallowUnknownFields。调用此方法会选择加入新行为,但不会调用则会为现有用户保留旧行为。

维护结构体兼容性

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

回想一下,net包的作者在 Go 1.11 中添加了ListenConfig,因为他们认为将来可能会出现更多选项。事实证明他们是正确的。在 Go 1.13 中,添加了KeepAlive字段以允许禁用保持活动或更改其周期。零的默认值保留了启用保持活动并使用默认周期的原始行为。

新字段有一种微妙的方式可能会意外地破坏用户代码。如果结构体中的所有字段类型都是可比较的——这意味着这些类型的数值可以使用==!=进行比较并用作映射键——那么整个结构体类型也是可比较的。在这种情况下,添加一个新的不可比较类型字段将使整个结构体类型不可比较,从而破坏任何比较该结构体类型值的代码。

为了保持结构体可比较,请不要向其中添加不可比较的字段。您可以为此编写一个测试,或依赖即将推出的gorelease工具来捕获它。

为了防止首先进行比较,请确保结构体具有不可比较的字段。它可能已经有一个了——任何切片、映射或函数类型都是不可比较的——但如果没有,则可以像这样添加一个

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

func()类型不可比较,零长度数组不占用任何空间。我们可以定义一个类型来阐明我们的意图

type doNotCompare [0]func()

type Point struct {
        doNotCompare
        X int
        Y int
}

您应该在结构体中使用doNotCompare吗?如果您已将结构体定义为用作指针——也就是说,它具有指针方法,并且可能还有一个返回指针的NewXXX构造函数——那么添加doNotCompare字段可能有点过头了。指针类型的用户理解该类型的每个值都是不同的:如果他们想比较两个值,他们应该比较指针。

如果您正在定义一个旨在直接用作值的结构体,例如我们的Point示例,那么通常您希望它可比较。在您不希望比较值的结构体的罕见情况下,添加doNotCompare字段将使您能够在以后更改结构体而无需担心破坏比较。不利的一面是,该类型将无法用作映射键。

结论

在从头开始规划 API 时,请仔细考虑 API 对未来新变化的扩展性。当您确实需要添加新功能时,请记住规则:添加,而不是更改或删除,并记住例外——接口、函数参数和返回值无法以向后兼容的方式添加。

如果您需要彻底更改 API,或者如果 API 在添加更多功能时开始失去其焦点,那么可能是时候发布新的主要版本了。但在大多数情况下,进行向后兼容的更改很容易,并且可以避免给用户带来痛苦。

下一篇文章:Go 1.15 发布
上一篇文章:泛型的下一步
博客索引