Go Vendor-时代的眼泪

Go Vendor

go vendor 也算时代的眼泪了,在 go1.5~1.11 之间被相对广泛的使用,在 1.11之后基本就退出历史舞台了,原因无他,go module 太好用了。 即便政治正确一点地说,它和 cargo、bazel 相比功能上没有那么丰富,但其轻量、简便在包管理工具中也难寻其右。

事实上,在我 21 年开始接触 Go 时,主流 Go 版本已经来到 1.16,因此我从未接触过 vendor 项目, 甚至连 GOPATH 也不用太了解。 不过,既然是时代的眼泪,vendor 就一定还留着其泪痕。

跳槽之后,新工作的核心仓库,是一个 14 年就开始的古老项目,现在还留着不少针对 go1.5 的条件编译代码和汇编。此仓库主打一个历史悠久, 历经古法包管理(直接复制仓库)、bazel、vendor 等多个模式,并且由于这些模式的混杂导致至今未能升级到 go module,此外, go 版本也停留在 go1.12,主要也没啥人敢升,一来没产出,二来容易出问题。虽说程序员和代码有一个能跑就行,但现在的大环境,程序员怕是不太好跑。

因此,为了开发这个仓库,我对 go vendor 进行了相对深入的了解,记录一下。

GOPATH

GOPATH 是「标记 Go 代码工作区」的环境变量,一般来说,GOPATH 下应该有三个子文件夹:bin、pkg、src,对应了主要的三个功能:

  • 标记 go 二进制地址。通过 go install/go get 安装的二进制文件,会放在 $GOPATH/bin 中,此规范始终是生效的。
  • 标记依赖包地址。在启用 go module 之后,go module 会将下载的依赖包放到 $GOPATH/pkg 中,并且加上版本 tag 以及 sum 信息。自然,此规范是在启用 go module 之后才有效。
  • 标记源码地址。这个主要是针对 vendor 模式,在下面会细讲,在 module 模式下已经事实上不再需要了吗,也不再有目录规范,当前源码想放哪就放哪,不过通常会基于历史习惯放到 src 下,但比如 Goland 新建项目则会默认放到 GolandProject 下。

简单总结下:在 module 模式下,GOPATH 下的两个主要功能:bin&pkg 都是由 go tools 自动管理的,对于程序员而言几乎不再需要被关注。

Vendor

Vendor 模式是在 Go 早期没有包管理的野蛮发展下形成的一个社区实践,并在 1.5 被正式纳入语言特性,1.13 结束生命周期。 Vendor 其实很简单,和 node_modules 差不太多,本质上都是「约定一个目录存放第三方包」,这个目录便是 vendor。

让我们以一个例子开始。假如你创建了一个项目,这个项目目前为止只使用了标准库,我们知道标准库在 GOROOT 下保存,那么,你们的代码只需要在 GOPATH/src 下挂着就行了,当然,原则上来说你挂哪都行,甚至根目录也无所谓,大不了临时改一下 $GOPATH。

不过,我们都是习惯良好的工程师,为了方便管理手上的诸多项目,我们决定固定 GOPATH,并且按照项目名称进行良好的目录层级划分,就像 JAVA 那样(先让我们这么做,后面我们会明白这么做的好处的,良好的习惯总是会有回报的),我们假定这个项目叫 github.com/hyphennn/proj1,于是,你的目录如下:

1
2
3
4
5
6
$GOPATH
- bin
- src
- github.com
- hyphennn
- proj1 // 下面的层级是你的源代码

接下来,让我们迭代这个项目,以及项目中的包管理机制

版本一 GOPATH 机制

现在,我们决定引入某一个功能,这个功能我们曾经写过,只不过在 github.com/hyphennn/proj2 这个项目中,假定代码在 github.com/hyphennn/proj2/waibiwaibi/waibibabo.go 这个文件里面,自然地,复制过去是个好想法,但假如这个文件依赖了项目中的其他文件呢?都复制过去吗?要是变量名、方法名有冲突咋办?更进一步的,以后 proj2 继续迭代,难道改一次代码就复制一次吗?这显然不合理。事实上,Go 提供了解决方案,你可以如下组织你的代码:

1
2
3
4
5
6
7
$GOPATH
- bin
- src
- github.com
- hyphennn
- proj1 // 下面的层级是你的源代码
- proj2

这样,你只需要 import 一下就好了:

1
import "github.com/hyphennn/proj2/waibiwaibi"

这种方式是很直观的,因为你引用项目内其他文件夹时你也是这么做的,当项目中被声明 import “xxx” 时,编译器会去寻找 GOPATH/src/xxx,如果找到,就会将其作为依赖库加入编译。当然,会优先在 $GOROOT 下找标准库。

