近几年接手了好几个“看起来不大、改起来要命”的业务系统之后,我对“重复代码”这三个字的厌恶已经到了生理层面。订单表要写增删改查,用户表要写增删改查,连配置表也得写增删改查——逻辑一模一样,只是字段名和表名在变。我实在不想再复制粘贴然后全局替换了,于是花了两周时间做了个叫t3code的小工具。它的名字不是随便起的,** t3 代表 Type → Template → Test 三段式流水线 **,把“从数据模型到可用代码”的过程拆成三个独立阶段,每个阶段只管一件事。
这篇博文就把 t3code 从设计动机、核心实现到实际踩坑完整讲一遍。如果你也在写大量重复的脚手架代码,或者想自己动手做一个代码生成器,可以参考这套思路。我会把关键代码贴出来,也会把那些只在真实项目里才会遇到的诡异问题交代清楚,保证不是纸上谈兵。
1. 为什么我会去写一个叫 t3code 的代码生成器
1.1 重复代码的痛,不是“数量多”而是“改不动”
先说一个很典型的场景。公司内部有个老系统,用的是经典的三层架构:Controller、Service、Mapper。每新增一张表,就要手动建实体类、VO、DTO、Mapper 接口、XML 映射文件、Service 实现、Controller 路由,虽说用 IDE 的模板能省一点事,但真正耗时间的是那些例外情况:某个字段要加校验、某个接口要拼 SQL、某个列表要支持模糊查询。每张表多多少少和“标准模板”不一样,于是你复制完还得一个个删改,改到后面自己都忘了哪张表加了什么特殊逻辑。
这种痛最麻烦的地方在于“改不动”。需求一变,比如要给所有列表接口加一个“创建时间倒序排序”,你要是不小心漏了一张表,线上 Bug 就直接找你。我经历过三次这种事之后,确定了一个结论:靠肉眼和 Ctrl + F 来管理重复逻辑,迟早要出事。
1.2 为什么一定要自己写,而不是用现成框架
其实市面上成熟的代码生成器不少,像 MyBatis Generator、JHipster,还有各类在线平台,按说没必要重复造轮子。但我把这几个都试过一遍之后,发现它们和团队现有代码风格很难完全对齐。工业级生成器默认的命名规范、目录结构、模板变量,和生产代码的差异肉眼可见,生成完还是要花大力气改。
自己写的好处在于边界可控。我不要一个无所不能的生成平台,我只要一个能把我这堆固定套路代码自动化掉的东西。于是我把需求收敛成三条:
- 输入是一份数据模型定义,尽量简单地描述字段类型、注释、校验规则。
- 输出是完整可编译运行的代码文件,不需要人工二次调整。
- 支持增量更新。表结构改了之后重新跑一次,只替换应该替换的部分,而不是全盘覆盖。
这个收敛很重要,因为一旦你试图兜住所有奇葩需求,工具本身就会变成一个比项目还难维护的系统。后来我在设计 t3code 时,最先定下的原则就是“只解决 80% 的常规情况,剩下 20% 留给人来处理”,这事才能落地。
2. t3code 的核心设计:三段式流水线是怎么拆出来的
2.1 模型定义和三段式结构总览
t3code 的输入不是数据库表结构,而是一个自定义的 .t3model 文件,格式接近 YAML。我把数据模型拆成三块:基础信息、字段列表、补充配置。
model: Order table: t_order comment: 订单表 fields: - name: id type: Long primaryKey: true comment: 主键 - name: orderNo type: String length: 32 comment: 订单编号 validation: required - name: amount type: BigDecimal precision: "10,2" comment: 订单金额 validation: range - name: status type: Integer comment: 状态:1待支付 2已支付 3已取消 - name: createdAt type: LocalDateTime comment: 创建时间 options: controller: true service: true mapper: true这个文件的作用是给所有生成阶段提供唯一数据源。接下来的三段式流水线就是围绕它展开的:
| 阶段 | 缩写 | 输入 | 输出 | 职责 |
|---|---|---|---|---|
| 类型解析 | Type | .t3model 文件 | 内部模型对象(字段列表、类型映射) | 把人类可读的描述转成程序可操作的数据 |
| 模板渲染 | Template | 内部模型对象 + 模板文件 | 各层代码文件的字符串 | 按既定模板产出代码内容 |
| 测试校准 | Test | 生成的代码文件 | 校验报告或补丁文件 | 检查生成结果是否符合语法和结构预期 |
三个阶段相互独立,因此每个阶段都能单独调试。比如你只是改了模板,不需要重新解析模型;你只想校验已有文件,也可以单独跑 Test 阶段。
2.2 为什么把“测试校准”作为独立阶段,而不是生成完就结束
这是我一开始没想明白,后来才补上的部分。最初的版本只有 Type 和 Template 两个阶段,生成完就把文件写到磁盘。结果模板一旦写错,比如某个字段名拼错了,就会生成一堆编译不过的代码,而且错误会在各个层里重复出现。
把 Test 独立出来后,我做了两件事:一是用 TypeScript 编译器 API 对生成的 .ts 文件做语法检查;二是写了一套结构断言,比如“Controller 必须包含 5 个标准方法”“Service 接口不能直接依赖 Mapper”。这样生成任何模块之后,能在落盘前先发现问题,不把错误带进项目。
三段式设计的另一个好处是扩展容易。后来我需要支持生成前端 API 调用代码,就把模板目录加了一类,数据模型完全复用,只新增了模板文件和对应输出规则。整个过程没有触碰 Type 和 Test 的核心逻辑,改动比预想小很多。
3. 核心实现拆解:从模型定义到代码落盘
3.1 第一阶段:Type 解析器如何处理 .t3model
t3code 是 Node.js + TypeScript 环境下的命令行工具。没有用花哨的 DSL,直接用一个轻量的 YAML 解析库把 .t3model 读进来,然后逐字段转换成内部类型系统。
export interface FieldModel { name: string; type: string; targetType: string; primaryKey?: boolean; length?: number; precision?: [number, number]; comment: string; validation?: string; } export interface ModelDefinition { model: string; table: string; comment: string; fields: FieldModel[]; options: Record<string, boolean>; }这里有一个很关键的映射逻辑:源类型是Long、String、BigDecimal、LocalDateTime这些贴近数据库或 Java 的类型,但我要同时生成后端 Java 代码和前端 TypeScript 代码,所以解析器里维护了一个双路映射表。
const JAVA_TYPE_MAP: Record<string, string> = { Long: 'Long', String: 'String', BigDecimal: 'java.math.BigDecimal', LocalDateTime: 'java.time.LocalDateTime', }; const TS_TYPE_MAP: Record<string, string> = { Long: 'number', String: 'string', BigDecimal: 'number', LocalDateTime: 'string', };如果哪天要输出 Go 或者 Python 代码,只需要在映射表里增加一个目标语言分支,而不需要重新定义字段模型。这就是把类型解析单独抽出来的价值:类型映射是纯函数,输入固定,输出固定,最容易测试。
3.2 第二阶段:模板引擎为什么选 Handlebars
模板引擎我对比过 EJS、Handlebars、Nunjucks,最后选的是 Handlebars。原因很简单:它语法干净,不鼓励写复杂逻辑,强制你只能定义 Helper。对于代码生成这种场景,模板里逻辑越少越安全。
我维护了一个基础模板集合,示例如下。这是一个简化版的后端实体类模板:
package com.example.entity; import java.math.BigDecimal; import java.time.LocalDateTime; /** * {{comment}} */ public class {{model}} { {{#each fields}} /** * {{comment}} */ {{#if primaryKey}} private {{targetType}} {{name}}; {{else}} private {{targetType}} {{name}}; {{/if}} {{/each}} {{#each fields}} public {{targetType}} get{{pascalCase name}}() { return {{name}}; } public void set{{pascalCase name}}({{targetType}} {{name}}) { this.{{name}} = {{name}}; } {{/each}} }真实项目中模板要复杂不少,尤其是 Mapper XML 里的 insert、selectByCondition、update 这些 SQL 片段,条件判断非常多。这时候 Handlebars 的#if、#unless和自定义 Helper 就派上用场了。
我注册了eq(相等判断)、and、contains这些逻辑 Helper,比如生成动态 SQL 的时候判断字段类型和校验规则:
{{#each fields}} {{#if (eq validation 'required')}} <if test="{{name}} != null"> AND {{name}} = #{ {{name}} } </if> {{/if}} {{/each}}模板渲染阶段的输出,就是一系列待落盘的文件内容。我把它设计成一个 map,key 是相对路径,value 是字符串内容,最后统一交给 Writer 处理,而不是边生成边写。这样如果你在渲染过程发现错误,直接抛异常,不会留下残缺文件。
3.3 代码落盘:覆盖策略怎么写才不会误删代码
生成文件最常见的坑是“全量覆盖还是保留修改”。很多代码生成器默认全量覆盖,这会造成严重问题,因为生成完你总会手动加点逻辑进去,下次再生成就直接被冲掉。
t3code 的策略是分文件类型区别对待:
- 实体类、DTO、常量类:一般不会手改,落盘时全量覆盖。
- Service 接口、Mapper 接口:几乎没有人为追加内容,可以覆盖。
- Service 实现、Controller:可能有人改,或者将来一定会有人改,必须合并或跳过。
我最终的做法是提供一个--mode参数,取值是overwrite、skip、merge。merge 模式只做一种简单的合并处理:如果目标文件存在且不是本工具生成的内容,就跳过;如果文件头部有@generated by t3code标记,就完整替换。这个方案保守,但安全。产出物上打标记这个习惯,是从一个线上事故学来的血泪教训。
3.4 命令行入口和工作流程
t3code 的核心命令长这样:
t3code generate --model ./models/order.t3model --output ./src --mode merge它内部跑的流程可以串成一条链路:
- 读取 .t3model 文件,解析为 ModelDefinition。
- 对 ModelDefinition 做结构校验,比如检查主键是否存在、字段命名是否合法。
- 依次遍历所有模板文件,渲染成内容 map。
- 根据 mode 参数决定覆盖、跳过还是合并。
- 最终输出“已完成 N 个文件,跳过 M 个文件”的报告。
生成器最容易被忽视的是未处理异常。模型里字段名写错、模板里变量拼错,这种问题如果只是打印一行错误然后又继续执行,生成结果会有半成品。所以我在第三步强制做全量渲染预检查,任何一个模板渲染失败就直接终止,不做部分输出。
4. 踩过的坑:模板调试、路径兼容和增量覆盖问题
4.1 模板里改一个空格,所有文件都变了的排查过程
第一次用 t3code 给真实模块生成代码时,我发现生成的代码文件全变了,git diff 看过去,每个文件都有一行“代码格式位置”的变动。仔仔细细对比之后才发现,模板文件里写了一行带注释的空格,这行空格是 IDE 格式化时帮我加上的。因为所有文件都从同一个模板渲染,所以这个空格污染了所有输出。
这次的教训是:模板文件应该和普通源码一样严格对待。我后来在模板的工程配置里设置了保存时不自动格式化,并且在渲染前后都对输出内容做了一次 normalize(统一换行符为 LF,去掉行尾多余空格)。这种隐蔽问题不亲自踩一次,根本不会想到。
4.2 Windows 和 Linux 路径分隔符的差异
第一次在一个 Windows 环境跑完整流程,生成出来的文件路径死活不对。调试了半天,问题出在path.join拼接模板输出路径时,Windows 下生成了反斜杠\,但代码里按正斜杠去查找文件,结果就是文件写到错误位置。
这个问题的修正很朴素:任何涉及输出路径的拼接,一律先split再join成正斜杠;在生成 map 的 key 时,统一使用/作为分隔符,落盘时再交给操作系统去解析。写生成器的人多数在 Mac 或 Linux 上开发,但工具真正跑起来的地方往往在 Windows 构建机上,所以这一类兼容问题必须提前做测试。
4.3 merge 模式下重复运行生成的“累积污染”
merge 模式的本意是“不覆盖手工修改的代码”,但这里有个陷阱。第一次运行生成了一批文件,也许你什么都没改;第二次运行之前,你往 Controller 里加了一个自定义接口,这时 merge 不会覆盖这个文件。可你第三次运行的时候,这个文件里既有 t3code 生成的标准方法,又有你手写的方法,如果再过一个月你自己都分不清哪些是工具生成的、哪些是人写的,维护起来就会很混乱。
解决思路是在 merge 逻辑里增加了块注释标记。生成器把代码按方法级别打上@generated注释,merge 时只替换已经标记的部分,插入新增的生成方法,保留没有标记的手写部分。这样重复运行不会累积,也不会破坏人工代码。虽然比简单 skip 复杂,但实际使用体验好非常多。
4.4 输出目录不是空目录时,误删问题
如果手滑把--output指向了项目根目录,且 mode 写成 overwrite,生成器理论上会把与模板同名的文件覆盖掉。幸好我在 Writer 层加了一层保护:只处理 t3code 模板清单里的文件名,其他文件一律不碰。这是代码生成器一个基本的安全底线——它只能改自己知道名字的文件,绝不能对目录做枚举式写入。
5. 用一次真实的生成过程检验 t3code 的效果
5.1 从订单模型到后端代码的完整操作
前面讲了不少设计思路和坑,你可能会觉得有点虚。这里我把一次完整操作贴出来,让效果说话。这是一个月结账单的模块,模型定义开头已经给过。执行命令:
t3code generate --model ./models/order.t3model --output ./src --mode overwrite输出的文件清单是:
generated: src/main/java/com/example/entity/Order.java generated: src/main/java/com/example/dto/OrderDTO.java generated: src/main/java/com/example/vo/OrderVO.java generated: src/main/java/com/example/mapper/OrderMapper.java generated: src/main/java/com/example/mapper/OrderMapper.xml generated: src/main/java/com/example/service/OrderService.java generated: src/main/java/com/example/service/impl/OrderServiceImpl.java generated: src/main/java/com/example/controller/OrderController.java总共 8 个文件,模型定义只有 70 行,生成出来的代码合计有 600 多行。如果人工手写,这段工作量至少要小半天;用 t3code 跑一遍,从输入到落盘不超过 10 秒。关键不只是快,而是每次的产物都严格一致——不会因为手抖漏掉某个校验。
5.2 生成后微调:哪些内容必须留给人工
我不建议把代码生成器做成“绝对不可改”的上帝视角工具。一次标准生成完成后,以下这些内容依然需要人来确认:
- 业务状态机的流转逻辑,比如订单从已支付到已取消,这中间有没有退款检查,工具不可能知道。
- 复杂 SQL 的 join 条件,自动生成一般只处理单表,涉及多表时必须手写。
- 权限控制注解,工具能知道方法名,但不知道哪些角色可以调这个接口。
t3code 有点像工地上的模板脚手架,它负责把结构的框架搭起来,但墙里的水管怎么走,还是得让水电工来定。这不是能力不足,恰恰是设计时主动留出来的边界,让工具不越界,就不至于坏事。
5.3 团队引入时的习惯调整
工具做出来之后,不是丢给团队一个命令行就可以。我整理了三条使用约定:
- 修改数据模型之前,先跑一次生成看 diff,让改动可控。
- 所有模板统一放在仓库单独目录里,模板变更要走代码评审。
- 禁止手工修改打上
@generated by t3code标记的文件,如果非要改,先把标记移除,后果自负。
这三条约定看着简单,但它们是把自动生成从“一次性福利”变成“长期可持续过程”的关键。没有约定,用不了多久工具就会变成团队里人人畏惧的黑洞。
6. 还能怎么扩展:t3code 后续的玩法和边界
6.1 反向解析已有数据库表结构
现在的 t3code 是“模型文件驱动”,也就是说你得先把 .t3model 写出来。这个门槛对老项目依然有点高。后续我计划做一个import-schema子命令,直接从数据库连接信息里读取 information_schema,把表结构转成 .t3model 文件。这样老模块迁移进来,就不需要手工写模型定义了。
这个功能实现上不复杂,但要注意类型映射的兼容。比如 MySQL 的decimal(10,2)、datetime(3),PostgreSQL 的jsonb、uuid都需要映射到内部类型系统里,否则生成出来的代码会有类型丢失。这一步做好,工具适用面能拓宽很多。
6.2 模板可视化预览与调试模式
模板渲染阶段最难受的就是调试。你不知道哪个字段没渲染出来,也不知道哪个 Helper 返回了空。我准备在下一版加上--preview模式,生成时把渲染后的结果导入一个临时目录,并生成一份“模板变量对照表”,方便检查每个字段在每层模板里的值。这个对新增语言支持时的排错会很有帮助——当一份代码在新语言里报错时,快速定位是类型映射的问题还是模板结构的问题,会非常有价值。
6.3 生成器自身也应该有测试
前面说了很多生成器代码的原理和坑,最后我真正想强调的一件事是:生成器的产物千变万化,但它自身的代码必须有自动化测试。
我给 t3code 写了三种类型的测试样例:
- 单元测试:覆盖解析器和模板 helper 的边界情况。
- 黄金样例测试:用固定的模型文件跑一次生成,把产物作为期望值保存,代码改动后比对。
- 编译校验测试:生成出真正的项目,再调用编译器做全量编译。
黄金样例测试对模板修改尤其敏感。如果我改了一个模板,所有依赖它的文件都会变,测试就会爆红,提醒你重新审视这次修改是否合理。这种机制极大地防止了“改一行模板、坏一片代码”的情况。
如果你也想做类似的工具,我建议你从最小闭环开始:拿一个真实模块,把它需要生成的文件全部模板化,先做到“能跑”;再慢慢补增量覆盖、类型映射、结构调整这些进阶能力。别一上来就想做一个平台级的东西,那会把自己绕进去。
t3code 现在能稳定支撑我手头几个项目的日常开发,每次新增一张表、调整一个字段,跑一遍命令,代码就位,省下的时间用来看 diff 和写真正的业务逻辑,这对我来说就是它最大的价值。