
前言
第八篇我们已经明确:cmd/go/internal/modload 的核心职责之一,是构建 module 依赖图(module graph)并据此得到 build list。
这一篇只聚焦一件事:modload 如何把多个 go.mod 的 require 串起来,构建出可计算、可查询、可缓存的 module 依赖图,核心逻辑还是在 cmd/go/internal/modload。
本文基于 Go 1.25.0 源码。
1. 什么是"依赖图"
首先要明确,"依赖图"只是一个概念(存储在内存里的数据结构),可以理解为:本次构建中,哪些 module@version 参与,以及它们在 go.mod require 里相互要求了哪些最低版本。
- 节点 :一个模块的一个版本,形如
module@version - 边 :
A@v -> B@w,含义是:A 的go.mod里require了 B(最低版本要求是 w)
这张图的直接用途有两个:
- 版本选择:把"各处写出来的最低版本要求"收集起来,交给 MVS,算出全局唯一的 build list
- 查询支撑:当查找"某个 import path 属于哪个模块"时,需要知道这次构建到底有哪些模块参与(build list 来自这张图)
2. 构图入口:主模块与 roots
在 modload 中,依赖图的起点就是 主模块(main modules) 的依赖声明。
- 普通 module 模式 :通常只有一个主模块,它的
go.mod里写着require列表 - workspace 模式 :会有多个主模块(
go.work的use列表),每个主模块各自有go.mod
modload 会把主模块的直接依赖整理成一组 roots(根依赖集合)。可以把 roots 理解成:
- "这张图从哪些边开始往外扩展"
- "MVS 的输入里,哪些模块是最顶层的约束来源"
到这一步为止,信息都只来自主模块(或主模块集合)的 go.mod,图还没有"延展"。
3. 边怎么来:读依赖模块的 go.mod
很简单,就看每个模块自己的 go.mod。
- 选一个模块节点
M@v - 读到它的
go.mod - 把
require列表变成若干条边:M@v -> Dep@ver - 对这些
Dep@ver继续重复
难点不在"怎么解析 go.mod",而在下面这些问题:
- 我从哪里拿到这个模块的
go.mod(本地 replace、模块缓存、需要下载) - 我什么时候需要读它(按需还是一次性全读)
- 我怎么避免重复读/重复下载(缓存与复用)
因此,在源码里会看到 modload 把"解析规则"和"获取内容"拆开:图上的边来自 require,但获取 go.mod 的路径可能要经过 replace、缓存、下载等一系列决策。
具体说明:
-
构图入口:
readModGraph把require变成"边"- 位置:
cmd/go/internal/modload/buildlist.go的readModGraph - 关键逻辑概括:
- 对每个模块
M@v调用goModSummary(m)读取它的go.mod摘要; - 把
summary.require填进图:mg.g.Require(m, summary.require),这一步就是"M@v -> Dep@ver"; - 然后对
summary.require里的每个依赖再递归调用enqueue(r, ...),继续读依赖的依赖。
- 对每个模块
- 位置:
-
解析规则:怎么从
go.mod里读出 require 列表- 位置:
cmd/go/internal/modload/modfile.go - 调用链:
goModSummary→rawGoModSummary→modfile.ParseLax→summaryFromModFile - 其中
summaryFromModFile会遍历f.Require,把每条require转成[]module.Version存到summary.require,供readModGraph用来加边。
- 位置:
-
获取内容:去哪儿拿这个模块的
go.mod字节- 位置:同一个文件里的
rawGoModData - 逻辑:
- 如果是本地/replace 模块(
m.Version == ""),根据路径找到对应目录,直接读dir/go.mod; - 如果是带版本的远程模块(
m.Version != ""),调用modfetch.GoMod(ctx, m.Path, m.Version)。
- 如果是本地/replace 模块(
modfetch.GoMod又在cmd/go/internal/modfetch/cache.go里,先尝试读本地缓存(readDiskGoMod),不命中时通过TryProxies + Lookup(...).GoMod(...)去代理/远程拉取,并写入缓存。
- 位置:同一个文件里的
-
replace / 缓存 / 去重的参与位置
resolveReplacement(同在modfile.go)会先把m映射到实际使用的"被替换模块"或本地目录,再交给rawGoModData去找go.mod;readModGraph内部用mg.loadCache.Do(...)和一个loading的sync.Map做缓存和去重,保证同一个module@version的go.mod最多读一次(无论是从本地还是通过modfetch下载)。
4. replace:它改变的是"内容来源",不一定改变"图上节点"
理解 module 图时可能会卡在 replace:既然 replace 能把模块替换成别的模块或本地目录,那图到底长什么样?
可以把"图"分成两层:
- 依赖图的关系 :A 的
go.mod声明了require B,图上就有A@v -> B@w这条边,表示依赖关系 - 依赖内容的实际来源 :读取 B 的
go.mod或源码时,实际从哪里获取(本地 replace、模块缓存或远程下载)
replace 主要影响第二层。
replace:它不一定改变"我依赖的是谁",但会改变"我从哪里拿到它"。
5. indirect:不影响版本决策,它只是根依赖关系的元数据标记
go.mod 里的 // indirect 常被误解成"间接依赖不参与版本选择"。实际上:
module图的边来自require,不来自注释MVS要的是"最低版本要求",不关心这条require在go.mod里是不是// indirect
那 indirect 的价值在哪里?
- 表达意图:哪些依赖是"我代码直接用到的",哪些只是传递依赖带来的
- 影响
go mod tidy等写回行为:哪些条目可以被删除/新增、哪些应该保留 - 配合图剪枝(pruning) :在"剪枝语义"下,主模块只保留必要的根依赖集合,
direct/indirect的判定会影响 roots 的形状
indirect 更像"go.mod 的维护信息",而不是"图构建/版本选择的硬规则"。
6. 图剪枝(pruning):为什么构图常常是"按需"的
从 Go 1.17 起,module 加载语义引入了剪枝:并非任何场景都需要把"所有传递依赖的 go.mod"读进来,才能完成当前任务。
对 modload 来说,剪枝带来两个直接变化:
- 扩图范围可变:有些命令只需要"与目标包相关"的那部分依赖信息,不必全量扩展
- 查询路径不同:在剪枝语义下,"缺少某个依赖"可能不等价于"图不完整",而是当前 roots/可达范围不足以证明它应该出现
所以你会看到 modload 倾向于把"建图"做成可缓存、可延迟触发的步骤:只有当某个查询真的需要时,才把更多依赖的 go.mod 拉进来。
7. 从依赖图到 build list:MVS 在这里接管版本选择
当 modload 收集到了足够的边(最低版本要求),就可以把问题交给 MVS:
- 输入:roots + 依赖边(每条边携带"最低版本")
- 输出:对每个 module path 选出一个版本(整体满足所有最低版本要求)
modload 在这之后做的工作,主要是把 build list 变成后续查询可用的状态:
- 解析 import path 时,把候选模块限制在 build list 中
- 定位模块源码时,把
module@version映射到本地目录(缓存目录或本地 replace)
8. 依赖图构建过程中,哪些地方最容易出错
依赖图的边来自 go.mod,而 go.mod 又可能来自网络/缓存/本地,因此常见错误往往集中在"读不到"或"读出来不合法":
- 模块版本不可解析:版本字符串不符合语义化版本规则,或不满足 Go 的版本约束
go.mod获取失败:网络不可达、代理不可用、鉴权失败、校验失败- 规则冲突导致不可满足 :比如某些约束(含
exclude这类版本空间约束)使得版本选择无法形成一致的 build list
modload 的处理思路通常是:
- 尽量把失败点定位到"哪个模块、哪个版本、在读什么时失败"
- 在需要时给出"缺的模块是谁、来自哪条
require链"的诊断线索
9. 小结
- 依赖图是
module级别的图:节点module@version,边来自go.mod的require replace主要改变"内容从哪来",indirect主要影响"go.mod 如何维护",它们不等价于"是否参与版本选择"- 图是为
MVS服务的:先把最低版本要求收集成图,再算出 build list,后续import归属与源码定位都围绕 build list 运转