项目跑的很不错,现在该添加新的功能了。功能太复杂,人力成本非常有限,不过好消息是我们找到了两个来自互联网的包,帮助我们解决了问题,他们分别是:github.com/helper1/pkg1 和 golang.org/helper2/pkg2。怎么把这两个包引入呢?一个很直观的思路就是:像使用我们自己的项目一样使用这两个包,参照一下上面 import 寻找目标的原理,我们能很轻易的给出如下目录:

1
2
3
4
5
6
7
8
9
10
11
12
$GOPATH
- bin
- src
- github.com
- hyphennn
- proj1 // 下面的层级是你的源代码
- proj2
- helper1
- pkg1
- golang.org
- helper2
-pkg2

同样的,import 语句如下:

1
2
3
4
5
import(
"github.com/hyphennn/proj2/waibiwaibi"
"github.com/helper1/pkg1" // 让我们省略掉后面的子目录,毕竟都差不多
"golang.org/helper2/pkg2"
)

尝试一下,运行良好,以后就这么干,可以不断引入新的包。

到此为止,我们完成了第一版的包管理机制,严格来说并不叫包管理,因为它完全依赖 GOPATH,不过这不重要,我们的项目很棒,赚大钱了(bushi

版本二 Vendor 机制

如果你了解过包管理工具,以及构建工程,显然,GOPATH 不是个好的方案,它至少有如下缺点:

  • 自己编写的包,和网络上的第三方包,都放置在GOPATH/src下,容易造成混乱,不方便管理。
  • 项目中用到的依赖包,都需要手动go get下载,大项目来说非常麻烦
  • 如果引入的三方包中又引入了其他三方包,就不好处理了,需要找到以后,使用go get去下载。
  • go get没有版本的概念,团队合作中,很容易出现使用了不同版本的包,造成不必要的错误。
  • 协作开发时,需要统一各个开发成员本地$GOPATH/src下的依赖包。
  • 引用的包引用了已经转移的包,而作者没改的话,需要自己修改引用。

让我们想想别的工具怎么解决的。Go 作为世界上第二好的语言,不妨学习下世界上最好的语言-PHP。PHP 使用 Composer 作为包管理器,将第三方包放到根目录下的一个名为 vendor 的目录下。好主意,让我们融会贯通下,制订 Go 的 vendor 机制。

当然,需要指出的是,我根本不清楚 Go Vendor 的起源,以上只是为了告诉大家世界上最好的语言是 PHP。

首先,我们把要求团队把所有的依赖包都放到一个叫做 vendor 的目录,这一步有点像 js 著名(臭名昭著)的 node_modules。不过,由于不同域名、仓库组下的仓库是可以同名的,因此,我们仍然要求在 vendor 下按照层级放置依赖包(就像 GOPATH 里面的那样)。由此,我们解决了「无法区分自有仓库和第三方依赖」的问题,在 vendor 下的是第三方依赖,GOPATH 下的是自有。

其次,我们要求代码仓库上传的时候把 vendor 带上,虽然这会一定程度使得代码仓库体积更大,但这无非增加点碳排放和代码仓库组同事的工作量罢了,却能很好的解决「手动下载依赖包」带来的诸多问题,这对我们当然是可接受的(代码仓库组同事:你礼貌吗。

最后,我们允许一个项目中存在多个 vendor 目录,他们位于不同的目录下,影响着不同目录的依赖,此规则的优势或许难以直观地看出,但使用过程中我们会发现它的好处的。这可能有点绕,我们举个例子,我们的项目 repo 依赖 pkg1, pkg2 和 pkg3,他们的目录分别如下:repo/module1/vendor/pkg1,repo/module2/vendor/pkg2,repo/vendor/pkg3。那么,你在 repo/module1/ 下可以 import pkg1, pkg3,在 repo/module2/ 下可以 import pkg2, pkg3,在 repo/ 和 repo/module3 下则只能 import pkg3。此外,我们还允许在 GOPATH/src 下的每一级目录(包括 src)都有 vendor,最终寻找依赖的规则在下文中列出。

以上,我们解决了一部分问题,至于剩下的问题,让我们相信后人的智慧吧,大家都喜欢这么做,我们自然也可以~

最终,我们形成了如下的一组规则,这便是 Vendor 机制:

  • 将依赖包统一放到一个叫 vendor 的目录下,并遵循:
    • 可以有多个不同目录下的 vendor,其作用域满足下面的查找规则
    • 上传项目的时候应该将 vendor 一并传到代码仓库中
    • 单个 vendor 目录下的包放置目录规则和 GOPATH 一致,就像 vendor 目录是 $GOPATH/src 一样
    • vendor 目录仅作为依赖包使用
  • 查找规则
    • 在遇到 import 语句时(非标准库),首先查找当前包下的 vendor 目录
    • 向上级目录查找,直到找到 src 下的 vendor 目录
    • 在 GOROOT 目录下查找
    • 在 GOPATH 下面查找依赖包

基于上述规则,我们调整项目如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
$GOPATH
- bin
- vendor
github.com
- helper8
- pkg10
- pkg11
- src
- github.com
- hyphennn
- proj1 // 下面的层级是你的源代码
- 其他代码
- vendor
- github.com
- helper1
- pkg1
- pkg2
- helper2
- pkg3
- pkg4
- golang.org
- helper3
- pkg4
- helper4
- pkg5
- pkg6
- proj2
- vendor
- github.com
- helper2
- pkg3
- pkg4
- golang.org
- helper3
- pkg4
- vendor
- github.com
- helper5
- pkg6
- pkg7
- golang.org
- helper2
-pkg2

其中的逻辑看起来可能有些绕,这里地方太小,写不下(绝对不是懒

按照上面说的规则,去理解一下,整体还是非常清晰的。

版本三 Module 机制

Vendor 当然有各种缺点,于是 Module 机制出现了,并且良好地运行至今。介绍 Module 的文章太多了,这里就不班门弄斧了~

govendor

govendor 是一个应用比较广泛的项目,主要目标是解决「vendor 目录过于占用空间」和「vendor 目录难以管理依赖包版本」的问题,一定程度上成为了 Go 包管理的事实标准。如今此项目已经不再维护了,也不接收任何新的 PR,但仍然不妨碍我们了解一下。

govendor 的思路有些像 npm,核心是:不再直接管理依赖包文件,而是使用 vendor.json 的文件来记录依赖包的元信息,是的,这个思路和 package.json 甚至 go.mod 都是非常相似的。使用 govendor 后,原来存放诸多依赖包的 vendor 目录下只需要放置一个 vendor.json 文件,由 govendor 提供的命令行工具维护此文件以及基于此文件下载依赖包。下面给出一个元信息的实例:

1
2
3
4
5
6
{
"checksumSHA1": "GcaTbmmzSGqTb2X6qnNtmDyew1Q=",
"path": "github.com/pkg/errors",
"revision": "a2d6902c6d2a2f194eb3fb474981ab7867c81505",
"revisionTime": "2016-06-27T22:23:52Z"
}

从此实例中就可以大体明白 govendor 的工作原理了,并不算非常复杂,我想大约也是因此 govendor 才广受欢迎。

使用 govendor 后,我们项目的目录便可以简化如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$GOPATH
- bin
- vendor
github.com
- vendor.json
- src
- github.com
- hyphennn
- proj1 // 下面的层级是你的源代码
- 其他代码
- vendor
- vendor.json
- proj2
- vendor
- vendor.json
- vendor
- vendor.json
- golang.org
- helper2
-pkg2

govendor 提供如下的命令参数,含义参照一下解释就能大致理清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<sub-command>
init Create the "vendor" folder and the "vendor.json" file.
list List and filter existing dependencies and packages.
add Add packages from $GOPATH.
update Update packages from $GOPATH.
remove Remove packages from the vendor folder.
status Lists any packages missing, out-of-date, or modified locally.
fetch Add new or update vendor folder packages from remote repository.
sync Pull packages into vendor folder from remote repository with revisions
from vendor.json file.
migrate Move packages from a legacy tool to the vendor folder with metadata.
get Like "go get" but copies dependencies into a "vendor" folder.
license List discovered licenses for the given status or import paths.
shell Run a "shell" to make multiple sub-commands more efficient for large
projects.

go tool commands that are wrapped:
`+<status>` package selection may be used with them
fmt, build, install, clean, test, vet, generate, tool

<status>
+local (l) packages in your project
+external (e) referenced packages in GOPATH but not in current project
+vendor (v) packages in the vendor folder
+std (s) packages in the standard library

+excluded (x) external packages explicitly excluded from vendoring
+unused (u) packages in the vendor folder, but unused
+missing (m) referenced packages but not found

+program (p) package is a main package

+outside +external +missing
+all +all packages

不过,govendor 由于已经不再维护了,因此其在 M 系列芯片的 Mac 上不可用,而且也不清楚高版本 Go 的兼容性。

由于工作项目需要使用 govendor,因此我复制了一份代码,处理了部分兼容性问题,目前在我本地(Mac M2)看起来没啥问题。地址:https://github.com/hyphennn/govendor 。但不清楚有没有未知问题。
ps. 之所以不 fork,是因为原项目不再维护了,fork 没有任何意义。


Go Vendor-时代的眼泪
https://hyphennn.com/2024/05/22/go-vendor/
Author
hyphennn
Posted on
May 22, 2024
Licensed under