1. 为什么我会做一个叫“t3code”的代码生成工具
1.1 从一次重复劳动说起
先说一下背景。我平时的工作流里有一个特别烦人的环节:每次新起一个业务模块,都要手动搭一遍 controller、service、repository、DTO、单元测试模板。代码本身没什么技术含量,但量特别大,而且字段一多,复制粘贴就容易串行——改了一个类忘了改另一个,编译过不了,跑来跑去修个没完。
一开始我也试过用网上现成的代码生成器,但大多数都绑定了固定的 Web 框架或者数据库,换个项目结构就玩不转。还有一类是“对话式生成”,你把需求扔给大模型,它给你一坨代码,看着挺对,但稍微微调细节就要重新生成一遍,改来改去代码风格都不统一,根本没法纳入正经的工程规范。
后来我花了两周的业余时间,自己写了一个叫t3code的终端小工具。它不接大模型,不做在线平台,就是一个跑在命令行里的“代码改写管线”:你给我一份结构化的 YAML 描述,它按固定模板帮你生成一整套代码文件,而且支持改完字段再跑一次就原地更新,不会重复插入。这篇文章就是把它的设计思路和踩坑记录完整地拆开讲一遍,给想做类似工具或者正在被重复代码折磨的朋友一个参考。
1.2 t3code 的定位:不是脚手架,是“三段式代码管线”
很多人一听到“代码生成器”就想到 create-react-app 那类脚手架,跑一条命令拉一堆模板项目。t3code 不太一样,它不打算替你决定项目长什么样,它只做一件事:把一份结构化的模型描述,转换为某个框架下的多个代码文件,并且保证转出来的代码可以直接编译、直接跑通测试。
t3code 的名字其实就是 Three-step Transform Code 的缩写,核心是这三段:
- 解析(Parse):读入 YAML/JSON 的模型定义,校验字段类型、关系、属性完整性。
- 转换(Transform):把解析后的模型映射成中间结构,这个结构是“跟语言无关”的,只描述类名、方法名、字段类型、验证规则这些信息。
- 生成(Generate):根据中间结构套用不同语言的模板,输出 controller、service、repository、测试代码等。
之所以叫“管线”而不是“脚手架”,是因为它的输入输出都是可重复、可追踪的。脚手架是“一次性握手”,跑完就散了;t3code 是“持续转换”,你随时可以改模型再次运行,得到的是增量更新后的文件。这一点差别,让它能真正融入日常开发循环,而不是只在项目初始化那天用一次。
2. 核心设计的三个取舍:可控性、安全性与可增量性
2.1 为什么用 YAML DSL 而不是对话式生成
做这个工具之前,我认真想过一个问题:现在大模型生成代码这么方便,为什么还要自己写个 YAML 定义然后套模板?这不是开倒车吗?
实际摸过一轮之后,我的结论是:生成代码这件事,核心诉求不是“写得多”,而是“写得稳”。
对话式生成适合探索和原型,但不适合重复产出。你让模型生成同一个 CRUD 模块十次,每次拿到的代码风格都可能有细微差异——引号、换行、命名方式、注释风格,这些差异在 code review 里都是噪音。而且模型生成的是“一次性文本”,它没有“记住”它上次给你生成过什么,所以增量修改很难做。
YAML DSL 的好处恰恰是稳定和可控。我只需要声明:
entity: Post fields: - name: title type: string length: 200 required: true - name: content type: text - name: published type: boolean default: false剩下的方法命名、参数顺序、导入语句、测试断言,全部由模板统一决定。哪怕我生成一百个模块,每个文件的风格都是一致的,这对我来说比“生成得快”更有价值。这也是 t3code 最底层的设计哲学:输入有限,输出才可控。
2.2 为什么用 AST 改写而不是正则拼接
第一版 t3code 其实用的是简单字符串拼接:读模板文件,把{{entity}}之类的占位符用字符串 replace 掉。最开始几个文件跑得挺顺,但一碰到“要在已有的类里追加一个新方法”这种需求就露馅了。
举个例子:我生成了一个PostService,跑完之后手动在里面加了一个自定义方法publishPost()。下次我修改模型再加一个字段,重新运行 t3code,它如果直接拼接一个新文件覆盖上去,那我手动加的publishPost()就没了。这是不能接受的。
后来我把生成链路改成了 AST(抽象语法树)操作。具体逻辑是:
- 先检查目标文件是否存在;
- 不存在就直接套模板生成;
- 存在就解析成 AST,找到类声明的位置,把“需要新增的方法”“需要新增的字段”“需要新增的 import”插入到对应的 AST 节点上;
- 最后再把 AST 序列化成代码文本。
AST 操作比正则拼接复杂得多,但它带来两个关键能力:一是增量更新,第二次运行只加该加的东西;二是安全解析,我不会用正则去猜类名括号在哪,而是让编译器自己告诉我类在哪里。如果你打算做一个能长期使用的代码生成工具,我建议直接上 AST,别在字符串拼接上浪费时间。
2.3 为什么把格式化交给 Prettier 兜底
AST 序列化出来的代码有个问题:不够好看。缩进可能不对,空行可能不规范,有些换行风格跟项目里的不同。这个阶段我不会去写自己的代码美化器——那是一个巨大的无底洞,我直接把生成结果丢给 Prettier 统一格式化。
实际上 t3code 的完整处理流程是:AST 生成代码 -> Prettier 格式化 -> 再写回文件系统。格式化这一步看起来不起眼,但它决定了生成代码是否“像人写的”。我曾经见过一些代码生成工具产出的文件,结构完全正确,但整个文件的排版是乱的,一眼就能看出是机器生成的,review 的时候所有人都不想碰。Prettier 一拍,这个问题就清零了。
3. 手把手跑通一次 t3code:以“博客文章模块”为例
3.1 初始化项目与 CLI 用法
t3code 本身是一个 Node.js CLI 工具,全局安装之后大概这样用:
npm install -g t3code t3code init my-blog cd my-blog t3code generate --model model/post.yaml --output src/modules/post --language typescript --framework nestinit会生成一个最小目录结构,里面包含模板目录和模型目录。generate就是核心命令,它有几个值得说明的参数:
--model:指定模型定义文件,如果没有指定,t3code 会自动扫描model/*.yaml。--output:输出目录,代码会落在这里。--language:目前支持 TypeScript 和 Java,原理一样,只是模板不同。--framework:决定生成哪些文件。比如nest会生成 controller/service/module 三层,”plain只生成纯类文件。
第一次 init 之后,你还会看到一个t3code.config.yml,里面可以配置默认的输出目录、文件后缀、是否生成测试代码等。这些东西如果你不显式配置,工具会用一套保守的默认值,保证开箱即用。
3.2 定义模型文件 model.yaml
模型文件是整个生成过程的“唯一真相”。我尽量把常用的字段类型都收敛成一套 DSL,避免每个文件里都写一堆重复校验逻辑。下面是一个博客文章的示例:
entity: Post table: posts fields: - name: id type: uuid primary: true - name: title type: string length: 200 required: true - name: slug type: string length: 220 unique: true - name: content type: text - name: authorId type: uuid relation: User - name: published type: boolean default: false - name: tags type: string[] methods: - listPublished - findByAuthor每个字段除了名字和类型,还可以标注是否主键、是否唯一、是否必填、默认值,以及关联关系。后面生成 DTO 校验、数据库 migration、service 查询方法时,这些信息都会被模板消耗掉。
3.3 生成 controller / service / repository / 测试四件套
运行生成命令之后,t3code 会输出如下文件结构:
src/modules/post/ ├── post.controller.ts ├── post.service.ts ├── post.repository.ts ├── dto/ │ ├── create-post.dto.ts │ ├── update-post.dto.ts │ └── post-query.dto.ts ├── post.module.ts └── tests/ └── post.service.spec.ts拿post.service.ts来说,生成出来大概长这样:
import { Injectable, NotFoundException } from '@nestjs/common'; import { PostRepository } from './post.repository'; import { CreatePostDto } from './dto/create-post.dto'; import { UpdatePostDto } from './dto/update-post.dto'; import { Post } from './post.entity'; @Injectable() export class PostService { constructor(private readonly repository: PostRepository) {} async create(dto: CreatePostDto): Promise<Post> { return this.repository.save(dto); } async findById(id: string): Promise<Post> { const post = await this.repository.findById(id); if (!post) { throw new NotFoundException(`Post with id ${id} not found`); } return post; } async listPublished(): Promise<Post[]> { return this.repository.findByCondition({ published: true }); } async findByAuthor(authorId: string): Promise<Post[]> { return this.repository.findByCondition({ authorId }); } }这个类不是随便拼的。create、findById是模板根据字段自动推断出的标准方法;listPublished、findByAuthor则是根据我在模型里写的methods声明映射出来的自定义方法。
生成测试文件时也有一套自己的逻辑。每个 service 方法都会对应一个测试用例:成功路径、失败路径(比如 not found),以及参数校验。这块特别适合机器生成,因为重复度太高了,手写三五个还好,写三十个真的会吐。
3.4 改字段再跑一次:验证增量更新
假设博客模块上线之后,我要给文章加一个coverImage字段。我只需要修改模型文件,加上:
- name: coverImage type: string optional: true然后重新跑一遍t3code generate --model model/post.yaml。这一次运行,t3code 不会重新创建已经存在的文件,而是:
- 解析
post.service.ts现有的 AST; - 检查类里是否已经有
create、findById等方法,已有的就跳过; - 在
CreatePostDto的 AST 节点里新增coverImage字段声明,并同步更新校验逻辑; - 把
coverImage字段加入 service 的create方法签名中; - 格式化后写回原文件。
整个过程大概几秒钟。最关键的是,我之前手动在PostService里加的publishPost()方法不会被删除,因为 AST 操作只做“增量和缺失部分补全”,不碰已有节点。这一点是 t3code 能够长期融入开发流程的前提——如果每次生成都会吞掉手写代码,没人敢用它。
4. 我踩过的坑和 t3code 的边界
4.1 Handlebars 的 helper 陷阱与自定义 block
t3code 的模板引擎我起初选了 Handlebars,因为它语法简单、生态成熟。但用着用着就发现一个烦人的问题:内置 helper 太少,很多逻辑在模板里根本没法表达。
比如说,我想根据字段类型决定“这个字段在 DTO 里是 string 校验还是 boolean 校验”,Handlebars 默认做不到,你需要自定义 helper。我第一次写 helper 时犯了个低级错误:在 helper 里直接返回了一段 HTML 式的字符串,结果模板输出了一大堆[object Object]。后来才明白,helper 返回值会被当成 SafeString 处理,如果是对象就要手动 JSON 化。
另外一个坑是 block helper 的options.fn(this)上下文。在嵌套循环里,this经常不是你预期的那一层对象。比如在 sections 里遍历 fields,再使用../entity引用外层变量时,路径层级只要错一层,整个模板就静默输出空字符串。这种问题还不会报错,排查起来特别费劲。
后来我定了一个规则:模板里只保留最简单的循环和条件,其他逻辑一律在生成代码里先计算好,再传入一个“扁平的上下文对象”。模板本来就是给人类读的,塞太多逻辑进去,维护成本会指数级上升。
4.2 AST 改写时注释和 JSDoc 容易丢
这是我在做“增量更新”时踩得最深的一个坑。用 TypeScript 的 AST 解析器解析完代码,再序列化回去,原本的注释、空行、格式化风格全部都会变化,甚至直接丢失。我第一次跑增量更新时,发现文件里的 JSDoc 全没了,手动加的注释也乱成一团。
解决思路是:不要直接在 AST 上做完所有事再一次性序列化,而是只在必要时做局部 AST 操作。我改成了两步走:
- 先用普通字符串规则判断目标类里有没有某个方法或字段,如果缺,就定位到类体的起始括号位置;
- 在这个位置上做精确的字符串插入,插入的内容是模板单独渲染好的代码片段。
这样做的优点是,没有被插入的部分完全保持原样,注释、空行、手动改过的代码都不会被破坏。代价是插入位置的定位逻辑要写得足够严谨,尤其要处理嵌套泛型、装饰器、多行签名这些边界情况。如果你想复刻这个方案,我建议好好研究一下你选择的语言的 parser 提供的position映射能力,这是精确插入的基础。
4.3 幂等性与并发:重复执行不能产生脏代码
代码生成工具最容易被人骂的一个问题就是:跑两次结果不一样。不是指结果更好,而是第二次跑会重复插入方法、重复生成 import,代码膨胀得没法看。
为了让 t3code 做到幂等,我给每一个可以唯一标识的代码元素都定了“指纹”:
- 类方法用“方法名 + 参数类型列表”做 key;
- 字段用“字段名 + 类型”做 key;
- import 用“导入路径 + 导入名称”做 key。
每次插入前,先扫一遍已有代码,看看这个 key 是否已经存在。如果存在就跳过,不存在才插入。这一套机制后来也顺带解决了并发问题——我把 generate 命令设计成内部按文件粒度加锁,即使同一个模块并行跑两个生成任务,也不会出现同一文件被同时写两次的情况。
另外我建议生成的代码里,尽量加上“本文件由 t3code 自动生成,请勿手动编辑”这类标记。虽然这句话有点烦,但它能有效提醒同事:这个文件有机器生成的部分,手改的内容最好写在不会被覆盖的区域。我一般是把标记放在文件头注释里,并在手动需要保留的区域上额外加一行# t3code: preserve注释。
4.4 什么场景别用 t3code
工具做出来之后,我自己也犯过“手里有锤子,看什么都像钉子”的毛病,什么代码都想拿它生成。后来才慢慢总结出 t3code 的适用边界,这里也给大家提个醒:
- 适合:CRUD 模块、标准数据层、DTO/VO 定义、接口测试桩代码、多项目里风格一致的 boilerplate。
- 不适合:核心业务逻辑、复杂状态流转、强耦合的领域模型、需要大量人工评审判断的代码。
生成代码的本质是“把重复劳动自动化”,但如果你面对的代码几乎每行都有业务特殊性,那模板反而会成为束缚。这时候老老实实手写,比硬套生成器更划算。
我也意识到,任何代码生成工具都只是放大器——它放大的不是创造能力,而是你设计 DSL 和模板的能力。如果你没有先把模型定义和模板组织好,工具生成出来的只会是一堆更难维护的垃圾。这是我做完 t3code 之后最大的体会。
最后分享一个小技巧:如果你也要做类似的工具,别一上来就追求“全语言全框架支持”。固定一种语言、一个框架,把端到端的体验做顺,再考虑扩展。我前两周只支持 NestJS + TypeScript,跑通一个真实模块之后才加入 Java 模板。工具这东西,先窄后宽,永远比先宽后窄要稳健。