《Go 语言编程实战》1.3 脚手架与代码生成

多模块最烦的不是拆,而是四份 go.mod、四份骨架、同一套枚举要抄四遍。本节给 TaskHub 装上生成器:用 go:generate 驱动一个从 YAML 生成 Go 枚举的工具,用 go.mod 的 tool 指令锁死 stringer 版本,实测生成、格式化、构建全流程,并讨论生成物该不该提交。

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

三条要点:

  1. //go:generate 与 // 之间不能有空格,否则不被识别。
  2. go generate 不分析代码,它只是「按注释执行命令」。所以命令写错了、工具没装,它不会提前警告。
  3. 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 最小版本选择与冲突排查 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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