本文主要是 《GO专家编程》 第12章的读书笔记

1. Golang 依赖管理

Golang 的依赖管理经过了以下三个阶段:

  • Go 1.6 版本之前,仅使用 GOPATH 进行依赖管理
  • Go 1.6 版本引入了 Vendor 机制
  • Go 1.11 版本引入了全新的 Go module 机制,该机制在 Go 1.14 版本走向成熟

在 Golang 官方依赖管理的演进过程中,还出现了大量第三方管理工具,但随着 Go module 的推出,这些工具也慢慢的退出了历史舞台。

1. GOPATH

在 Go 的最初几个版本,$GOROOT/src 和 $GOPATH/src 两个目录承担了包管理的所有职责。其中,$GOROOT/src 中存放的是 Go 语言标准库,$GOPATH/src 中存放的是第三方库和项目私有库。

当用户进行开发时,工程目录需要放在 $GOPATH/src 中,所有该项目依赖的工程也需要从 Github 下载到该目录存放。

当某个 package 需要引用其他包是,编译器依次从 $GOROOT/src 和 $GOPATH/src 中去查找。如果某个包在 $GOROOT/src 中被找到,就不再到 $GOPATH/src 中查找。所以第三方项目的包路径是不能和标准库冲突的。

GOPATH 存在的问题:

  • 无法在一个 GOPATH 路径下存储同一个包的不同版本。假设用户开发了两个项目 A 和 B,依赖不同版本的第三方库 T,那么 A 和 B 是无法共享同一个 GOPATH 的。
  • 不利于代码的分发,用户需要将 GOPATH 中的所有文件分发给对方,才能保证代码正常编译。

2. vendor

vendor 机制的出现是为了解决项目无法共享 GOPATH 的痛点。

自 Go 1.6 版本起,Golang 允许把项目的全部依赖放在本项目的 vendor 目录中,该目录可以理解为项目的私有 GOPATH/src 路径。编译器优先从 vendor 中寻找依赖,如果 vendor 中找不到在到 GOPATH 中寻找。

一个项目可以有多一个 vendor 目录,位于不同包路径下,编译器搜索依赖时时,从代码目录向上搜索所有的 vendor 目录。通常用户应该只在根目录放置一个 vendor 目录,而不是放置多个 vendor。

vendor 存在的问题:

vendor中的依赖包无法指定版本,某个依赖包,在把它放入vendor的那刻起,它就固定在当时版本,项目的使用者很难识别出你所使用的依赖版本

2. Go Module

Go Module 是 Go v1.11 版本非常重大的更新,旨在解决完全解决 GOPATH 和 vendor 时代遗留的所有问题。

作为一种全新的依赖管理方案,Go Module 主要解决以下两个重要问题:

  • 准确记录项目的依赖,包括:依赖那些 package、 package 的版本
  • 可重复的构建

上述两个问题中,准确记录项目依赖是更关键的问题。一旦项目的依赖被准确记录了,就很容易做到重复构建。

Go module实际上只是精准的记录项目的依赖情况,包括每个依赖的精确版本号。

1. go.mod 文件

go.mod 文件就是 Golang 用于准确记录项目依赖的手段。

一个项目若要使用 Go module,那么其本身需要先成为一个 module 。在 Go module 机制下,项目根目录中保存了文件 go.mod .

go.mod 文件可能包含了以下指令:

  • moudule:项目的 moudle 名称,其他项目引用本项目的 package 时,使用该名称
  • require:记录项目引用的 package 以及对应的版本号
  • replace:替换require中声明的依赖,使用另外的依赖及其版本号
  • exclude:禁用指定的依赖

通常情况下,用户在代码中 import package 后,可执行 go mod tidy / go mod vendor 命令,这两个命令会自动触发 golang 查找包含 package 的 module 并且还会在 go.mod 中自动添加 require 指令。

