Go包管理的前世今生

523 阅读9分钟

1. 历程

Go程序由Go包组合而成,Go程序的构建过程就是确定包版本,编译包并将目标文件链接在一起的过程

而包的管理经历了三个迭代,分别是最初的 GOPATH、1.5版本的 Vendor 机制以及现在的 Go Moudle。

2. GOPATH

Go编译器在本地 GOPATH 环境变量配置的路径下搜寻Go程序依赖的第三方包。

package main

import "github.com/sirupsen/logrus"

func main(){
    logrus.Println("hello");
}

这段代码依赖了第三方包logrus,而Go编译器则会在 GOPATH 配置的路径下寻找该包

假设我们的 GOPATH 如下

export GOPATH=/usr/local/goprojects:/home/lfd/go

那么Go编译器在编译Go程序时会在下面的路径搜索第三方包:

/usr/local/goprojects/src/github.com/user/repo
/home/user/go/src/github.com/user/repo

如果没有显示设置 GOPATH,GOPATH的默认值在Linux上为 $HOME/go

那么在一开始我们并没有安装logrus包该怎么办呢?

go get github.com/sirupsen/logrus

go get 命令将logrus包下载到 GOPATH 环境变量配置的目录下,并检查logrus所需依赖是否下载,没有则一并下载。

不过 go get下载的是最新版本的包,如果依赖包的修改导致程序无法通过编译,这个问题就很大了。

3. Vendor 机制

Go在1.5版本引入Vendor机制。Vendor机制本质上就是在Go项目的某个特定目录下缓存所有的依赖包,特定目录名为vendor。

Go编译器会优先感知vendor目录下缓存的第三方包版本,而不是 GOPATH 下的第三方包版本。这样无论第三方包如何变化都不会影响我们程序的构建。

将vendor目录和项目源码一同提交到代码仓库,其他开发者下载你的项目后可以实现可重现的构建。

vendor机制虽然一定程度解决了Go程序的可重现构建问题,但它的缺点也是明显的。庞大的vendor目录提交到代码仓库不仅占用空间,减慢仓库下载和更新的速度,还会干扰代码评审。而最头疼的一点是你需要手工管理vendor下面的go依赖包,包括依赖包的分析、版本的记录、依赖包的存放和获取等待。

为了解决这个问题

4. Go Module

从1.11版本开始,出现了一个新的构建模式

一个Go Module是一组Go包的集合,module是有版本的,所以module下的包也就有了版本属性, module 与这些包会组成一个独立的版本单元,它们一起打版本、发布和分发。

在 Go Module 模式下,通常一个代码仓库对应一个 Go Module。Go Module 的顶层目录下会放置一个 go.mod 文件,每个 go.mod 文件会定义唯一一个 module,即 Go Module 与 go.mod 是一一对应的。

go.mod 所在目录也被称为 module 的根目录,module 根目录以及它子目录下的所有 Go 包均归属于这个 Go Module,这个 module 也被称为 main module。

下面我们先从如何创建一个 Go Module 说起。我们先来将上面的例子改造成为基于 Go Module 构建模式的 Go 项目。

创建一个 Go Module

将基于当前项目创建一个 Go Module,通常有如下几个步骤:

  1. 通过 go mod init 创建 go.mod 文件,将当前项目变为一个 Go Module;
  2. 通过 go mod tidy 命令自动更新当前 module 的依赖信息;
  3. 执行 go build,执行新 module 的构建。

先建立一个新项目 test 用来演示 Go Module 的创建。 这个项目的 main.go 照抄上面的例子

通过 go mod init 命令为这个项目创建一个 Go Module

go mod init test

image.png

go mod的内容:

module test

go 1.20

第一行内容用于声明 module 路径,最后一行是一个 Go 版本指示符,用于表示这个 module 是在某个特定的 Go 版本的 module 语义的基础上编写的。

go mod init 命令还输出了两行日志,提示使用 go mod tidy 命令,添加 module 依赖以及校验和。go mod tidy 命令会扫描 Go 源码,并自动找出项目依赖的外部 Go Module 以及版本,下载这些依赖并更新本地的 go.mod 文件。执行一下 go mod tidy 命令:

image.png

go mod tidy 分析了当前 main module 的所有源文件,找出了所有第三方依赖并确定版本,还下载了当前 main module 的直接依赖包,以及相关间接依赖包。

由 go mod tidy 下载的依赖 module 会被放置在本地的 module 缓存路径下,默认值为 $GOPATH[0]/pkg/mod,Go 1.15 及以后版本可以通过 GOMODCACHE 环境变量,自定义本地 module 的缓存路径。 执行 go mod tidy 后,我们示例 go.mod 的内容更新如下:

module test

go 1.20

require github.com/sirupsen/logrus v1.9.0

