01
介绍
随着您添加新功能、更改行为和重新考虑模块公共功能的某些部分,模块将随着时间的推移而演变。
但是,发布新的主要版本对用户来说很难。他们必须找到新版本,学习新的 API,并更改他们的代码。有些用户可能永远不会更新,这意味着您必须永远维护代码的两个版本。因此,最好以兼容的方式更改现有包。
在这篇文章中,我们将探讨一些技术,以引入非中断的更改。常见的主题是:添加、不更改或删除。我们还将讨论如何从一开始就设计 API 的兼容性。
02
添加新函数
通常,重大更改以函数的新参数的形式出现。我们将介绍一些处理此类变化的方法,但首先让我们看看一种不起作用的技术。
在添加具有合理默认值的新参数时,很容易将它们添加为可变参数。
扩展函数
func Run(name string)
添加默认为零的附加大小参数
func Run(name string, size ...int)
理由是所有现有的调用者将继续工作。虽然这是事实,但 Run 的其他用途可能会中断,比如:
package mypkgvar runner func(string) = yourpkg.Run
原始 Run 函数在这里工作,因为它的类型是 func(string),但新的 Run 函数的类型是 func(string, ...int),因此分配在编译时失败。
此示例说明调用兼容性不足以向后兼容性。事实上,不能对函数的签名进行向后兼容的更改。
添加新函数,而不是更改函数的签名。例如,引入上下文包后,传递上下文已成为常见做法。上下文作为函数的第一个参数。但是,稳定的 API 无法更改导出的函数以接受 context.Context,因为它将中断该函数的所有用途。
而是添加了新的函数。例如,database/sql 包的查询方法的签名是(现在仍然是)
func (db *DB) Query(query string, args ...interface{}) (*Rows, error)
创建上下文包时,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 握手需要网络和地址,但它具有许多其他参数,且默认为合理。使用这些默认值为配置传递 nil;通过包含某些字段的 Config 结构将覆盖这些字段的默认值。将来,添加新的 TLS 配置参数只需要 Config 结构上的新字段,这一更改是向后兼容的(几乎总是-请参阅 Part 05 的"维护结构兼容性")。
有时,可以通过使选项结构方法接收器来组合添加新函数和添加选项的技术。考虑网络包在网络地址上监听的能力的演变。在 Go 1.11 之前,网络包仅提供具有签名的监听功能
func Listen(network, address string) (Listener, error)
对于 Go 1.11,在网络监听中添加了两个功能:传递上下文,并允许调用方提供"control function",在创建后但在绑定之前调整原始连接。结果可能是一个新的函数,它采取了上下文、网络、地址和控制功能。相反,包作者添加了一个监听配置结构,预计有一天可能需要更多的选项。他们不是用繁琐的名字定义一个新的顶级函数,而是向 ListenConfig 添加了监听方法:
type ListenConfig struct { Control func(network, address string, c syscall.RawConn) error } func (*ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)
将来提供新选项的另一种方式是"Option types"模式,其中选项作为可变参数传递,每个选项都是一个函数,用于更改所构造值的状态。一个广泛使用的例子是 google.golang.org/grpc 的 DialOption。
选项类型在函数参数中实现与结构选项相同的角色:它们是传递行为修改配置的可扩展方式。决定选择哪种因素很大程度上取决于风格问题。请考虑 gRPC 的拨号选项类型的以下简单用法:
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.Minute, Block: true, })
功能选项有一些缺点:它们要求在每次调用的选项之前写入包名称;它们会增加包命名空间的大小;如果提供两次相同的选项, 则不清楚该行为应该是什么。另一方面,使用选项结构的函数需要一个参数,该参数可能几乎总是为 nil,有些人觉得该参数不是很好。当类型的零值具有有效含义时,指定选项应具有其默认值(通常需要指针或额外的布尔字段)是笨拙的。
这两种选择都是确保模块公共 API 未来可扩展性的合理选择。