部分自动添加的 require 指令之后可能包含 // indirect 标识,该标识标识该 module 是被间接依赖的。没有// indirect 标识的 module 被项目直接依赖,即明确出现在某个 import 语句中。

需要着重强调的是:并不是所有的间接依赖都会出现在 go.mod 文件中。间接依赖出现在 go.mod 文件的情况,可能符合下面所列场景的一种或多种:

  • 直接依赖未启用 Go module
  • 直接依赖 go.mod 文件中缺失部分依赖

Go module提供了go mod why 命令来解释为什么会依赖某个软件包,若要查看 go.mod 中某个间接依赖是被哪个依赖引入的,可以使用命令 go mod why -m 来查看。

2. 依赖包版本

在 Go module 中,module版本号要遵循语义化版本规范,即版本号格式为 v..,如 v1.2.3。当有不兼容的改变时,需要增加 major 版本号,如 v2.1.0。

major 版本号

Golang 规定如果 major 版本号大于 1 则 major 版本号需要显式地标记在 module 名字中,如果 module 的版本为 v0.x.x 或者 v1.x.x 则不需要在 module 名字中体现版本号。

换句话说:当我们开发自己的 module 时,如果发布的 tag 版本为 v2.x.x ,那么我们需要修改 module 名称为 xxx/v2 。

版本选择机制

Go 的多个命令行工具都有自动选择依赖版本的能力,如go build 和go test,当在源代码中增加了新的import,这些命令将会自动选择一个最优的版本,并更新go.mod文件。

需要特别说明的是,如果go.mod文件中已标记了某个依赖包的版本号,则这些命令不会主动更新go.mod中的版本号。所谓自动更新版本号只在go.mod中缺失某些依赖或者依赖不匹配时才会发生。

命令行工具选择版本号遵循以下规则:

  • 最新版本选择
  • 最小版本选择

当 go.mod 中没有包含对应 module 的 require 指令时,自动添加并选择最新版。由于 Go module 中主版本体现在 module 名称中,因此最新版本指的是同一个主版本中的最新版本。

有时记录在 go.mod 文件中的依赖包版本会随着引入其他依赖包而发生变化。

假设项目 A 依赖 M v1.0.0,项目 B 依赖 M v1.1.1,此时如果 A 需要引入 B 作为依赖,由于 A 和 B 同时依赖 M 的不同版本,此时 go 的命令行会自动修改 go.mod 中的 M 依赖为 v1.1.1 版本。

最小版本选择指:当项目需要引入同一个 module 的不同版本时,选择这些版本中最大的,而不是最新的 tag 版本。

上述“最小版本选择”场景出现时,可能伴随着 module 的冲突,此时我们可以用 replace 、exclude 指令进行处理。

引用不兼容包

module 的版本信息和 Golang 要求并不一致时,称为不兼容包,常见的情况有以下几种:

  • Module 的版本号不遵循 vmajor.minor.patch 格式
  • Module 的 major 版本号大于 1,但是 module 名称不包含主版本信息

如果在 go.mod 中引入了不兼容包,go 命令会自动增加 +incompatible标识。

1
2
3
require (
    github.com/RainbowMango/m v3.6.0+incompatible
)

伪版本

go.mod 中使用的语义化版本本质上是项目的 tag 信息,有需要时可以通过 commit ID 导入 module 任意时刻的代码,这种情况下的版本号称为伪版本(pseudo-version)。

伪版本的格式通常为 vx.y.z-yyyymmddhhmmss-abcdefabcdef,其中 yyyymmddhhmmss 则表示该 commit 的提交时间,abcdefabcdef表示某个commit ID的前12位。

vx.y.z 是一个通常不存在的版本,但 vx.y.z 部分在不同情况下略有区别,有时可能是 vx.y.z-pre.0 或者 vx.y.z-0,甚至 vx.y.z-dev.2.0 等。

通过 go get 命令可以自动生成伪版本号,并自动添加到 go.mod 中

1
go get github.com/QQGoblin/go-sdk@ec6f47887fafc67d1d1e4be2c704dcba090027a2

