Golang 项目组织形式的演进
文章目录
包管理与项目组织形式的历史
很多从其它语言转 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 submodule 、
git 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
目录的过程,可以通过工具自动化实现,其中使用最广泛的工具是 glide
和 dep
,其中 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 目录可以自动生成。这样,做到了:
- 很大程度上消除了 GOPATH 的概念
- 解决了 vendor 方案引入的问题,可以轻松做到避免重复代码、编译缓存。
- 解决依赖同一依赖包不同版本的问题
这样,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 方案,可以发现,真正解决的主要有三个问题:
- 一定程度上干掉了
GOPATH
- 包的版本推导与管理
- 第三方包下载到本地的形式
- 依赖同一个包不同版本的问题
依然存在的问题:
GOPATH
仍然是一个无法完全抹去的概念- 各依赖库依然存在被删除、被屏蔽等问题
预期
由于 golang 没有中心化的包管理、下载仓库,无法保证所有包都能稳定、方便地下载与使用。同时,由于没有审核之类的东西,各个包的质量也难以保证,甚至充斥着大量低质、不规范的包。当然,这种做法的好处也是有的,就是我们发布自己的包非常方便,因此五花八门的包也会多些。
在可预见的未来,以下问题将愈加严峻起来:
- 语义化版本不规范问题
- 由于推出 vendor 之前,golang 一直没突出语义化版本的概念,导致目前大量库没有自己的版本管理,即使有,多数管理也做得很差。这就导致各工具在分析语义化版本时,无法以最正确的方式工作。
- 可以想象这样一个场景,某项目使用了一个第三方库,开发很活跃,质量也很好,但仓库只有一个两年前的
v0.0.1
的 tag,自动版本推导就被误导了,使用一些带有诡异 bug 的过时版本,这时我们就只能选择手动冻结所使用的版本 。
- 没有中心化的依赖库下载源,导致大量依赖库质量不高的库被使用,影响 golang 整个生态的发展速度。
随记:快速将现有项目依赖管理转换为 go modules
- 在当前项目目录中执行
GO111MODULE=on go mod init github.com/user/repo
GO111MODULE=on go mod tidy
- 需要使用 vendor 目录的话,执行
GO111MODULE=on go mod vendor
- git 提交当前代码
- 在
GOPATH
以外的地方下载当前项目,默认开启 go mod 相关功能
需要注意的问题
当启用 go modules
时候,编译器不使用 vendor
底下的内容。在 vendor
时期,golang 编译可以做到不用从网络下载任何包,而现在需要下载依赖包,这就意味着 ci 的 runner 必须设置好代理。如果不便使用代理,就必须在项目目录的 go.mod
包中写出所用到的包的替代地址,精确到版本(go 1.12 有解决)。
另外一个有意思的点就是,如果一个项目用到了某使用 go modules
进行版本管理的第三方包,go build
会下载该包 modules 配置文件(即 go.mod) 中提及的所有包,即使在我们的代码依赖树上用不到该包。但不会使用该包 go.mod
文件中的定义好的地址替换规则,需要在我们自己的项目中自行定义。