包管理与项目组织形式的历史

很多从其它语言转 golang 的程序员都会对 GOPATH 这个设定感到困惑,为什么其它语言不要求把所有代码放在同一个目录,golang 就有这样一个奇怪的要求。

根据了解,这是由于 golang 有几位来自 google 的主要缔造者。google 内部是把所有的代码放在一个大仓库里,很好用,于是 golang 的包管理也被设计成了适合这种模式的玩法。当我们把所有代码都放在一起时,很好用。

项目代码直接放 git repo 中

对应于 golang 自身的包管理,我们就有了最朴素,也是在 github 上最常见的项目组织形式:直接把包放在 git 目录中,托管在 github 等代码托管平台。这里相比其它语言,最重要的一点是:所有包都是以远程包的形式来组织与使用的。

这也就有一个大大的问题,所有依赖包都是远程包,如果我们依赖的包被删了或者进行了不兼容的更新,我们的包也会无法编译。同时,国家安全防火墙(功夫网)也给这种项目组织形式造成了很大的麻烦,很多常用的包下载很不方便。

$GOPATH
└── [  96]  src
    └── [  96]  github.com
        └── [  96]  wweir
            └── [  96]  test # git repository root
                └── [   0]  main.go

GOPATH 放在 git repo 中

于是,集思广益,在工程上充分利用 golang 支持多 GOPATH 的特性,使用一个项目对应一个 GOPATH的方式来进行管理,这也是 golang 1.6(vendor) 之前主要的项目组织方式。具体来说,就是把整个 GOPATH 都放在 git 目录中,这样所有依赖的代码就都在我们自己的项目中有了备份了,只要自己的项目目录在,就可以进行正常开发和编译。

但是,这种组织形式是真的丑,丑出天际。各种依赖库都在本地有完整的备份,这里面包括了大量我们不需要的代码。最难受的是,各远程依赖包 .git 目录没有优雅的处理方式,删掉就没法升级各依赖库,不删则严重污染我们的源码库。我们是要使用 git submodulegit subtree 进行稍微工程化一点的管理方式,还是图方便,强行把 .git 目录下的文件当成普通文件加到项目中呢?

$GOPATH # git repository root
└── [ 128]  src
    ├── [  64]  github.com
    └── [  96]  test
        └── [   0]  main.go

带上 vendor 放在 git repo 中

针对前面的问题,golang 在 1.5、1.6 的时候,又给出了新的解决方案,就是增加了对 vendor 目录的支持。自此,依赖管理方式终于不那么难受,可以使用一些自动化工具进行管理了。

我们只要把项目代码中加入vendor 目录,并把所有依赖的代码放在 vendor 目录下就可以了。而把依赖代码放入 vendor 目录的过程,可以通过工具自动化实现,其中使用最广泛的工具是 glidedep,其中 glide 出现较早,dep 作为官方推出的工具,出现较晚。这些依赖管理工具都有自己推导版本依赖的功能,自动选出适合自己项目的依赖库版本。

vendor 虽说已经可以使用了,但相比其它语言的包管理工具,还远远算不上好用。vendor 目录中保存了大量其它源码库的代码,如果加到我们的源码库中,则污染 git 历史,不加到源码库中,同样要首功夫网影响。

并且,相同的依赖包的代码,在多个项目中需要保存多份。这占用了磁盘空间,并且编译器无法重复利用编译的缓存文件,加长了编译时间。另外一个硬伤就是:不好处理同时依赖同一个包的两个不兼容版本的情况。

$GOPATH
└── [  96]  src
    └── [  96]  github.com
        └── [  96]  wweir
            └── [ 128]  test # git repository root
                ├── [   0]  main.go
                └── [  64]  vendor (optional: add to .gitignore)

带上 Go modules 配置文件

Go mod 是官方最新推出的依赖管理方案,这个方案很大程度上解决了之前的问题。首先,GOPATH 不再是一个强依赖的概念,项目代码可以放在任意目录,依赖库统一保存在 $GOPATH/pkg/mod 底下,GOPATH 目录可以自动生成。这样,做到了:

  1. 很大程度上消除了 GOPATH 的概念
  2. 解决了 vendor 方案引入的问题,可以轻松做到避免重复代码、编译缓存。
  3. 解决依赖同一依赖包不同版本的问题

