保持模块的兼容性

这是 Go Modules 系列的最后一篇文章,前面的四篇文章中介绍了如何使用模块,以及如何更新大版本,最后这篇文章将介绍一下在项目的维护过程中,如何保证项目的兼容性。

原文地址:https://blog.golang.org/module-compatibility

简介

这个系列的文章总共有五篇,这是第五篇:

你的模块随着不断的添加新特性,以及重新设计公开接口而不停变化。正如上一篇文章所讨论的,对 v1+ 模块做出破坏性修改,就必须升级主版本号(或者采用新的模块路径)。

然而,发布一个主版本对用户就不太友好。他们必须找到新的版本,并学习新的 API,然后修改他们的代码。有些用户可能永远也不会更新,这就意味着你也必须永远维护者两个版本的代码。所以最好是对你现在的代码用兼容的方式修改。

在这篇文章中,我们会介绍一些不做破坏性修改的技术。一句话概括就是:增加,不要修改或删除。我们也会讨论如何从一开始就对 API 的兼容性进行设计。

增加函数

通常,给方法增加参数就是一种破坏性修改。我们将会讨论一些处理这类变更的方法,但在这之前,先来看一些不合理的修改方式:

当想用一种好的方式来增加新参数时,却很容易添加成可变参数。来扩展下面这个函数:

1
func Run(name String)

添加一个默认值为 0 的 size 参数,有人可能会用下面的形式:

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

当前对这个方法的所有调用都能继续正常工作,但是对 Run 方法其他用法可能就会出问题,比如下面这种:

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

原来的 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)

当 context 包被创建之后,Go 的开发团队为 database/sql 包增加了一个新方法:

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

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

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

通过增加新的方法可以用户自己掌握迁移的速度。新增的方法读取方法类似,而且排列在一起,新方法的名称中也包含了 Context,因此 database/sql API 的扩展并没有降低包的可读性。

如果你预计函数在后续可能需要更多参数,可以提前做准备,把可选实参作为函数签名的一部分。最简单的方式是添加一个 struct 参数,比如 crypto/tls.Dial 方法就是这么做的:

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

通过 Dial 方法进行 TLS 通信需要 network 和 address 的参数,而其他的很多参数则有一个合理的默认值。如果直接给 config 参数传递 nil,则使用默认值。如果传入了一个 Config 结构体,那些设定了值的字段将覆盖默认值。以后如果要为 TLS 配置一个参数只需要在 Config 结构体中增加一个字段,这个修改是向后兼容的(几乎总是向后兼容的-见下文的维护结构体的兼容性)。

有时候添加新函数和可选参数可以把选项构造为方法接收器。下面来看一下 net 包中监听网络地址功能的演变。在 Go1.11 之前,net 包仅仅提供了一个 Listen 函数:

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

在 Go1.11 中,两个新功能被添加到 net 包的监听功能中:传入一个 context,允许调用者在创连接后而没有绑定之前,通过一个”控制函数”来调整原始连接。如果按照通常的做法,就会产生一个接受context、network、address 和控制函数的新函数。但包的作者没有这么做,而是新增了一个 ListenConfig 结构体,以便在将来添加更多的参数。而且他们没有在报级别添加一个名称很长的函数,而是直接在 ListenConfig 中添加了一个 Listen 方法:

1
2
3
4
5
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 的 Self-referential functions and the design of options 文中做了详细的描述。另外一个被广泛使用的例子是 google.golang.org/grpc 包中的 DialOption。

选项类型在函数参数中扮演与结构体选项相同的角色:它们都可以通过一种可扩展的方式来修改行为。选择哪个要看具体的场景。来看一下一下 gRPC 的 DialOption 选项类型的简单用法:

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

这也同样可以被实现为一个结构体选项:

1
2
3
4
5
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.Reader,而是接收 io.ReadSeeker,因为这样可以同时支持 io.Reader 和 Seek(通过 io.Seeker)。但是,正如之前所讨论的,修改函数签名也是一个破坏性的修改。

所有,他们决定不动 tar.NewReader 的签名,而是在 tar.Reader 中增加了对 io.Seeker 的支持:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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's private() function

1
2
3
4
5
6
7
8
9
10
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 包的作者在 Go1.11 中添加了 ListenConfig,因为他们认为可能会添加更多的参数,事实证明他们是对的。在 Go1.13 中,添加了 KeepAlive 参数,这个参数可以禁用 KeepAlive 或修改其周期。缺省值 0 将会保留启用 keep-alive 的默认周期的原始行为。

还有一种需要注意的情况,新字段可能会破坏用户代码。如果一个结构中的所有字段类型都是可比较的——这些类型的值可以用 == 和 != 进行比较,并用作map 中的 key —— 那么整个结构类型也是可比较的。在这种情况下,添加一个不可比较类型的新字段将使整个结构类型变得不可比较,这样比较该结构类型值的代码就会出错。

为了保持结构的可比性,不要向结构中添加不可比较性字段。你可以为此编写一个测试,或者使用即将发布的 gorelease 工具来捕获这个问题。

如果从一开始就不想让结构体进行比较,请确保该结构有一个不可比较的字段。它可能已经有了一个非切片、map 或函数类型是可比较的。但如果没有,可以这样添加一个:

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

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

1
2
3
4
5
6
7
type doNotCompare [0]func()

type Point struct {
doNotCompare
X int
Y int
}

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

如果你想定义一个直接作为值使用的结构体,就像我们的例子,那么你通常希望它具有可比性。在不常见的情况下,你有一个值结构,但不想让它有可比性,就可以添加一个 doNotCompare 字段,这样可以自由地更改结构,而影响比较性。缺点这类型不能用作 map 的 key。

小结

在从头开始规划 API 时,请仔细考虑 API 对于未来新更改的可扩展性。当你确实需要添加新特性时,请记住这条规则:添加、不更改或删除,记住异常、接口、函数参数和返回值不能以向后兼容的方式添加。

如果你需要大幅度改变一个API,或者随着更多特性的添加,一个 API 开始被使用的越来越少,那么可能是时候推出一个新的主版本了。但在大多数情况下,进行向后兼容的更改很容易,并且可以避免给用户造成痛苦。

译 / Rayjun

© 2020 Rayjun    PowerBy Hexo    京ICP备16051220号-1