ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

t3code:三阶段代码生成工作流,让CRUD模块自动产出

t3code:三阶段代码生成工作流,让CRUD模块自动产出

1. 项目起源:t3code 是怎么来的

t3code 这个项目我从零维护了大半年,最初它只是一套我给自己用的代码模板,慢慢地变成了一个覆盖“思考、转换、测试”三个阶段的编码工作流脚手架。如果你是一个经常要开新项目、重复搭模块、反复写 CRUD 的开发者,你大概率能从我下面这些踩坑记录里找到自己的影子。

当时触发我做这个事的直接原因是:团队里有几个后端项目,明明业务逻辑不复杂,但每次新增一个“用户模块”、“订单模块”,都要手动复制上一个项目的目录,改命名、改引用、补测试、配路由。复制粘贴多了以后问题就来了——类名改名不彻底、配置文件串了、测试用例还跑着上一个模块的断言。这种低级错误不致命,但特别耗时间,而且 code review 时几乎每次都要被提出来。我开始认真思考:能不能把“新增一个模块”的动作,从“人工复制 + 全局替换”变成一条命令?

t3code 的核心思路就是三个 T:Think(先思考清楚边界)、Transform(把思考翻译成模板和脚本)、Test(用自动化守住生成结果的质量)。这三个字连起来就是项目名。很多人第一次看到会以为是“第三代代码”的意思,其实不是,我取名字的时候纯粹是这三个关键词首字母恰好相同。

整个项目后来演变成了三部分:一套标准目录规范、一组可插拔的代码模板引擎、一条从生成到验证的 CLI 流程。它不是一个框架,不限定你用什么技术栈,而是把“生成代码”这个行为本身工程化。你可以把它理解成一个“模块生产线”:喂给它一份模块定义,它给你吐出能直接编译、能跑通测试、命名统一的代码目录。

这篇文章我会把 t3code 的设计取舍、模板怎么写、CLI 怎么串、以及我在实际项目中踩过的坑全部拆开讲。适合的人群比较明确:被重复劳动困扰的后端工程师、想给团队搭工程化基础设施的前端/全栈、以及对代码生成感兴趣但不知道从何下手的初学者。

2. 整体设计:三阶段到底在做什么

2.1 Think:先写“需求摘要”,再谈模板

t3code 的第一个阶段叫 Think。这个阶段不做任何代码生成,它的产物是一份结构化的模块描述文件。我在设计时参考了 API 设计和领域建模的常用做法,让使用者在动手之前先把模块的边界、涉及的数据字段、对外暴露的接口想清楚。

一份典型的模块描述长这样:

{ "moduleName": "user", "domain": "account", "fields": [ { "name": "id", "type": "string", "primary": true }, { "name": "name", "type": "string", "validate": "required" }, { "name": "email", "type": "string", "validate": "email" }, { "name": "status", "type": "enum", "values": ["active", "disabled"] } ], "operations": ["create", "read", "list", "update", "delete"] }

这个文件是整个流程的唯一真相来源。我踩过一次坑:一开始允许模板直接从使用者输入的命令行参数里拼字段,结果字段一多,shell 转义就把人搞疯了,而且完全没有回溯渠道。改成 JSON 配置文件之后,别人 review 这个文件就能看出模块设计是否符合预期,生成前还能在校验阶段发现问题。

Think 阶段看着多了一道手续,但实际省掉的是生成后改来改去的返工。我的体会是:如果一份模块描述在脑子里都理不清,那就别指望模板能帮你变出来。这一步同时也是给非技术同事的协作入口,产品人员调整字段只需要改 JSON,不需要碰真正的代码。

2.2 Transform:模板保持“薄”,渲染逻辑保持“少”

第二个阶段是 Transform,真正干活的环节。t3code 用了一个非常简单的模板渲染思路:准备一套标准模板文件,把可变的命名、字段、方法名当成渲染变量,用配置值填充。这里我特别想强调一个原则——模板里不要写业务逻辑。

举个例子,你可以在模板里写一个简单的条件判断,根据字段类型决定生成string还是number,这是没问题的。但如果你试图在模板里实现“根据 JSON 配置自动推断出三层继承结构”,那就完全走偏了。模板语言本身不擅长复杂逻辑,写多了既难调试,生成出来的东西也容易长得奇形怪状。我后来把所有复杂的推导动作全部前移到了 Think 阶段的解析器里,把解析结果组装成一份“渲染上下文”,模板只做最机械的替换。

