《Go 语言高级编程》11.1 monorepo 与 go.work 多模块

卷二 1.1 讲过工程目录结构,本节讲规模化机制:go.work 的 use 如何覆盖模块版本、工作区版本选择为何取最高、go work sync 怎样把统一版本回写进各模块 go.mod,以及实测的版本漂移(开工作区 v0.24.0 对 GOWORK=off v0.20.0)、构建缓存共享、./... 不跨模块与 CI 策略。

本节要回答:多个 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)与装配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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