Kyle's Notebook

Go Modules 管理总结

Word count: 3.8kReading time: 16 min
2021/11/06

Go Modules 管理总结

参考极客时间《Go 语言项目开发实战》。

基本概念

Go Modules 从 vgo 演变而来,是 Go 1.14 版本后官方建议的 Go 包管理方案,具备以下特点:

  • 使包的管理更加简单。

  • 支持版本管理。

  • 允许同一个模块多个版本共存。

  • 可校验依赖包的哈希值,确保包的一致性,增加安全性。

  • 内置在几乎所有的 go 命令中,包括go get、go build、go install、go run、go test、go list 等。

  • 具有 Global Caching 特性,不同项目的相同模块版本,只会在服务器上缓存一份。

(Package)是同一目录中编译的 Go 源文件的集合。在一个源文件中定义的函数、类型、变量和常量,对于同一包中的所有其他源文件可见。包括:

  • Go 标准包:在 Go 源码目录下随 Go 一起发布。

  • 第三方包:由第三方提供,比如 github.com。

  • 匿名包:只导入而不使用。只使用导入包产生的副作用,即引用包级别的变量、常量、结构体、接口、init() 函数等。

  • 内部包:只用于项目内部,位于项目目录下。

1
2
3
4
5
6
7
8
9
10
11
12
13
import (
// Go 标准包
"fmt"

// 第三方包
"github.com/spf13/pflag"

// 匿名包
_ "github.com/jinzhu/gorm/dialects/mysql"

// 内部包
"github.com/marmotedu/iam/internal/apiserver"
)

模块(Module)是存储在文件树中的包的集合,文件树根目录有 go.mod 文件,其定义了模块的名称及其依赖包,每个依赖包都需要指定导入路径和语义化版本(Semantic Versioning),通过导入路径和语义化版本准确地描述一个依赖。

1
2
ls hello/
# go.mod go.sum hello.go hello_test.go world

Go Modules 使用 go mod 命令管理,常用的子命令:

  • download:下载 go.mod 文件中记录的所有依赖包。

  • edit:编辑 go.mod 文件。

  • graph:查看依赖结构。

  • init:把当前目录初始化为模块。

  • tidy:添加丢失并移除无用的模块。

  • vendor:将所有依赖包存到 vendor 目录下。

  • verify:检查当前模块的依赖是否已经存储在本地下载的源代码缓存中、下载后是否有修改。

  • why:查看为什么需要依赖某模块。

模块下载

img

在 Go1.14 中仍然需要确保 Go Modules 特性处在打开状态。通过环境变量 GO111MODULE 开启或关闭(on/off),在 Go1.14 版本中默认值为 auto,即在 $GOPATH/src 下,且没有包含 go.mod 时则关闭 Go Modules,其他情况下都开启 Go Modules。

通过代理下载

使用环境变量 GOPROXY 设置 Go 模块代理,比如:

1
2
# 默认值为 https://proxy.golang.org,direct
export GOPROXY='https://proxy.golang.org,https://goproxy.cn,direct'
  • 其中 direct 表示 Go 回源到模块的源地址(比如 GitHub 等) 去抓取 ,当值列表中上一个代理返回 404 或 410,会自动尝试列表中的下一个。遇见 direct 时回源,遇见 EOF 时终止,并抛出类似 invalid version: unknown revision… 的错误。

  • GOPROXY=off,则不尝试从代理服务器下载模块。

  • 如果需要从私有仓库拉取依赖(从代理服务器访问会报错),则要设置将这些模块添加到环境变量 GOPRIVATE 中(即 GONOPROXYGONOSUMDB 的默认值)。

使用代理除了解决墙的问题,还有以下优点:

  • 代理会永久缓存和存储所有的依赖,依赖一经缓存则不可更改。因此不需要再维护 vendor 目录,同时节省空间。

  • 依赖永久存在于代理服务器,即使模块从互联网上被删除,也可以通过代理服务器获取。

  • 一旦将 Go 模块存储在 Go 代理服务器中就无法覆盖或删除,可保护开发者免受注入相同版本恶意代码带来的攻击。

  • 通过 HTTP 的方式从代理服务器下载依赖,无需通过 VCS 工具下载。

  • 代理通过 HTTP 独立提供源代码(.zip 存档)和 go.mod,下载和构建 Go 模块的速度更快;可以独立获取 go.mod(无需获取整个仓库),解决依赖也更快。

  • 开发者可自己设置 Go 模块代理,以对依赖包有更多的控制,并预防 VCS 停机导致下载失败。

