ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

代码生成工具 t3code:用配置驱动模板渲染打通工程化落地

代码生成工具 t3code:用配置驱动模板渲染打通工程化落地

1. t3code 是个什么项目,它到底想解决什么问题

t3code 这个名字第一次出现在我面前的时候,我第一反应是某个框架的第三个大版本。后来聊起来才明白,T3 不是版本号,而是Transform、Template、Tool三个单词的缩写——说白了就是一个以 TypeScript 为内核的代码生成工具。这类项目在工程化领域不算新鲜,但 t3code 的定位比较特殊:它不搞全量应用脚手架,也不碰 UI 组件库,它只做一件事,就是把“重复的代码编写工作”变成“配置驱动的模板渲染”。

很多人会觉得代码生成器没什么技术含量,不就是字符串拼凑吗。但真正在团队里推过代码生成的人都知道,这个事远没有想象中简单。我最早遇到这个需求是在做中后台管理系统的时候,几十个业务模块,每个模块都要写 Controller、Service、DAO、VO、DTO,结构高度雷同,只有字段和业务方法名在变。当时团队的做法是复制粘贴然后全局替换,每天都有同事在群里问“这个字段为什么没给我换过来”,后来换了思路才真正解决。t3code 的思路就是从这个痛点切入的:先定义好数据模型和接口规范,再写好模板,最后一条命令把所有文件批量生成出来。它解决的不只是写代码的时间,更是“多端模型不一致”和“命名不规范”这类隐性问题。

这篇内容适合所有做后端或全栈开发、且手里有大量重复样板代码要维护的工程师。前端如果涉及多端 API 层同步,也有参考价值。我会从设计原理、核心架构、一个完整的实操案例,到实际部署过程中踩过的坑,把一个代码生成工具从零到落地讲的比较通透。你看完以后,就算不直接用 t3code,也应该能把这套思路迁移到自己的项目里。

2. 核心设计思路拆解:T3 的四层架构为什么这么设计

2.1 先搞清楚代码生成器最怕什么

代码生成器很容易被写坏,这个东西我最开始尝试的时候踩过很多坑。第一版我用的是纯字符串拼接,模板里塞了各种 if 判断,函数都是几百行的大函数,新需求一来就改模板,改完以后以前生成的文件和旧逻辑全都乱了。后来我才意识到,代码生成器本质上不是一个“字符串工具”,它是一个“编译工具”。

它的输入是结构化的配置,输出是代码文件,中间需要经过模板解析、AST 变换和格式化,任何一个环节偷懒,最后都会以“生成的代码没法看”来收场。t3code 的四层架构说白了就是在回答这个问题:如何让输入足够精确、让模板足够稳定、让渲染足够可控、让落盘足够安全。

2.2 输入层:为什么用 JSON Schema 而不是 YAML 或数据库表

t3code 的第一层是输入层,负责接收用户的模型定义。这里它选了 JSON Schema 而不是更轻量的 YAML,很多人不理解,我的理解是 JSON Schema 有三个优势是 YAML 代替不了的。

第一,JSON Schema 自带类型系统和校验逻辑,配置写错了能在渲染之前报出来,而不是等代码生成完了才发现缺字段。第二,JSON Schema 可以直接转换为 TypeScript 类型定义,在写配置的时候就能获得完整的 IDE 自动提示,这比看文档写配置靠谱得多。第三,JSON Schema 可以被程序化地组合和扩展,比如我要做一个基础模型继承,可以直接在配置里写allOf,渲染层不用额外做一套继承逻辑。

举个简单的例子,一个用户表的配置大概是这样的:

{ "modelName": "User", "tableName": "sys_user", "fields": [ { "name": "id", "type": "bigint", "primary": true, "comment": "主键" }, { "name": "username", "type": "varchar", "length": 64, "comment": "用户名" }, { "name": "email", "type": "varchar", "length": 128, "comment": "邮箱" } ], "apiTemplates": ["controller", "service", "mapper"] }

这段配置表达的信息非常密集:模型叫什么、映射哪张表、有哪些字段、字段的属性是什么、要生成哪些 API 层文件。模板层拿到这个配置以后,不需要做任何猜测,它只需要忠实地按字段遍历输出。

