Skip to content

依赖管理

Go 语言依赖管理经历了三个重要的阶段:

  • GOPATH
  • vendor
  • Go Module

早期 Go 语言单纯使用 GOPATH 管理依赖,但 GOPATH 不方便管理依赖的多个版本,后来增加了 vendor,允许把项目依赖连同项目源码一同管理。
Go 1.11 引入了全新的依赖管理工具 Go Module,直到 Go 1.14,Go Module 才走向成熟

Go 官方依赖管理演进过程中还有为数众多的第三方管理工具,比如 Glide、Govendor,但随着 Go Module 的推出,这些工具终将逐步退出历史舞台

从 GOPATH 到 vendor,再到 Go Module,这是一个不断演进的过程,了解每种依赖管理的痛点可以更好地理解下一代依赖管理的设计初衷

GOPATH

1
2
3
4
5
# mac 示例
$ go env

GOPATH="/Users/nocilantro/go"
GOROOT="/usr/local/Cellar/go/1.16.6/libexec"

当某个 package 需要引用其他包时,编译器就会依次从 GOROOT/src/GOPATH/src/ 中查找,如果某个包在 GOROOT 下找到,则不再到 GOPATH 目录下查找,所以如果项目中开发的包名与标准库相同,则会自动忽略

GOPATH 的优点是简单,但它不能很好地满足实际项目的工程需求

比如,有两个项目 A 和 B,它们都引用某个第三方库 T,但这两个项目使用了不同的 T 版本,即:

  • 项目 A 使用 T v1.0
  • 项目 B 使用 T v2.0

由于编译器依赖固定从 GOPATH/src 下查找 GOPATH/src/T,无法在同一个 GOPATH 目录下保存第三方库 T 的两个版本,所以项目 A、B 无法共享同一个 GOPATH,需要各自维护一个,这给广大软件工程师带来了极大的困扰

针对 GOPATH 的缺点,GO 语言社区提供了 vendor 机制,从此依赖管理进入了第二个阶段: 将项目的依赖包私有化

vendor

vendor 机制仍然无法让多个项目共享同一个 GOPATH,但它提供了一个机制让项目的依赖隔离而不互相干扰

自 Go 1.6 起,vendor 机制正式启用,它允许把项目的依赖放到一个位于本项目的 vendor 目录中,这个 vendor 目录可以简单理解成私有的 GOPATH 目录。
项目编译时,编译器会优先从 vendor 中寻找依赖包,如果 vendor 中找不到,那么再到 GOPATH 中寻找

vendor 目录位置

一个项目可以有多个 vendor 目录,分别位于不同的目录级别,建议每个项目只在根目录放置一个 vendor 目录。

使用 vendor 的好处是在项目发布时,可以把其所依赖的软件一并发布,编译时不会受到 GOPATH 目录的影响,即便 GOPATH 下也有一个同名但不同版本的依赖包

vendor 的不足

vendor 很好地解决了多项目间的隔离问题,但它仍存在一些不足:

  • 项目依赖关系不清晰,无法清晰地看出 vendor 目录中依赖包的版本
  • 依赖包升级时不方便审核,当升级某个依赖包的版本时,将是代码审核人员的 "噩梦"

此外,更严重的问题是上面提到的二进制文件的体积急剧增大问题,比如项目依赖开源包 A 和 B,但 A 中也有一个 vendor 目录,其中也放了 B,那么项目中会出现两个开源包 B。
再进一步,如果这两个开源包 B 的版本不一致呢?如果二者不兼容,那么后果将是灾难性的。

最后,vendor 能够解决绝大部门项目中的问题,至于上面提到的不足也有相应的工程手段来解决,这也催生了众多的依赖管理工具。
围绕 Go 的依赖管理工具竟多达数十种,呈现百家争鸣之势,Go 急需一个权威的依赖管理工具来 "一统江湖"

直到 Go 1.11,官方团队才推出了依赖管理工具 Go Module,从此 Go 的版本管理走进第三个时代

Go Module 简介

