5

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

 2 years ago
source link: https://blog.wolfogre.com/posts/golang-package-history/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

2019/11/26.

Golang 0.8k+ 4

“一直想一篇关于 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,又或是更新的 v2.4.0v2.5.0 呢?答案是每个 module(包括你的项目)都描述了你的依赖的最小版本,比如你说你最小依赖 v2.2.0,而你这个项目依赖的另一个包 other/thing/v2 也依赖 my/thing/v2,且要求最小版本是 v2.3.0,所以这个时候可选项有 v2.3.0v2.4.0v2.5.0最小版本选择登场,它最终选择了满足需求的最小版本 v2.3.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 文件。这是好事,如前文所说,发散的设计意味着为找到最优解留下空间,收敛的工作意味着找到了一个可能的最优解。

以上是本文的全部内容。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK