遇到的问题
包管理工具常见的功能就是将项目依赖的其他模块以某种方式下载下来,然后在编译/运行项目代码时引用这些依赖的模块,这是基本功能。
但是,在基本功能下,会有更高级的需求,例如依赖解析,我们的项目可能只会依赖一个模块 A,但是模块 A 会依赖模块 B 和模块 C,这意味着我们的项目间接依赖于项目 B 和 C,这个工作也通常由包管理工具完成;还有例如版本管理的功能,我们经常会收到通知某某模块有什么漏洞,需要升级该模块,一个最常见的方式就是模块都有版本,当更新的时候,会使用更新版本的模块。
菱形依赖
正因为包管理工具帮我们做了这么多的自动化工作,所以就会遇到一些隐含的问题,最常见的问题就是菱形依赖问题:
假如我们的项目是 A,这个项目直接依赖了两个模块 B 和 C
而模块 B 和模块 C 都会依赖于模块 D,但是模块 B 依赖的模块 D 的版本是 v1.1.0,但是模块 C 依赖的模块 D 的版本是 v1.2.0
在这种场景下,我用过的一些包管理工具有不同的处理方式,例如拿 pip 来说,项目 A 可以在 requirements.txt 中指定 D 的版本,但是这可能会产生两种不同的处理方式:
- 指定 D 版本 ≥ 1.0.0,在这种情况下,pip 会选择最新的版本号,也就是说项目 A 中会使用 1.2.0 版本;
- 指定 D 版本
≥ 1.0.0 <1.2.0
,在这种情况下,pip 就会报错了,因为它找不到合适的 D 版本
看上去第一个方式可以解决菱形问题,但是,事实上在很多情况下这都不会解决问题,因为 1.1.0 版本和 1.2.0 版本的 D 很可能已经变更了代码的接口和结构,所以项目 B 很可能就运行不起来。
所以 Go 为了避免这种情况,就约定了语义化版本的概念,它用于限制版本号的改动范围,从而让依赖工具可以有效地选择最合适的依赖模块版本。
语义化版本(Semantic versions)
语义化版本(Semantic Versioning,SemVer)是由 Tom Preston-Werner、David Heinemeier Hansson 和 Guido van Rossum 等人共同提出和制定的一种版本号标准。该标准的最新版本是 SemVer 2.0.0,它定义了版本号的规范以及如何处理软件版本的增加、变更和修复。
SemVer 并没有正式的草案,而是通过 GitHub 上的一个公开的仓库来管理和维护。仓库地址为 Semantic Versioning Specification。这个仓库包含了关于 SemVer 的详细说明、历史和讨论。社区成员可以通过提出 issue 和提交 pull request 的方式参与标准的讨论和改进。
SemVer 的简单概要为:
- 版本号:Major.Minor.Patch-<alpha/beta….>
- 一旦你给一个 release 打了 tag,请不要删除或者修改它的内容
- 如果你删除了,用户通过 go 命令运行的时候将会因为找不到 tag 而出错
- 如果你修改了内容,那么用户项目中 go.sum 的 checksum 将会不匹配而出错
v0:不稳定的版本
在 Go Module 的约定中,v0 版本不需要保证 patch 的向前和向后兼容,因为处于不稳定的版本,可以重定义 API 以便在正式版本中提供稳定的 API。
- v0 版本不做任何接口稳定性保证;
- 对于大多数项目(例外:已经开发完成并被使用的现有项目之类的)来说,都应该从 v0 版本开始,这样可以方便调整 API 以便定义后续的正式 API
v1:第一个正式版本
v1 版本是第一个正式版本,它需要保证提供的接口的稳定,需要考虑迭代版本的时候按照 SemVer 的要求保持向前兼容。
v2:不兼容新版本
原则:
If an old package and a new package have the same import path,
the new package must be backwards compatible with the old package.
所以当我们开发一个新的不兼容旧版本的模块时,需要显式地在 go.mod 中加入版本的后缀,例如:
v1: github.com/liuliqiang/gomod/pkg
v2: github.com/liuliqiang/gomod/pkg/v2
这个模块名是放在 go.mod 中的第一行:
module github.com/liuliqiang/gomod/pkg/v2
在这一点上,Go Module 和其他的包管理软件是很不一样的。但是这是 Go 给出的解决菱形依赖问题的答案。
特例:gopkg.in
在出现 Go Module 之前,gopkg.in 允许模块使用 .vx
的后缀方式来区分版本号,所以 Go Module 为了兼容它,特地允许这个特例 gopkg.in
使用 .vx
的后缀,但是,对于其他路径的模块,必须是 /vx
的形式。
sample:
gopkg.in/yaml.v1
gopkg.in/yaml.v2
模块目录示例
对于不同版本的模块代码,推荐的代码组织路径为:
github.com/googleapis/gax-go @ master branch
/go.mod → module github.com/googleapis/gax-go
/v2/go.mod → module github.com/googleapis/gax-go/v2
- 好处
- 多版本可以同时开发;
- 即使不支持语义化的工具也不会失败;
Go 真的解决了问题吗?
包袱还是财富
在上中学的时候,我记忆很深的是老师问了一个问题,传统文化是一个包袱还是一种财富,因为我被点到回答这个问题,而我在那个年纪喜欢瞎扯,所以我回答了一个既是包袱也是财富,因为包袱里面包着财富(特么地回想起来我怎么感觉当时有当发言人的潜质)。
话回正题,同样的,Go 语言开始于 2006 年(从 Go 的 Time Format 中得知),发布于 2009 年,直到 Go 1.11(2018.08) 才在 Go 的子命令中支持 Go Module,并且在 Go 1.13(2019.09) 中默认使用 Go Module,在这期间,我至少使用过两种模块管理工具,分别是 govendor 和 godep,那么当我们开始使用 Go Module 之后,之前未按照 SemVer 规范发布的库该怎么办?
这主要是涉及两个问题,分别是:
- 代码迭代接口变更问题
- 未按 SemVer 发布版本问题
其实这两个问题是混合的,例如一个库它根本就没有打 Tag,那么依赖它的时候,就直接找最新的 master commit,但是,可能这个库代码它就根本没考虑兼容问题,它每个提交都是修改了接口的,这就让包管理工具很难受了,从包管理工具的角度来说,它也处理不了这种情况,这个时候,常见的操作方式有:
- 请求这个库代码按照 SemVer 来发布;
- 请求依赖这个库的依赖库去除对这个库的依赖;
- fork 库自己维护来解决接口变更问题;
我比较常见到的处理方式是第三种,因为它 Go Module 提供了 replace 的功能,并且这种方式不依赖于第三方的变更,可以在可控制的周期内解决问题,毕竟前两个依赖于别人帮你解决问题,时间是不可控的。
不讲武德的 gRPC
另外一个比较讽刺的事情是作为 Go 的官方维护和推广者 Google,自己家的 RPC 框架居然不遵从 SemVer,在 Minor Version 中更改了不向前兼容的接口,这着实让人难受了很久。我曾经有篇文档就介绍了这个问题:GRPC Go 连接握手问题处理,简单地说就是 gRPC-Go 在升级到 1.19 版本之后,之前的代码不能正常工作了,必须修改代码添加额外的参数才可以正常工作,这典型的就是不向前兼容,不符合 SemVer 的规范。
那么在这种情况下,最简单的方式肯定是我们修改自己的代码符合新版本的要求,但是,事情往往不是那么简单,因为我们的项目会依赖于其他项目,其他的依赖项目我们无法修改它的代码,所以即使我们自己改了,实际上我们的项目也是不能正常运行的,在这种情况下,处理方式就是压制住 gRPC 的版本不升级,所以当你看到你的项目依赖里面将 gRPC 的版本压制在 1.18 或者 1.33 版本的时候,不要手贱去除这个依赖,这肯定是遇过坑的。
难道我们就保持 gRPC 一直不升级?那肯定不是的,这个时候的解决办法一般是两路并行:
- 向我们依赖的项目提交需求,让他们兼容新版本的 gRPC
- 压制我们的项目 gRPC 版本,保证现有代码正常工作
当一段时间之后,我们依赖的项目或者大多数项目都兼容了新版本时,我们就可以升级修改我们的版本了,对于一些实在推不动或者没人维护的依赖,我们可以按照前面的处理方式,fork 一份并自己来维护的方式来支持我们升级。
版本兼容套路
向前兼容套路
添加默认参数
第一个常见的扩展函数参数的方式是给现有函数添加可变参数列表,例如:
func Run(name string)
--->
func Run(name string, size ...int)
通过这种方式,可以给 Run
函数添加额外的参数支持,但是,新的 Run
函数需要处理好新增参数的默认值。
创建新的方法
一个常见的扩展条件是旧函数没有 Context,但是在新函数中需要添加 Context 支持,这个场景一般是添加一个新的函数,然后新的函数名字后面加上 Context 的后缀,并且将旧函数修改为使用新函数的方式:
// 旧函数
func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
return db.QueryContext(context.Background(), query, args...)
}
// 新函数
func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)
相反的,我们需要处理好旧函数调用新函数的默认值。
向后兼容的套路
传递 Option 扩展参数
例如在第一次开发的时候,我们的函数只支持某些选项,但是,我们知道在将来,我们会支持更多的选项,所以在这种情况下,我们可以给函数设置一个选项参数,这种选项参数常见有两种写法:
- 一个带有私有变量的结构体;
- 一个带有各种设置和获取参数的接口;
这里以第一种方式为例:
type Config struct {
... ...
}
func Dial(network, addr string, config *Config) (*Conn, error)
小结
在本文中,我简单地介绍了一下 Go Module 核心要解决的问题以及它是如何解决的,以及介绍了可能遇到的问题和解决办法。在最后,我还介绍了几个常见的兼容套路(从官方文档学习来的)。
但是,Go Module 的这种机制其实衍生了一些骚操作,这些骚操作被应用在了 MonoRepo 中,我先挖个坑,后续在介绍 MonoRepo 的时候给大家介绍一下这些骚操作。