一文读懂go mod依赖管理

时间:2021-9-14     作者:smarteng     分类: Go语言


早期 Go 语言单纯使用 GOPATH 管理依赖,但 GOPATH 不方便管理依赖的多个版本,后来增加了 vendor,允许把项目依赖连同项目源码一起管理。
但是 vendor 也有不足,就是项目依赖关系不清楚,依赖包升级困难,这也催生了众多依赖管理的工具,呈现百家争鸣之势。
直到 GO 1.11,官方才推出了依赖管理工具 Go Module,逐渐一统江湖,从此 GO 依赖管理走向第3个时代。
b433389094218825216f7f3ac69f5_w780_h517.png

1. GOPATH

1.1 GOPATH 是什么?

初学 Go 语言,可能对 GOPATH 感到困惑。
GOROOT:Go 语言安装目录,属于 Go 语言顶级目录。
GOPATH:用户工作空间目录,属于用户域范畴。
举个栗子,实际 Go 项目有一个或者多个 package 组成,package 按照来源可能分为:标准库、第三方库、项目私有库。标准库全部位于 GOROOT 目录中,而第三方库和私有库,都位于 GOPATH 目录。

1.2 GOPATH 依赖查找

当某个 package 需要用到其他包时,编译器依次从 GOROOT/src/ 和 GOPATH/src/ 中查找,如果某个包在 GOROOT 下找到,那么就不会继续再到 GOPATH 下查找,所以项目开发中的包如果与标准库的包同名,会被查找忽略掉。

1.3 GOPATH 缺点

如果两个项目 A、B 都用了第三方库 T,但是使用的版本不同,项目 A 使用了 T v1.0,项目B 使用了 T v2.0,由于编译器固定从 GOPATH/src/ 下查找 GOPATH/src/T,无法在同一个 GOPATH 目录保存第三方库的两个版本,所以项目A和B无法共用一个 GOPATH,对开发极不方便。

2. vendor

2.1 vendor 是什么?

上一节提到,GOPATH 的痛点是多个项目无法共用同一个 GOPATH 目录。
vendor 也没有解决这个问题,它只是提供了一种机制让依赖互相隔离。它允许把项目依赖放到本项目的一个 vendor 目录,这个目录可以简单理解为该项目私有化的 GOPATH 目录。

2.2 vendor 依赖查找

项目编译时,优先从 vendor 目录中查找,找不到再去 GOPATH 中查找。
vendor 的好处是编译不会受到 GOPATH 目录影响,即使 GOPATH 中也有一个同名但不同版本的包。

2.3 vendor 缺点

项目依赖关系不清晰,无法清晰看出 vendor 目录中依赖的包版本,依赖包升级非常困难。

3. Go Module

GOPATH 无法让多个项目共享同一个包的不同版本,vendor 通过把依赖的包放在项目的 vendor 私有目录解决这个问题,但是缺点是依赖关系不清晰,升级困难。
Go Module 是一套全新的依赖管理方案,彻底解决 GOPATH 和 vendor 时代的问题。并且解决了2个非常重要的问题:

  • 准确记录项目依赖
  • 可重复构建

准确记录项目依赖是指,记录了项目中依赖了哪些包、以及精准的包版本;可重复构建是指,项目在任何环境或平台,构建产物相同。

3.1 Go Module 是什么?

3.1.1 module 概念

module 定义:一组 package 的集合,一起被标记版本,即一个 module。
一个仓库包含一个或多个 module,每个 module 包含一个或多个 package,每个 package 包含一个或多个源文件。

3.1.2 语义化版本

一个 module 的版本号规则必须遵循语义化规范:v(major).(minor).(patch) 格式。

  • major:大版本,发生不可兼容的改动时增加该版本。
  • minor:小版本,当有新特性时增加该版本。
  • patch:补丁版本,当有 bug 修复时增加该版本。

