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

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

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

项目代码直接放 git repo 中

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

但是,这也有一个大大的问题,就是所有依赖包都在远程,如果我们依赖的某个第三方库删掉或者进行了不兼容的更新,就麻烦了。同时,功夫网也给这种项目组织形式造成了很多麻烦。

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

GOPATH 放在 git repo 中

于是 golang 支持多 GOPATH 的特性得到了充足的开发,这也形成了 golang 1.6(vendor) 之前主要的项目组织方式。具体来说,就是把整个 GOPATH 都放在 git 目录中,这样所有依赖的代码就都有备份了,只要 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 虽说已经可以使用了,但相比其它语言的包管理工具,还远远算不上好用。比如相同的库的代码,在多个项目中需要保存多份。这占用了一些的磁盘空间,并且编译器无法重复利用编译的缓存文件,加长了编译时间。还有一个一直没有解决的事,就是同时依赖同一个第三方库的两个不兼容版本时怎么办。另外一个点就是我们的代码始终还是要放在 GOPATH 里面,相比其它语言,这始终是个很奇怪的事情。

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

带上 Go modules 配置文件

Go mod 是官方最新推出的依赖管理方案,这个方案很大程度上解决了之前的问题。项目代码可以放在任意目录,依赖库统一保存在 $GOPATH/pkg/mod 底下,可以轻松做到避免重复代码、编译缓存。基本和其它语言一些优秀的包管理策略相差不大。同时,其附带的版本管理方案,也还不错,解决了依赖同一个库不同版本的问题。这样,我们就可以把我们自己的代码干干净净地放在 git 目录中了。

当然,它的缺点依然很明显。首先,配置文件使用自定义的语法,就是一件很难受的事情,虽然语法不复杂,但想要对配置文件实现一些自动化操作,就很麻烦了。其次,到底还是没有避开 GOPATH 这个概念,默认 GOPATH 的位置比较碍眼,如果想修改对应的位置,就不得不去了解这个概念。还有一个问题,也全怪在 golang 身上,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 modules 解决了很多问题,但开头就描述的一些些问题,中间阶段看似没了,最后又突然冒出来了。这实际是由于 golang 没有包的中心下载仓库导致的,没有中心仓库,就无法保证所有包都能稳定、方便地下载与使用,同时由于没有审核之类的东西,各个包的质量也难以保证。当然,这种做法的好处也是有的,就是我们发布自己的包非常方便,因此五花八门的包也会多些。

对比最朴素的 go get 与最后的 go mod 方案,可以发现,真正解决的主要有三个问题:

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

依然存在的问题:

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

在可预见的未来,还会存在以下的严重问题:

  1. 语义化版本不规范问题
    • 由于推出 vendor 之前,golang 一直没突出语义化版本的概念,导致目前大量库没版本的概念(0.0.0),即使有,管理方面也做得很差。这就导致各工具在分析语义化版本的时候,会出问题,特别第二种情况,后果更加严重。
    • 可以想象这样一个场景,项目使用了一个第三方库,开发什么的都很活跃,质量也很好,但仓库只有一个两年前的 0.0.1 的 tag,自动版本推导功能就跪了,使用一些带有诡异 bug 的过时版本,这时我们就只能选择手动冻结所使用 commit 。
  2. 由于没有中心化的依赖库下载源,导致大量依赖库质量不高的库被使用,影响 golang 整个生态的发展速度。

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

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

需要注意的问题

当启用 go modules 时候,编译器不使用 vendor 底下的内容。在 vendor 的时代,golang 编译基本不用下载任何包,而现在需要下载依赖库,这就意味着 ci 的 runner 必须设置好代理,简直一朝回到解放前的既视感。如果想不使用代理,我们就必须在项目目录的 go.mod 包中写出所用到的包的替代地址,精确到版本。

另外一个有意思的点就是,如果一个项目用到了某个已经使用 go modules 进行版本管理的第三方包,go build 会下载该第三方包的 go.mod 文件中所有的包,不管是否在项目代码的依赖链中。然而,却不会使用该第三方包 go.mod 文件中的定义好的地址替换规则。