指定版本号下载

使用 go get <package[@version]> 命令下载模块的指定版本,用法:

img

使用 go get -u 可更新到 lastest 版本,或 go get -u=patch 更新到最小版本。

按最小版本下载

假设模块 A 同时依赖于模块 B 和模块 C,其中模块 B 依赖了模块 D 的 v1.3 版本、模块 C 依赖了模块 D 的 v1.4 版本,由于重复引用的模块 D(v1.3、v1.4),最终选用了 D 的 v1.4 版本。

  • 语义化版本的控制:因为模块 D 的 v1.3 和 v1.4 版本变更都属于次版本号的变更,v1.4 必须向下兼容 v1.3,因此选择高版本的 v1.4。

  • 模块导入路径的规范:如果主版本号不同,模块导入路径不一样。如果出现不兼容的情况,比如主版本号从 v1 变为 v2,模块的导入路径改变,不会影响 v1 版本。

go.mod

语法描述

Go Modules 核心文件,包含:

  • module:定义当前项目的模块路径。

  • go:设置预期的 Go 版本,目前起标识作用。

  • require:设置特定的模块版本,格式为 <导入包路径> <版本> [// indirect]

  • exclude:从使用中排除特定的模块版本,比如当知道模块的某个版本有严重问题。

  • replace:将一个模块版本替换为另外一个模块版本,格式为 $module => $newmodule(在代码中的导入路径仍然为 $module,且不会影响主模块依赖的其它模块)。$newmodule 可以是本地磁盘的相对路径(github.com/gin-gonic/gin => ./gin)或绝对路径(github.com/gin-gonic/gin => /home/lk/gin)、网络路径( golang.org/x/text v0.3.2 => github.com/golang/text v0.3.2)。

replace 的应用场景:

  • 开启 Go Modules 后,缓存依赖包是只读的,在日常开发中可能需要修改依赖包代码进行调试,这时可将依赖包另存到新位置,并在 go.mod 中替换。

  • 如果依赖包在 Go 命令运行时无法下载,可通过其他途径下载该依赖包、上传到开发构建机,并在 go.mod 中替换。

  • 在项目开发初期,A 项目依赖 B 项目的包,但 B 项目因为种种原因没有 push 到仓库,也可以在 go.mod 中把依赖包替换为 B 项目本地磁盘路径。

  • 在国内访问 golang.org/x 的各个包都需要翻墙,可以在 go.mod 中使用 replace,替换成 GitHub 上对应的库,比如 golang.org/x/text v0.3.0 => github.com/golang/text v0.3.0

版本号

Go Modules 要求模块的版本号格式为 v<major>.<minor>.<patch>,当 <major> 版本号大于 1,其版本号要体现在模块名字中(比如模块 github.com/blang/semver 版本号增长到 v3.x.x,则模块名应为 github.com/blang/semver/v3)。

实际上版本号有多种格式:

  • 如果模块具有符合语义化版本格式的 tag,会直接展示 tag 的值,比如 github.com/AlekSi/pointer v1.1.0

  • 除了 v0 和 v1 外,主版本号必须显式出现在模块路径的尾部,比如 github.com/appleboy/gin-jwt/v2 v2.6.3

  • 没有 tag 的模块,Go 命令会选择 master 分支上最新的 commit,并根据 commit 时间和哈希值生成符合语义化版本的版本号,比如 github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535

  • 当模块名字跟版本不符合规范,比如模块名字为 github.com/blang/semver,但是版本为 v3.5.0(正常应该是 github.com/blang/semver/v3),go 会在 go.mod 的版本号后加 +incompatible 表示。

  • 如果 go.mod 中的包是间接依赖,则会添加 // indirect 注释,比如 github.com/golangci/golangci-lint v1.30.0 // indirect

出现间接依赖时:

  • 直接依赖未启用 Go Modules:如果模块 A 依赖模块 B,模块 B 依赖 B1 和 B2,但是 B 没有 go.mod 文件,则 B1 和 B2 会记录到 A 的 go.mod 文件中,并在最后加上 // indirect

  • 直接依赖 go.mod 文件中缺失部分依赖:如果模块 A 依赖模块 B,模块 B 依赖 B1 和 B2,B 有 go.mod 文件,但是只有 B1 被记录在 B 的 go.mod 文件中,此时 B2 会被记录到 A 的 go.mod 文件中,并在最后加上 // indirect

修改方法

建议使用 go mod 子命令修改:

1
2
3
4
5
6
7
go mod edit -fmt  # go.mod 格式化
go mod edit -require=golang.org/x/text@v0.3.3 # 添加一个依赖
go mod edit -droprequire=golang.org/x/text # require的反向操作,移除一个依赖
go mod edit -replace=github.com/gin-gonic/gin=/home/colin/gin # 替换模块版本
go mod edit -dropreplace=github.com/gin-gonic/gin # replace的反向操作
go mod edit -exclude=golang.org/x/text@v0.3.1 # 排除一个特定的模块版本
go mod edit -dropexclude=golang.org/x/text@v0.3.1 # exclude的反向操作

go.sum

Go 根据 go.mod 文件记载的依赖包及其版本下载包源码,为避免下载的/缓存在本地的包被篡改、保证包的一致性,引入了 go.sum 文件记录每个依赖包(间接和直接)的 hash 值。

go.sum 每行记录由模块名、版本、哈希算法和哈希值组成,如 <module> <version>[/go.mod] <algorithm>:<hash>。每个依赖包包含两条记录,即依赖包所有文件的哈希值和该依赖包 go.mod 的哈希值:在计算依赖树时不必下载完整的依赖包版本,只根据 go.mod 即可计算依赖树,但如果没有 go.mod 则只有一条记录。

更新:在项目根目录下执行 go get 命令,依赖包($version.zip)被下载到 $GOPATH/pkg/mod/cache/download ,并对 zip 包做哈希运算、保存结果在 $version.ziphas 文件中,此时会同时更新 go.mod 和 go.sum 文件。

校验:执行构建时,go 命令会从本地缓存中查找所有的依赖包,并计算这些依赖包的哈希值,再与 go.sum 中记录的哈希值进行对比。如果哈希值不一致,则校验失败并停止构建(除非 GOSUMDB 设置为 off 或使用 go get -insecure 绕过安全校验)。

模块管理

创建一个项目:hello/hello.go

1
2
3
4
5
package hello

func Hello() string {
return "Hello, world."
}

hello/hello_test.go

1
2
3
4
5
6
7
8
9
10
package hello

import "testing"

func TestHello(t *testing.T) {
want := "Hello, world."
if got := Hello(); got != want {
t.Errorf("Hello() = %q, want %q", got, want)
}
}

配置Go Modules

$HOME/.bashrc 启用 Go Modules 配置:

1
2
3
4
export GO111MODULE=on
export GOPROXY=https://goproxy.cn,direct
# Checksum Database 国内也可能会访问失败
export GOSUMDB=off

创建模块

模块初始化:

1
go mod init github.com/marmotedu/gopractise-demo/modules/hello

如果不指定模块名称,可根据注释自行推导:

1
package hello // import "github.com/marmotedu/gopractise-demo/modules/hello"

如果没有注释且项目位于 GOPATH 下,则模块名称为根目录绝对路径去掉 $GOPATH/src 后的路径名。

如果要新增子目录创建新 package,则 package 的导入路径自动为模块名/子目录名:github.com/marmotedu/gopractise-demo/modules/hello/

增加依赖

修改源码:hello/hello.go

1
2
3
4
5
6
7
package hello

import "rsc.io/quote"

func Hello() string {
return quote.Hello()
}

执行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
go test
# go: finding module for package rsc.io/quote
# go: downloading rsc.io/quote v1.5.2
# go: found rsc.io/quote in rsc.io/quote v1.5.2
# go: downloading rsc.io/sampler v1.3.0
# PASS
# ok github.com/google/addlicense/golang/src/github.com/marmotedu/gopractise-demo/modules/hello 0.003s

# 查看 go.mod 文件,可见依赖已被添加。
cat go.mod
# module github.com/marmotedu/gopractise-demo/modules/hello
#
# go 1.14
#
# require rsc.io/quote v1.5.2

# 再次执行测试:
go test
# PASS
# ok github.com/marmotedu/gopractise-demo/modules/hello 0.003s

解析源码时识别到需要导入模块,会在 go.mod 文件中查询该模块的版本,如果有指定版本,就导入指定的版本;否则自动根据模块的导入路径安装模块,并将模块和其最新的版本写入 go.mod 文件中。

执行源码解析相关的命令(test、build、install 等)时加上 -mod 选项:

  • readonly:不更新 go.mod,任何可能会导致 go.mod 变更的操作都会失败。通常用来检查 go.mod 文件是否需要更新(CI 或者测试场景)。

  • vendor:从项目顶层目录下的 vendor(而不是从模块缓存中)中导入包,需要确保 vendor 包完整准确。

  • mod:从模块缓存中导入包(即使项目根目录下有 vendor 目录)。

如果没有 -mod 选项,且项目根目录存在 vendor 目录,go.mod 中记录的 go 版本大于等于1.14,此时执行效果等效于 go test -mod=vendor

查看依赖

包括直接依赖和间接依赖。

1
2
3
4
5
go list -m all
# github.com/marmotedu/gopractise-demo/modules/hello
# golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
# rsc.io/quote v1.5.2
# rsc.io/sampler v1.3.0

查看所有可用版本:

1
2
go list -m -versions rsc.io/sampler
# rsc.io/sampler v1.0.0 v1.2.0 v1.2.1 v1.3.0 v1.3.1 v1.99.99

更新依赖

默认更新到最新版本、且自动更新 go.mod 版本,同时会自动测试。

1
2
3
4
5
go get golang.org/x/text
# go: golang.org/x/text upgrade => v0.3.3
# $ go test
# PASS
# ok github.com/marmotedu/gopractise-demo/modules/hello 0.003s

其它参数:

img

添加新的 major 版本依赖

修改源码:hello/hello.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package hello

import (
"rsc.io/quote"
quoteV3 "rsc.io/quote/v3"
)

func Hello() string {
return quote.Hello()
}

func Proverb() string {
return quoteV3.Concurrency()
}

hello_test.go

1
2
3
4
5
6
func TestProverb(t *testing.T) {
want := "Concurrency is not parallelism."
if got := Proverb(); got != want {
t.Errorf("Proverb() = %q, want %q", got, want)
}
}

测试触发依赖更新,通过后可见当前模块同时依赖了同一个模块的不同版本:

1
2
3
go list -m rsc.io/q...
# rsc.io/quote v1.5.2
# rsc.io/quote/v3 v3.1.0

升级到不兼容的版本

之前使用 rsc.io/quote v1 版本的 Hello() 函数。按照语义化版本规则,如果升级 major 版本可能面临接口不兼容的问题、需要变更代码。

查看 rsc.io/quote/v3 函数:

1
2
3
4
5
6
7
8
9
10
go doc rsc.io/quote/v3
# package quote // import "github.com/google/addlicense/golang/pkg/mod/rsc.io/quote/v3@v3.1.0"
#
# Package quote collects pithy sayings.
#
# func Concurrency() string
# func GlassV3() string
# func GoV3() string
# func HelloV3() string
# func OptV3() string

由于已统一模块到一个版本,无需再重命名:hello/hello.go

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

import (
"rsc.io/quote/v3"
)

func Hello() string {
return quote.HelloV3()
}

func Proverb() string {
return quote.Concurrency()
}

删除不需要的依赖

只需要执行:

1
2
3
4
5
6
7
8
9
10
11
12
go mod tidy

cat go.mod
# module github.com/marmotedu/gopractise-demo/modules/hello
#
# go 1.14
#
# require (
# golang.org/x/text v0.3.3 // indirect
# rsc.io/quote/v3 v3.1.0
# rsc.io/sampler v1.3.1 // indirect
# )

使用 vendor

如果要把所有依赖都保存起来,在 Go 命令执行时不再下载,可以使用 vendor 把当前项目的所有依赖都保存在项目根目录的 vendor 目录下,并创建 vendor/modules.txt 文件记录包和模块的版本信息:

1
2
3
4
go mod vendor

ls
# go.mod go.sum hello.go hello_test.go vendor world
CATALOG
  1. 1. Go Modules 管理总结
    1. 1.1. 基本概念
    2. 1.2. 模块下载
      1. 1.2.1. 通过代理下载
      2. 1.2.2. 指定版本号下载
      3. 1.2.3. 按最小版本下载
      4. 1.2.4. go.mod
        1. 1.2.4.1. 语法描述
        2. 1.2.4.2. 版本号
        3. 1.2.4.3. 修改方法
      5. 1.2.5. go.sum
    3. 1.3. 模块管理
      1. 1.3.1. 配置Go Modules
      2. 1.3.2. 创建模块
      3. 1.3.3. 增加依赖
      4. 1.3.4. 查看依赖
      5. 1.3.5. 更新依赖
      6. 1.3.6. 添加新的 major 版本依赖
      7. 1.3.7. 升级到不兼容的版本
      8. 1.3.8. 删除不需要的依赖
      9. 1.3.9. 使用 vendor