引言
本文是本系列的第 5 篇。
- 第 1 篇:使用 Go 模块
- 第 2 篇:迁移到 Go 模块
- 第 3 篇:发布 Go 模块
- 第 4 篇: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.Reader 改为 io.ReadSeeker,后者同时支持 io.Reader 的方法和通过 io.Seeker 提供的 Seek。但前面已经看到,改变函数签名同样是破坏性修改。
因此,他们决定保持 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 字段,允许禁用 keep-alive 或修改其周期。默认值零保留原来的行为:以默认周期启用 keep-alive。
有一种微妙的情况会让新增字段意外破坏用户代码。如果一个结构体的所有字段类型都可以比较,也就是说,这些类型的值可以使用 == 和 != 比较,并可以用作 map 的键,那么整个结构体类型也可以比较。此时,增加一个不可比较类型的字段,会让整个结构体类型变得不可比较,从而破坏任何比较该结构体值的代码。
要保持结构体可比较,就不要为它增加不可比较的字段。可以编写测试来检查,也可以依赖原文当时即将推出的 gorelease 工具捕获这种问题。
要从一开始就阻止比较,应确保结构体中有一个不可比较的字段。结构体可能已经有这样的字段,因为切片、map 和函数类型都不可比较。如果没有,可以像下面这样增加一个:
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 开始失去明确的职责范围,那么可能就到了发布新主版本的时候。不过,大多数情况下,进行向后兼容的修改很容易,也能避免给用户带来负担。
原文:Keeping Your Modules Compatible。作者:Jean Barkhuysen、Jonathan Amsterdam;版权所有 The Go Authors。原文发表于 2020 年 7 月 7 日。本文为中文翻译,代码保留原文。许可及免责声明全文见 LICENSE。











暂无评论内容