Go 接口兼容性实践
之前我们讨论了在 Go 模块开发中,对于公开的函数和结构体在修改时如果需要保证向后兼容性,需要注意到的问题。那么在模块开发中,还有一种我们很常用的类型,就是接口类型,那么在对于接口类型修改的时候,同样会遇到兼容性问题,那么本文主要讨论 Go 模块中接口在兼容性方面要考虑的问题,比如在扩展接口方法时会有哪些问题,如何保证兼容,以及解决这些问题可能需要采用的方法。
接口增加方法
在模块中对于公开接口的修改,和之前的函数和结构体一样,无非就是增加接口中的方法,修改方法函数签名,删除方法。原则之前有提到过,不要使用修改和删除的方式,而要使用增加的方式来修改。但是和之前不同的是,函数和结构体可以通过增加的方式,不会影响到兼容性。但是接口不同,在接口中新增方法,也会导致用户使用接口有问题,因为新增方法后,用户默认情况下没有实现新增的方法,所以也就没有实现这个接口了。那么我们要如何处理的增加函数的情况呢?
这里我们考虑通过新增接口的方式保证兼容性,即新定义一个接口包含新增的函数,然后由用户选择实现新接口还是旧接口。请见下面示例:
1 | type Runner interface { |
这里我们有一个 Runner 接口,包含 Run 方法,我们假设场景时定义一个可以跑动的对象的接口。然后我们需要在这个接口中加一个休息的方法 Rest(一直跑不就累死了-..-),所以为了保证 Runner 接口兼容性,所以我们新增加了一个 RestRunner 接口,然后在其中定义 Run 和 Rest 接口,而且我们也可以通过嵌套接口的方式声明新的接口。
这时肯定有人会有问题了,我们在使用的时候要怎么去区分新旧接口呢?我们接着往下看代码:
1 | type Runner interface { |
可以看到,我们定义了一个 Running 方法接受一个 Runner 参数,然后我们在方法内可以检查传入参数的类型,如果是新的类型,则可以使用新接口的方法,否则按照旧的逻辑处理。
为什么可以这样实现呢?这就是 Go 语言接口的一个特性了,众所周知,在 Go 语言中,接口定义了一系列的方法,如果一个对象实现了接口中定义的所有方法,那么它就实现了这个接口了。所以这里如果实现了 RestRunner 接口就已经实现了 Runner 接口了,所以我们可以进行这种类型转换。
其实这种方法在 Go 标准库中也有使用过,在 archive/tar 包中有很多地方使用了到了这种方法,原因是由于最开始 tar 包中都是使用的 io.Writer 和 io.Reader 接口,需要从头开始读取文件和写入文件,后来发现其实很多时候如果可以从指定位置读写,会提高效率。所以需要使用 ReadSeeker 和 WriterSeeker 方法,然后就使用了这种方式来保证接口的兼容性。
1 | func (sr *sparseFileReader) WriteTo(w io.Writer) (n int64, err error) { |
上面列举了 Go 标准库中对于这种兼容性问题处理方法的示例。而且 Go 语言的标准库接口设计也是很适合这种方式处理的,因为大家可能有注意到,Go 语言的接口很多都是以嵌套的形式设计的,我们以 io 包里面的接口为例:
1 | type Reader interface { |
所以我们在设计接口时,也可以根据功能维度进行拆分,这样在使用时会更灵活,也更容易处理兼容性问题。
私有接口
上面提到的方法虽然看起来不太优雅,但是为了要向后兼容,所以不得不采取的方法。
在之前介绍函数和结构体兼容性的时候,我们有提到过,如果函数或者结构体不需要用户使用到,那么就把函数或者结构体设置为私有的。我见到过很多人为了使用方便或者对于 Go 公开和私有机制了解不清晰的原因,会把所有的方法设计成公开的,这是一个很不好的习惯,不仅可能由于用户不合理的调用导致出现问题,也会对未来兼容性设计产生巨大的影响。
所以对于接口,我们也是同样的建议,如果一个接口不希望用户实现,那么就把接口设计成私有的,那么这里我们只单纯的通过将首字母小写私有化接口可行吗?我们来看一个例子:
1 | type runner interface { |
这里我们不希望用户实现 runner 接口,所以我们将 runner 变为私有接口,但是在 Person 接口中的函数,需要使用 runner 作为参数,那么这时就会出现一个问题,用户在实现 Person 接口时,会发现参数 runner 接口没有办法访问到,就会导致 Person 接口也无法实现。那么我们要如何解决这个问题呢?
我们可以采用在不希望用户实现的接口中增加一个未导出的方法,这样这个接口就不会被用户实现了,而且还可以被访问到:
1 | type Runner interface { |
在 Go 标准库中也有这样的实现,而且是在我们很常用的库中,就是 testing.TB(如果不知道testing库的可以面壁了,测试一定是开发中最重要的一个环节):
1 | type TB interface { |
为何需要兼容性
这个系列通过三篇文章来讨论兼容性问题,那么肯定有人纳闷,我们为什么要做兼容处理,如果用户不更新版本不就可以了么。所以这里我们简单对于一种版本号的规范进行一个说明,大多数情况,大家会使用 major.minor.patch 的格式作为版本号,比如 1.1.0。
其中 major 表示主版本,在有不兼容的功能加入的时候就需要提升主版本号,所以major版本号的变化不需要考虑兼容性。那么肯定有人会想,我每次修改都修改major版本号不就可以了么。要知道,每次major版本的更新对于用户来讲影响都是很大的,如果说major版本更新过于频繁,就会导致用户没有意愿去更新版本。
然后 minor 版本表示功能版本,在这个版本号下的所有改动都需要考虑向后兼容性,这也就是我们这个系列的文章主要讨论的内容。这个版本号一般在有新功能加入的时候进行更改。
最后 patch 版本号是作为补丁版本号,一般在修复 bug 的时候进行修改。
这里很多人对于版本号的理解有一些误区,比如对于 minor 和 patch 版本的混用,这一点一定要明确两者的区别。还有就是有人会任务版本号就是0-9,然后到 9 以后就进位了,这里每一位版本号都是独立的,也是没有限定范围的,比如 v1.20.0 这个版本号是合法的。
所以如果我们对于模块的改动真的到了无法兼容的地步,那就只能去修改 major 版本号了。
结尾
至此我们对于在 Go 模块开发的兼容性问题的讨论就已经全部完成了,本文主要介绍接口兼容性实践,如果有对于函数和结构体兼容性感兴趣的可以查看前两篇文章。
- 本文标题:Go 接口兼容性实践
- 本文作者:kenticny
- 创建时间:2021-09-20 00:42:04
- 本文链接:https://luyun.io/2021/09/20/go-mod-compatibility-interface/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!