API保持模块兼容


随着您添加新功能、更改行为和重新考虑模块公共表面的部分,您的模块将随着时间的推移而发展。正如Go Modules: v2 and Beyond 中所讨论的,对 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引入包之后,将 acontext.Context作为第一个参数传递给函数成为常见做法。但是,稳定的 API 无法将导出的函数更改为接受 a,context.Context因为它会破坏该函数的所有使用。

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

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

context创建包,围棋队增加了一个新的方法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/sqlAPI 的这种扩展不会降低包的可读性或理解性。

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

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

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

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

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

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

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/grpcDialOption

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

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

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

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

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

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

使用接口

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

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

让我们用archive/tar包中的一个例子来说明这一点。tar.NewReader接受io.Reader,但随着时间的推移,Go 团队意识到,如果可以调用Seek. 但是,他们无法Seekio.Reader: 中添加方法,这会破坏io.Reader.

另一个排除的选项是更改tar.NewReader为 acceptio.ReadSeeker而不是io.Reader,因为它支持两种io.Reader方法 和Seek(通过io.Seeker)。但是,正如我们在上面看到的,更改函数签名也是一个重大更改。

因此,他们决定保持tar.NewReader签名不变,但io.Seekertar.Reader方法中键入检查(和支持):

package tartype 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.TBprivate()功能

// 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。调用此方法会选择用户加入新行为,但不这样做会为现有用户保留旧行为。

保持结构兼容性

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

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

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

为了保持结构的可比性,不要向它添加不可比较的字段。您可以为此编写一个测试,或者依靠即将推出的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字段将使您以后可以自由地更改结构,而不必担心破坏比较。不利的一面是,该类型不能用作映射键。

结论

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

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


原文链接:https://www.cnblogs.com/wenwei-blog/p/10435602.html