Go 博客
保持模块兼容性
引言
本文是系列文章的第 5 部分。
- 第 1 部分 — 使用 Go 模块
- 第二部分 — 迁移到 Go 模块
- 第三部分 — 发布 Go 模块
- 第四部分 — Go 模块:v2 及更高版本
- 第 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 握手需要网络和地址,但它有许多其他参数,这些参数具有合理的默认值。为 config
传递 nil
会使用这些默认值;传递一个设置了某些字段的 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/grpc 的DialOption。
选项类型在函数参数中扮演着与结构体选项相同的角色:它们是传递行为修改配置的可扩展方式。选择哪种方式主要取决于风格。考虑 gRPC 的 DialOption
选项类型的简单用法
grpc.Dial("some-target",
grpc.WithAuthority("some-authority"),
grpc.WithMaxDelay(time.Second),
grpc.WithBlock())
这也可以通过结构体选项来实现
notgrpc.Dial("some-target", ¬grpc.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.TB
的private()
函数。
// 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
字段,以允许禁用保持活动或更改其周期。默认值零保留了启用默认周期的保持活动的原始行为。
新字段有一种微妙的方式可能会意外地破坏用户代码。如果结构体中的所有字段类型都可比较——这意味着这些类型的值可以用 ==
和 !=
进行比较,并用作 map 键——那么整个结构体类型也都是可比较的。在这种情况下,添加一个不可比较类型的新字段将使整个结构体类型变得不可比较,从而破坏任何比较该结构体类型值的代码。
为了保持结构体可比较,不要向其中添加不可比较的字段。您可以为此编写一个测试,或者依赖即将推出的 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
字段将使您能够以后自由更改结构体,而不必担心破坏比较。缺点是,该类型将无法用作 map 键。
结论
从头开始规划 API 时,请仔细考虑 API 在未来新变化中的可扩展性。当您确实需要添加新功能时,请记住规则:添加,不要更改或删除,同时记住例外情况——接口、函数参数和返回值不能以向后兼容的方式添加。
如果您需要大幅度更改 API,或者如果 API 随着添加更多功能而开始失去其重点,那么可能需要一个新的主要版本。但大多数时候,进行向后兼容的更改很容易,并避免给您的用户带来麻烦。
下一篇文章:Go 1.15 发布
上一篇文章:泛型的下一步
博客索引