Go Module 相比 GOPATH 和 vendor 而言功能强大很多,它基本上解决了 GOPATH 和 vendor 时代遗留的问题。
我们知道,GOPATH 时代最大的困扰时无法让多个项目共享同一个 package 的不同版本,在 vendor 时代,通过把每个项目依赖的 package 放到 vendor 中可以解决这个困扰。
但是使用 vendor 的问题是无法很好地管理依赖的 package,比如升级 package

虽然 Go Module 能够解决 GOPATH 和 vendor 时代遗留的问题,但需要注意的时,Go Module 不是 GOPATH 和 vendor 的演进,理解这个对于接下来正确理解 Go Module 非常重要

Go Module 更像是一种全新的依赖管理方案,它涉及一系列的特性,但究其核心,它主要解决了两个重要的问题:

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

准确地记录项目依赖是指项目依赖哪些 package,以及精确的 package 的版本。
比如项目依赖 github.com/prometheus/client_golang,且必须是 v1.0.0,那么可以通过 Go Module 指定,任何人在任何环境下编译项目,都必须使用 github.com/prometheus/client_golang 的 v1.0.0

可重复的构建是指项目无论在谁的环境中(同平台)构建,其产物都是相同的。
回想一下 GOPATH 时代,虽然大家拥有同一个项目的代码,但由于各自的 GOPATH 中 github.com/prometheus/client_golang 的版本不一样,虽然项目可以构建,但构建出的可执行文件很可能是不同的。
可重复构建至关重要,避免出现 "我这里运行没问题,肯定是你的环境问题" 的类似问题

一旦项目的依赖被准确记录了,就很容易做到重复构建

事实上,Go Module 具有非常复杂的特性

Go Module 基础

module 的定义

首先,module 是一个新鲜又熟悉的概念,新鲜是指在以往的 GOPATH 和 vendor 时代都没有提及,它是一个新的词汇。
为什么说熟悉呢?因为它不是新的事物,事实上我们经常接触它,只是官方给了一个统一的称呼而已

以开源项目 https://github.com/blang/semver 为例,这个项目是一个语义化版本处理库,当需要时可以在项目中使用 import 引用,比如:

1
import "github.com/blang/semver" 

https://github.com/blang/semver 项目中可以包含一个或多个 package,不管有多少 package,这些 package 都随项目一起发布,即当我们说 github.com/blang/semver 某个版本时,说的是整个项目,而不是具体的 package。
此时项目 https://github.com/blang/semver 就是一个 module

官方给出的 module 的定义是 "A module is a collection of related Go packages that are versioned together as a single unit.",定义非常清晰,一组 package 的集合,一起被标记版本,即一个 module

通常而言,一个仓库包含一个 module(虽然也可以包含多个,但不推荐),所以仓库、module 和 package 的关系如下:

  • 一个仓库包含一下或多个 module
  • 每个 module 包含一个或多个 package
  • 每个 package 包含一个或多个源文件

此外,一个 module 的版本号规则必须遵循语义化规范,版本号必须使用 v(major).(minor).(patch) 格式,比如 v0.1.0v1.2.3v1.5.0-rc.1

语义化版本规范

语义化版本(Semantic Versioning)已成为事实上的标准,几乎知名的开源项目都遵循该规范,更详细的信息请前往 semver 官网查询,在此只提炼一点要点,以便于后续的阅读

版本格式 v(major).(minor).(patch) 中的 major 指的是大版本,minor 指小版本,patch 指补丁版本

  • major: 当发生不兼容的改动时才可以增加新版本,比如 v2.x.y 与 v1.x.y 是不兼容的
  • minor: 当有新增特性时才可以增加新版本,比如 v1.17.0 是在 v.16.0 的基础上增加了新的特性,同时兼容 v1.16.0
  • patch: 当有 bug 修复时才可以增加该版本,比如 v1.17.1 修复了 v1.17.0 上的 bug,没有增加新特性

语义化版本规范的好处是,用户通过版本号就能了解版本信息

除了上面介绍的基础概念,还有描述依赖的 go.mod 和记录 module 的 Hash 值的 go.sum 等内容,这部分内容比较多且比较复杂

