:::info 日期:2020 年 07 月 07 日
作者:Jean de Klerk and Jonathan Amsterdam
原文链接:https://go.dev/blog/module-compatibility :::

介绍

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

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

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

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

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

通常,重大更改以函数的新参数的形式出现。 我们将描述一些处理这种变化的方法,但首先让我们看看一种不起作用的技术。

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

  1. func Run(name string)
  1. 使用默认为零的附加大小参数,人们可能会建议
  1. func Run(name string, size ...int)
  1. 理由是所有现有的呼叫站点将继续工作。 虽然这是真的,但 Run 的其他用途可能会中断,例如:
  1. package mypkg
  2. var runner func(string) = yourpkg.Run
  1. 原来的 Run 函数在这里工作是因为它的类型是 func(string),但新的 Run 函数的类型是 func(string, ...int),所以在编译时赋值失败。

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

与其更改函数的签名,不如添加一个新函数。 例如,在引入 context 包之后,将 context.Context 作为第一个参数传递给函数成为常见做法。 但是,稳定的 API 无法将导出的函数更改为接受 context.Context,因为它会破坏该函数的所有使用。

相反,添加了新功能。 例如,database/sql 包的 Query 方法的签名是(现在仍然是)

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

创建上下文包时,Go 团队在 database/sql 中添加了一个新方法:

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

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

  1. func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
  2. return db.QueryContext(context.Background(), query, args...)
  3. }
  1. 添加方法允许用户按照自己的节奏迁移到新 API 由于这些方法读取相似并排序在一起,并且 Context 以新方法的名称命名,因此数据库/sql API 的这种扩展并没有降低包的可读性或可理解性。

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

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

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

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

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

  1. type ListenConfig struct {
  2. Control func(network, address string, c syscall.RawConn) error
  3. }
  4. func (*ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)

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

选项类型在函数参数中扮演与结构选项相同的角色:它们是传递行为修改配置的可扩展方式。 决定选择哪个在很大程度上取决于风格。 考虑 gRPC 的 DialOption 选项类型的这种简单用法:

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

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

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

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

任何一种都是确保模块公共 API 未来可扩展性的合理选择。

使用 interface 工作

有时,新功能需要对公开接口进行更改:例如,需要使用新方法扩展接口。 然而,直接添加到接口是一个突破性的变化——那么,我们如何在公开暴露的接口上支持新方法?

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

让我们用 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:

  1. package tar
  2. type Reader struct {
  3. r io.Reader
  4. }
  5. func NewReader(r io.Reader) *Reader {
  6. return &Reader{r: r}
  7. }
  8. func (r *Reader) Read(b []byte) (int, error) {
  9. if rs, ok := r.r.(io.Seeker); ok {
  10. // Use more efficient rs.Seek.
  11. }
  12. // Use less efficient r.r.Read.
  13. }

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

当您遇到要向现有接口添加方法的情况时,您可以遵循此策略。 首先使用新方法创建新接口,或使用新方法确定现有接口。 接下来,确定需要支持它的相关功能,为第二个接口键入检查,并添加使用它的代码。

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

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

提示:如果您确实需要使用接口但不打算让用户实现它,您可以添加一个未导出的方法。 这可以防止在包外定义的类型在没有嵌入的情况下满足您的接口,从而使您可以在不破坏用户实现的情况下稍后添加方法。 例如,参见 testing.TB 的 private() 函数

  1. // TB is the interface common to T and B.
  2. type TB interface {
  3. Error(args ...interface{})
  4. Errorf(format string, args ...interface{})
  5. // ...
  6. // A private method to prevent users implementing the
  7. // interface and so future additions to it will not
  8. // violate Go 1 compatibility.
  9. private()
  10. }

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

增加配置方法

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

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

保持 struct 的兼容性

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

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

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

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

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

  1. type Point struct {
  2. _ [0]func()
  3. X int
  4. Y int
  5. }

func() 类型没有可比性,零长度数组不占空间。 我们可以定义一个类型来阐明我们的意图:

  1. type doNotCompare [0]func()
  2. type Point struct {
  3. doNotCompare
  4. X int
  5. Y int
  6. }

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

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

总结

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

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