这样,golang 的包管理方案就基本和其它语言一些优秀的包管理策略相差不大,我们就可以把我们自己的代码干干净净地放在 git 目录中了。

当然,它还蹲在一些明显的缺点。

  • 首先,配置文件使用自定义的语法,就是一件很难受的事情,虽然语法不复杂,但想要对配置文件实现一些自动化操作,就很麻烦了。
  • 其次,还是没有彻底避开 GOPATH 这个概念,默认 GOPATH 的位置比较碍眼,如果想修改对应的位置,就不得不去了解这个概念。
  • 还有一个问题,golang 把全网当做包的远程源,无法解决包被删除以及被功夫网屏蔽的问题。
├── [ 128]  $GOPATH
│   ├── [  96]  pkg
│   │   └── [  64]  mod
│   └── [  96]  src
│       └── [  96]  github.com
└── [ 160]  test # git repository root
    ├── [   0]  go.mod
    ├── [   0]  go.sum
    └── [   0]  main.go

回望与希冀

回顾

从头再捋一遍整个项目组织形式,不襟感觉兜了个大圈又回到了最初出发时的样子,却是把很多问题都已经解决了。对比最朴素的 go get 与最后的 go mod 方案,可以发现,真正解决的主要有三个问题:

  1. 一定程度上干掉了 GOPATH
  2. 包的版本推导与管理
  3. 第三方包下载到本地的形式
  4. 依赖同一个包不同版本的问题

依然存在的问题:

  1. GOPATH 仍然是一个无法完全抹去的概念
  2. 各依赖库依然存在被删除、被屏蔽等问题

预期

由于 golang 没有中心化的包管理、下载仓库,无法保证所有包都能稳定、方便地下载与使用。同时,由于没有审核之类的东西,各个包的质量也难以保证,甚至充斥着大量低质、不规范的包。当然,这种做法的好处也是有的,就是我们发布自己的包非常方便,因此五花八门的包也会多些。

在可预见的未来,以下问题将愈加严峻起来:

  1. 语义化版本不规范问题
    • 由于推出 vendor 之前,golang 一直没突出语义化版本的概念,导致目前大量库没有自己的版本管理,即使有,多数管理也做得很差。这就导致各工具在分析语义化版本时,无法以最正确的方式工作。
    • 可以想象这样一个场景,某项目使用了一个第三方库,开发很活跃,质量也很好,但仓库只有一个两年前的 v0.0.1 的 tag,自动版本推导就被误导了,使用一些带有诡异 bug 的过时版本,这时我们就只能选择手动冻结所使用的版本 。
  2. 没有中心化的依赖库下载源,导致大量依赖库质量不高的库被使用,影响 golang 整个生态的发展速度。

随记:快速将现有项目依赖管理转换为 go modules

  1. 在当前项目目录中执行 GO111MODULE=on go mod init github.com/user/repo
  2. GO111MODULE=on go mod tidy
  3. 需要使用 vendor 目录的话,执行 GO111MODULE=on go mod vendor
  4. git 提交当前代码
  5. GOPATH 以外的地方下载当前项目,默认开启 go mod 相关功能

需要注意的问题

当启用 go modules 时候,编译器不使用 vendor 底下的内容。在 vendor 时期,golang 编译可以做到不用从网络下载任何包,而现在需要下载依赖包,这就意味着 ci 的 runner 必须设置好代理。如果不便使用代理,就必须在项目目录的 go.mod 包中写出所用到的包的替代地址,精确到版本(go 1.12 有解决)。

另外一个有意思的点就是,如果一个项目用到了某使用 go modules 进行版本管理的第三方包,go build 会下载该包 modules 配置文件(即 go.mod) 中提及的所有包,即使在我们的代码依赖树上用不到该包。但不会使用该包 go.mod 文件中的定义好的地址替换规则,需要在我们自己的项目中自行定义。