Go Module 快速实践

在项目中使用 Go Module 实际上是为了精准地记录项目的依赖信息,包括每个依赖的版本号、Hash 值

那么,为什么需要记录这些依赖情况,或者记录这些依赖有什么好处呢?

试想一下,在编译某个项目时,第三方包的版本往往是可以替换的,如果不能精确地控制所使用的第三方包的版本,最终构建出的可执行文件在本质上是不同的,这会给问题定位带来极大的困扰。

接下来,我们从一个 Hello World 项目开始,逐步介绍如何初始化 module、如何记录依赖的版本信息。
项目托管在 GitHub(https://github.com/renhongcai/gomodule)中,并使用版本号区别使用 Go Module 的阶段

  • v1.0.0 未引用任何第三方包,也未使用 Go Module
  • v1.1.0 未引用任何第三方包,已开始使用 Go module,但没有任何外部依赖
  • v1.2.0 引用了第三方包,并更新了项目依赖

Hello World

在 1.0.0 版本时,项目只包含一个 main.go 文件,只是打印一个简单的字符串:

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
    fmt.Println("Hello World")
}

此时,项目还没有引用任何第三方包,也未使用 Go Module

初始化 module

如果一个项目要使用 Go Module,那么其本身需要先成为一个 module,即需要一个 module 名字

在 Go Module 机制下,项目的 module 名字及其依赖信息记录在一个名为 go.mod 的文件中,该文件可以手动创建,也可以使用 go mod init 命令自动生成。
推荐自动生成的方法如下:

1
2
3
4
$ mkdir gomodule
$ cd gomodule 
$ go mod init github.com/renhongcai/gomodule
go: creating new go.mod: module github.com/renhongcai/gomodule

完整的 go mod init 命令格式为 go mod init [module],其中 [module] 为 module 名字,如果不填,则 go mod init 会尝试从版本控制系统或 import 的注释中猜测一个。
这里推荐指定明确的 module 名字,因为猜测有时需要一些额外的条件

上面的命令会自动创建一个 go.mod 文件,其中包括 module 的名字,以及我们所使用的 Go 的版本信息:

gomodule/go.mod:

1
2
3
module github.com/renhongcai/gomodule

go 1.16

module 的名字用于 import 语句中,如果 module 中包含多个 package,那么 module 的名字为 package 的前缀

在 go.mod 文件中记录 Go 的版本号是在 Go 1.12 中引入的小特性,该版本号表示开发此项目的 Go 语言版本,并不是编译该项目所限制的 Go 语言版本,如果项目中使用了 Go 1.13 的新特性,而使用 Go 1.11 编译,当编译失败时,编译器会给出 Go 版本不匹配的提示

由于我们的项目还没有使用任何第三方包,所以 go.mod 中并没有记录依赖包的任何信息。
我么把自动生成的 go.mod 提交,然后尝试引用一个第三方包

管理依赖

现在我们准备引用一个第三方包 github.com/google/uuid 来生成一个 UUID,这样就会产生一个依赖,代码如下:

gomodule/main.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package main

import (
    "fmt"

    "github.com/google/uuid"
)

func main() {
    id := uuid.New().String()
    fmt.Println("UUID: ", id)
}

在开始编译前,我们先使用 go get 来下降依赖包,go get 会自动分析并下载依赖包:

1
2
3
$ go get
go: downloading github.com/google/uuid v1.3.0
go get: added github.com/google/uuid v1.3.0

从输出的内容来看,go get 帮助我们定位到可以使用 github.com/google/uuid 的 v1.3.0 版本,然后下载并解压它

注意; go get 总是获取依赖的最新版本,如果 github.com/google/uuid 发布了新的版本,那么输出的版本信息会相应地变化。

此处,go get 命令会自动修改 go.mod 文件:

1
2
3
4
5
module github.com/renhongcai/gomodule

go 1.16

require github.com/google/uuid v1.3.0