如果团队里有人不想手写 JSON,t3code 也支持从数据库连接读取表结构反向生成 JSON 配置。这个功能看起来是体贴,实际上是很务实的功能,因为很多团队的字段定义在数据库里已经存在了,再手写一遍配置属于重复劳动,反而不符合“代码生成”的初衷。我个人的建议是:新项目建议用 JSON Schema 先定义模型,再通过模型生成数据库迁移脚本;老项目反过来,从现有表结构导出配置,然后再补模板。

2.3 模板层:为什么选择 Handlebars 而不是 EJS 或者 Nunjucks

模板引擎的选择是一个很容易被忽略但又特别关键的决策。t3code 用的是 Handlebars,原因很直接:它强制你保持模板简单。Handlebars 的逻辑能力非常弱,没有复杂的运算和任意 JavaScript 表达式,你只能在模板里做循环、条件判断和变量引用。这在很多人看来是缺陷,在我看来是优点。

代码生成模板最忌讳的就是逻辑过重。模板里一旦出现复杂逻辑,阅读和调试成本会急剧上升,而且模板语言的调试工具远不如普通代码成熟。Handlebars 逼着你去思考一个问题:模板里放不下的逻辑,应该放到哪去?答案是放到渲染函数里,也就是数据处理层。

比如我要生成一个 Service 文件,里面要处理字段校验逻辑,模板里只写:

