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。
golanggo modules依赖版本控制