《Go 语言编程实战》1.1 单仓多模块与 go.work

TaskHub 的第一个工程决策不是选框架,而是选仓库形态。本节把单模块项目拆成 domain/infra/api/cmd 四个模块,用 go.work 串成一个工作区,实测工作区下的构建、运行与 go work sync 的版本统一行为,并说清 go.work 到底该不该提交进版本库。

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只有该模块受影响
临时指向某个 forkgo.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 依赖方向与接口边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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