bigywater.tech
Go · 43

最小版本选择:go.mod 里的版本是怎么被选出来的

“最小版本选择”(MVS)这个名字容易让人误会成“选最小的”。它的意思是:对每个模块,选需求里出现过的最大版本——这已经是满足所有需求的最小版本,不会自动升级到更新的发布。这篇用一个自己搭的本机模块仓库,把这条规则以及升级、降级、exclude、replace 的行为逐个跑出来。

d v1.4.0
构建列表里 d 的版本:b 要 v1.3.0、c 要 v1.4.0,取大者
e v1.2.0
e 有 v1.3.0,但没有任何需求要它,所以不会被选中
removed c
降级 d 到 v1.1.0 时,c 的所有版本都要更高的 d,于是被从 go.mod 里移除

搭一个本机仓库

实测 · 本机执行输出
  b v1.1.0 需要:d v1.1.0
  b v1.2.0 需要:d v1.3.0
  c v1.1.0 需要:d v1.2.0
  c v1.2.0 需要:d v1.4.0
  d v1.1.0 需要:e v1.1.0
  d v1.2.0 需要:e v1.1.0
  d v1.3.0 需要:e v1.2.0
  d v1.4.0 需要:e v1.2.0
  e v1.1.0 需要:(无)
  e v1.2.0 需要:(无)
  e v1.3.0 需要:(无)
  go mod tidy 之后的 go.mod:
    module app
    
    go 1.21
    
    require (
    	b v1.2.0
    	c v1.2.0
    )
    
    require (
    	d v1.4.0 // indirect
    	e v1.2.0 // indirect
    )
    

为了不依赖网络与别人的模块,我在本机用 file:// 形式的 GOPROXY 放了四个模块的多个版本(目录里是 Go 代理协议要求的 list、.info、.mod、.zip),并且让每个模块的代码打印自己的版本,这样运行时能看到最终链接了哪个版本。

构建列表

实测 · 本机执行输出
  go list -m all:
    app
    b v1.2.0
    c v1.2.0
    d v1.4.0
    e v1.2.0
  go mod graph:
    app b@v1.2.0
    app c@v1.2.0
    app d@v1.4.0
    app e@v1.2.0
    app go@1.21
    b@v1.2.0 d@v1.3.0
    b@v1.2.0 go@1.21
    c@v1.2.0 d@v1.4.0
    c@v1.2.0 go@1.21
    d@v1.4.0 e@v1.2.0
    d@v1.4.0 go@1.21
    e@v1.2.0 go@1.21
    go@1.21 toolchain@go1.21
  e 仓库里有的版本:e v1.1.0 v1.2.0 v1.3.0
  go run . 输出(每个模块把自己实际用的版本打印出来):
    b v1.2.0 → d v1.4.0 → e v1.2.0
    c v1.2.0 → d v1.4.0 → e v1.2.0

go list -m all 给出的是 MVS 的结果。b 虽然只声明需要 d v1.3.0,链接的却是 d v1.4.0,因为图里另一条边(c 要 d v1.4.0)要求更高。go mod graph 列出所有需求边,go mod tidy 把构建需要的间接依赖(d、e)也写进主模块的 go.mod 并标注 // indirect——这是为了让主模块不用下载整个传递闭包的 go.mod 就能算出构建列表(这一点是我对 go.mod 里列出间接依赖的目的的理解,没有单独验证)。

“最小”的含义

实测 · 本机执行输出
  构建列表里 d 是 v1.4.0(b 要 v1.3.0、c 要 v1.4.0,取大者);e 是 v1.2.0(d v1.4.0 要 e v1.2.0)——即使 e 已经有 v1.3.0。
  go list -m e → e v1.2.0

e 在仓库里已经有 v1.3.0,但没有任何需求指向它,所以选的是需求里出现过的最大版本 v1.2.0。不会因为“有新版本”就自动升级,也不会因为“别的模块更新了”而变——构建是可重复的,这是 MVS 的卖点。

升级与降级

实测 · 本机执行输出
  go get e@latest:go: upgraded e v1.2.0 => v1.3.0
  升级之后 go list -m all:
    app
    b v1.2.0
    c v1.2.0
    d v1.4.0
    e v1.3.0
  go.mod 里新增的需求:e v1.3.0 // indirect
  go get d@v1.1.0(降级):
    go: downgraded b v1.2.0 => v1.1.0
    go: removed c v1.2.0
    go: downgraded d v1.4.0 => v1.1.0
  降级之后 go list -m all:
    app
    b v1.1.0
    d v1.1.0
    e v1.2.0
  降级后用 -mod=readonly 构建(不允许自动改 go.mod):rc=1
    main.go:6:2: cannot find module providing package c: import lookup disabled by -mod=readonly
  降级后 go.mod:
    module app
    go 1.21
    require b v1.1.0
    require (
    	d v1.1.0 // indirect
    	e v1.2.0 // indirect
    )

要新版本必须显式 go get e@latest,go.mod 里多一行需求。降级更微妙:降 d 会让所有要求更高版本 d 的模块一起降(b 降到 v1.1.0)或被移除(c),因为不存在满足条件的更低版本。降级之后 e 留在 v1.2.0,不会自动跟着降。这个例子里主模块 import 了 c,被移除后构建失败(-mod=readonly 时直接报错)。

why、exclude、replace

实测 · 本机执行输出
  go mod why -m e:
    # e
    app
    b
    d
    e
  go.mod 里 exclude d v1.4.0 之后 go list -m all:
    app
    b v1.2.0
    c v1.2.0
    d v1.3.0
    e v1.2.0
    go: dropping requirement on excluded version d v1.4.0
  replace e => ../e_local 之后 go run . 输出:
    b v1.2.0 → d v1.4.0 → e 本地替换版
    c v1.2.0 → d v1.4.0 → e 本地替换版

go mod why -m 给出“主模块为什么需要它”的最短链;exclude 让某个版本被当作不存在,所有指向它的需求被丢弃,图里下一个满足的版本接上(这里是 d v1.3.0);replace 把一个模块整体换成本地目录,所有需求指向本地版本。replace 只对主模块的 go.mod 生效。

分步演示

最小版本选择
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。所有模块来自本机 file:// 代理(GOPROXY=file://…,GOSUMDB=off,GOFLAGS=-mod=mod),不联网;每个模块一个很小的包,返回 “名字 版本 → 依赖的 Info()”;go.mod 与 go list 的输出来自真实命令
    • 没有验证:真实代理与校验和数据库(go.sum、GOSUMDB)的交互、模块图剪枝(go 1.17 之后 go.mod 的 go 版本对 MVS 的影响)的细节、retract 与 deprecated、多个 major 版本(/v2)并存、go work 下的 MVS(见下一篇)、go get -u 与 go mod tidy 在大型依赖图上的差异
    京ICP备2026067502号-1京公网安备11010502063487号