从 goinstall 到 module —— golang 包管理的前世今生

目录


“一直想一篇关于 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 的开发至少留下了两个设计遗产:

  1. 包名指示了包在哪儿;
  2. 包即是源码。

如果你学习过 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。

image

这是一个很精巧的、零入侵的设计,不是吗?但为什么我会称它为“不成功的革命”呢?应为如果它是成功的革命,那现如今你写 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”,这其实是个不起眼的变更,我甚至可以两句话讲清:

  1. 把项目的子文件夹 vendor 目录当做一个该项目专享的“虚拟 $GOPATH”;
  2. 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,其他工具难以望其项背。

这里简要描述一下它的使用方式:

  1. 在项目代码根目录运行 dep init 来初始化,得到 Gopkg.lock Gopkg.toml;
  2. Gopkg.lock 描述了项目正在使用的依赖包的 commit id 版本,和源码文件的哈希;
  3. Gopkg.toml 是项目对依赖包的版本的“约束”,以及其他配置;
  4. dep ensure 会尝试让 vendor 下文件内容匹配 Gopkg.lock,让 Gopkg.lock 满足 Gopkg.toml 的约束;
  5. ​把某个包更新到(满足Gopkg.toml约束的)最新版本:dep ensure -update pkg-name
  6. ​把某个包更新到指定版本:在 Gopkg.toml 添加约束,执行 dep ensure
  7. dep check 检查 vendor 下文件是否匹配 Gopkg.lock,Gopkg.lock 是否满足 Gopkg.toml。

image

而如何编辑 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 篇文章,来探讨一种新的包管理方式。

image

如果提炼一下的话,文章的对 golang 包管理方案提出了四个“指导方针”:

其中还重点说明了语义化版本的重要地位,版本号需要满足 v[major].[minor].[patch] 这样的格式,形如 v1.2.1,并严格遵循以下语义:

  • major:破坏性更新,不兼容旧版本;
  • minor:新特性更新,兼容旧版本;
  • patch:修复性更新,仅做 bug 修复。

并据此引出“语义化版本引入入(semantic import versioning)”:

image

如图所示,my/thing 不是一个包,而是一个 module,module 的具体版本 my/thing/v2 才是一个包,所以我引入它的子包时写的是 my/thing/v2/sub/pkg

据此,如何同时引入同一个包的不同版本就此解决,满足导入兼容性原则。你或许会问,那如果我希望同时引入 v2.2.0v2.3.0 呢?还记得语义化版本的约束吗?v2.3.0 应该是完全兼容 v2.2.0 的,所以你不会有这样需求。那你又要问了,那 go 编译时怎么知道 my/thing/v2/sub/pkg 是使用 v2.2.0 还是 v2.3.0 呢?答案是每个 module(包括你的项目)都描述了你的依赖的最小版本,比如你说你最小依赖 v2.2.0,而你这个项目依赖的另一个包 other/thing/v2 也依赖 my/thing/v2,且要求最小版本是 v2.3.0,这是时候最小版本选择登场,为了满足所有版本要求,它最终选择了 v2.3.0,即使可能还有版本更高的 v2.4.0

可见,这是一个从编译行为上就要做改变的大更新,所以实验阶段这个项目名叫“vgo”,即“带版本的 go”,原先的 go buildgo get 命令都要换成 vgo buildvgo get。但同时提供两套工具自然是下下策,所以最后正式发布时,“vgo” 和 “go” 事实上是合并了,同样的命令 go buildgo get,在不同的项目里可能有不同的行为,为了方便描述,我们暂且称为传统模式和 module 模式。

命令 传统模式 module 模式
go build 寻包路径依次是 $GOROOT、vendor、$GOPATH;
如果缺包,报错并中止。
寻包路径依次是 $GOROOT、$GOPATH/pkg/mod/;
默认不支持 vendor;
发现缺包,自动获取缺失的包。
go get 将包存到到 $GOPATH/src。 将包按照版本不同分别存到$GOPATH/pkg/mod/ 下不同路径。

其他命令,比如 go testgo list 在不同模式下也有不同的行为,这里不做介绍了。

那么如何判断当运行命令时,是处于传统模式还是 module 模式呢?这由三个因素共同决定:

  • 当前路径(或父路径)是否有 go.mod 文件(如果有,则倾向于 module 模式);
  • 当前路径是否在 $GOPATH 下(如果是,则倾向于传统模式);
  • 环境变量 $GO111MODULE 的配置(当发生分歧时起,最终决策)。

完整的决策逻辑经历了几次调整,所以现在我也有点搞不清了,但这没关系,你可以运行一下 go env 命令,看看 $GOMOD 这个变量,如果它有值,并指向了一个 go.mod 文件,便是处于 module 模式,否则则是处于传统模式,简单明了。

除了对已有的命令进行改造,go 也添加了新的命令 go mod,用于管理 module,这里简单介绍一下它的使用:

  • go mod init [moduel-name] 来初始化一个 module;
  • go tidy 检查当前 module 的依赖并写入 go.mod 和 go.sum;
  • go.mod 描述了本 module 的名称、go 版本依赖、依赖包的最小版本;
  • ​go.sum 记录依赖包语义化版本对应的哈希。

image

同时 module 模式 go get 不再是简单的执行 git clone 了,它有了为其定制的代理协议,由于一些网络方面的原因,这简直是中国人民的福音,一大堆代理实现方案、公开的代理站点如雨后春笋般出现,如 athensgoproxygoproxy.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 文件。这是好事,如前文所说,发散的设计意味着为找到最优解留下空间,收敛的工作意味着找到了一个可能的最优解。

以上是本文的全部内容。

评论加载中……

若长时间无法加载,请刷新页面重试。