1.3 脚手架与代码生成
模块拆完之后,你会遇到一种新的重复:同一个 Status 枚举,domain 要一份、api 的 JSON 输出要一份、数据库层要一份。手动同步的结果是三个月后它们开始漂移——domain 里加了 Archived,api 的 String() 却没跟上,线上日志里出现 Status(3) 这种鬼东西。
重复的代码不会自己保持一致,但生成的代码会。本节给 TaskHub 装上一套最小的代码生成流水线。
本节实现一个从 YAML 生成 Go 枚举的工具,用
go:generate把它挂到构建流程上,再用go.mod的tool指令锁定第三方生成器stringer的版本,实测生成 → 格式化 → 构建 → vet 全链路。
1.3.1 什么该生成,什么不该
代码生成不是越多越好。判断标准是**「这个文件的正确性是否由某个单一事实来源决定」**:
| 适合生成 | 不适合生成 |
|---|---|
枚举的 String() / Parse() | 业务逻辑分支 |
| Protobuf / Thrift 的类型 | HTTP handler 的具体实现 |
| 数据库表的 struct 映射 | 领域模型的业务方法 |
| 接口的 mock | 配置默认值(该手写常量) |
| 常量表(错误码、状态机) | 一次性脚本 |
一句话:「人写会写错、机器写不会错」的东西才生成。枚举的 String() 就是典型——人写一遍不会错,写第十遍必错。
1.3.2 go:generate 是什么
go:generate 不是编译器特性,它只是一个约定:以 //go:generate 开头的注释里写一条 shell 命令,go generate ./... 会扫描所有 Go 源文件,逐条执行这些命令。
//go:generate go run ./tools/genenums -in enum.yaml -out task/status_gen.go -package task
三条要点:
//go:generate与//之间不能有空格,否则不被识别。go generate不分析代码,它只是「按注释执行命令」。所以命令写错了、工具没装,它不会提前警告。go generate默认不递归,要写./...才扫子目录。
go generate 与 go build 是分开的两步:CI 里通常先 go generate ./...,再 go build。它不会在 go build 时自动触发——这是刻意的设计,避免构建过程有副作用。
1.3.3 写一个生成器:从 YAML 生成枚举
TaskHub 的状态机需要一组状态常量。与其在 Go 里手写,不如把「状态定义」抽成一份 YAML,作为单一事实来源:
# enum.yaml
type: Status
prefix: Status
values:
- name: Todo
doc: 待处理
- name: InProgress
doc: 进行中
- name: Done
doc: 已完成
生成器 tools/genenums/main.go 读这份 YAML,用 text/template 渲染,再用 go/format 格式化:
const tmpl = `// Code generated by genenums. DO NOT EDIT.
package {{.Package}}
type {{.Type}} int
const (
{{- range $i, $v := .Values}}
{{$.Prefix}}{{$v.Name}} {{$.Type}} = {{$i}}{{if $v.Doc}} // {{$v.Doc}}{{end}}
{{- end}}
)
func (e {{.Type}}) String() string {
switch e {
{{- range $i, $v := .Values}}
case {{$.Prefix}}{{$v.Name}}:
return "{{$v.Name}}"
{{- end}}
}
return "{{.Type}}(unknown)"
}
`
main 函数负责读文件、渲染、格式化、写盘:
func main() {
in := flag.String("in", "", "枚举 YAML 输入")
out := flag.String("out", "", "Go 输出文件")
pkg := flag.String("package", "task", "包名")
flag.Parse()
b, err := os.ReadFile(*in)
must(err)
var e Enum
must(yaml.Unmarshal(b, &e))
var buf bytes.Buffer
must(template.Must(template.New("enum").Parse(tmpl)).Execute(&buf, map[string]any{
"Package": *pkg, "Type": e.Type, "Prefix": e.Prefix, "Values": e.Values,
}))
src, err := format.Source(buf.Bytes())
must(err)
must(os.WriteFile(*out, src, 0o644))
fmt.Printf("生成 %s (%d 字节)\n", *out, len(src))
}
跑一次,实测输出:
$ go run ./tools/genenums -in enum.yaml -out task/status_gen.go -package task
生成 task/status_gen.go (403 字节)
生成的 task/status_gen.go:
// Code generated by genenums. DO NOT EDIT.
package task
type Status int
const (
StatusTodo Status = 0 // 待处理
StatusInProgress Status = 1 // 进行中
StatusDone Status = 2 // 已完成
)
func (e Status) String() string {
switch e {
case StatusTodo:
return "Todo"
case StatusInProgress:
return "InProgress"
case StatusDone:
return "Done"
}
return "Status(unknown)"
}
注意常量的对齐和注释位置——这是 go/format 的功劳,不是模板里的手工空格。
1.3.4 生成物必须过 gofmt
生成器最容易犯的错是输出一堆对齐错乱的代码。解决办法是在生成器内部调用 go/format(标准库的 format.Source),而不是生成后再手动跑 gofmt。
src, err := format.Source(buf.Bytes())
must(err)
format.Source 对语法正确但格式不对的源码返回格式化结果;对语法错误的源码返回错误——这顺带成了一个免费的正确性检查:如果你的模板生成了非法的 Go,format.Source 会直接报错,而不是把坏代码写进文件。
实测生成后跑 gofmt -l:
$ gofmt -l task/status_gen.go
$ # 输出为空,说明已符合 gofmt
「生成器输出必须通过 gofmt -l」应该写进 CI。它是一行命令,能挡住绝大多数「模板改崩了」的情况。
1.3.5 用 tool 指令锁定生成器版本
生成器的版本必须锁死,否则「我本地生成的结果和你不一样」。Go 1.24 起,go.mod 支持 tool 指令,把工具当作依赖管理:
$ GOTOOLCHAIN=go1.27.0 GOPROXY=https://goproxy.cn,direct \
go get -tool golang.org/x/tools/cmd/stringer gopkg.in/yaml.v3
$ cat go.mod
module example.com/gendemo
go 1.27.0
require (
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/tools v0.51.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
tool (
golang.org/x/tools/cmd/stringer
gopkg.in/yaml.v3
)
tool 指令记录的是工具的模块路径,go tool 能列出它们:
$ go tool | grep stringer
stringer (golang.org/x/tools/cmd/stringer)
关键在于:工具版本由 go.mod 决定,和普通依赖走同一套 MVS 解析。同事 clone 下来直接 go generate ./...,用的就是 golang.org/x/tools v0.51.0,不需要各自 go install 一个版本。
在 //go:generate 里通过 go tool 调用它:
//go:generate go tool stringer -type=Status
实测:
$ go generate ./task/
$ ls task/
status.go status_string.go
$ go build ./task/
$ go vet ./task/
生成、构建、vet 全部通过。
1.3.6 生成物冲突:一个真实的教训
把 stringer 生成的文件和手写/自生成的文件放在一起时,很容易撞方法名。实测把 genenums 生成的 String() 和 stringer 生成的 String() 同时放进 task 包:
task/status_string.go:20:17: method Status.String already declared at task/status_gen.go:12:17
同一个类型只能有一个 String() 方法。这不是 bug,而是提醒你:一个类型只应有一个生成器。要么用 stringer 管 String(),要么自己生成全套,不要两个工具各管一半。
同样的道理适用于 MarshalJSON、Scan、Value 这些「约定方法名」——它们天然是单占位的,生成器之间必须分工明确。
1.3.7 生成物要不要提交
和 go.work 一样,这是个需要团队达成一致的问题:
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 提交生成物 | CI 不需要装生成器;git diff 能看到生成结果变化 | 仓库变大;review 噪音多 | 生成器依赖重、CI 环境受限 |
| 不提交(CI 生成) | 仓库干净;单一事实来源明确 | 每次构建都要跑生成;本地可能忘跑 | 生成器轻、go tool 已锁定 |
| 提交 + CI 校验一致性 | 兼顾两者 | 需要一条「生成后 git diff 非空就失败」的检查 | 推荐 |
推荐做法是第三种:提交生成物,同时在 CI 里跑一遍 go generate ./... && git diff --exit-code。如果生成器和提交的文件不一致,CI 直接失败。这保证「生成物永远等于生成器当前该产出的内容」。
1.3.8 脚手架:生成模块骨架
除了「生成代码」,还有「生成项目」。当 TaskHub 要加第五个模块 billing 时,你不想手抄一遍 go.mod + 目录 + 空文件。做法是把骨架模板化,用 embed 打包进一个 taskhub 脚手架工具:
//go:embed templates/*.tmpl
var tmplFS embed.FS
embed 把模板文件编译进二进制,脚手架工具就变成单个可执行文件,不依赖运行目录。生成时把模块名、包名替换进去:
taskhub new billing --module example.com/taskhub
create billing/go.mod
create billing/service.go
create billing/service_test.go
脚手架的价值不在于省下那三分钟,而在于保证每个新模块的骨架一致:目录名、包名、测试文件、CI 配置都从同一份模板来,新人不用猜「上次那个模块是怎么建的」。
1.3.9 常见坑速查
| 现象 | 原因 | 处理 |
|---|---|---|
// go:generate 不执行 | // 后有空格 | 写成 //go:generate |
| 子目录的生成器没跑 | go generate ./... 漏了 ./... | 补上 |
| 生成物格式乱 | 生成器没调用 format.Source | 在生成器里格式化 |
| 同事生成的和我不同 | 工具版本不一致 | 用 go.mod 的 tool 指令锁定 |
method already declared | 两个生成器抢同一个方法名 | 一个类型一个生成器 |
| CI 上找不到工具 | 只 go install 在本地 | 改用 go tool + tool 指令 |
代码生成把「保持多份副本一致」这件苦差事交给了机器。到这里,TaskHub 的工程骨架就立住了:四个模块、明确的依赖方向、可复现的代码生成。下一章我们把注意力转向这些模块共同依赖的东西——第三方库的版本治理与供应链安全。
阅读导航:上一节:1.2 依赖方向与接口边界 · 下一节:2.1 最小版本选择与冲突排查 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。