3. replace 指令

replace 指令指示编译工具使用本地路径或者其他仓库的代码替换 require 中指定包的内容

replace 指令在当前模块不是 main module 时会被自动忽略的,即如果当前 module 是被其他项目引用的,那么其 go.mod 中记录的 replace 指令不会生效。

实际项目中replace在项目中经常被使用,其中不乏一些精彩的用法。

替换无法下载的包

由于中国大陆网络问题,有些包无法顺利下载,比如golang.org组织下的包,值得庆幸的是这些包在GitHub都有镜像,此时 就可以使用GitHub上的包来替换。

调试依赖包

当项目依赖的包需要调试时,我们可以从 Github 将包下载到本地添加调试日志,并将包地址 replace 成本地路径。

1
2
3
4
replace (
    github.com/google/uuid v1.1.1 => ../uuid
    golang.org/x/text v0.3.2 => github.com/golang/text v0.3.2
)

使用 fork 仓库

有时在使用开源的依赖包时发现了bug,在开源版本还未修改或者没有新的版本发布时,你可以使用fork仓库,在fork仓库中进行bug fix。 你可以在fork仓库上发布新的版本,并相应的修改go.mod来使用fork仓库。

1
github.com/google/uuid v1.1.1 => github.com/RainbowMango/uuid v1.1.2

禁止依赖

一些项目的 go.mod 文件中包含大量 v0.0.0 版本的依赖,而这些包实际上不存在 v0.0.0 版本,这些项目真实使用的包的版本被隐藏到了 replace 中。

项目的包被其他项目引用时,由只有 main module 中的 replace 指令会生效,因此大量 v0.0.0 版本的依赖包会无法找到,导致引用失败。

比如用户如果直接引用 “k8s.io/kubernetes” 那么需要将 kubernetes 工程 go.mod 相关的 replace 内容 copy 到工程中并且下载 kubernetes 源码到本地才能成功,这增加了工作量,从而迫使大家放弃(>_<!)。

 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
module k8s.io/kubernetes

require (
    ...
    k8s.io/api v0.0.0
    k8s.io/apiextensions-apiserver v0.0.0
    k8s.io/apimachinery v0.0.0
    k8s.io/apiserver v0.0.0
    k8s.io/cli-runtime v0.0.0
    k8s.io/client-go v0.0.0
    k8s.io/cloud-provider v0.0.0
    ...
)

replace (
    ...
    k8s.io/api => ./staging/src/k8s.io/api
    k8s.io/apiextensions-apiserver => ./staging/src/k8s.io/apiextensions-apiserver
    k8s.io/apimachinery => ./staging/src/k8s.io/apimachinery
    k8s.io/apiserver => ./staging/src/k8s.io/apiserver
    k8s.io/cli-runtime => ./staging/src/k8s.io/cli-runtime
    k8s.io/client-go => ./staging/src/k8s.io/client-go
    k8s.io/cloud-provider => ./staging/src/k8s.io/cloud-provider
    ...
)

4. exclude 指令

go.mod 文件中的 exclude 指令用于排除某个包的特定版本,其与 replace 类似,也仅在当前 module 为 main module 时有效,其他项目引用当前项目时,exclude 指令会被忽略。

exclude 指令在实际的项目中很少被使用,因为很少会显式地排除某个包的某个版本,除非我们知道某个版本有严重 bug 或者用于处理的依赖引起的冲突。

5. 其他内容

module 的存储

GOMODULE模式下,依赖包存储在 $GOPATH/pkg/mod,该目录中可以存储特定依赖包的多个版本。

参考:依赖包存储

go.sun 文件

参考:go.sum文件

6. 相关命令

1
2
3
4
5
6
7
8
# 查看 go.mod 中最终指定的的 package 版本
go list -m all

# 查看包的版本历史
go list -m -versions <pkg>

# 查找间接依赖来源
go mod why -m <pkg>

参考

GO专家编程