3.2 常用指令

  • module:声明 module 名称
  • require:声明依赖及版本号
  • replace:替换 require 中声明的依赖
  • exclude:禁用指定依赖
  • indirect:表示间接依赖

这里详细介绍下 replace 和 indirect 的用法。

3.2.1 replace

  1. 使用的前提条件
  • replace 仅在当前 module 为 main module 时生效,main module 指的是当前正在编译的项目。
  • replace 指令中 "=>" 前面的包以及版本号,必须出现在 require 中才有效,表示替换的包以及版本号必须是本项目中用到的。
  1. 使用的场景
  • 替换无法下载的包。比如有些包因为网络问题无法下载,如果这个包在 Github 上有镜像,那么可以替换为 Github 上包。
  • 替换为 fork 仓库。比如有的包有 bug,在开源版本还没有修复时,可以暂时fork下来修复 bug,替换为 fork 版本,等修复后再使用开源包,这种是临时做法。
  • 禁止被依赖。比如某个 module 不希望被直接引用,那么可以在 require 中把包的版本号都写为 v0.0.0,然后在下面 replace 中替换为实际的版本号。这样其他包引用这个包时,会因为找不到 v0.0.0而无法使用。

3.3.2 indirect

indirect 总是出现在 require 指令中,表示这个包是被间接依赖的,// 表示注释的开始,比如:

require (
github.com/google/uuid v1.3.0 // indirect
)

间接依赖的场景:

  • 直接依赖的那个包,没有使用 Go Module管理依赖,不存在 go.mod 文件,那么这个包的所有依赖都会以间接依赖的方式出现在当前的 go.mod 文件中。
  • 直接依赖的那个包,go.mod 文件不全,缺失的依赖也会以间接依赖的方式出现在当前的 go.mod 文件中。

间接依赖理论上不应该出现,如果 go.mod 中出现了间接依赖,那么要小心,看看自己是否使用了过时的开源包。查看间接依赖来源的办法:

// [pkg] 表示被间接依赖的包名
go mod why -m [pkg]

// 分析所有依赖的依赖链
go mod why -m all

3.3 版本选择机制

go get、go build、go mod tidy都会帮助我们自动选择依赖包的版本,都用到了版本选择机制。

  1. Go 官方约定
  • 包需要向后兼容,比如可导出的函数、变量、类型、常量,不能随便删除,比如函数要修改入参,那么就新增一个函数。
  • 如果新的包和旧的包有相同的 import 路径,那么新的包需要兼容旧的包,不能兼容的话,新的包就更换 import 路径。
  • 如果包的 major 版本号大于 1,那么在 require 的时候,路径要带上版本号,比如 github.com/my/uuid/v2,这么做是为了把 github.com/my/uuid 和 github.com/my/uuid/v2 当做2个不同的包去处理,因为 major 版本号不同表示包无法兼容。如果版本号小于等于1,那么在包名中不用带版本号。
  1. 版本选择
  • 最新版本选择:当某个包第一次被引用时,go get或go build 都会选择这个包的最新的版本使用。
  • 最小版本选择:当某个包的多个版本都被项目依赖时,依赖可能会发生变化,会自动选择依赖包的最小可用版本。
    比如项目 A 依赖包 T v1.0.0,A 还依赖包 B,而 B 依赖包 T v1.3.0,由于同一个大版本下依赖可以传递,那么最终 A 也会更新为依赖包 T v1.3.0 。

3.4 如何处理不规范的包?

如果包的版本号大于1,但是包引用时,包名没有带上版本号,那么就称这个包为不规范的包,此时 Go 命令会给这个包加上 +incompatible 标识,对于这个项目不影响使用。但是,如果其他项目使用这个不规范的包时,go get 不会自动选择不兼容的版本,既不会使用版本号大于1的版本。
所以如果发现 go.mod 中有不规范标识,应该及时修正。

require (
// major 版本号大于3,引用的时候,包名后应该带上版本号
// 正确的:github.com/google/uuid/v3 v3.3.0
github.com/google/uuid v3.3.0+incompatible
)