可以看到,现在 go.mod 中增加了 require github.com/google/uuid v1.3.0 的内容,表示当前项目依赖 github.com/google/uuid1.3.0 版本,这就是 go.mod 记录的依赖信息

由于这是当前项目第一次引用外部依赖,所以 go get 命令还会生成一个 go.sum 文件,记录依赖包的 Hash 值:

gomodule/go.sum:

1
2
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

该文件通过记录每个依赖包的 Hash 值来确保将来项目构建时依赖包不会被篡改。

经 go get 修改的 go.mod 和创建的 go.sum 都需要提交到代码库,这样别人获取项目代码后,在编译时就会使用项目所要求的依赖版本

至此,项目已经有一个依赖包,并且可以编译执行了,每次运行都会生成一个独一无二的 UUID:

1
2
$ go run main.go 
UUID:  a47532c5-b99d-4f5d-b717-8b1487502039

注: 如果之前没有使用 go get 命令下载过依赖,使用 go build main.go 命令时,依赖包也会被自动下载,并且也会自动更新 go.mod 文件。
在 Go v1.13.4 中有个 bug,即此时生成的 go.mod 中显示的依赖信息会是 require github.com/google/uuid v1.3.0 // indirect,注意行末的 indirect 表示间接依赖,这明显是错误的,因为我们是直接引用的

版本差异

由于 Go Module 在 Go 1.11 时初次引入,历经 Go 1.12、Go 1.13 的发展,最后到 1.14 成熟,部分实现细节会略有不同

比如,在 Go 1.11 中使用 go mod init 初始化项目时,不填写 module 名称是没有问题,但在 Go 1.13 中,如果项目不在 GOPATH 目录中,则必须填写 module 的名称

replace 指令

go.mod 文件中通过指令声明 module 信息,用于控制 Go 命令行工具进行版本选择。一共有四个指令可供使用:

  • module: 声明 module 的名称
  • require: 声明依赖及其版本号
  • replace: 替换 require 中声明的依赖,使用另外的依赖及其版本号
  • exclude: 禁用指定的依赖

其中 module 和 require 已介绍过,module 用于指定 module 的名字,如 module github.com/renhongcai/gomodule,那么其他项目引用该 module 时其 import 路径需要使用 github.com/renhongcai/gomodule 前缀。
require 用于指定依赖,如 require github.com/google/uuid v1.3.0,该指令相当于告诉 go build 使用 require github.com/google/uuid 的 1.3.0 版本进行编译

现在关注 replace 的用法,包括其工作机制和常见的使用场景

replace 的工作机制

顾名思义,replace 指替换,它用于替换 require 指令中出现的包。例如,我们使用 require 指定一个依赖:

1
2
3
4
5
module github.com/renhongcai/gomodule

go 1.16

require github.com/google/uuid v1.3.0

此时可以使用 go list -m all 命令查看最终选定的版本

1
2
3
$ go list -m all
github.com/renhongcai/gomodule
github.com/google/uuid v1.3.0

毫无意外,最终选定的 uuid 版本正是我们在 require 中指定的 v1.3.0

如果想使用 uuid 的 v1.1.0 进行构建,除了可以修改 require 指令,还可以使用 replace 来指定。
需要说明的是,正常情况下不需要使用 replace 来修改版本,最直接的方法是修改 require 指令,虽然 replace 也能够做到,但这不是 replace 的一般使用场景。
下面我们先通过一个简单的例子来说明 replace 的功能,然后介绍几种常见的使用场景

比如,修改 go.mod,添加 replace 指令

1
2
3
4
5
6
7
module github.com/renhongcai/gomodule

go 1.16

require github.com/google/uuid v1.3.0

replace github.com/google/uuid v1.3.0 => github.com/google/uuid v1.1.0

replace github.com/google/uuid v1.3.0 => github.com/google/uuid v1.1.0 指令表示替换 uuid v1.3.0 的版本为 v1.1.0,此时再次使用 go list -m all 命令查看最红选定的版本

1
2
3
4
5
6
$ go get
go: downloading github.com/google/uuid v1.1.0

