1.1 单仓多模块与 go.work
卷一已经演示了 TaskAPI 的拆包分层。进入 TaskHub 时,要判断的是是否需要多个独立模块,而不是把单模块等同于代码混杂:一个 go.mod 同样可以容纳清晰的包边界、多个可执行文件和可测试的服务层。
TaskHub 要解决的第一个工程问题不是选 Web 框架,而是先把仓库的物理结构定下来。
本节把 TaskHub 从一个平铺的单模块项目,改造成由
domain/infra/api/cmd四个模块组成的go.work工作区,并实测工作区下的构建、运行与go work sync行为。
1.1.1 先判断是否需要多模块
单模块适合许多多人维护的大型项目;多模块主要服务于独立版本发布、不同依赖集合与独立消费的需要。把两种形态摊开对比:
| 维度 | 单模块(一个 go.mod) | 多模块工作区 |
|---|---|---|
| 依赖边界 | 可用 internal 限制导入,并检查分层依赖 | 工作区允许本地源码引用;单模块独立构建仍需完整依赖声明 |
| 版本发布 | 整仓一个版本 | 每个模块可独立打 tag |
| 构建粒度 | go build ./... 全量 | 可按模块单独构建 |
| 循环依赖 | 编译器拒绝包导入环 | 同样拒绝包导入环,模块依赖图本身可有环 |
| 上手成本 | 低 | 多一层 go.work 需要理解 |
| 适用规模 | 依赖和发布节奏统一的项目 | 确实需要独立发布与消费的模块 |
TaskHub 的判断标准很简单:只有独立发布、依赖隔离等收益足以覆盖多模块维护成本时,才拆模块。domain 里的任务实体和仓储接口是要被多个模块复用的,cmd/taskhubd 里的 main 只属于这一个二进制——这些职责首先应拆成包;本节为了演示独立模块的联调,再进一步使用多模块布局。
1.1.2 目录与模块划分
TaskHub 的模块划分遵循一条主线:依赖只能从外向内指。
taskhub/
├── go.work # 工作区定义(不进生产镜像)
├── domain/ # 实体 + 仓储接口,零外部依赖
│ ├── go.mod # module example.com/taskhub/domain
│ └── task.go
├── infra/ # 仓储实现(内存 / Postgres / Redis)
│ ├── go.mod # module example.com/taskhub/infra
│ └── memory.go
├── api/ # HTTP 处理器,只认 domain 接口
│ ├── go.mod # module example.com/taskhub/api
│ └── handler.go
└── cmd/
└── taskhubd/ # 进程入口,负责组装
├── go.mod # module example.com/taskhub/taskhubd
└── main.go
模块名用 example.com/taskhub/<子目录>,这是本卷贯穿的命名约定。真实项目里换成你自己的域名前缀即可,关键是模块路径要能反推出目录。
domain 模块只有两个文件:实体与接口,且不 import 任何第三方库。
// domain/task.go
package domain
import (
"context"
"errors"
"time"
)
var ErrNotFound = errors.New("task not found")
type Task struct {
ID string
TenantID string
Title string
Done bool
CreatedAt time.Time
}
type TaskRepository interface {
Create(ctx context.Context, t Task) error
Get(ctx context.Context, tenantID, id string) (Task, error)
List(ctx context.Context, tenantID string) ([]Task, error)
}
infra 依赖 domain,提供一个内存实现;api 也依赖 domain,但它只认接口:
// infra/memory.go(节选)
package infra
import (
"context"
"sync"
"example.com/taskhub/domain"
)
type MemoryTaskRepo struct {
mu sync.RWMutex
tasks map[string]domain.Task
}
func NewMemoryTaskRepo() *MemoryTaskRepo {
return &MemoryTaskRepo{tasks: make(map[string]domain.Task)}
}
func (r *MemoryTaskRepo) Create(_ context.Context, t domain.Task) error {
r.mu.Lock()
defer r.mu.Unlock()
r.tasks[t.TenantID+"/"+t.ID] = t
return nil
}
api 的 handler 结构体字段类型是 domain.TaskRepository(接口),不是 *infra.MemoryTaskRepo(具体类型):
// api/handler.go(节选)
type Handler struct {
repo domain.TaskRepository
}
func NewHandler(repo domain.TaskRepository) *Handler {
return &Handler{repo: repo}
}
把「组装」留给 cmd/taskhubd/main.go,那里是唯一同时认识 infra 和 api 的地方:
// cmd/taskhubd/main.go
func main() {
repo := infra.NewMemoryTaskRepo()
h := api.NewHandler(repo)
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", h.ListTasks)
log.Println("taskhubd listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
1.1.3 初始化工作区
先给每个模块建 go.mod,再用 go work init 把它们串起来:
mkdir -p taskhub/domain taskhub/infra taskhub/api taskhub/cmd/taskhubd
cd taskhub
for m in domain infra api cmd/taskhubd; do
(cd "$m" && go mod init "example.com/taskhub/$(basename "$m")")
done
go work init ./domain ./infra ./api ./cmd/taskhubd
每个模块的 go.mod 都只有两行(module 与 go),因为它们之间还没有依赖:
module example.com/taskhub/domain
go 1.27.0
go work init 生成的 go.work 同样很短:
go 1.27.0
use (
./api
./cmd/taskhubd
./domain
./infra
)
注意 use 里的路径是目录,不是模块路径。工作区通过扫描这些目录下的 go.mod 来识别模块。
1.1.4 go.work 的三条指令
本节先关注 go、use 和 replace 三条指令;现代 Go 的工作区文件还支持 toolchain 与 godebug,不能把这张表当成完整语法清单。
| 指令 | 作用 | 典型用法 |
|---|---|---|
go | 工作区要求的最低 Go 版本,不替代各模块自身的语言版本 | go 1.27.0 |
use | 把本地模块加入工作区 | use ./domain |
replace | 覆盖某模块的解析目标 | 调试上游 bug 时指向 fork |
use 是工作区的核心:它让本地模块之间直接以源码互相引用,不需要 go get、不需要 replace、不需要打 tag。你改了 domain,api 立刻就能编到新代码——这正是单仓多模块比「多仓 + 版本发布」在开发期快得多的地方。
go.work 里的 replace 会覆盖所有模块各自的 replace,优先级最高。它适合临时场景,比如「全工作区先统一指向某个修复分支」,但长期依赖某个 fork 应该写进对应模块的 go.mod,否则别人单独构建那个模块时行为不一致。
1.1.5 工作区下的构建:一个必踩的坑
工作区建好后,第一反应往往是 go build ./...。实测结果会让你困惑:
$ GOTOOLCHAIN=go1.27.0 go build ./...
pattern ./...: directory prefix . does not contain modules listed in go.work or their selected dependencies
原因是:工作区根目录本身不是模块,./... 从根展开时找不到属于它的包。有三种正确写法:
# 1) 按模块目录展开
GOTOOLCHAIN=go1.27.0 go build ./domain/... ./infra/... ./api/... ./cmd/taskhubd/...
# 2) 用工作区伪模式 all
GOTOOLCHAIN=go1.27.0 go build all
# 3) 指定模块路径,构建单个可执行文件
GOTOOLCHAIN=go1.27.0 go build -o bin/taskhubd example.com/taskhub/taskhubd
all 模式包含工作区主模块的包及相关依赖,范围可能很大。日常验证优先列出各模块目录;需要连同依赖一起构建时再使用 go build all。CI 里要产出二进制时用第 3 种,把产物落到 bin/。
go vet、go test 有同样的限制:从根跑 ./... 会失败,优先写成 go vet ./domain/... ./infra/... ./api/... ./cmd/taskhubd/... 与对应的 go test;go test all 还可能测试依赖包,范围不同。
1.1.6 跑起来验证
四个模块各自只是「一层」,组装在 cmd/taskhubd/main.go 里完成:infra 提供一个 domain.TaskRepository 实现,api 的 handler 只接收接口,main 把两者接上。
$ GOTOOLCHAIN=go1.27.0 go build -o bin/taskhubd example.com/taskhub/taskhubd
$ ./bin/taskhubd &
2026/10/10 10:19:26 taskhubd listening on :8080
$ curl -s -o /dev/null -w "status=%{http_code}\n" -H 'X-Tenant-ID: t1' http://localhost:8080/tasks
status=200
返回体是 null(内存仓储还没有数据,json.Marshal 对 nil 切片输出 null),但 200 说明整条链路——main → api → domain 接口 → infra 实现——是通的。这正是多模块工作区要证明的事:跨模块的调用在编译期就被完整检查过。
1.1.7 go work sync:把版本统一回各模块
工作区还有一个不常用但重要的命令 go work sync。它的作用是:把工作区解析出的构建版本,写回每个模块的 go.mod。
设想 domain 声明 golang.org/x/text v0.14.0,api 声明 v0.20.0。工作区会按 MVS 统一到 v0.20.0。跑一次 go work sync:
$ grep x/text domain/go.mod
require golang.org/x/text v0.14.0
$ GOTOOLCHAIN=go1.27.0 go work sync
$ grep x/text domain/go.mod
require golang.org/x/text v0.20.0
domain/go.mod 里的版本被自动抬到了工作区的统一版本。它可以减少工作区与各模块构建列表的版本差异,但不会替你发布兄弟模块或消除工作区 replace 带来的差异。单模块独立构建仍需 GOWORK=off 验证;在工作区目录内仅仅 cd domain 不会关闭工作区。
是否标为 // indirect 不是“不更新”的判据;间接依赖仍可能在构建列表中。go work sync 根据工作区的 MVS 构建列表同步各模块需要的版本,具体是否改写还与依赖图、模块裁剪等有关。参见 官方 go work sync 定义
。
1.1.8 工作区与 replace 的分工
初学者常把 use 和 replace 混为一谈,其实两者解决的问题不同:
| 需求 | 用哪个 | 说明 |
|---|---|---|
| 多个本地模块一起开发 | go.work 的 use | 源码级引用,无需版本号 |
| 单个模块临时指向本地目录 | go.mod 的 replace | 只有该模块受影响 |
| 临时指向某个 fork | go.work 的 replace | 全工作区生效,慎提交 |
| 发布给外部使用者 | 都不用 | 外部拿不到你的工作区 |
判断标准:use 选择工作区的主模块,replace 改变某个依赖的解析目标。go.work 里的 replace 介于两者之间,最容易失控,因为它是全工作区覆盖,且经常在调试完后忘了删。
1.1.9 go.work 该不该提交
这是团队里争论最多的一点。结论是分场景:
| 场景 | 是否提交 go.work | 理由 |
|---|---|---|
| 单仓多模块的日常开发 | 提交 | 让所有人 git clone 后直接能构建,省去手动 go work init |
| 模块要独立发布成库 | 谨慎 | 外部使用者拿不到你的 go.work,必须保证每个模块能独立 go build |
| CI 构建单一可执行文件 | 可不提交 | 在入口模块中以 GOWORK=off go build . 验证,并准备完整的 go.mod 与已发布依赖 |
| 只想本地临时联调 | 不提交 | 用 go.work 但写进 .gitignore |
一个稳妥的折中:提交 go.work,但同时维护 CI 里的「无工作区构建」任务——即在各模块目录设置 GOWORK=off 后构建,无需删除工作区文件,确保每个模块自洽。这条 CI 任务能挡住「依赖工作区才能编过」的隐性耦合。
1.1.10 常见坑速查
| 现象 | 原因 | 处理 |
|---|---|---|
directory prefix . does not contain modules | 从工作区根跑 ./... | 用 go build all 或指定模块路径 |
改了 domain 但 api 没生效 | 不在同一工作区,走了模块缓存 | 确认 go.work 的 use 包含两者 |
go mod tidy 报找不到兄弟模块 | tidy 按当前模块维护依赖,兄弟模块未发布或解析规则缺失 | 使用已发布版本,或明确配置本地 replace;sync 不能替代依赖解析 |
| 单模块构建能过、工作区挂 | 版本不一致 | go work sync 统一版本 |
提交了 go.work.sum 却冲突 | 校验和随本地缓存变化 | 一般提交 go.work.sum,冲突时重生成 |
go.work 不是银弹,它的价值在于让多个主模块在开发期直接使用本地源码,同时保留各自的模块定义。下一节我们把边界的另一半补上:模块内部的依赖方向,以及跨模块通信为什么要靠接口而不是具体类型。
阅读导航:上一节:目录 · 下一节:1.2 依赖方向与接口边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。