require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect

当前 module 的直接依赖 logrus,还有它的版本信息都被写到了 go.mod 文件的 require 段中。而间接依赖也被打上了 indirect的注释

而且,执行完 go mod tidy 后多了一个新文件 go.sum,内容是这样的:

image.png

它存放了特定版本 module 内容的哈希值,是 Go Module 的一个安全措施。当将来这里的某个 module 的特定版本被再次下载的时候,go 命令会使用 go.sum 文件中对应的哈希值,和新下载的内容的哈希值进行比对,只有哈希值比对一致才是合法的,这样可以确保你的项目所依赖的 module 内容,不会被恶意或意外篡改。因此,推荐把 go.mod 和 go.sum 两个文件与源码一并提交到代码仓库。

不知道你会不会有这样的疑惑:项目所依赖的包有很多版本Go Module 是如何选出最适合的那个版本的呢

5. Go Module 版本选择

Go 语言设计者在设计 Go Module 构建模式,解决“包依赖管理”的问题时,进行了几项创新,这其中就包括语义导入版本 ,以及和其他主流语言不同的最小版本选择等机制。

Go Module 的语义导入版本机制

在上面的例子中,我们看到 go.mod 的 require 段中依赖的版本号,都符合 vX.Y.Z 的格式。一个符合 Go Module 要求的版本号,由前缀 v 和一个 满足语义版本规范的版本号组成。

语义版本号分成 3 部分:主版本号、次版本号和补丁版本号。例如上面的 logrus module 的版本号是 v1.9.0,这就表示它的主 版本号为 1,次版本号为 9,补丁版本号为 0。

image.png

借助于语义版本规范,Go 命令可以确定同一 module 的两个版本发布的先后次序,而且可以确定它们是否兼容。

按照语义版本规范,主版本号不同的两个版本是相互不兼容的。在主版本号相同的情况下,次版本号大都是向后兼容次版本号小的版本。补丁版本号也不影响兼容性。

Go Module 规定:如果同一个包的新旧版本是兼容的,那么它们的包导入路径应该是相同的。我们以 logrus 为例,从中选出两个版本 v1.7.0 和 v1.8.1.。按照上面的语义版本规则,这两个版本的主版本号相同,新版本 v1.8.1 是兼容老版本 v1.7.0 的。那么如果一个项目依赖 logrus,无论它使用的是 v1.7.0 版本还是 v1.8.1 版本,它都可以使用下面的包导入语句导入 logrus 包:

import "github.com/sirupsen/logrus"

那么问题又来了,假如在未来的某一天,logrus 的作者发布了 logrus v2.0.0 版本。那么根据语义版本规则,该版本的主版本号为 2,已经与 v1.7.0、v1.8.1 的主版本号不同了, 那么 v2.0.0 与 v1.7.0、v1.8.1 就是不兼容的包版本。然后我们再按照 Go Module 的规定,就不能按上面的方式导入了,那我们该如何导入v2.0.0呢?

Go Module 创新性地给出了一个方法:将包主版本号引入到包导入路径中:

import "github.com/sirupsen/logrus/v2"

这就是 Go 的“语义导入版本”机制,即通过在包导入路径中引入主版本号的方式,来区别同一个包的不兼容版本,这样一来我们甚至可以同时依赖一个包的两个不兼容版本:

import(
    "github.com/sirupsen/logrus"
    logv2 "github.com/sirupsen/logrus/v2"    
)

你可能会问,v0.y.z 版本应该使用哪种导入路径呢?

按照语义版本规范的说法,v0.y.z 这样的版本号是用于项目初始开发阶段的版本号。在这个阶段任何事情都有可能发生,其 API 也不应该被认为是稳定的。Go Module 将这样的 版本 (v0) 与主版本号 v1 做同等对待,也就是采用不带主版本号的包导入路径。

Go 语义导入版本机制是 Go Module 机制的基础规则,同样它也是 Go Module 其他规则的基础。

Go Module 的最小版本选择原则

在前面的例子中,Go 命令都是在项目初始状态分析项目的依赖,并且项目中两个依赖包之间没有共同的依赖,这样的包依赖关系解决起来还是比较容易的。但依赖关系一旦复杂起来,比如像下图中展示的这样,Go 又是如何确定使用依赖包 C 的哪个版本的呢?

image.png

myproject 有两个直接依赖 A 和 B,它们又有一个共同的依赖包 C,但 A 依赖 C 的 v1.1.0 版本,而 B 依赖的是 C 的 v1.3.0 版本,并且此时 C 包的最新发布版为v1.7.0。这时 Go 命令是如何为 myproject 选出间接依赖包 C 的版本呢?

当前存在的主流编程语言,以及 Go Module 出现之前的很多 Go 包依赖管理工具都会选择依赖项的“最新最大版本”,对应到图中的例子就是 v1.7.0。

