《Go 语言编程入门》7.3 go get/tidy/vendor 与依赖治理

本节收尾 TaskAPI 的依赖治理:讲清 go get 的版本查询语法与 go mod tidy 到底增删了什么,用本地模块实测 go mod vendor 生成的 vendor/modules.txt,并对比 -mod=vendor 与模块缓存的取舍。最后给出一份可执行的依赖治理清单——如何审计、升级、剔除无用依赖,让 go.mod 长期保持干净。

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 的行为可以精确描述为四步:

  1. 扫描源码,收集所有 import,算出直接依赖集合。
  2. 补齐间接依赖,把依赖图里被引用到的模块写进 go.mod(带 // indirect)。
  3. 删除无用条目,go.mod 里没人再引用、且不在依赖图中的模块被移除。
  4. 同步 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 表驱动单元测试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练