前阵子整理工位的时候翻出来一个老项目,叫t3code。我心里一动,把它重新拉起来跑了一遍,顺手把之前一直没来得及处理的几个坑也填了。这名字很简单——template + three tiers + code,当初起名的时候就是想做一个“三段式”的轻量代码生成器:模板、模型、输出各管一段。跟市面上那些动不动就来一套图形界面、在线预览、微服务扩展点的重型脚手架不一样,t3code 就是个命令行工具,跑起来不花哨,但真正能把我从每天的重复 CRUD 里捞出来。
这个东西适合谁?如果你平时要写大量的实体类、接口定义、数据库访问层、前端列表页和表单页,或者你所在的团队还没有统一代码生成规范,程序员各自用各自的办法在复制粘贴,那这篇内容对你会有用。如果你是那种喜欢“一刀切”生成器、希望一个命令产出完整可运行项目的用户,t3code 会不太适合,它的定位是“解决局部重复”,不搞“全家桶”。
我把实际用下来的设计思路、配置方式、踩坑经验全部整理到这篇文章里,涉及的模板语法、配置文件写法、生成策略都是可以直接抄去用的。
1. 需求审视与整体设计思路
先聊清楚一个前提问题:代码生成器到底解决什么问题?我自己的答案是——消除结构性重复,而不是消除思考。同样一组字段,在数据库里建表要写一遍,Java 实体里要再写一遍,DTO 里换个注解又要一遍,前端表格列再来一遍,表单校验规则还得再来一遍。这种重复最可怕的地方在于:任何一次改动都要同步改好几处,漏掉一个就等着线上数据出问题。
1.1 立项背景:为什么不做大而全的“脚手架全家桶”
做之前我也调研过不少现成方案。有些生成器功能确实强,支持从数据库反向建模,还能直接生成前端路由、状态管理、接口封装,看起来无所不能。但用了两三个项目之后我发现几个尴尬的问题。第一,学习成本太高,光配置项就上百个,团队里不是每个人都能记住那些概念;第二,生成结果“过重”,它默认帮你把项目结构也拉起来了,可很多老项目根本不允许你把整个架构推倒重来;第三,自定义能力受限,你改模板需要理解它的抽象概念,否则只能在生成的代码上手动补丁。
所以 t3code 立项时我把目标压得很窄:只要把“一类代码”的生成做好,就够了。比如这一趟专门生成接口层的 CRUD 模板,下一趟专门生成前端表格页模板,模块之间互相独立,配置文件互相隔离。这样有个好处,每套模板的维护成本非常低,改一个生成规则,影响的只是这一类代码,不会波及全局。
1.2 核心设计原则:模板与数据分离,代码与配置分离
t3code 的目录结构是这样的。根目录下一个templates文件夹专门放模板文件,一个models文件夹放数据描述文件,output文件夹放生成结果。模板里面不写死任何业务字段,只写变量占位符和逻辑控制语句;模型文件里只描述“有几个字段、各自什么类型、有哪些约束”,完全不涉及输出格式。两者通过一条命令关联起来。
这个分离思路是从写页面的时候学来的。做前端的时候我们讲模板和数据分离,服务端渲染也好、客户端渲染也好,只要看见模板里塞着写死的数据,后面维护就是灾难。代码生成器也一样——模板一旦混入具体表字段,这个模板就废了,换一个表立刻就得复制一份改半天。所以 t3code 严格要求模板文件里只出现占位符,任何一个具体字段名都不允许出现在模板里。
1.3 技术选型背后的取舍
实现语言我选了 Python,没有太多花哨理由:团队里所有人都会一点,跨平台也没问题,字符串处理能力又强。模板引擎选了 Jinja2,这是 Python 生态里最成熟的方案,语法对熟悉服务端开发的人几乎零门槛,{{ }}插值、{% for %}循环、{% if %}条件,干净清爽。配置文件格式用 YAML,比起 JSON 少写一堆逗号括号,比起 XML 又不那么啰嗦,嵌套表达列表和字典都很自然。
这里有个取舍想单独说说。有人可能觉得用 JavaScript 写会更现代,Node.js 生态里的 EJS、Handlebars 也很成熟。但我坚持用 Python 的原因只有一个:生成代码这个场景下 Python 处理路径、编码、文件操作极其顺手。比如 Windows 下文件名编码、路径拼接那些坑,Python 标准库基本都帮你兜住了。工具是拿来用的,不是拿来炫技的,选择团队最顺手的技术,让维护成本降到最低,这才是这类工具的长久之道。
2. 核心模块拆解与关键技术实现
把 t3code 拆开看,其实就三个核心模块:模型解析器、模板渲染器、文件写出器。听起来都不复杂,但每个模块里都有一些容易被忽略的细节,这些细节恰恰决定了这个生成器是“能用”还是“好用”。
2.1 模型解析器:一切生成的基础
模型文件是这个工具的“灵魂”,描述的是业务结构,不掺任何展示逻辑。我用的 YAML 格式来写,比如描述一张用户表,大概长这样:
table: user comment: 用户信息表 fields: - name: id type: bigint comment: 主键ID primary: true autoIncrement: true - name: username type: varchar(64) comment: 登录名 nullable: false length: 64 form: input - name: status type: tinyint comment: 状态 0-禁用 1-启用 default: 1 form: radio options: - { label: 禁用, value: 0 } - { label: 启用, value: 1 }模型解析器要做的事不只是读 YAML 这么简单。它还要做类型映射,比如把数据库类型varchar(64)映射成 Java 的String,把bigint映射成Long,或者映射成 TypeScript 的number。这些映射规则集中在单独的配置里,不写死在代码里,这样换一套技术栈,只需要改映射表,模型文件不需要动。
字段属性这块我想多讲一句。很多人写代码生成器,字段只描述“名称+类型+注释”,结果生成出来的前端表单永远需要手工再补很多东西。我在 t3code 里给每个字段预留了form参数,可以标记这个字段在前端用什么组件渲染——input、textarea、radio、select、date,还能挂options选项列表。这样生成前端表单的时候就能自动选择对应的组件,校验规则也能跟着生成。模型描述得越细,生成结果就越接近最终想要的效果。
2.2 模板渲染器:从变量插值到逻辑控制
模板引擎这块是整个系统的核心,Jinja2 的能力要为生成代码这个场景做好取舍。变量插值是最基础的能力,比如实体类模板里常见这么一段:
{% for field in fields %} /** {{ field.comment }} */ private {{ field.javaType }} {{ field.name }}; {% endfor %}这段模板会把模型文件里的字段列表循环展开,每个字段生成一段带注释的 Java 属性声明。Jinja2 的语法本身就是为这种场景设计的,循环里还能做条件判断,比如主键字段自动加@Id注解,时间字段自动加@JsonFormat注解。
模板渲染器还有一个容易被忽略的功能叫空白控制。Jinja2 默认会在标签位置留下多余的空行,生成的代码看起来一言难尽。我用的是这种写法:
{% for field in fields -%} private {{ field.javaType }} {{ field.name }}; {%- endfor %}标签后面加一个-,就能吞掉相邻的空格和换行。这个小技巧单独拿出来说,是因为很多人初次写代码模板,最大的困惑不是逻辑写不出来,而是生成的代码格式乱到没法看。
模板继承功能也值得讲。比如 Controller 层和 Service 层的模板结构高度相似,就可以抽一个公共的 base 模板,通过{% extends %}和{% block %}来复用公共部分。我实际用下来,代码模板的复用率比想象中要高得多——毕竟一个项目的分层结构是稳定的,变来变去的只是业务字段而已。
2.3 文件写出策略:覆盖、跳过与补丁
生成代码之后怎么落地,这是个非常现实的问题。最粗暴的做法是每次生成直接覆盖同名文件,但实际项目中,生成的代码往往会被手动改过——比如某个 Controller 方法你加了缓存逻辑、某个页面你加了自定义交互。如果直接覆盖,这些手写内容全部丢失。所以我设计了三种写出模式。
第一种是overwrite模式,直接覆盖目标文件,适合那些“生成之后绝不会手动改”的文件,比如纯 DTO、VO 这一类。第二种是skip模式,如果文件已存在则跳过,适合配置文件这类初始化一次就不该被反复生成的场景。第三种是merge模式,这个模式最复杂,我用的策略是在手写文件和生成文件之间做“标记位”的合并:模板里用指定注释把“自动生成区”和“手写保留区”隔离,比如 Java 文件里用:
// ===== AUTO-GENERATED BEGIN ===== // ===== AUTO-GENERATED END =====生成器只重写这个标记区间内的内容,区间外的手写代码原样保留。这套方案确实有效,但要求模板作者有很强的自律性,必须保证手写代码写不进生成区,否则一覆盖就没了。后来我简化了一下,大多数场景下直接采用“生成到独立文件 + 手动复制少量内容”的方式,反而比强求自动化更省心。
2.4 命令行交互设计
t3code 的命令行入口是我花心思比较多的地方。没有做复杂的参数解析库,就用了 Python 标准库argparse,命令长这样:
t3code gen -t templates/entity.j2 -m models/user.yaml -o output/java/-t指定模板文件,-m指定模型文件,-o指定输出目录。参数越少越好,几个固定的槽位就够,不做花哨功能。我用了一段时间后又加了一个--list参数,专门列出当前项目下所有可用的模板和模型,方便团队其他人快速上手,不用去翻文档。
后来我还加了一个交互式引导,在没有任何参数的时候进入问答模式,一个个问“模板用哪个?模型用哪个?输出到哪个目录?”这样新同事第一次用的成本几乎为零,不用看任何说明文档。这也是我踩过坑之后才加上的——刚做完 t3code 的时候,团队里根本没人愿意用,他们说“还得记参数,还不如自己复制粘贴”,后来加了引导问答,情况立刻好转。工具要让人愿意用,体验和功能同等重要。
3. 实操过程:从零配置一个完整的 CRUD 模块
讲了大半天理论,不如看个完整的案例。这一步我拿“权限管理”里最常见的角色表来做演示,从模型编写、模板制作到最终生成代码,整个链路走一遍。看完之后你可以拿着这个流程照搬到自己的业务模块上。
3.1 编写角色表的模型描述文件
角色表sys_role结构很简单:主键、角色编码、角色名称、状态、排序、备注、创建时间、更新时间。我整理成如下的 YAML 保存到models/sys_role.yaml:
table: sys_role comment: 系统角色表 javaPackage: com.example.admin.entity fields: - name: id type: bigint comment: 角色ID primary: true autoIncrement: true - name: role_code type: varchar(64) comment: 角色编码,例如 admin nullable: false unique: true length: 64 form: input - name: role_name type: varchar(64) comment: 角色名称 nullable: false length: 64 form: input - name: sort type: int comment: 排序值,越小越靠前 default: 0 form: number - name: status type: tinyint comment: 状态 0-禁用 1-启用 default: 1 form: radio options: - { label: 禁用, value: 0 } - { label: 启用, value: 1 } - name: remark type: varchar(255) comment: 备注 nullable: true length: 255 form: textarea - name: create_time type: datetime comment: 创建时间 nullable: true form: datetime - name: update_time type: datetime comment: 更新时间 nullable: true form: datetime注意几个细节。第一,凡是数据库里可能为空的字段,我都在nullable: true里面标注了,这样后面生成校验注解时能自动区分必填和非必填。第二,每个字段都补了form参数,标明表单组的类型,这样前端模板就能直接选取对应的组件。第三,排序、状态这类字段单独标注了默认值,为后面生成“新增接口”提供默认参数。
3.2 制作实体类模板
实体类模板放在templates/java_entity.j2,内容是这样的:
package {{ javaPackage }}; import lombok.Data; import javax.persistence.*; import java.time.LocalDateTime; @Data @Table(name = "{{ table }}") public class {{ entityName }} { {% for field in fields %} @Column(name = "{{ field.name }}") {% if field.primary %}@Id{% endif %} {% if field.autoIncrement %}@GeneratedValue(strategy = GenerationType.IDENTITY){% endif %} private {{ field.javaType }} {{ field.name }}; {% endfor %} }这个模板里用了一个模型里没有直接给出的变量entityName。它是由sys_role转换来的驼峰写法SysRole。这种“命名转换”是模板引擎的常用手法,t3code 在渲染之前会自动给模型对象注入这类派生属性:把表名转类名、转字段名、转首字母小写格式。模板里就不用写复杂的转换逻辑了,只管消费这些现成的变量。
类型映射配置放在configs/type_map.yaml里,关键几行是这样:
java: bigint: Long varchar: String int: Integer tinyint: Integer datetime: LocalDateTime decimal: BigDecimal这个配置文件独立于模板和模型,想切换数据类型映射规则的时候只改这里,不需要动其他任何地方。
3.3 生成 VO、DTO 与查询参数
实际项目里很少直接拿实体类去接收前端的请求参数,通常还要有入参对象和出参对象。这类类结构高度相似,可以共用一个模板,通过传入参数来区别文件命名。我写了templates/java_vo.j2:
package {{ javaPackage }}; import lombok.Data; {% for field in fields %} {% if field.fieldValidate %} import javax.validation.constraints.*; {% endif %} {% endfor %} import java.io.Serializable; @Data public class {{ className }} implements Serializable { {% for field in fields %} {% if field.nullable == false %} @NotNull(message = "{{ field.comment }}不能为空") {% endif %} {% if field.length and field.length > 0 %} @Size(max = {{ field.length }}, message = "{{ field.comment }}长度不能超过{{ field.length }}") {% endif %} private {{ field.javaType }} {{ field.name }}; {% endfor %} }注意到没有?模板里用了field.nullable和field.length这两个属性去做逻辑判断,自动给字段追加校验注解。这比手写每个字段的校验要高效得多,而且改模型描述的时候校验规则自动跟着变,一致性天然有保障。
生成命令是:
t3code gen -t templates/java_vo.j2 -m models/sys_role.yaml -o output/java/vo/ \ --var className=SysRoleVO--var参数可以在命令行额外指定模板里缺失的变量,这个设计很灵活,能满足不同场景下对类名、包名的特殊要求。
3.4 生成前端列表页与表单页
后端类生成完之后,前端页面才是重头戏。早期我手写管理后台的列表页,一个字段一个瞎写,光是表格列就得磨上半天。后来用 t3code 做了个 Vue 3 + Element Plus 风格的列表页模板,写一次模板,所有列表页都自动化了。
模板核心片段长这样:
<template> <div class="page-container"> <el-card> <el-form inline> {% for field in fields %} {% if field.formQuery %} <el-form-item label="{{ field.comment }}"> {% if field.form == 'input' %} <el-input v-model="queryParams.{{ field.name }}" placeholder="请输入{{ field.comment }}" clearable /> {% elif field.form == 'select' or field.form == 'radio' %} <el-select v-model="queryParams.{{ field.name }}" placeholder="请选择{{ field.comment }}" clearable> <el-option v-for="item in {{ field.name }}Options" :key="item.value" :label="item.label" :value="item.value" /> </el-select> {% endif %} </el-form-item> {% endif %} {% endfor %} <el-form-item> <el-button type="primary" @click="handleQuery">查询</el-button> </el-form-item> </el-form> <el-table :data="tableData" stripe> {% for field in fields %} <el-table-column prop="{{ field.name }}" label="{{ field.comment }}" /> {% endfor %} <el-table-column label="操作" width="200"> <template #default="scope"> <el-button type="primary" link @click="handleEdit(scope.row)">编辑</el-button> <el-button type="danger" link @click="handleDelete(scope.row)">删除</el-button> </template> </el-table-column> </el-table> </el-card> </div> </template>这里模板里用到field.formQuery这个属性,是模型解析阶段根据字段属性自动推断的。主键、时间字段默认不进查询条件,状态、编码这类才进入查询区。这种方式的好处是前端模板也是一个通用的“渲染规则”,规则集中维护,改一处全局生效。
生成前端文件:
t3code gen -t templates/vue_list.j2 -m models/sys_role.yaml -o output/web/views/sys/ \ --var pageName=role一次生成得到的 index.vue 里表格列、查询条件、编辑按钮都齐了,手动要做的事情基本只剩接入接口 API。
3.5 生成之后的人工整合环节
必须承认,任何代码生成器都不可能完全免掉人工操作。t3code 聚焦的是“结构化代码的批量产出”,生成完之后,你仍然需要做这几件事:把生成的文件按项目分层结构放进正确的目录;把对应的路由注册配置补上;如果模板没有覆盖到特殊交互,得在生成的代码基础上继续写。
我自己一般约定“文件落盘后默认进入人工复核阶段”。生成器负责把重复劳动降到最低,但业务逻辑的判断权必须留在人这里。比如列表页的删除按钮,如果角色被用户绑定,删除前需要二次确认并调后端检查接口,这种逻辑就要手写,但手写成本远比从零开始写低得多——因为表格、表单、弹窗这些骨架已经都齐了。
4. 实际使用中的常见问题与排查记录
用了大半年,t3code 帮团队省了非常多的时间,也积累了一堆问题和对应的处理办法。这部分挑几个典型问题写出来,都是实际踩过的坑。
4.1 模板变量名冲突
模板文件里名为name或者type的变量非常容易跟 Jinja2 的内置变量或者模型自身的默认属性重名。有一次我在模板里写了{{ type }}想渲染字段类型,结果渲染出来的是模型的顶层类型标识,排查了半天才发现是变量冲突。后来我明确规定:模型属性统一加前缀f_,比如f_name、f_type、f_comment,模板里永远使用全名引用。这个看似有点麻烦的规范,直接消灭了所有类似问题。
4.2 空白控制导致的格式问题
前面提到过 Jinja2 的空白控制。使用{%- -%}这种语法的时候,只要有一个地方少写了-,生成的代码就会多出无数空行,检查起来非常痛苦。我的排查经验是:先关掉所有空白控制,看看哪个标签附近出现多余空行,再针对性修改这个位置的语法。别全模板替换,容易产生新的格式问题。这个排查思路虽然简单,但能省很多时间。
4.3 文件覆盖误伤
这是使用成本最高的一类问题。生成的 Service 实现类被手写业务逻辑填满了,结果一次误操作重新生成,全部被覆盖回初始状态。这个问题的根源在于我早期所有文件都用了overwrite模式。后来对写策略做了调整,只有纯数据类文件允许覆盖,凡是“人类需要持续写入”的文件一律用skip模式或者只生成到临时目录,确认无误后手工合并。宁可多一步人工操作,也绝不能让生成器毁掉手写代码。
4.4 模板维护不及时导致的不一致
团队里有几个人各自维护不同模板,改了一处没同步另一处,导致不同模块生成的代码风格不统一。后来我把模板仓库集中到 Git 管理,并约定任何模板修改必须过代码评审。这听起来有点行政化,但实际的收益非常显著——模板是团队的公共资产,它决定了线上代码长什么样,这类资产的变更质量比业务代码变更更值得守规矩。
4.5 类型映射遗漏
数据库里有json类型字段,而类型映射表里没有配,结果生成出来的 Java 属性是 Object,前端渲染直接报错。我现在的类型映射表里内置了常见数据库类型的默认映射,同时支持用户在映射配置里追加自定义规则。排查思路就是:生成完代码后全局搜一遍Object或者any这类“兜底类型”,凡是出现这类字样的字段,一定是类型映射没配好。
我把这些问题整理成一张速查表,给团队新人也用得上:
| 常见问题 | 典型原因 | 快速处理办法 |
|---|---|---|
| 生成文件格式乱 | 模板空白控制不当 | 单独检查每个{% %}附近的空白控制-标记 |
| 变量渲染成奇怪值 | 变量名与内置属性冲突 | 模型属性统一加f_前缀,禁用裸变量名 |
| 手写代码被覆盖 | 文件策略选了 overwrite | 改为 merge 或手动合并模式 |
| 类型映射缺失 | type_map 未配置 | 搜索兜底类型全局排查 |
| 模板修改影响范围大 | 模板拆分不够细 | 一个模板只负责一类代码,保持小粒度 |
5. 从 t3code 延伸出的思考与个人经验
工具本身讲得差不多了,最后聊聊方法论层面的一些体会。做代码生成器这件事,最大的陷阱不是技术实现,而是“边界感”。
5.1 生成器的边界在哪
坦白说,我早期犯过一个很严重的错误:想把所有东西都塞进生成器里,包括鉴权逻辑、日志切面、分布式事务回调。结果模板越写越复杂,模型文件里塞满了跟业务字段无关的框架参数,生成的代码可读性越来越差,最后退化成“生成一下,然后推倒重写”。这个教训让我明白,生成器应该只负责“结构明确、规则固定”的那部分代码生成,凡是“需要业务灵感、需要上下文判断”的逻辑,都应该留给人工。
具体到这个案例,角色管理模块里的“新增角色时初始化角色数据权限”这种逻辑,就不应该进模板。模板里的东西应该是恒定的结构,而业务规则是变动的语义。这两者混在一起,模板就失去复用价值了。
5.2 小步快跑的迭代方式
如果你也想做一个类似的工具,我强烈建议你不要一开始就想“做一个完整的生成器平台”,而是从一个最迫切的痛点出发,比如“这周又要写 5 张表的 CRUD”,那就先做一个实体类生成器,解决这一件事。跑顺之后,再慢慢往里补 VO 生成、补前端表格、补表单页面。t3code 的每个阶段新增功能都不大,但每一步都解决了实实在在的问题,工具是跟着需求长出来的,不是一次性设计出来的。
5.3 模板与模型的资产化意识
代码生成器的真正资产不是那份工具代码,而是沉淀下来的模板和模型描述文件。新项目进来,只需要把模型文件写出来,模板一套,代码自动就有雏形。模型文件描述的是业务结构,它本身就是一份高质量的、可读性很好的领域文档。这个资产的价值不是一次性的,它会随着每个新项目的推进而不断积累。等到模板库覆盖了你团队常见的技术栈和业务模式,新人上手项目的速度会有质的提升。
关于模板管理,我还想提一个细节:一定要为模板保留版本记录。团队里发生过因为改了模板顺手改了命名规范,导致旧模块重新生成时代码风格跟历史代码不一致。有了模板版本记录,回溯和对比的成本就会小很多。
5.4 后续可能的扩展方向
目前 t3code 停留在“本地命令行工具”的阶段,后续有些方向值得考虑。比如接入数据库元数据自动生成模型描述文件,省去手工写 YAML 的步骤;再比如接上 Git 预提交钩子,代码生成后自动跑一遍格式化命令;又或者做一个简单的 Web 界面,让非技术团队也能填写模型文件生成简单页面。这些都是合理的扩展方向,但要记住前面讲的边界原则——每一步扩展都应该先回答一个问题:这个功能到底能不能显著减少重复劳动,如果不能,就不做。
做工具和做业务代码是两种不同的思维模式。写业务代码关注的是“把这个功能实现出来”,做工具关注的是“如何让一类功能重复实现的成本无限趋近于零”。两者没有高下之分,但思维方式差异很大。t3code 这个项目的意义不在于他本身有多复杂,而在于它帮我把很多已经写过的代码从脑海里搬了出来,变成了可以反复调用的规则。这种感觉,跟第一次封装出自己的前端组件库时有点像——原来重复的工作堆在一起,居然能沉淀出一点可以复用的东西。
如果你现在正被大量结构相似、内容重复的代码折磨,不要急着复制粘贴,先停下来想想:这部分重复能不能抽象成模板?需要变化的点能不能抽成参数?如果可以,花半天时间做一个简单的生成器,收益能持续到下一个项目。t3code 的代码量不大,核心逻辑可能比你想象中还要简单,但它带来的效率提升是实打实的。工具不需要有多大的野心,能持续解决一个问题,就是好工具。