3.5 依赖包怎么存储的?

GOPATH 模式下,依赖包存储在 $GOPATH/src 目录下,只能存一个版本。
Go Module 模式下,依赖包存储在 $GOPATH/pkg/mod 目录下,可以存多个版本。

3.6 go.sum 文件是做什么用的?

3.6.1 go.sum 文件是什么

go.sum 文件中每行记录,由包名、版本号、哈希值组成,使用空格分开。go.sum 文件存在的意义是,希望在任何环境中构建项目时使用的依赖包必须跟 go.sum 中记录的完全一致,从而达到一致构建的目的。
可以看到 go.sum 文件中行数会比 go.mod 文件函数多很多,这是为什么呢?

  • 因为 go.mod 一般不会记录间接依赖,而 go.sum 会把直接依赖、间接依赖,全部记录上。
  • 而且任何一个依赖包,在 go.sum 中都会有2条记录,第一条是整个包所有文件一起算的哈希值,第二条表示该依赖包的 go.mod 文件计算得到的哈希值。单独存储依赖包的 go.mod 文件是为了计算依赖树的时候,不必下载整个依赖包,只根据这个包的 go.mod 文件计算即可。

3.6.3 go.sum 文件怎么用

当拿到源码并尝试构建时,Go 会先从本地缓存中获取依赖包,然后计算本地依赖包的哈希值,和 go.sum 中的哈希值对比,如果不一致,就会拒绝构建。有可能时本地缓存的包被篡改,也有可能时 go.sum 文件中的值被篡改,不过Go 更倾向于相信 go.sum 文件中的哈希值,因为第一次写入的时候是经过校验的。
此时可以尝试删除 go.sum 文件,使用 go build 时会自动生成 go.sum 文件,重新写入哈希值,且第一次写入的时候,哈希值是经过校验和数据库校验的。这个校验和数据库的地址在环境变量 GOSUMDB 中有写到,它是一个提供依赖包哈希值查询的服务。

3.7 GOPROXY 模块代理是什么?

在 Go Module 模式下,如果本地没有依赖包的缓存,那么 Go 会尝试从各个版本控制系统去拉取,为了提高拉取速度,Go 团队提供了镜像服务,即 proxy.golang.org,该服务通过缓存公开的模块,来提供下载服务,该服务实际就充当了各个版本控制系统的代理。
任何实现代理协议的 WEB 服务器都可以充当 GOPROXY 模块代理,代理协议就是几个格式固定的 GET 请求,且没有参数。
比如获取模块列表的 GET 请求:服务器需要响应 GET $GOPROXY//@v/list 请求,并返回当前代理服务器上该模块版本列表。

## 3.8 GOSUMDB 是什么?

环境变量 GOSUMDB 用于指示 go 命令校验模块时应该信任哪个数据库,完整的 GOSUMDB 配置包含校验和数据库名字、校验和数据库公钥和数据库服务的URL。
校验和数据库会存储所有公开的包的哈希值,来提供类似公证的服务。
某个新模块被下载后,go 就会对下载的模块做哈希值计算,然后和校验和数据库中该模块的哈希值对比,一致才会把这个哈希值写入 go.sum 文件中,以确保这个模块时合法的。
GOSUMDB 存储的是所有公开模块的哈希值,它不是一般的关系型数据库,而是类似于 Certificate Transparency 的技术,该技术采用一种称作透明日志的数据结构。该数据库的数据结构决定了它的数据不容易被篡改,因为一旦底层节点被篡改,上层数据节点也会改变。该数据库服务只提供查询模块哈希值的功能。

## 3.9 小结

Go Module 历经多个版本的打磨才逐渐走向成熟,业界很多项目中也会把 Go Module+vendor 结合使用,在 Go 1.14 及以后的版本中,只要项目中有 vendor 目录,那么就会默认启动 vendor。

标签: golang Module