Go 代码生成的三层认知:从忍住不用到自己造轮子

发布时间:2026/5/30 8:31:14

Go 代码生成的三层认知:从忍住不用到自己造轮子 我见过太多项目的 Makefile 里有一行go generate ./...。跑一次 40 秒生成的代码没人看得懂但没人敢删。每次有新同事入职看到项目里散落着 30 个//go:generate指令第一反应是恐惧——“这些是什么动了会不会炸”这不是代码生成的问题。这是判断力的问题。Go 社区教你怎么用代码生成的文章已经够多了。教你go generate语法教你写text/template教你用stringer。但很少有人告诉你什么时候不该用。我花了两年时间把自己项目里的代码生成指令从 30 个砍到 10 个。这个过程中我明白了一件事——代码生成是一种能力但判断什么时候不用是更高级的能力。这篇文章的重点不是生成器怎么写——而是什么时候该写、什么时候不该写。它帮你建立三层认知克制什么时候该忍住不 generate原则该用时正确的打开方式是什么能力真需要自己造时最短路径是什么一、克制——什么时候不该用代码生成一个真实的砍 codegen 经历两年前我的项目里有 30 个//go:generate指令。分布在十几个包里用途五花八门mockgen 生成 mock 接口自写模板生成类型安全的集合easyjson 生成 JSON 序列化代码内部 RPC 框架的 client stubCI 跑一次go generate ./...要 40 秒。偶尔还因为团队成员本地 mockgen 版本不一致生成代码有 diffPR 卡在 lint 阶段。Go 1.18 发布后我做了一次系统性清理。不是冲动式删除而是逐个审视这个 generate 指令解决的问题现在有没有更好的方案最终结论砍掉了 20 个剩下 10 个。CI 时间从 40 秒降到 12 秒。新人看到的 generate 指令少了每一个都有明确的存在理由。砍掉的那 20 个有什么共同点它们解决的问题泛型或者手写都能更好地解决。泛型能替代的就不该 generate最典型的例子是类型安全的容器。泛型之前想要一个IntSet、一个StringSet、一个UserIDSet你有三种选择用interface{}做通用容器——丢掉类型安全为每种类型手写一份——代码膨胀用代码生成写一个模板为每种类型生成一份——工程复杂度上升Go 1.18 之后一个泛型Set[T comparable]搞定一切typeSet[T comparable]struct{m map[T]struct{}}func NewSet[T comparable]()*Set[T]{returnSet[T]{m: make(map[T]struct{})}}func(s *Set[T])Add(v T){s.m[v]struct{}{}}func(s *Set[T])Contains(v T)bool{_, ok :s.m[v];returnok}// Remove, Len... 接口一致不赘述10 行代码。支持所有 comparable 类型。编译时类型安全。改一处所有使用方自动更新。免去了 CI generate、版本锁定、生成代码 drift 等所有额外成本。代码生成方案呢我简单算一笔账维度泛型方案代码生成方案初始代码量~15 行~80 行模板生成器新增类型成本0 行1 行 generate 指令修改接口改 1 处改模板 重新 generate 所有文件CI 额外步骤无go generate diff 检查可读性高代码就在那中要看模板才能理解生成逻辑判定很简单如果你的需求是同一接口用于多种类型——泛型完胜codegen 是 overengineering。类似的还有泛型之前用 codegen 生成的 sort 接口实现、sync.Pool 的类型安全包装、channel 的 fan-in/fan-out 工具函数。这些在 Go 1.18 之后全部可以用泛型替代。但这不意味着代码生成没用了。泛型解决的是同一算法用于不同类型这一类问题。还有一大类问题泛型解决不了——比如根据类型的字段列表生成不同结构的代码。ORM 要为每张表生成不同的 structprotobuf 要为每个 message 生成不同的序列化逻辑。这类场景代码生成仍然不可替代。5 信号判定法该不该 generate砍了 20 个、留了 10 个之后我归纳出一个判定框架。当你犹豫这个场景要不要写生成器时数一下这 5 个信号信号问题Yes 的例子No 的例子1类型集合是否开放的用户可以定义新的 protobuf message内部固定的 3 种缓存类型2每种类型是否需要不同结构的代码ORM 为每张表生成不同字段的 structSet 对所有类型逻辑相同3是否在做反射替代用 codegen 替代encoding/json的反射只是避免手打无性能收益4是否有明确的schema.proto 文件、OpenAPI spec、SQL DDL“大概是这个格式”5团队有人能维护生成器有人写过 AST 工具或了解模板引擎全员靠拷贝粘贴规则满足 ≥ 3 个信号 → 值得用代码生成。满足 ≤ 2 个 → 大概率是 overengineering。这不是算分公式——信号 4有明确 schema在实践中权重最高。如果没有 schema即使其他四个信号都满足也要慎重。回头看那 20 个被砍掉的≤ 2 是一个清晰的分界线。留下的 10 个 generate 指令我逐个检查过每个至少满足 3 个信号。砍掉的 20 个呢大多只满足 1-2 个——而那 1-2 个往往只是写起来方便或以前就有没人敢动。这个框架不是死规则。信号 5团队维护能力是一个容易被忽视的隐性成本你今天写了一个精巧的生成器半年后你离职了新来的同事看不懂模板语法生成器 panic 了没人能修——这时候 codegen 的成本远高于手写。一个容易犯的错误用 codegen 解决组织问题还有一种更隐蔽的误用团队内部约定每个服务都要实现 Health Check 接口有人写了个生成器来自动给每个服务加 Health Check。表面上看满足了信号 2不同结构的代码和信号 4有 schema。但实际上这个schema是人为创造的——Health Check 的实现逻辑各服务差异很大有的查数据库、有的查 Redis、有的只返回 OK生成器要么生成一个空壳让人手动填充要么生成一个万能版本但大部分服务用不上。本质问题这不是代码生成能解决的问题这是接口设计和团队规范的问题。生成器掩盖了真实问题——应该讨论的是Health Check 到底应该怎么设计而不是怎么让每个服务都有一个。判断依据如果你的生成器产出的代码90% 以上的使用者都需要手动修改——那它不是在生成只是在占位。占位不需要生成器一个cp template.go my_service.go就够了。二、原则——该用时正确的打开方式散落的模板为什么会失控通过了 5 信号判定确认了代码生成确实有价值。但用和用对是两回事。项目里常见的失控模式是这样的5 个text/template文件分散在不同包的internal/gen/目录里。每个模板的格式不同——有的用{{.FieldName}}有的用自定义函数生成逻辑不同——有的用go run gen.go有的用独立的二进制工具版本管理也不同——有的提交生成代码有的只提交模板。新人来了第一天就懵改了api.proto要跑哪个 generate改了数据库 schema 要跑哪个 generate它们之间有依赖关系吗跑的顺序重要吗根因在于缺少 single source of truth。每个模板自成一派没有统一的设计原则把它们串起来。Schema 驱动成功项目的共同模式你可能在 API 设计中已经熟悉 contract-first 思维。这里我要论证的是同样的原则应用到代码生成时schema 的角色不只是接口定义——它是生成器的输入规范也是多方向一致性的保证源。看看 Go 生态里最成功的代码生成工具它们都把这个原则用到了极致工具Schema 是什么生成什么protobuf/buf.proto文件Go 类型 gRPC 客户端 HTTP 网关 文档entGo 代码ent schema DSL类型安全 ORM 迁移脚本wireProvider 函数声明完整的依赖注入初始化代码oapi-codegenOpenAPI specYAMLHTTP 客户端 服务端 类型定义sqlcSQL 查询文件类型安全的数据库访问代码它们的共同模式——Schema 驱动开发Schema唯一真实来源 ├─→ 方向 A生成 Go 类型定义 ├─→ 方向 B生成客户端代码 ├─→ 方向 C生成文档 └─→ 方向 D生成测试桩代码生成的真正价值在于一处修改多方向自动更新保证一致性。“少写重复代码只是副产品。protobuf 是这个模式的教科书案例你改了.proto文件中某个 message 的字段重新buf generate一次Go 类型定义、gRPC 客户端方法签名、REST 网关的路由绑定、Swagger 文档——全部自动更新。不存在改了接口定义忘了改客户端的问题。可以把 schema 理解为合同——上游定义约束下游必须遵守。改了合同所有下游自动按新合同重新生成。散落模板则像各自签的私人协议互相不通气。这就解释了为什么有些项目的代码生成越来越混乱缺少 schema 这一层抽象。模板直接从源代码或配置文件中提取信息每个模板自己定义输入是什么格式”。时间一长输入格式漂移了模板之间的假设开始冲突。一个快速自检当你决定用代码生成时问自己一个问题我的 schema 是什么能指出一个明确的 schema 文件.proto、.sql、OpenAPI yaml、Go struct 定义→ 方向正确schema 散落在多个文件里 → 先整理把 schema 收敛到一个位置根本说不清什么是 schema → 停。先想清楚什么是唯一真实来源再谈生成K8s 的 code-generator 是大规模 Schema 驱动的极致案例。API Group 中的 Go type 定义是 schema一次generate-groups.sh产出四类代码typed client、lister、informer、deepcopy。每个 CRD 自动生成数千行样板代码视 API 复杂度通常在 2000-5000 行之间。没有人手写这些代码——手写不仅低效更重要的是无法保证与 type 定义的一致性。Schema 驱动的核心价值在于保证正确性省力气只是顺带的。反面案例从散落模板到 Schema 驱动的改造一个典型的改造思路。假设你的项目有这样的代码生成现状internal/api/gen.go用text/template从 Go struct 标签生成路由注册代码internal/client/gen.go用另一个模板从同一批 struct 生成 HTTP 客户端internal/docs/gen.go又一个模板生成 API 文档三个模板各自解析 struct各自定义哪些标签有什么含义。某天你给某个字段加了个新标签路由代码更新了但客户端和文档没有同步更新——因为那两个模板不认识这个新标签。Schema 驱动的改造思路收敛 schema把 API 定义抽成一个独立的.yaml文件或 OpenAPI spec统一解析写一个 schema parser输出结构化的中间表示多方向生成路由、客户端、文档三个生成器共用同一份中间表示改造后加新字段只改 schema 文件一处。三个方向的生成代码自动保持一致——不需要你记住改了 schema 要跑哪三个 generate因为它们共享同一个入口。这个改造的投入不小——要花一两天重构现有的三个生成器。但回报是之后每次 API 变更的维护成本从改三个地方祈祷没漏变成改一处跑一次 generate。项目越大这个投资越值。三、能力——自己造生成器的最短路径什么时候需要自己造前两层讲的是用别人的工具。但总有一些场景现成工具覆盖不到你的内部 RPC 框架有自己的 IDL 格式你想为所有 error code 自动生成文档页面你想为 enum 类型自动生成类型安全的 JSON marshal 逻辑你想扫描代码中的特定 annotation 注释来生成路由注册代码这时候就需要自己造生成器。好消息是Go 标准库给了你一套完整的元编程工具链。Go 的元编程三件套Go 没有 Rust 那样的宏系统没有 Java 那样的 annotation processor。但标准库里有一组强大的代码分析工具包职责一句话理解go/parsergo/ast解析源代码为语法树以结构化节点表示 Go 代码“阅读理解”——把代码变成可遍历的数据结构go/types在语法树上做类型检查推断表达式类型“逻辑推理”——搞清楚每个表达式是什么类型go/format把语法树或代码字符串格式化为标准 Go 风格“排版美化”——等价于 gofmt三件套的工作流源文件(.go)↓ go/parser.ParseFile()语法树(ast.File)↓ 遍历 ast.Decls提取类型/字段/常量信息 数据结构(你定义的中间表示)↓ text/template 渲染 代码字符串([]byte)↓ go/format.Source()生成文件(.go)← 风格统一可直接编译go/types是可选的——简单场景只用go/ast就够。但如果你需要知道这个变量是什么类型、“这个接口有哪些实现者”就需要go/types做类型推断。最小 demo自动生成 String() 方法来看一个完整的例子。假设你有一组 enum 常量typeStatus int const(StatusPending Statusiota StatusActive StatusCompleted StatusCancelled)你想自动生成String()方法让fmt.Println(StatusActive)输出StatusActive而不是1。这就是官方stringer工具的简化版。核心流程四步完整实现不到 80 行//1. 解析一行代码把源文件变成语法树 fset :token.NewFileSet()f, _ :parser.ParseFile(fset,types.go, nil, parser.ParseComments)//2. 提取遍历 AST找到 const 组中的类型和值for_, decl :range f.Decls{genDecl, ok :decl.(*ast.GenDecl)if!ok||genDecl.Tok!token.CONST{continue}// genDecl.Specs 里就是每个常量——类型名、名字、值都能拿到}//3. 渲染用 template 把数据填充到代码骨架 var tmpltemplate.Must(template.New().Parse(// Code generated by enum_gen;DO NOT EDIT. func(v{{.TypeName}})String()string{switchv{{{- range .Values}}case{{.Name}}:return{{.Name}}{{- end}}default:returnfmt.Sprintf({{.TypeName}}(%d), int(v))}}))//4. 格式化format.Source 确保输出通过 gofmt formatted, _ :format.Source(buf.Bytes())os.WriteFile(types_string.go, formatted, 0644)这里的关键认知比代码本身更重要AST 节点类型是确定的*ast.GenDecl对应const/var/type/import*ast.FuncDecl对应函数。你不需要正则匹配源代码——AST 已经帮你结构化了format.Source是你的安全网模板里缩进写得再乱输出也是标准格式80 行搞定一个生成器解析、提取、渲染核心概念就这三个工程实践四个必须做的事写完生成器只是开始。要让它在团队中可靠运行而不变成定时炸弹你需要做好四件事1. 版本锁定——生成器本身也需要版本管理//go:generate go run github.com/your/generatorv1.2.3 ./...用version锁定生成器版本。版本不一致是 codegen 项目最常见的摩擦源——“本地 mockgen 是 v1.6CI 上装的是 v1.5导致生成代码有无意义的 diff。团队越大这个问题越严重。注意go run pkgversion会启动独立的模块上下文不使用当前项目的go.sum。如果你的生成器需要 import 当前项目的类型如 mockgen、wire应改用tools.gogo.mod管理//go:build tools package toolsimport_github.com/golang/mock/mockgen然后通过go run github.com/golang/mock/mockgen不带 version运行依赖版本由go.mod统一管理。2. CI 一致性校验——让机器帮你兜底steps: - run: go generate ./... - run:gitadd-N.gitdiff--exit-code# 有 diff 有人改了 schema 但忘了重新 generate# git add -N 确保新增的未跟踪文件也被检测到做法是生成代码提交到版本控制。CI 每次构建重新 generate然后检查有没有 diff。有 diff 就说明两种情况之一有人改了 schema 但忘了跑 generate或者有人手动改了生成文件不应该手动改。这比CI 里现场 generate 但不提交更好——因为提交了生成代码code review 能看到接口变化的全貌不只是 schema 的变化。3. 执行时机——隐含依赖是最常踩的坑这是我踩过的最痛的坑go generate指令之间有隐含依赖。具体场景A 包的 generate 产出types.goB 包的 generate 要 import A 包的类型。如果 CI 里先跑了 B 再跑 AB 的 generate 会编译失败——因为 A 的新类型还没生成。go generate ./...的执行顺序是按文件系统遍历顺序不是依赖顺序。大多数时候碰巧没事但项目大了之后迟早会撞到这个坑。我的解决方案在 Makefile 里显式声明顺序不依赖隐式遍历。.PHONY: generate generate: go generate ./internal/schema/...# 先类型定义生成go generate ./internal/client/...# 后依赖类型的客户端生成go generate ./internal/mock/...# 最后mock 依赖前两者的接口看起来多此一举”等你在 CI 上调了两天偶尔失败的 generate之后你会感谢这几行 Makefile。4. 标准头注释——让工具链认识你的生成文件生成的文件第一行必须包含// Code generated by your-tool;DO NOT EDIT.这不是客气话。Go 工具链依赖这行注释识别生成文件go vet会跳过某些检查golangci-lint 默认不扫描生成文件IDE 会标灰显示。少了这行你的生成文件会被 linter 报一堆问题白白浪费 code review 时间。什么时候用go/types前面的 enum stringer demo 只用了go/ast——纯语法层面就够了因为我们只需要常量名字和所属类型名。但有些场景光看语法不够。比如你想生成所有实现了io.Reader接口的类型的列表——这需要类型信息。go/ast不知道一个 struct 是否实现了某个接口只有go/types才能做这个判断。经验法则只需要名字、字段列表、常量值 →go/ast够用需要这个类型是否实现了某接口“这个表达式的类型是什么” → 要引入go/types需要跨文件、跨包分析 → 必须用golang.org/x/tools/go/packages加载完整类型信息大多数自定义生成器只需要go/ast。不要过早引入go/types——它需要加载完整的类型检查器构建速度会变慢复杂度也会上升。先用最简方案解决问题真的不够再升级。决策速查表回到开头的问题——面对一个要不要用代码生成的决策这张表帮你 30 秒内做出判断你的场景建议判断依据同一接口用于多种类型泛型类型参数化就够了codegen 是 overengineering为外部 schema 生成 Go 代码codegenschema 驱动保证一致性替代运行时反射提升性能codegen编译时确定 运行时推断只是想少写几行重复代码手写投入产出不成比例生成器维护成本被低估有 schema 需要多方向产出codegen这是代码生成的甜蜜点团队没人能维护生成器手写生成器坏了没人修比手写更贵枚举类型需要 String()/Marshalcodegen经典场景用 stringer 或自写内部 RPC 框架需要生成 stubcodegen满足信号 1/2/3/4代码生成是一种强大的工具。protobuf、ent、wire 这些项目证明了它在正确场景下的巨大价值。但工具的价值从来不在于会用——在于知道什么时候用、什么时候不用。一个三人团队花三天写了一个生成器解决了一个手写半小时就能搞定的问题然后花半年维护这个生成器。这种故事每天都在发生。下次你看到 Makefile 里那行go generate ./...时先问自己那 5 个问题。30 秒就能避免半年的维护成本。附录实验代码本文 2 组实验的代码已开源GitHubzhiyulab-evidence/go-code-generationenum-generator/第三章 AST enum 生成器完整实现enum_gen.go 测试类型定义generics-vs-codegen/第一章泛型 vs codegen Set 实现对比每个子目录都有独立的源码文件可直接go run复现。原文发布于止语 Lab

相关新闻