t3code实战:模板驱动代码生成,告别重复CRUD
项目正文:t3code是一个帮开发者把重复代码生成这件事变成"写配置"的开源命令行小工具。核心思路是:你维护一份模板定义,它按约定帮你批量产出结构一致、细节各异的业务代码。适合后端接口开发、前端页面脚手架、脚本任务模板等场景。本文从一个真实项目实践出发,讲清楚t3code的设计取舍、完整实操过程、避坑经验和团队落地技巧。
1. 我为什么动手搞了一个叫 t3code 的东西
先交代一下背景。我们团队维护着一套电商后台管理系统,光核心业务模块就有二十多个:订单、商品、用户、优惠券、库存、售后……
听起来挺多,但真正做过的人都知道,这些模块的代码长得几乎一模一样。
1.1 从一次让人崩溃的需求变更说起
有一回产品要改"优惠券"模块的字段,要求在券面信息里增加一个"适用范围"的枚举。我当时的操作流程是这样的:先打开 Entity 实体类,加字段;再打开 VO,加字段;再打开 DTO,加字段;再打开 Converter,加字段映射;再去 Service 接口加方法;再去 ServiceImpl 加实现;再改 Controller 的入参出参……前后动了十几个文件。
改完优惠券,产品又说"库存模块也要加同样的字段"。我内心是拒绝的,但没办法,又重复了一遍同样的操作。这中间最怕的是什么?是漏改。你改了 Entity 忘了改 VO,编译可能不报错,但接口文档和前端就莫名其妙少了字段,等联调的时候才暴露出来,返工成本极高。
这种场景我相信大家都不陌生。复制粘贴十次,第九次一定会漏东西,跟细心不细心没关系,这是人力做机械重复劳动时的天然缺陷。
1.2 我想要的工具长什么样
当时我脑子里冒出来的需求很朴素:我想要一个命令,输入模块名和几个关键字段,它能自动把这一整套文件都生成好,结构统一、命名规范、字段齐全,我只需要在生成之后去填具体的业务逻辑。
市面上其实有不少代码生成器,比如各种 MyBatis Generator、低代码平台、脚手架工具。但我用下来总觉得有点"重":要么绑定了特定 ORM,要么需要在网页后台配数据库表,要么模板写起来要单独学一套复杂的 DSL。我只是想要一个轻量的、放在项目里随取随用的东西。
于是就有了 t3code。它不是一个商业产品,就是一个我自用、后来开放出来的小工具。核心定位八个字:模板驱动、约定优先。
1.3 t3code 解决的三个核心问题
回过头看,t3code 主要解决了三个团队协作层面的问题:
第一,消除复制粘贴的漏改风险。生成代码的源只有一个——模板和配置。配置里写了这个字段,所有关联文件都会带上;没写,就都没有。不会出现实体类改了、VO 忘改的尴尬。
第二,统一团队代码风格。新来的同事只要照着约定写一份配置,生成的代码跟老员工写出来的结构一模一样。代码评审的时候不用再为了"为什么你这个 service 方法没有统一返回体"这种事争论半天。
第三,把规范变成工具而不是文档。我们团队之前有个《接口开发规范》文档,写了三千多字,说实话没几个人会去看。现在规范直接焊死在模板里,工具生成的就是符合规范的代码,文档反而成了补充参考。
2. t3code 整体设计与核心机制拆解
聊完了动机,说一说这个工具内部是怎么设计的。这不是教科书式的架构图,就是我在实际写代码过程中的真实取舍。
2.1 模板引擎:为什么我只用最朴素的字符串插值
t3code 的核心是一个模板解析引擎。最初我想过用一套现成的模板引擎,比如 JavaScript 生态里很成熟的 EJS、Handlebars,或者后端同学熟悉的 Velocity、FreeMarker。但最后我选择了自己实现一套非常轻量的语法,只支持三种能力:
- 变量插值:
{{ variable }} - 条件判断:
{{#if condition}} ... {{/if}} - 简单遍历:
{{#each list}} ... {{/each}}
为什么这么"简陋"?因为代码生成这个场景里,模板的逻辑复杂度天花板很低。你想想,一个 Controller 文件的模板里,最复杂的逻辑不过就是"如果有分页需求,就生成分页请求参数;如果有导出需求,就生成导出接口"。这些用 if 和 each 完全可以覆盖。
如果用功能完整的模板引擎,反而会带来两个问题。第一是模板文件会被写得越来越复杂——有人真的会在模板里写循环嵌套循环,生成出来的代码根本没法看,调试难度爆炸。第二是学习成本,团队里的同事不一定要会前端模板语法,越简单越好理解。
设计原则就一条:模板保持"看起来就像最终代码"。一个 Controller 的模板,哪怕扔给一个完全没接触过 t3code 的人看,他也能一眼看出这大概是个 Controller,只是有些地方用花括号占位了。这样的模板才容易维护。
2.2 一条配置搞定整套模块生成
t3code 的工作方式特别直白,你给它一份配置文件,它按配置文件里的约定去读模板、填变量、输出目录。配置文件长这样:
// t3code.config.js module.exports = { // 生成的代码根目录 output: 'src', // 生成规则,可以配置多组 rules: [ { // 功能模块名,比如 order、product、coupon name: '{{module}}', // 模板目录:templates/rest-controller 下放着 .t3 后缀的模板文件 templateDir: 'templates/rest-controller', // 输出目录映射,模板名 -> 最终文件名 outputs: { 'Controller.t3': 'controller/{{Module}}Controller.java', 'Service.t3': 'service/{{Module}}Service.java', 'ServiceImpl.t3': 'service/impl/{{Module}}ServiceImpl.java', 'Entity.t3': 'entity/{{Module}}Entity.java', 'VO.t3': 'vo/{{Module}}VO.java', 'DTO.t3': 'dto/{{Module}}DTO.java' } } ] };你发现没有,配置本身也充满了"约定"。我没有设计复杂的 schema 校验,就是几个固定的字段:name是模块名,templateDir指向模板文件夹,outputs是模板到目标文件的映射。
变量来源则是命令行传入的 JSON。比如运行:
t3code run --name coupon --data '{"fields":[{"name":"scope","type":"String","comment":"适用范围"}],"hasPage":true}'工具就会读取templates/rest-controller下的模板,把{{module}}替换成coupon、{{Module}}替换成Coupon,然后按outputs的映射生成六个文件,从 controller 到 vo 全部到位。
2.3 命名规则背后的"约定优于配置"
这里有个细节我觉得值得展开讲讲,就是命名转换。代码生成工具做得难用,十有八九是命名转换没做好。
数据库里是coupon_scope,Java 里要couponScope,前端 JS 里要couponScope,文件目录有时候要coupon-scope,有时候要CouponScope。如果这些转换都要开发者在配置里手写,那配置会爆炸——每生成一个模块就要写一堆映射。
t3code 的做法是内置一组命名转换规则,你只需要在模板里写不同的占位符风格:
| 占位符写法 | 转换结果 | 适用场景 |
|---|---|---|
{{module}} | coupon | 包名、目录名 |
{{Module}} | Coupon | 类名、文件名 |
{{MODULE}} | COUPON | 常量前缀 |
{{module_var}} | couponScope | 变量名、字段名 |
比如一个 Java 字段声明,模板里写:
private String {{fieldVar}};如果你在配置数据里传的是"field": "scope",生成出来就是private String scope;。如果你传的是"field": "coupon_scope",工具会自动转成驼峰couponScope。
这个小设计省了团队里大量的沟通成本。大家约定好,配置数据一律用"字段原始名",代码里的命名规则由工具负责。
2.4 目录归属:永远不生成"孤儿"文件
还有一个我自己踩过坑之后加进去的设计:目录完整性检查。
早期版本 t3code 就是傻乎乎地按 outputs 映射生成文件,结果经常出现一个问题——生成了controller/CouponController.java,但controller目录在项目里压根不存在,工具也不会给你建,最终文件散落一地,或者直接报错。
后来我改成:生成之前先检查输出路径,如果目录不存在就自动创建。同时支持一个--base参数,指定生成代码的根路径,让整个模块的文件都聚在一个目录树下。例如--base src/main/java/com/company/project/module,那么 coupon 模块的所有文件就都在.../module/coupon/下面,内聚性比散落在各个通用目录里好得多。
这是结构设计上一个比较重要的决定:让每个功能模块自包含。后续如果想做模块的独立部署,或者直接复制某个模块目录到另一个项目,都会非常方便。
3. 从零开始:t3code 的完整实操全过程
理论说了一堆,这部分直接上手。我假设你已经有一个 Spring Boot 项目(或者任意 Java 工程),想用 t3code 给"商品评价"模块生成一整套接口代码。
3.1 环境准备与安装
t3code 基于 Node.js 实现,安装之前先确认环境:
node -v建议 Node.js 16 及以上版本。然后全局安装:
npm install -g t3code安装完之后验证一下:
t3code --help如果能看到Usage: t3code [options] [command]这样的输出,就说明安装成功了。整个工具是零依赖的运行时方案,不需要额外装 Java、Python 之类的环境,这也是我选 Node.js 实现的主要原因之一——只要有 Node 就能跑,团队里前端同事和后端同事都不会有环境障碍。
3.2 编写你的第一套模板
安装好了,我们来写模板。新建一个目录templates/review,在里面创建一个文件Controller.t3:
package {{packageName}}.controller; import com.company.project.framework.web.Result; import com.company.project.framework.web.PageResult; import {{packageName}}.dto.{{Module}}DTO; import {{packageName}}.vo.{{Module}}VO; import {{packageName}}.service.{{Module}}Service; import org.springframework.web.bind.annotation.*; import org.springframework.beans.factory.annotation.Autowired; /** * {{Module}}模块 接口入口 */ @RestController @RequestMapping("/api/{{module}}") public class {{Module}}Controller { @Autowired private {{Module}}Service {{module}}Service; /** * 查询{{module}}详情 */ @GetMapping("/{id}") public Result<{{Module}}VO> getById(@PathVariable Long id) { return Result.success({{module}}Service.getById(id)); } /** * 分页查询{{module}}列表 */ @GetMapping("/page") public PageResult<{{Module}}VO> page({{Module}}DTO dto) { return {{module}}Service.page(dto); } /** * 创建{{module}} */ @PostMapping public Result<Long> create(@RequestBody {{Module}}DTO dto) { return Result.success({{module}}Service.create(dto)); } /** * 更新{{module}} */ @PutMapping("/{id}") public Result<Boolean> update(@PathVariable Long id, @RequestBody {{Module}}DTO dto) { return Result.success({{module}}Service.update(id, dto)); } /** * 删除{{module}} */ @DeleteMapping("/{id}") public Result<Boolean> delete(@PathVariable Long id) { return Result.success({{module}}Service.delete(id)); } }敏感的读者应该看出来了,这个模板里用到的变量有{{packageName}}、{{Module}}、{{module}}三类。{{packageName}}是包名,需要额外指定;后两个是从模块名自动转换来的。
同理,再写一个Service.t3模板和数据层的Entity.t3模板。这块我就不全部贴出来了,写法完全一样,把占位符填进去即可。
3.3 配置文件与命令行参数详解
模板准备好了,下一步是配置规则。在项目根目录创建t3code.config.js:
module.exports = { base: 'src/main/java/com/company/project/module', rules: [ { name: '{{module}}', templateDir: 'templates/review', outputs: { 'Controller.t3': '{{module}}/controller/{{Module}}Controller.java', 'Service.t3': '{{module}}/service/{{Module}}Service.java', 'ServiceImpl.t3': '{{module}}/service/impl/{{Module}}ServiceImpl.java', 'Entity.t3': '{{module}}/entity/{{Module}}Entity.java', 'Mapper.t3': '{{module}}/mapper/{{Module}}Mapper.java' } } ] };然后执行生成命令:
t3code run --name review --packageName com.company.project.module.review这里--packageName是我们在模板里用到的一个自定义变量。t3code 支持任意额外的--key value参数,都会传给模板引擎。
然后你再看看src/main/java/com/company/project/module/review/目录下,Controller、Service、ServiceImpl、Entity、Mapper 五个文件全都已经生成了,而且目录结构也是自动创建的。
如果你不确定配置有没有写错,可以先加--dry-run参数试运行:
t3code run --name review --dry-run--dry-run不会真正写入文件,只会在终端打印出"哪一步会生成什么文件、内容是什么",适合改动模板或者新增规则之后先验证一遍。
3.4 必要时才覆盖:--force 参数的正确用法
实际开发中经常遇到一种情况:模块已经生成过了,后来发现 Entity 里少了一个字段,于是你改了模板,想重新生成一次。但 t3code 默认行为是:目标文件如果已存在,直接跳过,不覆盖。
这个设计是为了防止误操作把已经写好的业务逻辑全部冲掉。比如 ServiceImpl 生成之后,开发者在里面加了很多业务代码,这时候你重新跑一次生成,如果直接覆盖,那些手写的逻辑就全没了。这种事故一次都别发生。
所以如果你确定要覆盖,必须显式加--force:
t3code run --name review --force我的建议是:只有当你明确知道这个文件还没有被改动过,或者说改动可以不要了,才用--force。文件已经写了业务逻辑,就老老实实手工改文件,不要依赖工具去覆盖。
3.5 用钩子把生成的代码自动格式化
代码生成出来之后,还有一个问题:缩进、换行可能跟团队的格式化配置不一致。我见过太多工具把代码生成得七扭八歪,放到 IDE 里满屏黄色警告。
t3code 的处理方式是在生成流程里加了钩子机制,内置了一个--format参数,它在生成完成后自动执行prettier --write或eslint --fix。具体执行什么命令,可以在配置文件里指定:
module.exports = { formatCommand: 'prettier --write', formatExtensions: ['.java', '.js', '.ts', '.vue'] };生成完代码,工具会把本次生成的所有文件拿过来跑一遍格式化命令,保证产出的代码风格统一。这里强烈建议在团队规范里强制开启--format,否则不同的开发机器上生成出的代码可能有细微差异。
4. 常见问题与排查技巧实录
工具用起来之后,团队里陆陆续续遇到一些问题。这部分我整理成了速查表,都是真实发生过的坑。
4.1 模板变量不生效,卡了大半天
最经典的一个问题:同事说"我明明在配置里写了{{Module}},为什么生成出来还是原样?"
排查下来发现,他在模板里写的是{{Module}},但在命令行传参时写的是--module,注意大小写不同。t3code 的变量是严格区分大小写的,{{Module}}和{{module}}是两个完全不同的变量名。
这类问题最好的解法不是在工具里做容错,而是在模板文件顶部写清楚"本模板使用到的变量":
{{! 变量说明: module: 模块名(小写) Module: 模块名(帕斯卡) packageName: 包名 }}模板引擎会忽略以{{!开头的注释行,但看模板的人一眼就能知道有哪些变量可以填。
4.2 数据库下划线字段映射到前端时,命名对不上
前端同事接手一个生成好的页面,发现接口返回的字段是couponScope,但前端组件里绑定的是coupon_scope。这种问题的根源在于:生成 VO 时用的字段名转换策略,跟前端消费接口时用的字段名策略不一致。
t3code 默认把数据库下划线字段转成 Java 驼峰,但如果你需要保留下划线风格,可以在配置里指定字段风格:
module.exports = { fieldStyle: 'camelCase', // 可选: camelCase | snake_case };前端同事的项目如果统一用 snake_case,那就两边约定好都用fieldStyle: 'snake_case',生成出来的 VO 字段就叫coupon_scope。关键是一旦定了,整个团队必须统一,不要一个模块一个风格。
4.3 模板里写相对路径,生成的 import 老出错
这个问题特别隐蔽。比如模板里生成 Controller 时,引用 Service 的 import 是这么写的:
import {{packageName}}.service.{{Module}}Service;这个在绝大多数情况下没问题,但如果你的项目改了base目录,或者模块所在的包名层级变了,import 就全部失效。编译报一堆红叉,非常吓人。
我的建议是:模板里尽量使用完全限定名(全类名),虽然长一点,但绝对不会因为路径相对关系出错。配合 IDE 的自动 import 整理,写的时候麻烦一点,生成之后省心很多。
4.4 git 提交时整个文件 diff 炸了
团队里有个同事反馈:"明明只是加了一个注释,git diff 出来却显示整个文件全改了。" 我第一反应是换行符问题。Windows 上默认是 CRLF,Linux 上是 LF,而 t3code 在生成文件时默认用的是 LF。
解决方案是在项目根目录加一个.gitattributes文件:
* text=auto eol=lf强制所有文本文件以 LF 存储。如果团队里有 Windows 同学,建议把 t3code 的lineEnding配置成lf:
module.exports = { lineEnding: 'lf' };统一之后,git diff 就清爽了,只显示真正有改动的行。
4.5 生成大量文件是不是会让工程构建变慢?
有人问过:"每次生成这么多文件,如果模板很多,会不会影响编译速度?" 这个问题其实跟 t3code 本身没关系,生成的文件就是普通的源码文件,编译速度和手写的文件没有任何区别。之所以感觉"慢",八成是 IDE 在文件新增时做索引,一会儿就自己好了。
5. 把 t3code 用出花来:团队级落地技巧
工具本身不难,难的是怎么在团队里真正跑起来。这一部分分享我在实际推动 t3code 落地过程中的几个小技巧。
5.1 生成后立刻检查:把 t3code 接进 pre-commit
我们团队接入 t3code 之后,最担心的一件事是:有人手工改了模板生成的文件,但改得不规范,提交上去之后后续再生成就会冲突。
所以我把 t3code 的核心能力扩展成了一个check模式:
t3code check --name review这个命令不生成文件,而是对比"如果现在用模板重新生成,内容和当前文件有没有差异"。如果有差异,说明当前文件被改过了,命令会列出差异点。
在pre-commit钩子里加上一段检查:
{ "hooks": [ { "id": "pre-commit", "name": "t3code-check", "command": "t3code check --all" } ] }这样如果有人改了生成文件的公共部分(比如把 Result 换成了另一个返回体),pre-commit 会提示他:这个文件与模板不一致,请确认是否要手工修改。这个机制比代码评审更前置,也更好执行。
5.2 把团队规范焊死在模板里
很多团队写代码规范文档,但真正严格执行的少。我的经验是:能通过工具解决的规范,不要靠人。
举个例子,公司要求所有 Controller 都要返回统一结果体Result<T>。这个规范在 t3code 模板里直接写死了,生成的代码不可能不遵守。又比如所有接口都要有 Swagger 注解,模板里就把@ApiOperation加上:
@ApiOperation("查询{{Module}}详情") @GetMapping("/{id}") public Result<{{Module}}VO> getById(@PathVariable Long id) {新人如果自己手写代码,很有可能忘记加注解,但生成代码永远不会忘。
这就是我把 t3code 定位成"团队规范的载体"的原因——模板不是死的,它是活的,是团队的工程效率资产。
5.3 增量演进:模板要分"稳定区"和"易变区"
用久了你会发现,模板里的内容其实分成两类。一类是非常稳定的,比如 Controller 的@RestController、@RequestMapping、统一返回体,这些几乎永远不会变。另一类则是容易变化的,比如"分页查询的参数是 DTO 还是直接传 Page"、"创建接口返回的是 ID 还是完整 VO"。
我的建议是:每个模板内部用条件判断把易变区显式标出来。比如:
{{#if hasPage}} /** * 分页查询{{module}}列表 */ @GetMapping("/page") public PageResult<{{Module}}VO> page({{Module}}DTO dto) { return {{module}}Service.page(dto); } {{/if}}当接口暂时不做分页,运行时不传hasPage,这段代码就不会生成。这样模板的稳定区保证基础质量,易变区通过参数控制灵活取舍,一套模板可以适配多种需求。
5.4 权限设计与安全边界
代码生成工具还有一个容易忽略的点:生成的文件是否会自动覆盖敏感信息。比如 Controller 层如果涉及用户上传文件的接口,模板里就不应该自动加权限校验注解,而是生成后再手动补充,避免批量生成时把所有接口都暴露出去。
这个属于模板设计层面的安全意识。我一般在模板里默认不加权限控制,留一个{{#if auth}}条件:
{{#if auth}} @PreAuthorize("hasAuthority('{{module}}:manage')") {{/if}}需要权限的接口传入--auth true,不需要的就不传。宁可手动添加,不要默认放开。
5.5 模板版本化:别让模板改崩了全部模块
团队规模大了之后,模板肯定会迭代。最常见的问题:你更新了一个模板,重新跑了生成命令,结果影响到了所有历史模块,产生大量 diff。这些事情不是 bug,但很烦人。
所以我把 t3code 的模板目录也纳入了 git 管理,并且约定:模板变更必须走 MR(合并请求),要有代码评审,不能直接改完就批量生成。另外强调一点,重新运行生成命令前,一定要用--dry-run先看影响范围。
6. 个人使用体会与后续方向
最后聊一点我自己的体会。
做了 t3code 之后,我对"代码生成"这件事的看法变化挺大。以前总觉得生成代码是"偷懒",是做技术的人不太愿意提起的话题。但实际落地之后发现,代码生成的价值不在于帮你写代码,而在于帮你把"怎么做"的决策从每个开发者脑子里搬到了模板里。
团队里的新人上手效率明显提高了。以前一个新同学接到"做一个新模块"的需求,得先去翻老代码看人家是怎么写的,照葫芦画瓢。现在直接跑一条命令,出来一套骨架代码,他只需要钻到业务逻辑里去填东西就好。这个落差还是很直观的。
后续我准备做的方向是:让 t3code 支持从数据库连接直接读取表结构,自动生成字段配置,免得每次都要手动在命令行里填写字段列表。另外一个想法是做一个简单的 web 界面,可视化地编辑模板和配置,让不熟悉命令行的同事也能用起来。
如果你也在为重复代码发愁,我建议先别急着上什么重型平台,拿一个顺手的小工具,把自己的项目里重复度最高的那部分代码抽成模板,跑通了再慢慢扩展。工具永远是为效率服务的,别让使用工具本身变成一种负担。