public async create({{camelCase modelName}}Dto: {{modelName}}CreateDto): Promise<{{modelName}}> { const entity = plainToInstance({{modelName}}, {{camelCase modelName}}Dto); {{#each fields}} if ({{this.name}} === undefined) { throw new BusinessException('{{this.comment}}不能为空'); } {{/each}} return await this.{{camelCase modelName}}Repository.save(entity); }

注意模板里的camelCase是一个自定义 helper,它在渲染前把modelName转换成首字母小写的形式。所有字符串处理和命名转换都外置到 helper 里,模板本身只负责控制结构。这个设计让模板变得非常容易维护,非深度前端工程师也能看懂模板在干嘛。

实际落地过程中,我强烈建议团队给模板做一个严格的目录分层。t3code 的推荐结构是:

templates/ ├── common/ # 跨项目共享的基础模板 │ ├── dto.hbs │ ├── vo.hbs │ └── base-controller.hbs └── ${projectName}/ # 项目私有模板 ├── controller.hbs ├── service.hbs └── mapper.hbs

共享模板放的是所有项目都通用的结构,业务模板放的是当前项目特化的逻辑。这样当你在多个项目里推广 t3code 时,基础部分不用重复写,只维护增量部分就够了。模板的变更也要像代码一样走 Git 评审,别把模板当一次性脚本,模板是长期资产。

2.4 渲染层:模板引擎之外,还有一个 AST 级别的自定义管线

t3code 的渲染层不只是调用 Handlebars 把模板渲染成字符串,它多了一步:把渲染出来的代码交给 TypeScript 编译器做 AST 解析,然后用自定义规则做二次变换。这一步是用来收拾模板引擎的烂摊子的。比如多个模板片段拼接后可能出现重复 import、未使用的变量、或者明明可以合并的方法定义,这些在模板层面很难控制,但在 AST 层就非常清晰。

AST 变换可以完成几类任务:import 去重和排序、类型导入的自动合并、移除未使用变量、统一代码风格。这些任务的共同特点是它们不适合写进模板,因为模板不知道最终代码长啥样,它只管生成片段。而 AST 看到的是整棵语法树,可以在全局层面做修正。举一个具体场景:一个 DTO 文件里引用了三个实体的类型,模板生成时可能产生重复 import,比如import { User } from './user.entity'出现了两次。AST 变换扫描一遍之后会把这些重复的 import 合并成一次,并按照字母序排列。整个过程中,输出的代码就比模板直接输出的干净很多。

这里我还想补充一个观点:用 AST 做代码处理虽然强大,但门槛也高,它不是必要的。小团队、模板数量少的时候,完全可以在写模板时就把 import 处理好,不需要引入 AST 层。t3code 把 AST 作为可选管线,默认是关的,需要时再开启。这种设计比较务实,不会一上来就给使用者一个高门槛。

2.5 输出层:为什么生成前要做一次“差异检查”

最后是输出层。t3code 生成文件时,不会直接覆盖目标文件,而是先把新内容写到内存里,然后与磁盘上的旧内容做 diff。如果没有变化,跳过;如果有变化,写入临时文件,再通过 git diff 展示差异,用户可以决定是否合并。这个流程是我个人认为整个工具里最重要的设计之一。

代码生成有一个隐性风险:它是“一次性写入”还是“持续更新”?如果用户运行一次生成命令后,手动修改了生成文件,下次再运行时,直接覆盖会把用户的修改全部冲掉。t3code 的 diff 机制不会自动覆盖用户改动,它会明确提示哪些文件有本地修改,并让用户手动确认合并。这样一来,生成工具就从“破坏性操作”变成了“受控操作”,它在你的版本控制之外提供了第二道保险。

实际使用中我建议团队在 CI 里加一条检查命令,跑一次 t3code 的 dry-run 模式,看看有没有文件被意外改动。如果发现有,说明模型的某个字段或模板的某个逻辑影响了现有代码,应该先 review 再合入。这个实践能让代码生成器和团队协作流程真正磨合好,而不是每次生成都带来一堆 diff 噪音。

3. 完整实操:用 t3code 生成一组用户管理模块代码

3.1 从配置到模板,先看一份可运行的最小工程

理论讲多了容易飘,还是直接跑一个案例比较实际。假设我们正在做一个博客系统,需要给“文章表”生成标准的一套 Controller、Service、Mapper 和 DTO。先创建工程目录,安装依赖,然后写配置。

mkdir blog-generator cd blog-generator npm init -y npm install @t3code/core@latest handlebars typescript

安装完成以后,创建模型配置文件models/article.json:

{ "modelName": "Article", "tableName": "blog_article", "fields": [ { "name": "id", "type": "bigint", "primary": true, "comment": "主键ID" }, { "name": "title", "type": "varchar", "length": 255, "comment": "文章标题" }, { "name": "content", "type": "text", "comment": "文章内容" }, { "name": "status", "type": "tinyint", "comment": "状态 0草稿 1发布", "default": "0" } ], "apiTemplates": ["controller", "service", "mapper", "dto"] }

这里有两点要注意。第一,字段的comment不只是文档注释,它会被模板用来生成校验异常消息,所以写的时候尽量完整。第二,apiTemplates决定渲染哪些模板文件,这个数组元素与模板目录下的文件名一一对应。接着写模板文件,放在templates/article/下。

controller.hbs模板:

import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { {{modelName}}Service } from './{{camelCase modelName}}.service'; import { {{modelName}}CreateDto } from './dto/{{camelCase modelName}}-create.dto'; @Controller('{{kebabCase modelName}}') export class {{modelName}}Controller { constructor(private readonly {{camelCase modelName}}Service: {{modelName}}Service) {} @Get() async findAll() { return this.{{camelCase modelName}}Service.findAll(); } @Get(':id') async findOne(@Param('id') id: bigint) { return this.{{camelCase modelName}}Service.findOne(id); } @Post() async create(@Body() dto: {{modelName}}CreateDto) { return this.{{camelCase modelName}}Service.create(dto); } }

service.hbs模板:

import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { {{modelName}} } from './{{camelCase modelName}}.entity'; import { {{modelName}}CreateDto } from './dto/{{camelCase modelName}}-create.dto'; import { BusinessException } from '../common/exceptions/business.exception'; @Injectable() export class {{modelName}}Service { constructor( @InjectRepository({{modelName}}) private readonly {{camelCase modelName}}Repository: Repository<{{modelName}}>, ) {} async findAll(): Promise<{{modelName}}[]> { return this.{{camelCase modelName}}Repository.find(); } async findOne(id: bigint): Promise<{{modelName}}> { const entity = await this.{{camelCase modelName}}Repository.findOne({ where: { id } }); if (!entity) { throw new BusinessException('{{modelName}}不存在'); } return entity; } async create(dto: {{modelName}}CreateDto): Promise<{{modelName}}> { {{#each fields}} {{#unless this.primary}} if (dto.{{this.name}} === undefined) { throw new BusinessException('{{this.comment}}不能为空'); } {{/unless}} {{/each}} const entity = this.{{camelCase modelName}}Repository.create({ ...dto, }); return this.{{camelCase modelName}}Repository.save(entity); } }

模板里我用了一些自定义 helper,比如kebabCase、camelCase、unless。这些 helper 在 t3code 内置了一批,也可以自己在配置里注册,后面会讲到。

3.2 写渲染脚本和 CLI 调用

模板写完后,创建入口脚本generate.mjs。t3code 的核心 API 是模板化的,你可以用一行命令直接跑,也可以写脚本自定义流程。脚本的好处是可以注册额外的 helper,也可以把 diff 结果打进一个 HTML 报告里。

import { defineConfig, generate } from '@t3code/core'; import { readFileSync } from 'node:fs'; const config = defineConfig({ templatesDir: './templates', outputDir: './src/modules', helpers: { camelCase: (str) => str.charAt(0).toLowerCase() + str.slice(1), kebabCase: (str) => str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(), }, models: [JSON.parse(readFileSync('./models/article.json', 'utf-8'))], }); generate(config);

命令行里执行:

node generate.mjs --dry-run

--dry-run会打印出将要生成的文件清单和差异,不会真正写盘。确认无误后,去掉--dry-run再执行一次:

node generate.mjs

预期的输出文件结构长这样:

src/modules/ ├── article/ │ ├── article.controller.ts │ ├── article.service.ts │ ├── article.mapper.ts │ ├── article.entity.ts │ └── dto/ │ ├── article-create.dto.ts │ └── article-update.dto.ts

打开生成的article.service.ts,你会发现我们模板里写的循环字段逻辑已经被渲染成具体的校验代码,比如status字段会生成if (dto.status === undefined) { throw new BusinessException('状态不能为空'); }。这个渲染过程是可预测的,输入配置变,输出跟着变,模板不动。

这里我要提一个容易踩的坑:模板里循环字段时,字段顺序会影响生成代码的顺序。如果希望在 DTO 里固定字段顺序,在 JSON 配置里就要按预想顺序排列。同时注意,模板变量名不要拼错,Handlebars 对于不存在的变量不会报错,它只会渲染成空字符串。我就在生产环境遇到过 modelName 拼错导致生成的文件类名少了一个字母的情况,这种问题排查起来还挺费劲的,因为编译不过才知道,而且报错的地方离模板很远。建议在每个模板文件开头加一行:

{{!-- 本文件由 t3code 生成,请勿手动编辑,如需修改请修改模板 --}}

这样至少下次看到文件时知道源头在哪里。

3.3 多个模型批量生成与增量更新

现实中项目不会只有一个模块。t3code 支持把多个模型配置放在models/目录下,执行命令时它会自动读取所有配置并一次性生成。我比较推荐的做法是给每个模型单独建一个配置文件,而非合并成一个大文件。这样多人并行维护模型时,Git 冲突会少很多。

增量更新的场景也很常见。比如上周生成了article模块,这周又要加一个tag字段。正确流程是修改models/article.json,增加字段定义,然后重新执行生成命令。由于 t3code 做了 diff 检查,它只会更新与字段校验相关的代码片段,不会把你自己手动在 Controller 里加的业务方法覆盖掉。这也是为什么我前面说输出层的 diff 检查不是可有可无的,它是这个工具可持续使用的基础。

4. 工具选型与扩展:怎么把 t3code 融入现有研发流程

4.1 模板分发:共享模板与项目模板的管理方式

小型项目把模板写在项目内部没有问题,但团队大起来后,模板共享就是一个必须考虑的问题。t3code 支持模板从本地目录读取,也支持从 npm 包加载。我们内部的做法是把公共模板发布成一个私有 npm 包,比如@company/t3-templates,然后在各个项目里通过依赖引用的方式使用。

这样做的好处是:第一,公共模板有独立的版本号,修改模板不会直接炸掉正在运行的老项目,新项目用新模板,老项目锁旧版本;第二,模板的 review 流程可以跟代码一样走 Merge Request,谁改了模板、为什么改,都有迹可循;第三,新项目初始化时,不需要再拷贝一份模板到仓库里,直接一条脚本生成基础代码,干净利落。

对应到 t3code 配置里,templatesDir可以设置为一个 npm 包内的绝对路径:

templatesDir: require.resolve('@company/t3-templates/controllers')

这个方案唯一需要注意的是模板的向后兼容性。模板输出结构一旦被下游项目依赖,改动前要评估影响范围。我们团队的经验是:模板宁可多版本共存,也不要直接在公共包里改破坏性变更。如果必须改,至少先保留旧版本一个月过渡期,并且在生成日志里打 warning。

4.2 命名规范:模型名、模板名、文件名如何保持一致

代码生成器有一个很容易犯的错误:生成的文件名来自模型名,但模板文件名又来自apiTemplates数组。如果二者不对齐,就会产生找不到模板的报错。t3code 内部的处理逻辑是:对于models/下的每个模型,读取apiTemplates数组,分别去模板目录中找对应的.hbs文件。所以模板文件的名字必须跟apiTemplates里的字符串完全一致,哪怕大小写差异都会导致失败。

我通常会让团队成员在新增模板时,先拷贝一个现有模板再改内容,而不是从零写一个,这样既继承了命名规范,也不容易遗漏 helper。当然你也可以写个简单的校验脚本,在 CI 里检查 model 配置中的apiTemplates是否都存在于模板目录,省得生成到一半才报错。

4.3 与 Git 工作流结合:生成代码是提交进仓库,还是忽略掉

这个问题几乎每个用过生成器的人都会问:生成的代码要不要提交到版本库?我的答案很直接:提交,但要在文件头部留下“请勿手动编辑”的标记。生成代码提交进仓库的好处是,团队成员不需要安装同样的生成器才能看到代码,review 和搜索都方便。坏处是,模型配置改了以后如果不跑生成命令,仓库里的代码就会处于过期状态,容易造成误导。

最稳的做法是用 Git Hooks 或 CI 作业来强制同步。在 pre-commit 阶段跑一次 dry-run,检测到有模型文件比生成代码新,就阻止提交,提示开发者先跑生成命令。这个过程依赖 GitHooks,需要团队配合,但一旦形成习惯,比靠自觉靠谱得多。

我见过另一个极端做法是把生成代码全部加入.gitignore,只在构建时生成。这个方案对于某些场景(如完全免改动的标准代码)有用,但大多数业务代码生成后免不了要微调,忽略掉反而会造成线上行为和仓库代码不一致。所以我的建议里,业务相关代码要提交,通用的、完全由模板决定的代码可以考虑忽略。

5. 实战中踩过的坑和排查建议

5.1 模板变量丢失:生成代码里出现了空字符串

前面提过,Handlebars 对未定义变量不会报错。这个特性既是优点也是坑。实际项目里最容易出现的情况:配置里写了fields,但模板里写的是{{#each field}},少了一个 s。代码生成不会失败,生成的 Service 里循环体为空,所有校验逻辑都没了,而且编译可能还正常,因为校验逻辑缺失不一定会导致语法错误。

排查方法比较笨:先检查配置字段,再对照模板变量。但这类问题多了以后,我养成了一个习惯——看一眼生成文件的长度和内容,核心校验代码如果一夜之间变少了,先怀疑模板和配置不匹配。另外,可以在渲染前把模板上下文完整打印一遍放到日志里,排查时直接看模板拿到的数据对象是什么,比猜变量名快得多。

5.2 换行符与编码:在 Windows 上生成的代码和 Mac 上没有可比性

团队里如果有人用 Windows 开发,换行符问题十有八九会碰到。t3code 默认生成的代码用的是\n(LF),但 Windows 上某些编辑器会把新建文件写成\r\n(CRLF)。如果在同一台机器上反复生成和修改,文件的行尾会来回变,Git diff 里会出现一整片“疑似全部改动”的噪音。

解决思路是让 t3code 的模板文件统一使用 LF,然后在项目根部放一个.editorconfig文件:

[*] end_of_line = lf insert_final_newline = true charset = utf-8

再配合 Git 的core.autocrlf设置,基本上就能消除这类问题。如果团队成员对行尾比较敏感,可以在 gitattributes 里对生成目录强制指定text eol=lf,这样就算 Windows 上写入了 CRLF,Git 的归一化也会在 commit 时转回 LF。

编码问题也不可忽视。模板和配置文件都必须保持 UTF-8,一旦混入 GBK 或其他编码,生成的中文注释就会乱码。建议在 CI 里加一个简单的编码检测步骤,用file命令检查生成文件是否都是 UTF-8。

5.3 手动修改过的生成文件被反复覆盖

前面讲输出层时已经说了 diff 检查,但靠工具兜底不如靠流程规避。我见过团队使用 t3code 时,在生成文件里手动改了一处逻辑,结果下次模型配置一更新,重新生成就把手动修改覆盖了。虽然 diff 检查能提示文件有差异,但如果开发者没看 diff 直接强制合并,改动就会丢。

最稳妥的做法是:所有涉及业务逻辑的补充,一律不要写进生成的文件里。要么写在独立的扩展类中,要么利用继承机制覆写。比如 t3code 默认生成的 Service 是一个具体的类,如果你需要加自定义方法,可以在同目录建一个.custom.ts文件,通过继承或 Mixin 扩展。这样无论生成多少次,自定义逻辑都不会被模板的重跑给冲掉。

如果实在没法避开,那就把生成目录里可以手动编辑的标记做成一个明确的规则:只有带@manual注释的函数才允许手动改,模板里检测到手动修改就跳过该文件的自动覆盖。这个机制需要一点定制开发,但对长期迭代的价值非常明显。

5.4 多模型并发生成时的资源冲突

在我们团队一次性生成 30 个模块文件的时候,遇到过 Windows 下并发写文件导致的权限冲突。t3code 默认是串行生成,但如果你同时开多个终端跑不同的模型集合,最终写入同一个目录就可能撞车。解决方式是给每个生成任务指定独立的输出目录,然后任务结束后再合入主目录,或者在生成脚本里加一个简单的文件锁,保证同一时刻只有一个进程在写盘。

这类问题在 Linux 上很少出现,因为文件系统对并发写的容忍度更高,但为了跨平台稳妥,我仍然建议在生成入口处加锁。一个可用的 Node.js 实现是检查.t3code.lock文件是否存在,存在则延时重试,不存在则创建文件、执行生成、最后删除锁文件。

5.5 模板 debugger:如何在 Handlebars 模板里快速定位问题

Handlebars 模板很容易出现变量层级不对导致渲染结果偏离的情况。t3code 内置了一个--debug参数,打开后它会在控制台输出每一步渲染的上下文,相当于给模板加了一个断点。如果你在做特别复杂的模板时觉得不好查,可以在模板的特定位置临时输出上下文:

{{log modelName fields}}

log是 t3code 内置的 helper,执行时会直接把参数打印到 stdout。排查完记得删掉,要不然每次生成都会刷出一堆调试信息。另外一个不对称的问题是:字段名用 snake_case 还是 camelCase,团队内部必须统一,否则模板里到处都要做转换 helper。我的建议是配置里全部用 camelCase,写模板时表达更自然,数据库层面的转换交给另一个 helper 处理。

最后再说点个人体会

用 t3code 做了几个项目之后,我最大的感受是代码生成器的价值不在“快”,而在“一致性”。手写代码的时候每个人都有自己的风格,同样的接口,A 写的校验逻辑和 B 写的可能完全不同。生成器把这种差异抹平了,代码统一了,代码审查的负担就降下来了。团队新同学上手时,也不用缠着老同事问“咱们 Controller 一般怎么写”,跑一次生成就拿到一份可以直接改的骨架。

不过我也得说句实话,代码生成不是银弹。模板本身需要维护,模型配置需要规范,团队得在前期投入精力把基础打好。如果你只是想生成几个类文件然后不管了,那这个工具就是个一次性脚手架,价值有限。但如果你把它当作项目里一个正式的工程基础设施来运营,模板演进、版本管理、CI 校验都跟上,它带来的长期收益会非常可观。

如果你正在为团队里几十个结构相似的业务模块头疼,或者每次开发新表都要花半小时手写增删改查,不妨用 t3code 这类的思路先搭一个最小的生成闭环出来。一开始别贪多,先做一两个模块打个样,跑顺了再慢慢铺开。工具永远不是死的,思路通了,你会发现它真正的价值比“省几分钟”大得多。

返回列表