目录
“一直想一篇关于 golang 包管理的文章,一直踌躇不决。”
——这句话其实是我在去年,也就是 2018年 8 月份,起草这篇文章时所写的开头,当时 go module 发布,于是想停止“踌躇”,一鼓作气写下这篇文章,结果写到一半又踌躇了,于是留下了半篇草稿,准备继续看看发展动向再说。
一拖就是一年多,如今在我看来大局已定,go module 经历了从 go 1.11 到 go 1.13 的迭代,已经广得人心,大量的 golang 代码仓库里出现了 go.mod 文件,所以说 go module 是 golang 包管理的最佳工具已经无可争议。恰逢最近需要做一次部门分享,选题就是 golang 包管理工具,故借此机会,把这篇拖了这么久的文章完结了吧。
大家应该知道,golang 包管理这件事简直是团乱麻,所以我会从头讲起,希望读者耐心,这是一个不那么枯燥的故事。
我每讲一段故事时,都会介绍一下这事儿发生在什么时候,以及当时最新版的 go 是什么版本,便于你代入其中。但要注意的时候,其中有些是说“伴随着这次 go 版本发布,产生了这件事”,是因果关系;有些是说“这件事发生时,同时期 go 的最新版本是什么”,没有关联。所以注意区分。
蛮荒时期:goinstall
时间:2010 年 2 月
版本:go 尚未正式发布
这是在 golang 发布之前,编译 golang 用的是还是 makefile 和 gobuild
,注意是 gobuild
(执行 gobuild)而不是 go build
(执行 go 并传入参数 “build”),这时候还没有 go
这个工具。
同样,获取三方包也是用的一个单独的工具叫 goinstall
,它的功能就是从代码管理仓库(如 GitHub、Bitbucket)获取指定的源码到本地。很显然,这是 go get
的原型,但不同的是,goinstall
是将源码下载到 $GOROOT/src/pkg/
而不是 $GOPATH/src/
,此时还没有 $GOPATH。
这个时期的很多事情已经不可考了,所以我们不做过多展开,但需要知道这个时期为后来 golang 的开发至少留下了两个设计遗产:
- 包名指示了包在哪儿;
- 包即是源码。
如果你学习过 golang 的开发应该清楚这两件事。golang 的包拥有一个“类似 URL” 的包名,这样一来,goinstall 不需要配置仓库地址便能从互联网下载到指定的包,避免使用单个仓库来集中管理所有的三方包。没有静态链接库,也没有动态链接库,更没有字节码文件,golang 的“包”,大多数时候就是一个有若干源文件的文件夹,既没有预编译,也没有打包。
这两个设计奠定了 golang 包管理的基调。
创世纪:go get
时间:2011 年 12 月
版本:go 1
go get
是伴随着 go 的正式版一道发布的,也是最基本的、最为人熟知的 go 三方包管理工具。
它的用法基本沿用了 goinstall
,但 go get
不再将三方包放置到 $GOROOT 下,而是新定义了一个环境变量 $GOPATH,原先的 $GOROOT 仅存放内置的包,与三方包加以区别。go 在编译时优先搜索 $GOROOT 下是否有所需要的包,若未找到,则在 $GOPATH 搜索。
从此,为什么要配置且如何配置 $GOPATH,成了无数 golang 初学者的第一堂课,它是如此重要,乃至于长久以来如果没有配置 $GOPATH 就无法正常使用 go,最后登峰造极,从 go 1.8 开始,即使用户没有配置 $GOPATH,它也将拥有一个默认值($HOME/go
或 %USERPROFILE%/go
)。
然而,在我看来这是一个有争议的设计。从此,“一个包的包名”,与“包代码存放的文件路径”,与“包在互联网上的存放位置”,与“代码中 import
的写法”,逐级耦合了起来。
如果你不能理解这种耦合有多么让人头痛,我写了一个小故事来作为例子说明,见《$GOPATH 耦合之殇》,你可以有时间看一下,但现在我们先不断开思路。
注意一点,目前为止,“版本”概念仍然没有出现,你只知道你使用了某个包 github.com/xx/yy
,你不知道你使用了这个包的什么版本,go get
永远只会傻乎乎的获取代码仓库 github.com/xx/yy
主分支的最新版本,根本不在乎最新版本是否是稳定版。
正因如此,你某个项目所依赖的 github.com/xx/yy
可能是一年前你 go get
时所获得的,你们组新来的实习生想接手你的项目,于是 go get github.com/xx/yy
,却拿到了大相径庭的版本,导致项目无法编译,而你也说不清你用的到底是哪一版,只能拿 U 盘将你 $GOPATH 下面的内容拷贝给实习生。
更可怕的是,如果实习生又负责了一个新项目,需要用到 github.com/xx/yy
的最新版本的新特性,冲突便爆发了,毕竟 $GOPATH 只有一个,总不可能在同一个文件夹下存两份同名的文件,新版与旧版,只能留一个。
正因为这样的矛盾,出现了一样奇技淫巧:不同项目使用不同的 $GOPATH。即:为每一个项目都安排一个文件夹作为 $GOPATH,在开发某个项目之前,修改 $GOPATH 指向该项目专属的文件夹,当需要开发另一个项目时,则再次修改 $GOPATH。当然手动修改自然不方便,有些早期的 golang 服务框架,会生成一个写有环境变量的 env.sh
文件,让你在每次开发之前 source
它。
所以有人会误解,认为 $GOPATH 等同于工作区(workspace),每一个项目都要有自己的工作区。然而,我从未在官方资料中看到过这样的说法,这就是奇技淫巧。所以当有人要你修改 $GOPATH 或往 $GOPATH 里追加更多路径时,请小心。
无论如何,$GOPATH 和 go get
一起,开启了 golang 包管理从无到有的新时代。
不成功的革命:gopkg.in
时间:2014 年 3 月
版本:go 1.2.1
你应该见过长这样的包:
import (
"gopkg.in/yaml.v2"
"gopkg.in/ini.v1"
"gopkg.in/redis.v5"
"gopkg.in/jcmturner/aescts.v1"
)
这些包有两个明显的特点,一是都是“gopkg.in”开头,而是都是 “.v + 版本”结尾。所以它的先进之处在于,从包的名字就可以得知我用的是这个包的哪个版本,且如果我愿意,我可以在同一份代码引用同一个包的多个版本,而此前这是不可能的:
import (
yamlv1 "gopkg.in/yaml.v1"
yamlv2 "gopkg.in/yaml.v2"
"gopkg.in/yaml.v3"
)
func main() {
_, _ = yamlv1.Marshal(nil)
_, _ = yamlv2.Marshal(nil)
_, _ = yaml.Marshal(nil)
}
既然 golang 包名是“类似 URL”的,所以 gopkg.in 当然也是一个可以打开的网址,打开之后,你会立马明白它做了什么:
gopkg.in/包名.v3
会被重定向到github.com/go-包名/包名
的v3/v3.N/v3.N.M
分支或 tag;gopkg.in/用户名/包名.v3
会被重定向到github.com/用户名/包名
的v3/v3.N/v3.N.M
分支或 tag。
这是一个很精巧的、零入侵的设计,不是吗?但为什么我会称它为“不成功的革命”呢?因为如果它是成功的革命,那现如今你写 golang 代码 import 的每一个三方包都应该是 “gopkg.in” 开头,但事实不是。
gopkg.in 的问题在我看来至少有两点,一是它违背了去中心化,这很好理解,golang 包的设计就是要去中心化,结果现在所有的包都在 gopkg.in 之下,那可不行。二是它其实不是零入侵的,举个例子,看这段摘抄至 github.com/go-redis/redis v5 版本:
package redis
import (
"fmt"
"math/rand"
"sync"
"sync/atomic"
"time"
"gopkg.in/redis.v5/internal"
"gopkg.in/redis.v5/internal/hashtag"
"gopkg.in/redis.v5/internal/pool"
"gopkg.in/redis.v5/internal/proto"
)
// ……
注意到了吗,因为这个包有子包,当它使用子包时,必需要写“gopkg.in/redis.v5/internal”,这就意味着 gopkg.in 已经入侵到其代码中了。所以当 v6 版本决定弃用 gopkg.in 时,又不得不改成“github.com/go-redis/redis/internal”。
虽然这次革命不成功,但它留下了一个启示,就是“使用不同的 import 路径来引入同一个包的多个版本”,这为后来的设计埋下了伏笔。
百花齐放的时代:vendor
时间:2015 年 6 月
版本:go 1.5
我们前文说不要把 $GOPATH 当 workspace,但平心而论,$GOPATH 又真的很像 workspace,因为整个 $GOPATH 目录才是真正的“可编译单元”。
我把我开发的“github/wolfogre/test”发你瞧瞧时,你即使把文件放到了 $GOPATH/src/github/wolfogre/test
下,也很可能编译失败,因为“github/wolfogre/test”的依赖包仍在我的$GOPATH目录下,而我需要逐个挑出来发给你,以保证你在编译时用的依赖包和我是同一个版本,与其如此,不如我把整个 $GOPATH 打包发给你得了,即使文件可能有好几 GB 大小。
就不能把“github/wolfogre/test”的所有依赖放到“github/wolfogre/test”所在的文件下吗?
go 1.5 发布时,带来了一个新特性“vendor”,这其实是个不起眼的变更,我甚至可以两句话讲清:
- 把项目的子文件夹 vendor 目录当做一个该项目专享的“虚拟 $GOPATH”;
- go build 时的寻包路径依次是 $GOROOT、vendor、$GOPATH。
所以我就可以把 “github/wolfogre/test” 的所有依赖包都放到它的 vendor 目录下,这样打包发给你的时候你就可以顺利编译了。
所以 go 又提供了什么工具帮我把依赖包全部拷贝到 vendor 目录下呢?答案是没有,官方只是实现了对 vendor 的支持,没有提供相应的管理工具。
但没关系,听,一大批帮忙解决这个问题的三方工具即将到达战场,谁都想着在那个时候,拔得“最佳 golang 包管理工具”的头筹,千军万马,百花齐放:
这些包不仅是帮忙将代码拷贝到 vendor,也引入了包版本管理的概念,但要注意,官方可还没来没有给“go 包版本”下过定义,那么这里的包版本指啥呢?没错,就是依赖包所在的 git 仓库的 commit id(b5fcb62),或 branch(master),或 tag(v1.2.0),我可以要求我是要用某个包的的哪次 commit,或哪个分支的最新一次提交(这通常不靠谱),或哪个 tag。
由此可见,虽然说 golang 并不仅仅支持 git,但在事实上,git 已经成了 golang 的默认代码版本控制工具了,所以此后就很少有人再谈论使用其他代码版本控制工具开发 golang 项目,但这不是件坏事。
发散的设计意味着为找到最优解留下空间,收敛的工作意味着找到了一个可能的最优解。
争鸣的终结者:dep
时间:2017 年 5 月
版本:go 1.8.3
当这些同质化非常严重的工具争鸣谁最好用的时候,来自官方的方案终结了民间的喧嚣。
它叫 dep,是不是像“狗蛋”一样是个不起眼的名字?但它全名叫“golang/dep”,这个狗蛋其实是“爱新觉罗·狗蛋”。
说它是争鸣的终结者不仅仅因为它出身正统,也是因为它积攒了足够了群众基础,当前(2019 年 11 月)它的 star 数是 12.9k,其他工具难以望其项背。
这里简要描述一下它的使用方式:
- 在项目代码根目录运行
dep init
来初始化,得到 Gopkg.lock Gopkg.toml; -
Gopkg.lock
描述了项目正在使用的依赖包的 commit id 版本,和源码文件的哈希; -
Gopkg.toml
是项目对依赖包的版本的“约束”,以及其他配置; dep ensure
会尝试让vendor
下文件内容匹配 Gopkg.lock,让 Gopkg.lock 满足 Gopkg.toml 的约束;- 把某个包更新到(满足Gopkg.toml约束的)最新版本:
dep ensure -update pkg-name
; - 把某个包更新到指定版本:在 Gopkg.toml 添加约束,执行
dep ensure
; dep check
检查 vendor 下文件是否匹配 Gopkg.lock,Gopkg.lock 是否满足 Gopkg.toml。
而如何编辑 Gopkg.toml 可能需要稍加学习,但你可以打开默认生成的 Gopkg.toml 文件就可以看到一些简单的例子,或者其注释里有个文档地址,打开后有详尽的介绍。网上也有很多优秀的教程,所以这里不做过多展开了。
终于有来自官方的 golang 包管理方案了!人民欢欣鼓舞。但同时,却仍为两个问题感到隐隐不安。
一是,编译一个有 vendor 文件夹的包时,虽然不需要 $GOPATH 里的三方包了,但还是需要将这个包本身放到 $GOPATH 特定路径下,来决定这个包本身叫什么。举例来说,“github.com/wolfogre/test” 依赖 “github.com/wolfogre/test/sub”,这是对子包的依赖,而不是依赖三方包,但如果我把这个包放到“$GOPATH/src/github.com/a/b”,那虽然它也有叫“sub”的子文件夹,但代码里写的是 import "github.com/wolfogre/test/sub"
,猛然变成了对“三方包”的依赖了。
二是 vendor 仍然没有解决“如何在同一个项目里引入同一个包的多个版本”,所以它没有终结 gopkg.in 的使命。
dep 是争鸣的终结者,但不是真正的终结者。
真正的终结者:go module
时间:2018 年 8 月
版本:go 1.11
在 go module 发布之前,golang 的核心作者之一 Russ Cox 在其博客上连发了 10 篇文章,来探讨一种新的包管理方式。
如果提炼一下的话,文章的对 golang 包管理方案提出了四个“指导方针”:
- 导入兼容性原则:如果旧包和新包的导入路径相同,则新包必须兼容旧包。
- 最小版本选择:使用满足需求的最旧版本,而不是最新版本。
- go module 的概念:go module 是共享 import 路径前缀的包的集合,被 import 的包是 go module 的具体版本。
- 改造成现有的 go 命令:不是提供一个包管理工具,而是提供一个全新的 go。
其中还重点说明了语义化版本的重要地位,版本号需要满足 v[major].[minor].[patch]
这样的格式,形如 v1.2.1
,并严格遵循以下语义:
- major:破坏性更新,不兼容旧版本;
- minor:新特性更新,兼容旧版本;
- patch:修复性更新,仅做 bug 修复。
并据此引出“语义化版本引入入(semantic import versioning)”:
如图所示,my/thing
不是一个包,而是一个 module,module 的具体版本 my/thing/v2
才是一个包,所以我引入它的子包时写的是 my/thing/v2/sub/pkg
。
据此,如何同时引入同一个包的不同版本就此解决,满足导入兼容性原则。你或许会问,那如果我希望同时引入 v2.2.0
和 v2.3.0
呢?还记得语义化版本的约束吗?v2.3.0
应该是完全兼容 v2.2.0
的,所以你不会有这样需求。那你又要问了,那 go 编译时怎么知道 my/thing/v2/sub/pkg
是使用 v2.2.0
还是 v2.3.0
,又或是更新的 v2.4.0
、v2.5.0
呢?答案是每个 module(包括你的项目)都描述了你的依赖的最小版本,比如你说你最小依赖 v2.2.0
,而你这个项目依赖的另一个包 other/thing/v2
也依赖 my/thing/v2
,且要求最小版本是 v2.3.0
,所以这个时候可选项有 v2.3.0
、v2.4.0
、v2.5.0
,最小版本选择登场,它最终选择了满足需求的最小版本 v2.3.0
。
可见,这是一个从编译行为上就要做改变的大更新,所以实验阶段这个项目名叫“vgo”,即“带版本的 go”,原先的 go build
、go get
命令都要换成 vgo build
、vgo get
。但同时提供两套工具自然是下下策,所以最后正式发布时,“vgo” 和 “go” 事实上是合并了,同样的命令 go build
、go get
,在不同的项目里可能有不同的行为,为了方便描述,我们暂且称为传统模式和 module 模式。
命令 | 传统模式 | module 模式 |
---|---|---|
go build |
寻包路径依次是 $GOROOT、vendor、$GOPATH; 如果缺包,报错并中止。 |
寻包路径依次是 $GOROOT、$GOPATH/pkg/mod/; 默认不支持 vendor; 发现缺包,自动获取缺失的包。 |
go get |
将包存到到 $GOPATH/src。 | 将包按照版本不同分别存到$GOPATH/pkg/mod/ 下不同路径。 |
其他命令,比如 go test
、go list
在不同模式下也有不同的行为,这里不做介绍了。
那么如何判断当运行命令时,是处于传统模式还是 module 模式呢?这由三个因素共同决定:
- 当前路径(或父路径)是否有 go.mod 文件(如果有,则倾向于 module 模式);
- 当前路径是否在 $GOPATH 下(如果是,则倾向于传统模式);
- 环境变量 $GO111MODULE 的配置(当发生分歧时起,最终决策)。
完整的决策逻辑经历了几次调整,所以现在我也有点搞不清了,但这没关系,你可以运行一下 go env
命令,看看 $GOMOD 这个变量,如果它有值,并指向了一个 go.mod 文件,便是处于 module 模式,否则则是处于传统模式,简单明了。
除了对已有的命令进行改造,go 也添加了新的命令 go mod
,用于管理 module,这里简单介绍一下它的使用:
go mod init [module-name]
来初始化一个 module;go tidy
检查当前 module 的依赖并写入 go.mod 和 go.sum;go.mod
描述了本 module 的名称、go 版本依赖、依赖包的最小版本;go.sum
记录依赖包语义化版本对应的哈希。
同时 module 模式 go get
不再是简单的执行 git clone
了,它有了为其定制的代理协议,由于一些网络方面的原因,这简直是中国人民的福音,一大堆代理实现方案、公开的代理站点如雨后春笋般出现,如 athens、goproxy、goproxy.cn,你可以通过配置 $GOPROXY、$GONOPROXY 等环境变量来设置代理,详细介绍可以看这里。
且从 go 1.13 开始,module 引入了文件检查,go get
会将获取到的包与官方的包哈希数据库,进行对比,你可以通过修改 $GOSUMDB、$GONOSUMDB、$GOPRIVATE 等环境变量来控制这一行为。如果你引入私有包时,因为无法通过文件检查而失败(私有包无法被官方的包哈希数据库收录),可以在这里找到解决方案。
你应该还注意到了一点,go.mod 文件中描述了这个 module 的名字(图中 go.mod 文件的 module github.com/wolfogre/test
一行),而不需要借助 $GOPATH 路径,所以 module 项目是不需要放到 $GOPATH 下的,可以放在任何位置,编译时也不依赖 $GOPATH/src 下存放的包。至此,module 基本摆脱了了对 $GOPATH 的依赖,只是需要借 $GOPATH/pkg/mod 这个位置存一下文件而已,算不得什么。
由此你可以看到 module 的先进性,所以 dep 不得在其 README 中声明,它是一个“实验项目”,虽然会继续维护,但事实上已经处于“废弃状态”了。
go module 仍然在迭代中,还是有一些缺点的,尤其是对 vendor 的支持不完善,比如编译时默认不支持 vendor(#27348),go mod verify
不会帮忙检查 vendor 下文件是否完整(#33848)等等。
但瑕不掩瑜,自此,golang 包管理的发展,尘埃落定。
尾声
现在是 2019 年 11 月,最新的版本是 go 1.13.4。
用于 module 模式对 vendor 的支持问题,我尚没有常态化的在生产项目中使用 go mod,而是一边用着 dep,一边观望。但历史的巨轮是无法被阻挡的,我已经发现了一些三方包开始只支持 module 模式,dep 不能帮助识别这些包。
所以可以预见的是,go 分为传统模式和 module 模式这件事,迟早会成为历史,module 模式将成为 go 唯一的工作状态,每一个包的目录下,都会有一个 go.mod 文件。这是好事,如前文所说,发散的设计意味着为找到最优解留下空间,收敛的工作意味着找到了一个可能的最优解。
以上是本文的全部内容。
评论加载中……
若长时间无法加载,请刷新页面重试,或直接访问。