Go 模块开发函数兼容性保证

kenticny

在平时开发中,尤其是在开发 Go 模块的时候,我们需要对于已经公开的结构体,变量,函数,接口等类型进行兼容性的考虑,我们要给函数增加参数时,如果我们直接在原有方法中增加参数,这样会有什么问题吗?这样修改本身并没有什么问题,但是如果这样修改的话,如果调用方把这个模块更新到最新版本以后,那么就会出现编译失败的问题:因为函数的参数数量变化了。

有人会说,这个问题调用方改动一下不就没有问题了么。虽然是这样,但是对于开发者来讲,在开发一个公开使用的模块时,如果不考虑兼容性,那么对于用户来讲很可能造成极差的体验。所以今天我们就讨论一下 Go 开发时对于兼容性的一些实践。

导读

本文主要针对 Go 模块开发时常见一些兼容性问题进行讨论,本文列举的一些原则和实践有借鉴 Go 标准库的一些实现方式。大家也可以自行到 Go 标准库源码中发掘实际的实现方法。本文主要对函数的兼容性进行讨论。

函数增加参数

我们最开始提到的例子,就是典型的函数兼容性的问题:

1
2
3
4
5
// 原有的函数签名
func Save(b []byte) error

// 更新以后的函数签名
func Save(b []byte, allowEmpty bool) error

我们以函数增加参数的场景为例,有很多人会想到使用可变参数的方式来增加新参数,方法如下:

1
func Save(b []byte, allowEmpty ...bool) error

这种方式在绝大部分场景下是可行的,为什么说是绝大部分场景呢,我们看下面的例子:

1
var copyFunc func(b []byte) error = Save

这里声明一个新的函数变量,然后将Save方法赋值给变量,那么这时候如果函数签名发生变化,这里的赋值就不成立了。所以我们一定要注意,不要修改函数签名

那么如果我们有需要添加函数的需求的时候,采用添加新函数的方式可能是更合适的方案。

1
2
func Save(b []byte) error
func SaveUpsert(b []byte, allowEmpty bool) error

上述方法定义一个新的函数,既满足了新的需求,而且不影响原有的实现。

其实在 Go 标准库中也有类似的实现,如果大家有 database/sql 使用经验的话,就会发现,在这个库中的函数签名很多是这样的:
database/sql函数前面

可以看到,这里的函数很多都是区分带Context和不带Context的函数,这里就是因为在 context 包引入以后,Go 团队为了考虑模块的兼容性,所以使用了这种方式。

函数配置信息

上面提到的方法在我们对函数改动比较少的时候,是比较好的方法,但是如果我们对于函数的改动较多较频繁,比如我们假设每周一个版本都是增加参数的,如果我们每次都增加一个新函数,那么可能一段时间之后这个模块基本就没办法维护了。所以我们需要用另一种方法来应对这个场景。

通常我们可以给函数增加一个配置信息的结构体:

1
2
3
4
5
6
7
8
9
10
type Options struct {
AllowEmpty bool
}

func Save(b []byte, opt *Options) error {
if !opt.AllowEmpty && len(b) == 0 {
return errors.New("empty buffer")
}
return save(b)
}

这样的话如果我们需要增加其他的配置项,只需要在结构体中增加新的字段就可以,不会影响函数签名,只要我们使用恰当,就可以保证兼容性。

那么这种方法在使用时需要注意哪些呢?首先要考虑默认值的问题,结构体的特点就是可以设置全部的值,也可以只设置部分值,而且在 Go 语言中所有类型都是有默认值的,所以我们要考虑到如果没有设置的字段,默认值是否会对兼容性产生影响。这里我们举一个的例子:

1
2
3
4
5
6
7
8
9
10
type Options struct {
AllowEmpty bool
Default []byte
}
func Save(b []byte, opt *Options) error {
if opt.AllowEmpty && len(b) == 0 {
return save(opt.Default)
}
return save(b)
}

这里 Save 函数之前只有 AllowEmpty 字段,如果这个字段为 false 时,则返回一个 error;然后我们在配置结构体中加入一个新的字段 Default,并且修改逻辑如果 AllowEmpty 为 false 时,则保存默认值 Default。

我们可以发现,这种不兼容的问题会更加隐蔽,因为上面我们修改函数签名的方式,用户可以在编译时第一时间发现这个问题,但是我们这个场景用户没办法在第一时间发现,甚至有可能在发现的时候已经造成了很大的影响了。所以我们在使用这种方式时,一定要对逻辑和默认值进行检查。

除此之外,我们还要注意一个很重要的点,配置结构体中字段只能够增加不能够修改或删除。否则我们就没办法保证函数的兼容性了。

可变函数配置类型

除了上述增加配置结构体的方法,相信大家有开发经验的肯定还见过另一种增加函数配置的方法,就是在 GRPC 中使用的配置参数:是通过定义一个配置类型,然后将这个类型以可变参数的形式传入函数中:

1
2
3
4
5
grpc.Dial(address,
grpc.WithInsecure(),
grpc.WithTransportCredentials(creds),
grpc.WithMaxDelay(time.Second),
grpc.WithBlock())

GRPC 中定义了一个 DialOption 接口类型,将各种配置定义成一个返回值为 DialOption 的函数,然后将通过可变参数的形式传入的 Dial 中。至于 DialOption 的实现,这里不做过多描述,大家有兴趣的可以自行到 GRPC 中查看源码,或者以后再单独对这部分进行详细说明。

结尾

今天我们这里整理了三种常见的对于函数兼容性保证的方法。对于这些方法有几点是一定要注意的:

  1. 不要修改函数签名
  2. 不要删除和修改已有信息,尽可能通过增加新的内容保证兼容。

除了对于函数的兼容性保证之外,我们在开发中还有其他的兼容性问题,我们会在之后再和大家一起讨论。

  • 本文标题:Go 模块开发函数兼容性保证
  • 本文作者:kenticny
  • 创建时间:2021-09-14 00:44:33
  • 本文链接:https://luyun.io/2021/09/14/go-mod-compatibility-function/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
 评论
此页目录
Go 模块开发函数兼容性保证