t3code 代码生成器实战:从零搭建轻量级模板引擎
1. 从“t3code”这个关键词说起它到底指什么第一次看到“t3code”这个词很多人会一头雾水。它不像“Vue”“Docker”那样有明确的官方定义也不像“LeetCode”那样有清晰的平台指向。我在几个技术社区翻了一圈发现大家对这个词的使用场景其实相当分散有人拿它当某个内部工具链的代号有人用它指代一套轻量级的编码规范还有人把它当成“Type 3 Code”的缩写用来描述某种介于伪代码和可执行代码之间的中间态表达。先把话说在前面“t3code”并不是一个业界标准术语它更像是一个在特定团队、特定项目里约定俗成的叫法。这一点很关键因为如果你把它当成某个现成的框架去搜文档大概率会扑空。我踩过的第一个坑就是——花了大半天去找“t3code 官方文档”结果发现根本没有统一入口。那它通常出现在哪些场景里根据我的观察和实际接触主要有三类内部脚手架/代码生成器的别名一些团队会把自研的模板代码生成工具叫做 t3code取“template 3 code”或者“type-to-code”的意思输入一个类型定义或配置输出一套可运行的骨架代码。轻量编码约定的代称在代码评审里有人用“t3code”来指代一套“三层结构、三段式命名、三行以内注释”的简化规范强调代码要短、要直白、要能一眼看懂。教学/演示用的中间代码在讲编译原理或转译器的时候老师会把介于高级语言和机器码之间的那层表示叫做 t3code类似“第三层代码”的说法。所以这篇内容我不会假装它是某个知名开源项目而是把它当成一个“你可能在团队里遇到、需要自己动手落地”的工程实践主题来写。适合谁看如果你正在搭内部工具链、想统一团队的代码风格、或者需要做一个“输入配置就吐代码”的小工具那这篇的实操思路可以直接拿去改。提示如果你所在团队已经有一个叫 t3code 的私有项目那下面的内容可以当作对它的一次“反向拆解”帮你判断现有实现有没有走弯路。2. 为什么值得自己动手做一套 t3code 式的代码生成很多人第一反应是代码生成器不是有现成的吗Swagger Codegen、Yeoman、Plop、Hygen随便挑一个不就行了。这话没错但我在实际项目里发现通用工具最大的问题是“通用”本身——它们为了覆盖所有场景配置项多到吓人学习成本高而且生成出来的代码往往带着一股“模板味”跟团队现有风格对不上。t3code 式做法的核心价值恰恰在于“窄”。它不追求支持十种语言、二十种框架而是只解决你团队最高频的那一两个重复劳动。我举个真实例子之前做一个后台管理系统每个新模块都要写一遍 controller、service、dao、dto、mapper 这五件套字段还都差不多。手动写一个模块平均 40 分钟一天下来光复制粘贴就耗掉小半天。后来用 t3code 的思路做了个生成器输入一个 JSON 描述10 秒出全套代码剩下的时间全用来写真正的业务逻辑。具体来说自己动手做有四个实打实的好处第一风格完全可控。生成出来的代码就是团队自己的风格缩进、命名、注释格式、异常处理方式全都按现有规范来。新人拿到生成结果直接照着改就行不用再被“这个模板怎么跟我们的写法不一样”困扰。第二维护成本低。通用工具的模板是别人维护的你想改一个细节得去啃它的模板语法。自己的 t3code模板就是几个文本文件改起来跟改普通代码没区别。第三能嵌入现有流程。可以做成命令行工具也可以挂到 IDE 的快捷指令上甚至集成到 CI 里做校验。通用工具想做到这一点往往要写一堆适配层。第四知识沉淀。生成器的模板本身就是团队最佳实践的固化。哪个字段该加索引、哪个接口该做幂等、异常怎么抛全写在模板里。时间一长这套模板就成了团队的技术资产。当然也不是所有场景都值得做。我的判断标准很简单如果某个重复劳动一周内出现超过 5 次且每次结构高度相似那就值得做成 t3code。反之如果只是偶尔写一次或者每次差异都很大那手动写反而更快。2.1 一个反直觉的结论模板越少越好刚开始做的时候我总想着“多支持几种情况”于是给模板加了一堆 if-else 分支。结果三个月后回头看模板文件里全是条件判断改一个地方要测五种组合维护成本比手动写还高。后来我调整了策略一个生成器只对应一种“标准形态”特殊情况一律不生成手动补。比如生成 CRUD 代码时只处理最普通的单表增删改查涉及多表关联、复杂事务的生成器直接跳过提示“此场景请手动实现”。这样一来模板干净了生成结果的正确率也上去了。这个经验我觉得挺重要t3code 的定位是“处理 80% 的常规情况”而不是“覆盖 100% 的场景”。想清楚这条边界后面设计起来会轻松很多。2.2 和低代码平台的区别在哪有人会问这不就是低代码吗我的理解是低代码平台是“用配置代替编码”目标是让不懂代码的人也能搭应用而 t3code 是“用生成代替重复编码”目标用户仍然是开发者只是把机械劳动自动化了。区别体现在结果上低代码平台产出的东西往往绑定平台运行时想脱离平台很难t3code 产出的是纯原生代码生成完就跟手写的一样后续想怎么改就怎么改没有任何运行时依赖。这一点对长期维护来说非常关键——你不会被某个平台锁死。3. 拆解 t3code 的核心构成输入、模板、输出一套能用的 t3code结构其实很朴素就三块输入描述、模板引擎、输出落盘。但每一块里都有不少细节值得抠。3.1 输入层用什么格式描述“要生成什么”输入格式的选择直接决定了使用体验。我试过三种输入方式优点缺点适用场景JSON 配置文件结构清晰易校验手写略繁琐字段较多的实体生成命令行参数快捷适合简单场景参数一多就难记单文件、少量参数交互式问答对新手友好无法脚本化批量执行偶尔使用、探索性生成我最终选的是JSON 为主、命令行参数为辅的组合。JSON 负责描述实体结构命令行参数负责指定输出路径、是否覆盖等运行时选项。这样既能脚本化批量生成又保留了灵活性。一个典型的输入 JSON 长这样{ entity: User, table: t_user, fields: [ { name: id, type: long, primary: true }, { name: userName, type: string, length: 64, comment: 用户名 }, { name: email, type: string, length: 128, comment: 邮箱 }, { name: createdAt, type: datetime, comment: 创建时间 } ] }这里有个细节字段类型不要直接用目标语言的类型而是用一套中间类型long、string、datetime 这种。因为同一份输入可能要生成 Java 代码也可能要生成 TypeScript 前端类型中间类型起到解耦作用。这个设计是我踩坑之后才加的——最早直接写 Java 类型后来想生成前端代码时发现完全复用不了。3.2 模板层选什么引擎怎么组织模板模板引擎的选择上我用过 Freemarker、Velocity、Handlebars 和 Mustache。如果只做代码生成我的推荐顺序是Handlebars Mustache Freemarker。原因在于代码生成这个场景模板里需要的是“变量替换 简单循环 条件判断”不需要太复杂的逻辑。Handlebars 的语法足够简洁而且它的{{#each}}和{{#if}}写起来很直观模板文件读起来就像普通文本非开发人员也能看懂。模板的组织方式我建议按“目标文件”来分目录templates/ java/ controller.hbs service.hbs dao.hbs dto.hbs typescript/ api.hbs model.hbs每个模板文件对应一个输出文件。这样结构一目了然想改哪个文件直接找对应模板就行。写模板时有几个实操技巧缩进用模板里的真实空格不要用变量控制。我试过用变量控制缩进层级结果模板里全是{{indent}}可读性极差。后来改成模板里直接写好缩进简单直接。注释里带上“此文件由 t3code 生成”的标记。这样别人一看就知道是生成的改的时候会谨慎一些。但要注意如果生成后手动改过下次重新生成会覆盖所以标记里最好带上生成时间。留出“扩展点”。比如在生成的类里留一个空的// TODO: custom logic here注释提示开发者在这里补充业务逻辑。3.3 输出层落盘策略与覆盖保护输出层最容易被忽视但坑最多。我总结了几条必须处理的规则规则一默认不覆盖已存在的文件。这条是血泪教训。早期版本我图省事直接覆盖结果有一次把同事手动改过的 service 文件冲掉了找回来费了好大劲。现在的做法是如果目标文件已存在生成到.new后缀的文件让开发者自己对比合并。规则二支持 dry-run 模式。加一个--dry-run参数只打印将要生成的文件列表和内容摘要不实际写盘。这个功能在调试模板时特别有用能快速看到效果。规则三生成后自动格式化。模板里很难保证缩进完全正确所以生成完最好调用一下目标语言的格式化工具。Java 用 google-java-formatTypeScript 用 prettierPython 用 black。这一步能让生成结果的“手写感”大幅提升。规则四记录生成日志。每次生成都往一个.t3code.log文件里追加一条记录包含时间、输入参数、生成的文件列表。出问题时可以回溯。4. 手把手实现一个最小可用的 t3code理论说再多不如动手。下面我用 Node.js 实现一个最小版本大概 200 行代码能生成 Java 的实体类和 TypeScript 的接口定义。选 Node.js 是因为它跨平台、依赖少装个 Node 就能跑。4.1 环境准备与依赖安装先建目录、初始化项目mkdir t3code-demo cd t3code-demo npm init -y npm install handlebars commander fs-extra三个依赖各司其职handlebars做模板渲染commander解析命令行参数fs-extra提供更顺手的文件操作 API比如ensureDir、outputFile。目录结构规划如下t3code-demo/ bin/ t3code.js # 入口 src/ generator.js # 核心生成逻辑 type-map.js # 中间类型到目标类型的映射 templates/ java/ entity.hbs typescript/ model.hbs examples/ user.json # 示例输入4.2 中间类型映射表的设计type-map.js是整套工具的核心之一。它把输入 JSON 里的中间类型翻译成目标语言的具体类型const typeMap { java: { long: Long, string: String, datetime: LocalDateTime, boolean: Boolean, decimal: BigDecimal }, typescript: { long: number, string: string, datetime: string, boolean: boolean, decimal: number } }; function mapType(lang, midType) { const map typeMap[lang]; if (!map || !map[midType]) { throw new Error(未定义的类型映射: ${lang} - ${midType}); } return map[lang][midType]; } module.exports { mapType };这里有个设计取舍遇到未定义的类型直接抛错而不是静默返回原值。早期我图省事返回原值结果生成出来的代码里出现了private long userName这种明显错误的类型排查半天才发现是映射表漏了。抛错虽然粗暴但能第一时间暴露问题。4.3 模板文件的编写要点Java 实体模板templates/java/entity.hbspackage com.example.entity; import java.time.LocalDateTime; /** * {{entity}} 实体类 * 此文件由 t3code 生成生成时间{{generatedAt}} */ public class {{entity}} { {{#each fields}} /** {{comment}} */ private {{javaType}} {{name}}; {{/each}} {{#each fields}} public {{javaType}} get{{capitalize name}}() { return {{name}}; } public void set{{capitalize name}}({{javaType}} {{name}}) { this.{{name}} {{name}}; } {{/each}} }TypeScript 模板templates/typescript/model.hbs/** * {{entity}} 数据模型 * 此文件由 t3code 生成生成时间{{generatedAt}} */ export interface {{entity}} { {{#each fields}} /** {{comment}} */ {{name}}: {{tsType}}; {{/each}} }注意模板里用到的javaType、tsType、capitalize这些都不是 Handlebars 自带的需要在渲染前通过 helper 或预处理注入。我选择在生成逻辑里预处理把每个字段的类型提前算好模板里直接用这样模板更干净。4.4 生成逻辑的完整实现src/generator.js的核心逻辑const Handlebars require(handlebars); const fs require(fs-extra); const path require(path); const { mapType } require(./type-map); // 注册 capitalize helper Handlebars.registerHelper(capitalize, (str) { return str.charAt(0).toUpperCase() str.slice(1); }); function buildContext(input, lang) { const generatedAt new Date().toISOString(); const fields input.fields.map((f) ({ ...f, comment: f.comment || , javaType: lang java ? mapType(java, f.type) : null, tsType: lang typescript ? mapType(typescript, f.type) : null })); return { ...input, fields, generatedAt }; } async function generate(inputPath, lang, outDir, dryRun) { const input await fs.readJson(inputPath); const context buildContext(input, lang); const templateDir path.join(__dirname, .., templates, lang); const files await fs.readdir(templateDir); const results []; for (const file of files) { const tplPath path.join(templateDir, file); const tplContent await fs.readFile(tplPath, utf-8); const template Handlebars.compile(tplContent); const rendered template(context); const outName file.replace(.hbs, ) entity ? ${input.entity}.java : ${input.entity}.ts; const outPath path.join(outDir, outName); if (dryRun) { results.push({ path: outPath, preview: rendered.slice(0, 200) }); continue; } if (await fs.pathExists(outPath)) { const backupPath outPath .new; await fs.outputFile(backupPath, rendered); results.push({ path: outPath, action: 已存在输出到 .new }); } else { await fs.outputFile(outPath, rendered); results.push({ path: outPath, action: 新建 }); } } return results; } module.exports { generate };入口文件bin/t3code.js#!/usr/bin/env node const { program } require(commander); const { generate } require(../src/generator); program .name(t3code) .description(轻量代码生成器) .requiredOption(-i, --input path, 输入 JSON 文件路径) .requiredOption(-l, --lang lang, 目标语言: java | typescript) .option(-o, --out dir, 输出目录, ./output) .option(--dry-run, 只预览不写盘, false) .parse(); const opts program.opts(); generate(opts.input, opts.lang, opts.out, opts.dryRun) .then((results) { results.forEach((r) { console.log(${r.action || 预览}: ${r.path}); if (r.preview) console.log(r.preview \n---); }); }) .catch((err) { console.error(生成失败:, err.message); process.exit(1); });跑起来试试node bin/t3code.js -i examples/user.json -l java -o ./output node bin/t3code.js -i examples/user.json -l typescript -o ./output --dry-run第一条命令生成 Java 实体第二条只预览 TypeScript 输出。实测下来从输入到出结果不到一秒比手动写快太多了。4.5 实测中遇到的三个意外情况意外一字段名是 Java 关键字。有一次输入里有个字段叫class生成出来的 Java 代码直接编译不过。解决办法是在生成前做一次关键字检查命中就自动加前缀或后缀比如class变成clazz。这个检查表我直接抄了 Java 关键字列表二十几个词维护成本很低。意外二中文注释乱码。在 Windows 上生成时如果模板文件编码不是 UTF-8中文注释会变乱码。解决办法是读取模板时显式指定utf-8写文件时也指定utf-8。这个坑在 Linux 和 macOS 上不会遇到但团队里只要有一个人用 Windows就得处理。意外三生成时间导致 git 冲突。模板里带了generatedAt时间戳每次生成时间都不同导致 git diff 里全是这一行的变化。后来改成只在文件头注释里带时间而且提供--no-timestamp选项CI 环境生成时关掉时间戳避免无意义的 diff。5. 把 t3code 用顺手的几个进阶思路最小版本跑通之后如果想让它真正融入日常开发还有几个方向可以扩展。这些不是必须的但做了之后体验会好很多。5.1 从数据库直接读表结构生成手写 JSON 描述字段还是有点麻烦尤其是表字段多的时候。更顺手的做法是直接从数据库的 information_schema 读表结构自动转成输入 JSON。这样只要表建好了一条命令就能生成全套代码。以 MySQL 为例核心查询是SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA ? AND TABLE_NAME ? ORDER BY ORDINAL_POSITION;拿到结果后做一次类型映射MySQL 类型 → 中间类型再走原来的生成流程。这一步做完整个工具就从“半自动”变成了“全自动”。5.2 集成到 IDE 的快捷操作命令行虽好但每次切到终端还是有点打断思路。我的做法是在 VS Code 里配一个 task绑定快捷键选中 JSON 文件后一键生成。配置大概长这样{ version: 2.0.0, tasks: [ { label: t3code: generate java, type: shell, command: node bin/t3code.js -i ${file} -l java -o ./output, problemMatcher: [] } ] }绑定到CtrlShiftG之后写代码的过程中随手就能生成几乎无感。5.3 生成结果的二次校验生成完不代表就完事了。我在 CI 里加了一步校验把生成结果和仓库里的代码做 diff如果不一致就报错。这能防止有人手动改了生成的文件却忘了同步模板导致下次生成时冲突。具体做法是在 CI 脚本里跑一次 dry-run把输出和现有文件对比。一致就通过不一致就提示“检测到生成代码与模板不同步请重新生成”。注意这个校验只对“完全由生成器产出”的文件生效。如果某个文件生成后又手动改过那它就不应该再被生成器管理要么从生成列表里移除要么改成“只生成一次”的模式。5.4 模板版本管理模板是会演进的。今天加个字段明天改个命名规范模板一变生成结果就变。如果不做版本管理很容易出现“同一个输入昨天生成的和今天生成的不一样”的情况。我的做法是给模板目录打 tag每次修改模板都记一笔 changelog。生成时把模板版本号写进文件头注释这样一看生成结果就知道用的是哪版模板。对于已经生成的文件如果模板升级了可以选择性地重新生成而不是无脑覆盖。6. 关于 t3code 的一些个人体会做这套东西前后折腾了小半年中间推翻重来过两次。最大的感受是工具的价值不在于功能多而在于它能不能让人“愿意用”。我第一版做了很多花哨功能支持七八种语言、十几种模板结果团队里没人用因为配置太复杂。第二版砍到只剩 Java 和 TypeScript 两种输入格式简化成一个 JSON使用率反而上去了。另一个体会是生成器要和团队规范同步演进。规范变了模板要跟着改模板改了生成结果要重新校验。这是一个持续维护的过程不是做完就一劳永逸。所以如果决定做最好指定一个人负责维护不然半年后模板就没人管了。最后分享一个小技巧在生成的文件头注释里写清楚“如何重新生成”。比如加一行// regenerate: t3code -i user.json -l java。这样半年后别人看到这个文件也知道该怎么重新生成不用去翻文档。这个细节很小但实际用起来能省不少沟通成本。如果你也在团队里做类似的工具我的建议是先从最小的场景开始跑通了再慢慢加功能。别一上来就想着做通用平台那大概率会烂尾。