7.3 go get/tidy/vendor 与依赖治理
上一节我们看清了 go.mod 与 go.sum 的结构,但日常真正打交道的命令其实是三个:go get(加依赖)、go mod tidy(清依赖)、go mod vendor(搬依赖)。它们各自有反直觉的地方——go get 不是「安装」,tidy 删东西不看你的脸色,vendor 复制出来的目录该不该提交也众说纷纭。这一节把这些一次性讲透。
本节给 TaskAPI 的依赖流程补上最后一块:用本地
example.com/tasklib实测go get的版本查询、go mod tidy的增删、go mod vendor产出的目录结构与-mod=vendor构建,最后沉淀出一份依赖治理清单。学完本节,第 7 章的「工程化」主题就完整了。
7.3.1 go get 不是「安装」,是「改 go.mod」
最容易误解的一条:go get 只负责把依赖写进 go.mod 并下载到缓存,它不安装可执行文件。这与 npm install -g、pip install 的心智完全不同。
版本查询语法是 go get path@query,query 的取值如下:
| 写法 | 含义 |
|---|---|
path | 升级到满足当前 go.mod 约束的最新版 |
path@latest | 解析为最新发布版本 |
path@v1.2.3 | 指定精确版本 |
path@v1 | 该主版本线内最新 |
path@upgrade | 升级到满足约束的最高版本 |
path@patch | 仅升补丁号 |
path@none | 移除该依赖 |
path@<commit> | 指定某次提交(生成伪版本) |
一条实践准则:go get 只在你想「改依赖」时用。只想让代码编译通过、把缺失依赖补齐,应当用 go mod tidy——它会自己算出该加什么。
# 添加一个精确版本
GOTOOLCHAIN=go1.27.0 go get example.com/tasklib@v0.3.0
# 移除它
GOTOOLCHAIN=go1.27.0 go get example.com/tasklib@none
7.3.2 go mod tidy 到底做了什么
go mod tidy 的行为可以精确描述为四步:
- 扫描源码,收集所有
import,算出直接依赖集合。 - 补齐间接依赖,把依赖图里被引用到的模块写进
go.mod(带// indirect)。 - 删除无用条目,
go.mod里没人再引用、且不在依赖图中的模块被移除。 - 同步 go.sum,下载缺失模块并补齐哈希,删掉不再需要的哈希行。
第 3 步是它「不听话」的来源。实测中,当我们把 tasklib 从 main.go 的 import 里删掉后:
$ GOTOOLCHAIN=go1.27.0 go mod tidy
# go.mod 中的 require 行被整行移除
tidy 只看源码事实,不看你的「可能以后要用」。所以被 tidy 删掉的依赖,要么是你确实没在用,要么是你只在 _test.go 里用但包没导出——后者要用 go mod tidy -e 或补全测试依赖。注意:tidy 会扫描测试文件,测试里用到的依赖不会被视为无用。
7.3.3 go mod vendor:把依赖搬进仓库
vendor/ 目录的作用是把依赖源码复制到项目内,构建时不再访问模块缓存或网络。实测给 TaskAPI 执行:
$ GOTOOLCHAIN=go1.27.0 go mod vendor
$ find vendor -type f
vendor/modules.txt
vendor/example.com/tasklib/tasklib.go
vendor/modules.txt 是 vendor 模式的「账本」,记录了每个被复制模块的版本与提供的包:
# example.com/tasklib v0.0.0-00010101000000-000000000000 => /tmp/gowork/tasklib
## explicit; go 1.27.0
example.com/tasklib
# example.com/tasklib => /tmp/gowork/tasklib
其中 ## explicit 表示这是直接依赖,go 1.27.0 是该模块声明的语言版本。用 -mod=vendor 构建会强制只读 vendor/:
$ GOTOOLCHAIN=go1.27.0 go run -mod=vendor ./cmd/taskapi
added id=1
写第 7 章 <nil>
total: 1
如果 vendor/ 与 go.mod 不同步,Go 会直接报 inconsistent vendoring,这是它刻意的保护——防止你改了依赖却忘了重新 vendor。
7.3.4 vendor 还是不 vendor
这是 Go 社区少数「没有唯一答案」的争论。把两种模式放一起对比:
| 维度 | 模块缓存(默认) | vendor/ |
|---|---|---|
| 构建依赖网络 | 首次需要,之后走缓存 | 完全离线 |
| 仓库体积 | 小 | 大(依赖源码进版本库) |
| 可复现性 | 靠 go.mod+go.sum | 靠 vendor 目录内容 |
| 依赖审计 | 看 go.sum | 可直接读源码 |
| CI 速度 | 需下载或预热缓存 | 直接编译 |
| 升级体验 | go get 一步 | 每次都要重新 vendor |
选择建议:
- 库项目(被别人 import 的)不要 vendor,否则会把依赖源码带给下游。
- 应用项目(要出二进制、要离线构建)可以 vendor,尤其是构建环境网络受限时。
- 无论是否 vendor,都要提交
go.sum。
-mod=vendor 也可以写进环境变量,避免每次敲:
$ GOFLAGS=-mod=vendor GOTOOLCHAIN=go1.27.0 go build ./...
但更推荐的做法是在 go.mod 所在目录存在 vendor/ 且 go 版本 ≥ 1.14 时,Go 会自动进入 vendor 模式,无需显式指定。显式 -mod=mod 则强制忽略 vendor/。
7.3.5 依赖治理清单
依赖不会自己保持干净。下面这份清单建议每两周跑一次,或至少在每个发布前跑一次。
一、审计:知道你有什么
GOTOOLCHAIN=go1.27.0 go list -m all # 全部模块
GOTOOLCHAIN=go1.27.0 go mod why -m <path> # 某依赖为何存在
GOTOOLCHAIN=go1.27.0 go mod verify # 缓存哈希是否可信
go mod why -m 输出从 main 到该模块的引用链;如果链只到「(main module does not need module …)」,说明它是残留,可以清掉。
二、瘦身:只留用得到的
- 跑
go mod tidy,删掉源码里不再引用的依赖。 - 检查
require里的// indirect,确认它们确实被依赖图需要。 - 警惕「为了一个函数引入一个大模块」,这类依赖往往可以用标准库替代。
三、升级:小步、可回滚
- 升级前先看依赖的 CHANGELOG,尤其注意主版本跃迁(
v1→v2)。 - Go 的主版本是路径的一部分:
v2之后的模块路径形如example.com/lib/v2,所以v1与v2可以共存于一个go.mod。 - 升级用
go get path@version,跑完测试再提交。 - 想批量看可用升级:
go list -m -u all(需要网络查询,本节未实跑)。
四、安全:扫漏洞
Go 官方提供了漏洞扫描器:
GOTOOLCHAIN=go1.27.0 go run golang.org/x/vuln/cmd/govulncheck@latest ./...
它会读取依赖图并比对 Go 漏洞数据库。该命令需要联网拉取工具,本节环境未实跑,留作练习。
7.3.6 go.work:多模块工作区
当 tasklib 与 taskapi 是两个独立模块、你要同时改它们时,逐条写 replace 会很烦。Go 1.18 引入的**工作区(workspace)**解决了这个问题:在父目录放一个 go.work,把两个模块都纳进来,之后所有命令都会以「工作区」为视角解析依赖。
$ GOTOOLCHAIN=go1.27.0 go work init ./taskapi ./tasklib
$ cat go.work
go 1.27.0
use (
./taskapi
./tasklib
)
有了 go.work,taskapi 对 tasklib 的引用会直接落到本地目录,无需 replace。几个常用子命令:
| 命令 | 作用 |
|---|---|
go work init [dirs...] | 新建工作区并纳入模块 |
go work use ./dir | 增加一个模块到工作区 |
go work use -r ./dir | 递归查找并纳入 |
go work edit -json | 以 JSON 形式查看/编辑 |
go work sync | 把工作区依赖同步回各模块 |
实测 go work edit -json 的输出结构如下:
{
"Go": "1.27.0",
"Use": [
{ "DiskPath": "./taskapi" },
{ "DiskPath": "./tasklib" }
]
}
使用工作区有两条纪律:
go.work通常不提交(类似 IDE 的本地配置),因为它只服务于本机开发。若团队确实要共享,再单独讨论。- CI 里不要依赖工作区。CI 应当用模块本身的
go.mod构建,否则本地能过、CI 失败的「幽灵问题」会层出不穷。
工作区与 replace 的关系:工作区优先于 replace。两者都在时,工作区里 use 的本地模块胜出。
7.3.7 本卷为什么只用标准库
回到本书的语境。BRIEF 明确卷一原则上只用标准库,这不是偷懒,而是刻意的教学设计:
- 依赖是变量。第三方库的 API 会漂移,而标准库在 1.27 内保持向后兼容承诺,示例十年后仍能跑。
- 标准库足够。
net/http、database/sql、log/slog、testing、slices覆盖了 TaskAPI 到第 18 章的全部需求。 - 依赖治理是进阶话题。等你在真实项目里被「依赖地狱」折磨过,本节这套清单才有切肤的意义。
因此 TaskAPI 的 go.mod 在整个卷一里都只有一个 module 行和一个 go 行。本节用本地模块模拟依赖,是为了让你在零第三方依赖的前提下把工具链用熟——等到高级卷真正引入外部库时,这些命令会立刻派上用场。
7.3.8 小结
第 7 章到此结束。三节连起来是一条完整的工程化路径:
| 节 | 解决的问题 | 关键产物 |
|---|---|---|
| 7.1 | 代码怎么组织 | internal/task、internal/store、cmd/taskapi |
| 7.2 | 依赖怎么声明 | go.mod / go.sum / MVS |
| 7.3 | 依赖怎么治理 | tidy / vendor / 治理清单 |
把第 7 章的三节串起来看,它回答的其实是同一个问题:代码如何从「一个人写的脚本」变成「一个团队维护的工程」。包边界让职责清晰,模块系统让依赖可复现,治理清单让依赖不腐化。这三件事与语言语法无关,却决定了项目能不能活过第一年。
| 场景 | 推荐动作 |
|---|---|
| 新加一个依赖 | go get path@version,随后 go mod tidy |
| 怀疑有残留依赖 | go mod why -m path 查引用链 |
| 构建要完全离线 | go mod vendor 后提交 vendor/ |
| 同时改两个本地模块 | 用 go.work,不要往 go.mod 里塞 replace |
| 发布前 | go mod tidy && go mod verify && go build ./... |
下一章我们进入测试:如何给 store 和标题校验写表驱动测试,如何用 testing.B 做基准、用 go test -cover 量覆盖率,以及如何用手写 fake 替掉真实存储。
阅读导航:上一节:7.2 go.mod/go.sum 与最小版本选择 · 下一节:8.1 表驱动单元测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。