Go modules: 依赖管理和版本控制
1. 总概
从 Go 1.11 起,Go modules 是官方的依赖管理机制。从 Go 1.16 起,这是默认模式,完全替代了基于 GOPATH 的旧做法。本文介绍了一个模块的完整生命周期:初始化、添加依赖、选择和升级版本、处理主版本 v2+,以及使用本地代码或私有仓库。读完后,你将理解 go.mod 和 go.sum 包含的内容。你也会知道为什么 Go 选择特定版本,以及如何修复常见的依赖错误。
2. 初始化模块
模块是一个在根目录中包含 go.mod 文件的目录树。使用 go mod init 创建新模块:
mkdir order-service && cd order-service
go mod init github.com/nonla/order-service
创建的 go.mod 文件:
module github.com/nonla/order-service
go 1.24
每行的含义:
module是 模块路径。这是模块内所有包的导入前缀。如果模块将被其他人go get,此路径必须与仓库 URL 匹配。go 1.24是模块需要的最低语言版本。从 Go 1.21 起,此行是必需的。如果你的工具链较旧,Go 将自动下载匹配的工具链(当GOTOOLCHAIN=auto时)或报告错误。
注意: 对于永远不会被外部导入的内部应用,你可以使用短路径如
order-service。但是,从一开始就使用 URL 形式的路径会避免以后大量更改导入。
3. 添加依赖
3.1. 使用 go get
假设你需要为订单生成 UUID ID。添加 github.com/google/uuid 库:
go get github.com/google/uuid@v1.6.0
然后编写使用该库的代码:
// order/order.go
package order
import "github.com/google/uuid"
type Order struct {
ID string
Customer string
Total int64 // 单位:越南盾
}
func NewOrder(customer string, total int64) Order {
return Order{
ID: uuid.NewString(),
Customer: customer,
Total: total,
}
}
go.mod 现在有了额外的 require 块:
module github.com/nonla/order-service
go 1.24
require github.com/google/uuid v1.6.0
你也可以跳过 go get 步骤:先写 import,然后运行 go mod tidy。Go 将自动找到最新版本并写入 go.mod。
3.2. go.sum 文件
同时,Go 创建 go.sum 文件:
github.com/google/uuid v1.6.0 h1:<hash nội dung module>
github.com/google/uuid v1.6.0/go.mod h1:<hash của file go.mod>
每个依赖有两行。第一行是模块全部内容的哈希,第二行是其 go.mod 文件的哈希。加载模块时,Go 将实际哈希与 go.sum 比较。它还与公开的校验和数据库 (sum.golang.org) 对照。如果有人修改了已发布标签的内容,构建会失败,而不是默默使用陌生代码。
注意: 始终提交
go.mod和go.sum。不要将go.sum添加到.gitignore。如果缺少这个文件,CI 机器将无法验证依赖。
3.3. 直接和间接依赖
添加 testify 来编写测试:
go get github.com/stretchr/testify@v1.10.0
go.mod 将有两个 require 块:
require (
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.10.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
注释 // indirect 标记你的代码不直接导入但其他依赖需要的模块。Go 将它们写入 go.mod 以确保完全可重现的构建。这样,Go 就不需要重新读取每个依赖的 go.mod 来构建图表。
4. 版本和 Go 如何选择版本
4.1. 语义版本控制
Go modules 使用 vMAJOR.MINOR.PATCH 形式的语义版本控制:
PATCH(v1.6.0 → v1.6.1):修复错误,不改变 API。MINOR(v1.6.0 → v1.7.0):添加功能,向后兼容。MAJOR(v1 → v2):有破坏兼容性的变更。
版本必须是带 v 前缀的 git 标签。如果你指向一个未标记的提交,Go 会创建一个 伪版本:
go get github.com/google/uuid@0f3b7a2
github.com/google/uuid v1.6.1-0.20240815101530-0f3b7a2c1d4e
伪版本由三部分组成:基础版本、提交的 UTC 时间戳和哈希的前 12 个字符。这样,伪版本仍按时间顺序排列正确。
4.2. 最小版本选择
这是与 npm 或 Maven 相比最大的区别。Go 使用 最小版本选择 (MVS) 算法。对于每个模块,Go 选择 所有需要的版本中的最高版本,而不是存在的最新版本。
例如,你的项目有两个依赖:
order-service
├── payment-sdk → require github.com/google/uuid v1.3.0
└── shipping-sdk → require github.com/google/uuid v1.5.0
即使 uuid v1.6.0 已发布,Go 仍会选择 v1.5.0。原因是:这是满足所有需求的最低版本。结果是:
- 构建是 可重现的。库发布新版本不会自动改变你的构建。
- 不需要单独的锁定文件。
go.mod足以确定版本。 - 要使用更新的版本,你必须主动升级。
查看实际选择的版本:
go list -m all
github.com/nonla/order-service
github.com/davecgh/go-spew v1.1.1
github.com/google/uuid v1.5.0
...
4.3. 升级和降级
检查哪些依赖有新版本:
go list -m -u all
github.com/google/uuid v1.5.0 [v1.6.0]
github.com/stretchr/testify v1.10.0
方括号中的版本是可以升级到的最新版本。常用的升级命令:
go get github.com/google/uuid@latest # 最新版本
go get github.com/google/uuid@v1.5.0 # 到确切版本(可能是降级)
go get -u=patch ./... # 仅为所有依赖升级 PATCH
go get -u ./... # 为所有依赖升级 MINOR + PATCH
go get github.com/google/uuid@none # 完全移除依赖
注意:
go get -u ./...也升级间接依赖。差异可能很大。在生产项目中,你应该逐个升级模块、运行测试,然后再提交。
5. 主版本 v2 及以上
Go 实施 导入兼容性 规则:具有相同导入路径的两个版本必须向后兼容。因此,从 v2 开始,主版本是模块路径的一部分。
例如 chi 路由器:
go get github.com/go-chi/chi/v5@v5.1.0
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
r.Get("/orders", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("订单列表"))
})
http.ListenAndServe(":8080", r)
}
后缀 /v5 在导入路径中。包名仍然是 chi,所以你照常调用 chi.NewRouter()。因为 chi/v4 和 chi/v5 有不同的路径,两个版本可以在一个二进制中共存。这有助于你逐步迁移。
如果你是库作者想发布 v2:
module github.com/nonla/pricing/v2
go 1.24
你将 module 行改为带 /v2 后缀的路径,更新内部导入,然后标记 v2.0.0。
注意: 常见错误是仅标记
v2.0.0而忘记更改模块路径。然后go get github.com/nonla/pricing@v2.0.0会报错类似invalid version: module contains a go.mod file, so module path must match major version。
6. 有用的 go mod 命令
go mod tidy # 添加缺少的模块,删除未使用的模块
go mod why -m gopkg.in/yaml.v3 # 为什么这个模块存在
go mod graph # 打印完整的依赖图
go mod verify # 检查本地缓存是否与 go.sum 匹配
go mod download # 预下载依赖(对 Docker 层有用)
go mod why 的示例输出:
# gopkg.in/yaml.v3
github.com/nonla/order-service/order
github.com/stretchr/testify/assert
gopkg.in/yaml.v3
从上到下读取:包 order(在测试中)导入 testify/assert,而 testify/assert 导入 yaml.v3。
在每次提交前运行 go mod tidy。在 CI 中,你可以检查 go.mod 是否已整理:
go mod tidy -diff
从 Go 1.23 起,-diff 标志打印差异并在文件未整理时返回非零退出代码。此命令不覆盖文件。
7. 使用本地代码:replace 和 go.work
7.1. replace 指令
假设你正在修复相邻目录中的 pricing 库,并希望 order-service 立即使用修改版本:
require github.com/nonla/pricing v1.2.0
replace github.com/nonla/pricing => ../pricing
Go 将从 ../pricing 读取代码而不是从网络下载。replace 也可以指向一个分支:
replace github.com/nonla/pricing => github.com/minh/pricing v1.2.1-fix
注意:
replace仅在主模块中有效。如果你的模块是库,它的用户不会获得你的replace。此外,不要提交指向本地路径的replace。CI 上的构建会失败,因为../pricing目录在那里不存在。
7.2. 使用 go.work 的工作空间
从 Go 1.18 起,工作空间是同时开发多个模块的更干净方式:
go work init ./order-service ./pricing
创建的 go.work 文件:
go 1.24
use (
./order-service
./pricing
)
在工作空间中运行 go build 或 go test 时,所有到 github.com/nonla/pricing 的导入都将使用本地代码。你不需要修改 go.mod。通常 go.work 不被提交,每个开发人员根据自己的目录组织方式创建。
7.3. exclude 和 retract
exclude 在主模块中用于排除特定版本:
exclude github.com/google/uuid v1.4.0
retract 由 库作者 声明以宣布已发布的版本不应使用:
retract v1.2.0 // 国际订单增值税计算错误
发布包含此行的新版本后,go get @latest 将跳过 v1.2.0。go list -m -u 命令也会警告使用该版本的任何人。
8. 私有模块和代理
默认情况下,Go 通过 GOPROXY=https://proxy.golang.org,direct 加载模块并通过 sum.golang.org 检查校验和。对于内部仓库,公开代理无法访问,你也不想泄露模块名称。声明:
go env -w GOPRIVATE=github.com/nonla/*,gitlab.nonla.internal/*
匹配 GOPRIVATE 中模式的模块将直接通过 git 加载并跳过校验和数据库。你仍需要为 git 配置身份验证,例如通过 ~/.netrc 或 SSH:
git config --global url."git@github.com:".insteadOf "https://github.com/"
如果公司有私有代理(Athens、Artifactory…),将其放在 GOPROXY 的前面:
go env -w GOPROXY=https://goproxy.nonla.internal,https://proxy.golang.org,direct
9. 通过依赖测试行为
为 NewOrder 编写测试,使用第 3.3 节中添加的 testify:
// order/order_test.go
package order
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewOrder_GeneratesValidUniqueID(t *testing.T) {
first := NewOrder("Nguyễn Văn An", 250_000)
second := NewOrder("Trần Thị Bình", 120_000)
_, err := uuid.Parse(first.ID)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID)
assert.Equal(t, "Nguyễn Văn An", first.Customer)
assert.EqualValues(t, 250_000, first.Total)
}
运行测试:
go test ./...
ok github.com/nonla/order-service/order 0.004s
测试检查三件事:ID 是有效的 UUID、两个订单有不同的 ID,其他字段正确分配。如果 ID 无法解析,require.NoError 会立即停止测试。assert 记录错误然后继续运行。首次在新机器上运行时,Go 将按 go.mod 中的确切版本加载依赖,并在编译前使用 go.sum 验证哈希。
10. 常见错误
missing go.sum entry for module providing package:go.sum缺少相应行,通常是因为有人手动编辑了go.mod。修复方法:运行go mod tidy。checksum mismatch:模块内容与哈希不匹配。尝试go clean -modcache。如果公开模块仍然出错,这是严重的迹象,不要删除go.sum中的行来解决。go: updates to go.mod needed:CI 通常以-mod=readonly运行,而go.mod未整理。在本地运行go mod tidy然后重新提交。- 导入错误的主版本:使用
github.com/go-chi/chi而不是.../chi/v5。你会获得旧的 v1 版本或奇怪的伪版本。首先检查导入路径。 - 滥用
vendor/:go mod vendor在需要完全离线构建时仍然有用。否则,代理与go.sum的结合已确保可重现性,所以你不需要提交数千个供应商文件。
11. 结论
Go modules 用几个简单的想法解决依赖问题。go.mod 声明需求,go.sum 确保完整性,最小版本选择使构建无需锁定文件即可重现。主版本直接在导入路径中,所以破坏兼容性的变更总是在代码中明显。
在日常工作中,你只需要记住四个习惯:
- 使用
go get module@version有目的地添加或升级依赖。 - 在提交前运行
go mod tidy。 - 同时开发多个模块时使用
go.work而不是replace。 - 为内部仓库配置
GOPRIVATE。