这也是 t3code 和很多重量级代码生成器的本质区别:它不想做“万能翻译机”,它只做“填空器”。就像做菜,解析器负责洗菜切菜配菜,模板只是最后下锅翻炒那一下。这样设计的好处是模板极其容易维护,新人看一眼就知道每个变量是从哪来的;坏处是太过复杂的模块结构确实不适合用它生成,这属于预期内的取舍。

2.3 Test:生成完毕,立刻验证

第三阶段 Test 是这个项目价值密度最高的地方。传统做法是把代码生成出来提交就算完事,但 t3code 会在每次生成之后自动执行一组验证命令:格式检查、类型检查、单元测试、甚至编译打包。只要任一步失败,CLI 就报错,而且会把失败原因高亮展示,提醒你去改模板而不是去改生成结果。

这个设计的出发点很朴素:如果一个生成工具的产物不能通过质量门槛,那它就只是一个“麻烦制造器”,用了几次之后团队一定会放弃它。所以我在设计 t3code 时把“生成 → 验证 → 反馈”串成了一个闭环。哪怕你只生成一个空壳模块,也必须能跑通测试,这个底线守不住,后面所有流程都会失去意义。

在实际使用中,这个阶段最常抓到的不是模板语法错误,而是命名不一致导致的问题。比如模板里某个变量的引用写错了一个字母,渲染结束后类型检查直接报错,一眼就能定位到是模板的锅。这种快速反馈,正是 t3code 相比“复制粘贴再全局替换”的最大优势。

3. 实操:从零跑通一个 t3code 项目

3.1 初始化配置与目录规划

先说初始化。安装 t3code 之后,第一步是执行t3code init,它会在当前目录生成一个配置文件t3code.config.ts和一整套推荐目录结构。

export default defineConfig({ language: "typescript", templateDir: "./.t3code/templates", outputDir: "./src/modules", testRunner: "vitest", formatter: "prettier", lintOnGenerate: true, testOnGenerate: true, buildOnGenerate: false });

配置项里有几个关键决策值得说。templateDir默认放在项目的.t3code目录下,和源码目录分开,避免模板文件混进业务代码里,也方便在 git 上区分哪些是“生成器的资产”。outputDir默认指向./src/modules,这是模块化组织最常见的路径,你也可以改成./lib或者./packages/<moduleName>,取决于你的项目管理粒度。

目录结构方面,我推荐长这样:

my-app/ ├── src/ │ ├── modules/ │ │ └── user/ # 生成后的模块 │ ├── shared/ # 手写的公共代码 │ └── index.ts ├── .t3code/ │ ├── templates/ # 模板文件目录 │ ├── generated/ # 渲染上下文缓存 │ └── logs/ # 生成日志 ├── t3code.config.ts └── package.json

需要注意一点:.t3code/generated这个缓存目录一定要加进.gitignore。它不是项目源码,只是上一次生成的中间产物,不参与打包也不参与运行。我的项目早期没忽略它,导致每次生成后 git diff 都是一大片噪音,严重削弱了 code review 的体验。

3.2 编写第一个模块模板

模板文件语法我用的是最简单的 handlebars 风格,{{ variable }}插值加上少量条件判断。一个最小可用的 CRUD 服务模板大概长这样:

import { Router } from "express"; import { {{ moduleName }}Controller } from "./{{ moduleName }}.controller"; const router = Router(); router.get("/{{ moduleName }}", {{ moduleName }}Controller.list); router.get("/{{ moduleName }}/:id", {{ moduleName }}Controller.read); router.post("/{{ moduleName }}", {{ moduleName }}Controller.create); router.put("/{{ moduleName }}/:id", {{ moduleName }}Controller.update); router.delete("/{{ moduleName }}/:id", {{ moduleName }}Controller.delete); export default router;

这不是一个很复杂的模板,但它演示了模板最核心的价值:只要命名规则统一,一次性把 5 个路由全部生成出来,而且不会像人手写那样出现第 3 个路由忘记加前缀的低级错误。

我建议第一次写模板的人从“路由模板 + 控制器骨架 + DTO 定义”这类组合开始,这是最容易见效也最容易验证的。别一开始就搞仓储层、Event Bus、消息队列,复杂度上去了调试成本也上去了。