当然了,理想状态下如果语义版本控制被正确应用,并且这种“社会契约”也得到了很好的遵守,那么这种选择算法是有道理的,而且也可以正常工作。在这样的情况下,依赖项的“最新最大版本”应该是最稳定和安全的版本,并且应该有向后兼容性。至少在相同的主版本依赖树中是这样的。

但 Go 设计者另辟蹊径,在诸多兼容性版本间,他们不光要考虑最新最大的稳定与安全,还要尊重各个 module 的述求:A 明明说只要求 C v1.1.0,B 明明说只要求 C v1.3.0。所以 Go 会在该项目依赖项的所有版本中,选出符合项 目整体要求的“最小版本”。

这个例子中,v1.3.0 是符合项目整体要求的版本集合中的版本最小的那个,于是 Go 命令选择了v1.3.0,而不是最新最大的v1.7.0。

6. Go各版本构建模式机制和切换

在 Go 1.11 版本中,Go 开发团队引入 Go Modules 构建模式。这个时候,GOPATH 构建模式与 Go Modules 构建模式各自独立工作,我们可以通过设置环境变量 GO111MODULE 的值在两种构建模式间切换。

随着 Go 语言的逐步演进,从 Go 1.11 到 Go 1.16 版本,不同的 Go 版本在 GO111MODULE 为不同值的情况下,开启的构建模式几经变化,直到 Go 1.16 版本,Go Module 构建模式成为了默认模式。

所以,要分析 Go 各版本的具体构建模式的机制和切换,我们只需要找到这几个代表性的版本就好了。
image.png

7. 工作区

Go 1.18新增了工作区模式(workspace mode),让你可以同时跨多个Go Module进行开发。

在1.18以前,如果遇到以下场景:Module A新增了一个feature,Module B需要使用Module A的这个新feature,你有2种方案:

  • 发布Module A的修改到代码仓库,Module B更新依赖的Module A的版本
  • 修改Module B的go.mod,使用replace指令把对Module A的依赖指向你本地未发布的Module A所在目录。等Module A发布后,在发布Module B时,删除Module B的go.mod文件里的replace指令。

Go工作区模式给予了更为简单的方案:在工作区目录维护一个go.work文件来管理你的所有依赖。go.work里的usereplace指令会覆盖工作区目录下的每个Go Module的go.mod文件,因此没有必要去修改 go.mod文件了。

go work init

使用go work init创建workspace:

go work init [moddirs]

moddirs是Go Module所在的本地目录。多个Go Module 用空格分开。

执行go work init后会生成一个go.work文件,go.work里列出了该workspace需要用到的Go Module所在的目录,workspace目录不需要包含你当前正在开发的Go Module代码。

go work use

如果要给workspace新增Go Module,可以使用如下命令:

go work use [-r] moddir

或者手动编辑go work文件。

如果带有-r参数,会递归查找-r后面的路径参数下的所有子目录,把所有包含go.mod文件的子目录都添加到go work文件中。

如果某个Go Module的目录已经被加到go.work里了,后面该目录没有go.mod文件了或者该目录被删除了,那对该目录再次执行go work use命令,该目录的use指令会从go.work文件里自动移除。

go.work

go.work的语法和go.mod类似,包含如下3个指令:

  • go: go的版本,例如 go 1.18
  • use: 添加一个本地磁盘上的Go Module到workspace的主Module集合里。use后面的参数是go.mod文件所在目录相对于workspace目录的相对路径,例如use ./mainuse指令不会添加指定目录的子目录下的Go Module到workspace的主Module集合里。
  • replace: 和go.mod里的 replace指令类似。go.work里的 replace指令可以替换某个Go Module的特定版本或者所有版本的内容。

实例

image.png

我们先创建在根目录下创建example目录和tools目录并分别创建他们的go mod。

go mod init github.com/lfd/example

go mod init github.com/lfd/tools

在tools目录下创建tool.go

package tools

func Tool(){
	println("use the tool")
}

在example目录下创建main.go

package main

import "github.com/lfd/tools" 

func main(){
	tools.Tool()
}

由于这时没有把tools这个module推上代码仓库,我们无法通过go get 或 go mod tidy 获取到该库。除了将代码推送到仓库外,我们在1.18版本前最常见的操作是在example 的 go mod里手动添加require并将依赖replace成本地目录

go 1.20
// 以下都是自己添加的,版本可以自己定
require github.com/lfd/tools v1.0.0
replace github.com/lfd/tools => ../tools

而拥有了工作区之后,我们只需要在根目录下执行

go work init example tools

就会生成一个go work文件

go 1.20

use (
	./example
	./tools
)

这时我们把example的 go mod 中的replace那行删掉。也可以执行main.go了