$ go list -m all
github.com/renhongcai/gomodule
github.com/google/uuid v1.3.0 => github.com/google/uuid v1.1.0

可以看到其最终选择的 uuid 版本为 v1.1.0

到此,我们可以看出 replace 的作用了,它用于替换 require 中出现的包,它正常工作还需要满足以下两个条件:

  • replace 仅在当前 module 为 main module 时有效,比如我们当前在编译 github.com/renhongcai/gomodule,此时就是 main module,如果其他项目引用了 github.com/renhongcai/gomodule,那么其他项目编译时,此处的 replace 就会自动忽略
  • replace 指令 "=>" 前面的包及其版本号必须出现在 require 中才有效,否则指令无效,也会被忽略。比如,上面的例子中,我们指定 replace github.com/google/uuid => github.com/google/uuid v1.1.0,或者指定 `replace github.com/google/uuid v1.0.9 => github.com/google/uuid v1.1.0,二者均无效

replace 的使用场景

replace 在实际项目中经常被使用,其中不乏一些精彩的用法。但不管应用在哪种场景中,其本质都一样,都是替换 require 中的依赖

(1) 替换无法下载的包

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

比如,项目中使用了 golang.org/x/text 包:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
    "fmt"

    "github.com/google/uuid"
    "golang.org/x/text/language"
    "golang.org/x/text/message"
)

func main() {
    id := uuid.New().String()
    fmt.Println("UUID: ", id)

    p := message.NewPrinter(language.BritishEnglish)
    p.Printf("Number format: %v.\n", 1500)

    p = message.NewPrinter(language.Greek)
    p.Printf("Number format: %v.\n", 1500)
}

上面的例子中使用两种语言 language.BritishEnglish 和 language.Greek 分别打印数字 1500 来查看不同语言对数字格式的处理,一个是 1,500, 另一个是 1.500。
此时就会分别引入 golang.org/x/text/language 和 golang.org/x/text/message

执行 go get 或 go build 命令会再次分析依赖情况,并更新 go.mod 文件。网络正常的情况下,go.mod 文件会变成下面的内容:

1
2
3
$ go get
go: downloading golang.org/x/text v0.3.7
go get: added golang.org/x/text v0.3.7

gomodule/go.mod:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
module github.com/renhongcai/gomodule

go 1.16

require (
    github.com/google/uuid v1.3.0
    golang.org/x/text v0.3.7
)

replace github.com/google/uuid v1.3.0 => github.com/google/uuid v1.1.0

我们看到,依赖 golang.org/x/text 被添加到了 require 指令中(多条 require 语句会自动使用括号合并)。
此外,我们没有刻意指定 golang.org/x/text 的版本号,Go 命令行工具根据默认的版本计算规则使用了 0.3.7 版本,此处我们暂不关心具体的版本号

在没有合适的网络代理情况下,golang.org/x/text 很可能无法下载。此时就可以使用 replace 来让项目使用 GitHub 上相应的镜像包。我们可以添加一条新的 replace 条目:

1
2
3
4
replace (
    github.com/google/uuid v1.3.0 => github.com/google/uuid v1.1.0
    golang.org/x/text v0.3.7 => github.com/golang/text v0.3.7
)
1
2
$ go get
go: downloading github.com/golang/text v0.3.7

此时,项目编译时就会从 GitHub 上下载包。源代码中 import 路径 golang.org/x/text/xxx 仍然不需要改变

也许有开发者会问,是否可以将 import 路径由 golang.org/x/text/xxx 改成 github.com/golang/text/xxx? 这样一来,就不需要使用 replace 来替换包了

遗憾的是,不可以。因为 github.com/golang/text 只是镜像仓库,其 go.mod 文件中定义的 module 还是 module golang.org/x/text,这个 module 名字直接决定了 import 的路径

(2) 调试依赖包

有时我们需要调试依赖包,此时就可以使用 replace 来修改依赖;

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

github.com/google/uuid v1.3.0 => ../uuid 语句使用本地的 uuid 来替换依赖包,此时,我们可以任意地修改 ../uuid 目录的内容来进行调试

除了使用相对路径,还可以使用绝对路径,甚至还可以使用自己的 fork 仓库

(3) 使用 fork 仓库

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

比如,"fork" 了开源包 github.com/google/uuid,fork 仓库地址为 github.com/RainbowMango/uuid,我们就可以在 fork 仓库里修改 bug 并发布新的版本 v1.3.1,此时使用 fork 仓库的项目的 go.mod 文件中的 replace 部分可以相应地做如下修改:

1
github.com/google/uuid v1.3.0 => github.com/RainbowMango/uuid v1.3.1

需要说明的是,使用 fork 仓库仅仅是临时的做法,一旦开源版本变得可用,则需要尽快切换到开源版本

(4) 禁止被依赖

另一种使用 replace 的场景是 module 不希望被直接引用,比如开源项目 Kubernetes,在它的 go.mod 中的 require 部分有大量的 v0.0.0 依赖,比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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
    ...
)

由于上面的依赖都不存在 0.0.0 版本,所以其他项目直接依赖 k8s.io/kubernetes 时会因无法找到版本而无法使用。
因为 Kubernetes 不希望作为一个整体的 module 被直接使用,所以其他项目如有需要则可以引用 Kubernetes 的相关子 module

Kubernetes 对外隐藏了依赖版本号,其真实的依赖通过 replace 指定:

1
2
3
4
5
6
7
8
9
require (
    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
)

前面我们说过,replace 指令在当前模块不是 main module 时会自动忽略,Kubernetes 正是利用了这一特性来实现对外隐藏依赖版本号来达到禁止直接引用的目的

exclude 指令

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

exclude 指令在实际项目中很少被使用,因为很少会显式地排除某个包的某个版本,除非我们知道某个版本有严重的 bug。
比如指令 exclude github.com/google/uuid v1.1.0,表示不使用 1.1.0 版本
在选择版本时,如果 uuid v1.1.0 后还有更新的版本比如 v1.1.1 可用,Go 命令行工具可以自动选择 v1.1.1,如果没有更新的版本时将报错而无法编译

indirect 指令

在使用 Go Module 的过程中,随着引入的依赖增多,细心的开发者也许会发现 go.mod 文件中部分依赖包后面会出现一个 "// indirect" 标识。
这个标识总是出现在 require 指令中,其中 // 与代码的行注释一样表示注释的开始,indirect 表示间接的依赖

在执行命令 go mod tidy 时,Go Module 会自动整理 go.mod 文件,如果有必要会在部分依赖包的后面增加 // indirect 注释。
被添加 indirect 注释的依赖包说明该依赖包被间接引用,而没有添加 // indirect 注释的依赖包则是被直接引用的,即明确地出现在某个 import 语句中

这里需要着重强调的是: 并不是所有的间接依赖都会出现在 go.mod 文件中。

间接依赖出现在 go.mod 文件中的情况,可能符合下面所列场景的一种或多种:

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

直接依赖未启用 Go Module

假如 module A 依赖 B,但是 B 还未切换成 module,即没有 go.mod 文件,当使用 go mod tidy 命令更新 A 的 go.mod 文件时,B 的两个依赖 B1 和 B2 会被添加到 A 的 go.mod 文件中(前提是 A 之前没有依赖 B1 和 B2),并且 B1 和 B2 还会被添加 // indirect 的注释

此时 module A 的 go.mod 文件中的 require 部分将变成:

1
2
3
4
5
require (
    B vx.x.x
    B1 vx.x.x // indirect
    B2 vx.x.x // indirect
)

依赖 B 及 B 的依赖 B1 和 B2 都会出现在 go.mod 文件中

直接依赖 go.mod 文件不完整

如果 B 拥有 go.mod,但是 go.mod 文件不完整,则 module A 依然会记录部分 B 的依赖到 go.mod 文件中

例如 module B 的 go.mod 文件中只添加了依赖 B1,那么 A 在引用 B 时,则会在 A 的 go.mod 文件中添加 B2 作为间接依赖,B1 则不会出现在 A 的 go.mod 文件中

1
2
3
4
require (
    B vx.x.x
    B2 vx.x.x // indirect
)

小结

(1) 为什么要记录间接依赖

在上面的例子中,如果某个依赖 B 没有 go.mod 文件,在 A 的 go.mod 文件中已经记录了依赖 B 及其版本号,那么为什么还要增加间接依赖呢?

我们知道 Go Module 需要精确地记录软件的依赖情况,虽然此处记录了依赖 B 的版本号,但 B 的依赖情况没有记录下来,所以如果 B 的 go.mod 文件缺失了(或没有)这个信息,则需要在 A 的 go.mod 文件中记录下来。
此时间接依赖的版本号会根据 Go Module 的版本选择机制确定一个最优版本。

(2) 如何处理间接依赖

综上所述,间接依赖出现在 go.mod 中,可以在一定程度上说明依赖有瑕疵,要么是其不支持 go Module,要么是其 go.mod 文件不完整

由于 Go 语言从 v1.11 才推出 module 的特性,众多开源软件迁移到 Go Module 还需要一段时间,在过渡期必然会出现间接依赖,但随着时间的推进,在 go.mod 中出现 // indirect 的概率会越来越低

出现间接依赖可能意味着你在使用过时的软件,如果有可能的话还是推荐尽快消除间接依赖。
可以通过使用依赖的新版本或替换依赖的方式消除间接依赖

(3) 如何查找间接依赖来源

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

比如,我们有如下的 go.mod 文件片段:

1
2
3
4
5
6
7
require (
    github.com/Rican7/retry v0.1.0 // indirect
    github.com/google/uuid v1.0.0
    github.com/renhongcai/indirect v1.0.0
    github.com/spf13/pflag v1.0.5 // indirect
    github.com/x/text v0.3.2
)

如果希望确定间接依赖 github.com/Rican7/retry v0.1.0 // indirect 是被哪个依赖引入的,则可以使用 go mod why 命令来查看:

1
2
3
4
5
$ go mod why -m github.com/Rican7/retry
# github.com/Rican7/retry
github.com/renhongcai/gomodule
github.com/renhongcai/indirect
github.com/Rican7/retry

上面的打印信息中 # github.com/Rican7/retry 表示当前正在分析的依据,后面几行则表示依赖链。
github.com/renhongcai/gomodule 依赖 github.com/renhongcai/indirect,而 github.com/renhongcai/indirect 依赖 github.com/Rican7/retry
由此我们就可以判断出间接依赖 github.com/Rican7/retry 是被 github.com/renhongcai/indirect 引入的

另外,go mod why -m all 命令可以分析所有依赖的依赖链

依赖包存储

1
2
3
$ go env
GO111MODULE=""
GOPATH="/Users/nocilantro/go"

在 GOPATH 模式下不方便使用同一个依赖包的多个版本。
在 GOMODULE 模式下这个问题得到了很好的解决,因为两种模式下依赖包的存储位置发生了显著的变化

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

需要注意的是,$GOPATH/pkg/mod 目录下有一个 cache 目录,它用来存储依赖包的缓存,简单来说,go 命令每次下载新的依赖包都会在该 cache 目录中保存一份。

接下来我们以开源项目 github.com/google/uuid 为例分别说明在 GOPATH 模式和 GOMODULE 模式下特定依赖包的存储机制。
在下面的操作中,我们使用 GO111MODULE 环境变量控制具体的模式。

  • export GO111MODULE=off: 切换到 GOPATH 模式
  • export GO111MODULE=on: 切换到 GOMODULE 模式

在 GOMODULE 模式下,go get 命令会将依赖包下载到 $GOPATH/pkg/mod 目录下,并且按照依赖包的版本分别存放。

相较于 GOPATH 模式,GOMODULE 有两处不同点:

  • 依赖包的目录中包含了版本号,每个版本占用一个目录
  • 依赖包的特定版本目录中只包含依赖包文件,不包含 .git 目录

由于依赖包的每个版本都有唯一的目录,所以在多项目场景中使用同一个依赖包的多版本时才不会产生冲突。
另外,由于依赖包的每个版本都有唯一的目录,表示该目录内容不会发生改变,也就不必再存储其位于版本管理系统(如 Git) 中的版本历史信息
在 GOMODULE 模式下,只需要下载模块的代码文件,而不必克隆整个仓库,这大大节省了网络带宽和存储资源

包名大小写敏感问题:

有时我们使用的包名中会包含大写字母,比如 github.com/Azure/azure-sdk-for-go,在 GOMODULE 模式下,在存储时会将包名做大小写编码处理,即每个大写字母将变成 "!+相应的小写字母",比如 github.com/Azure 包在存储时将被放置在 $GOPATH/pkg/mod/github.com/!azure 目录中

需要注意的是,在 GOMODULE 模式下是哟哦那个 go get 命令时,如果不小心将某个包名大小写搞错,比如将 github.com/google/uuid 写成 github.com/google/UUID,在存储依赖包时会严格按照 go get 命令指示的报名进行存储

在 go get 中使用错误的包名,除了会增加额外的不必要的存储空间,还可能影响 go 命令解析依赖,甚至将错误的包名使用到 import 指令中,所以在实际使用时应该尽量避免

go.sum

为了确保一致性构建,Go 引入了 go.mod 文件来标记每个依赖包的版本,在构建过程中 go 命令会下载 go.mod 中的依赖包,下载的依赖包会换存在本地,以便下次构建。
考虑到下载的依赖包有可能是被黑客恶意篡改的,以及缓存在本地的依赖包也有被篡改的可能,单单一个 go.mod 文件并不能保证一致性构建

为了解决 Go Module 的这一安全隐患,Go 开发团队在引入 go.mod 的同时引入了 go.sum 文件,用于记录每个依赖包的 Hash 值,在构建时,如果本地依赖包的 Hash 值与 go.sum 文件中记录的内容不一致,则会拒绝构建

模块代理

在 GOMODULE 模式下,如果本地没有缓存,那么 go 命令将从各个版本控制系统中拉取模块,比如 github.com、bitbucket.org、golang.org 等
面对如此众多的版本控制系统,考虑到不同国家和地区的网络状况,很可能出现模块下载缓慢或无法下载的情况

为了提高模块的下载速度,Go 团队提供了模块镜像服务,即 prox.golang.org。
该服务通过缓存公开课获得的模块来为Go 开发者服务,该服务实际上充当了众多版本控制系统的代理

1
2
$ go env
GOPROXY="https://goproxy.io,direct"

Go 自 1.13 版本起支持配置多个用逗号分隔的镜像服务器地址,使用 go 命令下载模块时会依次从各镜像服务器下载,直到下载成功为止,行尾的 direct 表示如果前面的镜像服务器没有指定的模块则从源地址下载

第三方代理

伴随 Go Module 特性,官方分别推出了模块代理服务与检验和数据库服务。这两个服务均由 Go 官方团队运营,面向全球开发者提供服务

然而,并不是所有开发者都可以顺利地访问这两项服务,这就催生了众多的第三方代理服务

第三方代理服务与官方代理服务一样,仅能处理公开可获得的模块,对于企业私有的模块则需要企业单独部署代理服务

不管使用哪个代理服务,都需要将代理服务器地址配置到 GOPROXY 环境变量中,假如第三方代理服务地址为 https://proxy.example.org,Go 版本为 1.13 及以上,则可以使用如下命令:

1
export GOPROXY="https://proxy.example.org,direct"

可以同时指定多个代理服务器,使用逗号分隔即可,额外指定一个 direct 可以保证在代理无法工作时使用 go 命令仍能从源版本控制系统中获取模块

可用的第三方代理:

目前国内用得较多的公共代理服务主要有以下几个:

  • goproxy.io (开源项目), 国内最早的代理服务
  • goproxy.cn (开源项目), 目前由七牛云提供服务
  • mirrors.tencent.com/go,由腾讯云提供服务
  • mirrors.aliyun.com/goproxy, 由阿里云提供服务