模板文件里还可以用占位符来处理“某些模块不需要软删除、某些模块需要它”的差异:

{{#if hasSoftDelete}} deletedAt?: Date | null; {{/if}}

这种条件渲染的粒度是安全的,因为它只是根据配置决定“生成这段代码还是不生成这段代码”,不会产生复杂的多层嵌套逻辑。

3.3 核心命令:一次性生成完整功能

准备好模板和配置之后,生成操作变得很短:

npx t3code generate module user --spec ./specs/user.json

执行过程会在终端里实时打印进度:解析 JSON、渲染模板、写入文件、运行 ESLint、执行 Vitest、输出结果。实测下来,一个包含 5 个操作、8 个字段的模块生成到测试跑完,大约耗时 15-20 秒。这个速度主要花在测试启动和依赖预编译上,纯模板渲染其实不到 1 秒。

还有一个我后期加上的参数--dry-run,它会在不写入任何文件的情况下模拟一次完整生成,并且输出哪些文件会被创建、哪些会被覆盖。这个参数在 CI 上特别有用,可以防止生成器意外覆盖手写代码。我在某次重构中差点把手工调过逻辑的一个 controller 文件直接覆盖掉,就是因为忘了检查“目标文件已存在”的提示。加了--dry-run之后,每次生成前先跑一遍模拟,等于给手写代码加上了一道保险。

3.4 生成后的验证闭环

生成完成后,t3code 会自动尝试编译被生成模块的依赖。这里说的编译不是为了产出实际产物,而是为了确认新模块和现有模块之间没有引用断裂。很多生成器只负责“生成”而不负责“生成后的世界”,t3code 的做法是把它纳入验证闭环:类型检查过了才允许提交。

这套流程在 CI 上也跑得通。我在 GitHub Actions 里加了一步:

- name: Check generated code run: | npx t3code generate --check npx tsc --noEmit npx vitest run

--check模式会对比当前环境和上次生成的记录,如果检测到模板或 spec 有变更但没有重新生成,它会直接让 CI 失败。这样强制保证了模板仓库里提交的代码和实际运行代码始终是同步的,不会出现“模板改了但生成物还是旧的”这种隐蔽问题。这一点我在实际项目里收获最大,它把代码生成从一次性的手工操作升级成了可持续维护的研发基建。

4. 实测总结:那些最容易被忽略的坑

4.1 命名一致性:生成代码的最大难点

用 t3code 这半年,如果说只能分享一条经验,那就是命名一致性比想象中难得多。user、User、user_id、users、getUser、fetchUser——这些看着都是同一个词,但在模板里如果大小写规则不统一,生成出来的代码就会出现一堆互相矛盾的引用。类型检查当然能拦住一部分,但你不想在 CI 上反复撞墙。

我这里给一个快速参考表,是 t3code 内置的命名规则:

输入变量名类名文件名数据库字段
useruserUseruser.tsuser_id
orderItemorderItemOrderItemorder-item.tsorder_item_id
blogPostblogPostBlogPostblog-post.tsblog_post_id

设计这些规约的初衷很简单:一个模块内,驼峰、帕斯卡、短横线、蛇形必须可推导。凡是可推导的东西都能写进工具,凡是靠人记的规则都会在凌晨三点的加班里被忘掉。t3code 在渲染上下文中会提前把这几个命名变体都算好,模板里只需要选对应的变量,不需要自己写滤波器。

这块最容易被忽略的是复数形式。英语单词的复数有些是直接加 s,有些不规则,比如category变categories。模板逻辑不可能覆盖所有不规则复数,我的建议是:在 spec 里显式声明复数形式,没声明就默认加 s。这不是偷懒,是避免生成器在你眼皮底下把变量名搞成不存在的单词。

4.2 改模板还是改生成物:一个影响长远的原则

刚开始用的时候,我就犯过一个典型错误:某个模块生成了代码,但缺一个只有该模块需要的特殊方法,我直接改了生成物。后果是下次重新生成时,这个改动的丢掉了。这个案例在我接触到的每个代码生成工具使用者身上几乎都会发生。

t3code 对此的应对是两层设计。第一层,模板中预留扩展点:每个模块的目录里生成一个.custom.ts文件,专门放手写逻辑,生成器不会覆盖它。第二层,把那些真正通用的、每个模块都可能需要的补充逻辑追加到模板里,而不是交给个人手动调整。这个分界线很重要,它决定了生成器和手写代码能不能愉快共处。

我在内部团队里立了一个约定:生成物里看到的任何看起来像“通用能力”的手改代码,必须向模板提 PR,而不是继续手改。这条约定初见时会觉得繁琐,但几周后就会尝到甜头——模板越来越贴合真实需求,重复手写量逐渐下降,整个工程的一致性肉眼可见地在提升。

4.3 生成速度与依赖体积的平衡

代码生成器很容易变成依赖库收藏家。我一度想集成 swagger 文档生成、数据库迁移脚本生成、前端类型定义生成,后来全部砍掉了。原因很简单:每多一个集成,配置复杂度、测试覆盖面和维护成本都会指数上涨。

t3code 最终维持了“只生成某一层”的克制:后端模块和 API 定义,其它的让专门工具去干。前端类型如果需要,通过 API 定义的 JSON 再跑一次转换脚本即可,不放进核心流程。控制依赖体积还有一层现实考量:CLI 工具的安装体积如果动辄几百兆,团队成员在 CI 上安装一次的时长就会让人烦躁,使用意愿会直线下降。

如果你要扩展 t3code,我建议遵循一个原则:新增能力默认做成插件,而不是核心功能。按需安装、按需加载,核心保持轻巧。市面上很多好工具最后都被自身的功能压垮,t3code 不想走那条路。

5. 适用场景与后续演进

5.1 哪些团队真正适合上这套流程

t3code 不是万能的,它解决的是“模式化代码的高频产出”问题。哪些场景最合适?我在实践里总结了两个特征:第一,你的项目里存在大量结构相似的模块,比如微服务里一堆负责 CRUD 的资源,或者中后台系统里成片成片的表单页面。第二,模块之间通过目录结构和命名约定进行协作,而不是靠大量配置中心或魔法注入来串联。

中小团队特别适合,因为它能直接把统一的工程规范固化成代码模板,新成员看模板就能知道项目长什么样,而不是翻 20 篇文档。大型团队也可以用,但前提是组织层面能接受“约定大于配置”的强约束,否则每个小组都有自己偏好的目录风格,模板就很容易变成四不像。

如果你是独立开发,t3code 同样有价值。个人项目最大的问题是上下文切换成本,两周没碰一个项目,重新拿起来时早就忘了当初的模块结构。用生成器把重复结构稳定下来,等于保存了一种“项目潜意识”,重新进入状态会快很多。我自己的几个个人项目现在都是 t3code 初始化加管理,节省出来的时间相当可观。

5.2 从单模块到多语言:扩展的方向

t3code 目前原生支持 TypeScript 和 JavaScript,主要原因是我的主力栈就是这两门。但从设计上,它并没有把自己锁死在某一种语言上。模板就是纯文本,渲染上下文就是 JSON,理论上生成 Python、Go、Java 都没有障碍,差异主要在于命名规则的实现方式不同。

我自己做过的扩展实验是生成了 Go 的 handler 和 model 骨架,主要就是新增了一套模板目录和命名规则映射。过程比想象中顺利,因为 t3code 的核心运转思路和语言无关。如果你需要接多语言,我给的路径是:先确认模块描述 JSON 够不够通用,够用的话,剩下的只是写模板。

这个项目目前还有一个没有完全解决的问题,就是“模板版本漂移”。当几十个微服务仓库都用了同一套 t3code 模板,你想升级模板添加一个新字段时,怎么让所有仓库同步?我现在的做法是把模板单独抽成一个 git 仓库,各项目通过 git submodule 引用,升级就是 bump 版本。这个方案不算完美,但胜在简单直接。更优雅的方案目前还在验证中,但我相信“模板本身就是代码资产”这个定位一定会越来越被重视。

最后说一点个人体会:t3code 最值钱的其实不是那几条 CLI 命令,而是它逼着你去想清楚“手写代码里哪些部分是模式的重复,哪些部分是真正的业务差异”。这个问题想透了,你会发现代码生成器帮你省掉的时间还是小事,更大的收获是团队的代码风格会变得越来越像一个人写的——这种一致性带来的维护效率提升,才是长久来看真正重要的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进