本节要回答:多个 module 放在一个仓库时,
go.work到底改变了什么、版本冲突怎么裁决、go work sync回写了什么。与卷二 1.1 的分工:卷二讲「一个项目的目录怎么分层」,本节讲「多个 module 共存时的依赖图、版本选择与构建缓存」这些规模化机制。
适用版本:Go 1.27(实测go1.27.0),go.work语法自 Go 1.18 引入。
11.1 monorepo 与 go.work 多模块
一个仓库放多个 module 是大型 Go 项目的常态:services/order、services/user、pkg/shared 各自是一个 module,各自有 go.mod。问题随之而来——pkg/shared 改了接口,怎么让 services/order 立刻用上本地改动,而不是先 go mod tidy 再等一个发布版本?go.work 就是答案。
11.1.1 go.work 的结构
go.work 是工作区文件,用 use 列出参与工作区的 module 目录:
GOTOOLCHAIN=go1.27.0 go work init ./app ./lib
生成的内容(实测):
$ cat go.work
go 1.27.0
use (
./app
./lib
)
它还有一份机器可读视图,go work edit -json 直接给出结构化结果:
$ GOTOOLCHAIN=go1.27.0 go work edit -json
{
"Go": "1.27.0",
"Use": [
{ "DiskPath": "./app" },
{ "DiskPath": "./lib" }
]
}
use 里的路径是目录,不是 module 路径。Go 会读每个目录下的 go.mod 拿到 module 路径(这里是 example.com/app、example.com/lib),再把它们加入工作区的构建列表。注意 go.work 不参与发布:它是本地开发文件,通常加进 .gitignore(团队约定不一致时可提交,但要在文档里写清)。
go.work 支持的指令与 go.mod 高度对称:
| 指令 | 作用 | 与 go.mod 的差异 |
|---|---|---|
go 1.27.0 | 声明工作区最低 Go 版本 | 多带补丁号,格式更严 |
toolchain | 指定工具链版本 | 与 go.mod 同义 |
use ./dir | 把 module 目录加入工作区 | go.mod 无此概念 |
replace a => b | 工作区级替换,覆盖所有 module 的 replace | 作用域是整个工作区 |
replace 的差异尤其重要:go.work 里的 replace 优先级高于任何单个 module 的 replace,是全局覆盖。所以做临时替换(如指向 fork)时,放 go.work 里最省事,但也要意识到它会影响工作区内所有 module——排查「为什么某个 module 用了奇怪的版本」时,先看 go.work。
11.1.2 工作区 vs replace vs 发版:三种联调方式
在 go.work 之前,跨 module 联调靠 replace;再之前靠「发一个版本再拉」。三者对比:
| 方式 | 生效范围 | 改动量 | 是否污染 go.mod | 适合 |
|---|---|---|---|---|
| 发版本 | 所有人 | 大(要打 tag) | 否 | 稳定依赖 |
replace ../lib | 单个 module | 小 | 是(会写进 go.mod) | 临时、单人 |
go.work | 整个工作区 | 小 | 否(独立文件) | 本地多 module 开发 |
replace 最大的问题是它会污染 go.mod:忘了删就提交,CI 里指向一个不存在的本地路径,直接构建失败。go.work 把这类临时替换隔离在独立文件里,不碰 go.mod,天然不会误提交(配合 .gitignore)。这是它取代 replace 成为主流联调方式的原因。
11.1.3 use 覆盖了模块版本
工作区最核心的行为:只要一个 module 被 use 了,它的版本就从「依赖图里的版本」变成「本地源码」。实测:app 依赖 example.com/lib,但工作区把本地 ./lib 用了进来:
$ GOTOOLCHAIN=go1.27.0 go list -m example.com/lib
example.com/lib
输出是光秃秃的模块路径,没有版本号——这正是「工作区模块」的标志。普通依赖会显示 路径 v版本(如 golang.org/x/sync v0.24.0),而工作区模块显示为无版本的路径,因为它的来源是磁盘而不是模块代理。
这条覆盖规则解决了两件事:一是本地联调(改 lib 立刻在 app 生效,不用发版),二是原子提交(跨 module 的重构可以一次提交,CI 里跑整个工作区)。
11.1.4 版本选择:工作区取最高
工作区里,所有 module 的 go.mod 会被合并成一个构建列表,同一个依赖出现多个版本时取最高的那个(MVS,Minimal Version Selection 的扩展)。实测:app 要求 golang.org/x/sync v0.20.0,lib 要求 v0.24.0:
$ GOTOOLCHAIN=go1.27.0 go list -m golang.org/x/sync
golang.org/x/sync v0.24.0
工作区视角下选的是 v0.24.0(两者取高),而不是 app 自己写的 v0.20.0。这与「单个 module 内的 MVS」是同一个规则,只是输入变成了所有工作区 module 的需求之并集。
这带来一个重要的本地/CI 差异:本地开着工作区时,app 实际用的是 v0.24.0;但如果只构建 app(GOWORK=off),它用的是 v0.20.0。本地测过、CI 挂掉的经典事故往往出在这里——CI 没开工作区,版本回退到各 module 自己声明的旧版本。
11.1.5 go work sync:把统一版本回写进各 go.mod
上面的差异可以用 go work sync 消除。它把工作区的构建列表回写到每个 module 的 go.mod:
$ GOTOOLCHAIN=go1.27.0 go work sync
sync OK
$ cat app/go.mod
module example.com/app
go 1.27
require golang.org/x/sync v0.24.0
注意 app/go.mod 里的 v0.20.0 被改写成了 v0.24.0——即使 app 自己从没声明过这个版本。go work sync 的语义是:「既然工作区最终用的是 v0.24.0,那就让每个 module 的 go.mod 都写成 v0.24.0」,从而让单独构建每个 module 时也得到和工作区一致的版本。
所以 go work sync 的正确用法是:在工作区里调完版本后,跑一次 go work sync,然后提交各 module 的 go.mod 改动。这样 CI(不开工作区)和本地(开工作区)才用同一套版本。它只升不降——因为 MVS 的结论就是「取最高」,回写自然也是把低版本抬高到统一的高版本。
11.1.6 版本漂移:本地与 CI 不一致的根源
不跑 go work sync 会怎样?同一个 app,在「开工作区」和「GOWORK=off」两种视角下,依赖版本完全不同。实测(app 声明 v0.20.0、lib 声明 v0.24.0):
$ GOTOOLCHAIN=go1.27.0 go list -m golang.org/x/sync
golang.org/x/sync v0.24.0
$ cd app && GOTOOLCHAIN=go1.27.0 GOWORK=off go list -m golang.org/x/sync
golang.org/x/sync v0.20.0
开工作区是 v0.24.0,GOWORK=off 是 v0.20.0——差了三个小版本。本地开发时你以为在用 v0.24.0 的 API,CI 里 GOWORK=off 构建却只拿到 v0.20.0,于是「本地能编译、CI 报 undefined」。跑一次 go work sync 后,app/go.mod 被改成 v0.24.0,两条路径才收敛:
$ GOTOOLCHAIN=go1.27.0 go work sync
$ tail -1 app/go.mod
require golang.org/x/sync v0.24.0
结论:只要团队里有人的环境开工作区、有人不开(或 CI 不开),就必须在提交前跑 go work sync,否则版本漂移是必然的。这也是为什么工作区的最佳实践里,「sync 后提交」是不可省的一步。
11.1.7 构建缓存与依赖图
工作区的构建缓存是共享的:所有 use 的 module 编译产物都进同一个 GOCACHE,跨 module 的包只编译一次。
$ GOTOOLCHAIN=go1.27.0 go env GOCACHE
/Users/bingrong.yan/Library/Caches/go-build
GOCACHE 是用户级的、与工作区无关,所以同一台机器上所有工作区、所有 module 共用一份缓存。这意味着**「改一个 module、只重编受影响的部分」**在工作区里天然成立——lib 没变时,构建 app 会直接命中 lib 的缓存产物。
一个必须记住的行为:./... 不会跨模块。在工作区根目录直接 go build ./... 会报错:
$ GOTOOLCHAIN=go1.27.0 go build ./...
pattern ./...: directory prefix . does not contain modules listed in go.work or their selected dependencies
因为工作区根目录本身不是一个 module,./... 匹配不到任何包。正确写法是显式列出各 module:
$ GOTOOLCHAIN=go1.27.0 go build ./app/... ./lib/...
OK
$ GOTOOLCHAIN=go1.27.0 go list ./app/... ./lib/...
example.com/app
example.com/lib
这是 monorepo 里最容易踩的一脚:把「工作区」当成「一个大 module」,以为 ./... 能一把构建全部。它不是——工作区只是「一组 module 的并集视图」,包模式仍按 module 边界解析。
11.1.8 工作区的取舍
go.work 不是越多越好,它有利有弊:
| 维度 | 开工作区 | 不开工作区 |
|---|---|---|
| 本地联调 | 改依赖立刻生效 | 要发版或 replace |
| 版本一致性 | 需 go work sync 回写 | 各 module 自管 |
| CI 行为 | 与本地一致(需提交 go.work) | 各 module 独立构建 |
| 依赖图 | 全工作区并集,可能互相污染 | 隔离,各自最小 |
| 新人上手 | 一条命令进入全栈开发 | 逐 module 理解 |
推荐的实践:工作区用于本地开发与联调,CI 尽量按 module 独立构建(GOWORK=off)以暴露版本差异;每次调完版本跑 go work sync 并提交。若团队决定不提交 go.work,就必须在 CI 里也用 GOWORK=off,保证「本地怎么测、CI 怎么跑」。
11.1.9 常见坑
./...跨模块:工作区根目录不匹配任何包,必须显式列./mod/...。go.work没提交但 CI 依赖它:CI 里GOWORK为空,构建行为与本地不一致。go.work.sum与各go.sum不同步:工作区有自己的go.work.sum记录校验和,改了依赖要让它一并更新。- 对工作区模块写死一个不存在的版本:若某个 module 的
go.mod对一个工作区模块require了一个不存在的版本,且构建需要加载完整模块图(比如引入了外部依赖),go仍会去解析那个版本并失败——实测报unrecognized import path,别在工作区模块之间互相写「假版本」require。 - 以为工作区会降版本:
go work sync只升不降,MVS 取最高。 - 把工作区当发布单元:
go.work不参与发布,发布仍以每个 module 的go.mod为准。
11.1.10 CI 里的工作区
CI 有两种策略,取决于 go.work 是否提交。若提交了 go.work,CI 直接在工作区里构建,注意 ./... 不跨模块的坑:
# .github/workflows/ci.yml(工作区已提交的写法)
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.27.0"
- run: go build ./app/... ./lib/... # 显式列各 module
- run: go test -race -count=1 ./app/... ./lib/...
若不提交 go.work(更常见),CI 应显式 GOWORK=off,按 module 独立构建,从而暴露版本漂移——本地开工作区跑得通、CI 不开工作区却失败,正是需要被发现的信号:
- run: GOWORK=off go build ./... # 在单个 module 目录内执行
working-directory: services/order
- run: GOWORK=off go test -race -count=1 ./...
working-directory: services/order
两条原则:要么本地和 CI 都开工作区,要么都关,不要一边开一边关;go work sync 的产物(各 go.mod 的版本统一)必须提交,它是唯一能让两种环境收敛的东西。
小结
go.work用use把多个 module 拉进一个构建列表;被use的模块版本被本地源码覆盖(go list -m显示为无版本路径)。- 工作区版本选择取最高:
app要 v0.20.0、lib要 v0.24.0,工作区用 v0.24.0。 go work sync把统一版本回写进各go.mod,消除「本地开工作区、CI 不开」的版本差异,且只升不降。GOCACHE用户级共享,跨 module 只编译一次;但./...不跨模块,必须显式列./mod/...。- 工作区是「一组 module 的并集视图」,不是「一个大 module」。
模块的边界理清了,但 module 内部的「对象怎么被创建和装配」仍是另一回事——New 函数互相调用会迅速变成一团意大利面。下一节看 wire 与 fx 如何把装配这件事从手写代码里抽出来。
阅读导航:上一节:10.3 goleak 与泄漏检测 · 下一节:11.2 依赖注入(wire/fx)与装配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。