保持您的GO模块兼容

2020-07-08 02:33:48

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

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

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

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

在添加具有合理缺省值的新参数时,很容易将它们添加为可变参数。要扩展函数,请执行以下操作。

理由是所有现有的呼叫点都将继续工作。虽然这是真的,但Run的其他用法可能会被打破,就像下面这样:

原来的run函数在这里有效,因为它的类型是func(String),而新的run函数的类型是func(string,.int),所以赋值在编译时失败。

此示例说明仅有调用兼容性还不足以实现向后兼容。事实上,您不能对函数的签名进行向后兼容的更改。

添加一个新函数,而不是更改函数的签名。例如,在引入上下文包之后,通常将context.Context作为第一个参数传递给函数。但是,稳定的API无法将导出的函数更改为接受context.Context,因为这会中断该函数的所有使用。

相反,添加了新的函数。例如,数据库/SQL包的查询方法的签名曾经是(现在仍然是)。

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

添加一种方法允许用户按照自己的进度迁移到新的API。由于这些方法读取相似并一起排序,并且上下文在新方法的名称中,因此数据库/SQLAPI的这种扩展不会降低包的可读性或可理解性。

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

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

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

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

类型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。

选项类型实现了与函数参数中的struct选项相同的作用:它们是传递行为修改配置的一种可扩展方式。决定选择哪一个在很大程度上是一个风格问题。请考虑以下GRPC DialOption选项类型的简单用法:

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

为了确保模块的公共API将来的可扩展性,这两种方法都是合理的选择。

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

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

让我们用存档/tar包中的一个示例来说明这一点。NewReader接受io.Reader,但随着时间的推移,Go团队意识到,如果可以调用Seek,从一个文件头跳到下一个会更有效。但是,他们无法向io.Reader添加Seek方法。Reader:这会破坏io.Reader的所有实现者。

另一个被排除的选项是将tar.NewReader更改为接受io.ReadSeeker而不是io.Reader,因为它同时支持io.Reader方法和Seek(通过io.Seeker)。但是,正如我们在上面看到的,更改函数签名也是一个突破性的更改。

因此,他们决定保持tar.NewReader签名不变,但键入check for(并支持)io.seeker in tar.Reader方法:

Package tartype Reader struct{rio.Reader}func newread(rio.Reader)*Reader{return&;Reader{r:r}}func(r*Reader)read(b[]byte)(int,error){if rs,ok:=R.r(io.Seeker);ok{//使用更高效的rs.Seek。}//使用效率较低的r.r.Read。}。

当您遇到要向现有接口添加方法的情况时,或许可以遵循此策略。从使用新方法创建新接口开始,或使用新方法标识现有接口。接下来,确定需要支持它的相关函数,为第二个接口键入check,并添加使用它的代码。

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

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

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

type TB interface{error(args.interface{})Errorf(Format String,args.interface{})//.//一种专用方法,用于阻止用户实现//接口,因此将来向其添加内容不会//违反GO 1兼容性。私有()}

乔纳森·阿姆斯特丹在“检测不兼容的API更改”讲座(视频、幻灯片)中也更详细地探讨了这一主题。

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

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

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

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

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

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

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

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

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

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

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

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