ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code:基于TypeScript的三层代码生成器,告别重复CRUD

t3code:基于TypeScript的三层代码生成器,告别重复CRUD

写业务系统写多了,你会发现 CRUD 代码其实长得很像。列表要分页,详情要查单条,新增要校验,编辑要落库,删除要判断外键。这些逻辑单独拿出来都不难,但每个实体都写一遍,团队里的风格还不统一,代码量一多就特别难受。我最近折腾的这个小工具,代号就叫 t3code,它就是一个专门解决这个问题的代码生成器 CLI。它本身不是脚手架,也不是框架,而是基于 TypeScript 的三层代码生成工具:你给我一个业务实体定义,它还你一套“展示层、服务层、数据访问层”都能跑的模块代码。写这篇分享,是想把 t3code 从设计思路到实操细节、再到我踩过的坑,完整梳理一遍。适合谁看呢?被重复 CRUD 拖累的 Node/Java 后端,想统一团队代码规范的技术负责人,还有对代码生成器感兴趣、想自己造轮子的同学。

1. 项目整体设计与思路拆解

1.1 T3 到底是什么:TypeScript、三层架构、代码生成的组合

t3code 这个名字,不同团队可能有不同解读,我这里指的是TypeScript + Three-tier + Codegen的组合。TypeScript 是基础语言,Three-tier 是目标结构,Codegen 是核心能力。说白了,它就是一个面向 TypeScript 项目的三层代码生成器,专门产出 Web 后端常见的 Controller、Service、Repository 三层代码。

为什么只做这三层?因为业务系统里最稳定的就是这三层。页面样式会频繁调整,路由和控制器却很少大改;业务规则会变,但服务层的基本骨架还在;数据库表结构虽然会演进,数据访问层的基础操作却永远是增删改查。与其把整个框架都接管,不如把最模板化、最重复的那部分交给生成器,把容易变化的部分留给开发者自己扩展。这个取舍非常关键,决定了 t3code 不会变成一个“为了生成而生成”的无脑工具。

t3code 的输出目标也不限于某一种框架。配置里可以指定 NestJS、Fastify、Express 等不同后端框架,ORM 也可以选 Prisma、TypeORM、Sequelize。模板把框架差异封装起来,这样同一份业务实体配置,换一个模板目录就能适配不同技术栈。这也是我一开始坚持的:配置必须与框架无关,模板才负责具体框架的细节。

1.2 为什么用配置驱动而不是手写脚手架

很多人会问,已经有了 create-t3-app 这类脚手架,为什么还要自己折腾一个代码生成器?脚手架解决的是“项目实施初期的一次性问题”,它会帮你把项目骨架拉起来,然后很快就不再介入你的日常开发。t3code 不一样,它的定位是“持续在场的代码工厂”。

我举一个具体例子。项目里新增了一个订单实体,字段有订单号、用户ID、总金额、状态。用脚手架起好项目后,你还是得手动写 Order 的控制器、服务、仓储,还要写前端列表页里的列定义。但用 t3code,你只需要在 schema 文件里增加一个 Order 实体,然后重新跑一次生成命令,三层代码全部更新。改配置、重新生成、走 Git 对比,整个过程比手写快一个量级。

配置驱动的另一个好处是规范可沉淀。团队的代码风格如果只靠口头约定,很快就会出现一百种不同写法。但当你把所有模板统一放在一个仓库里,每次生成出来的代码结构都经过评审,就会发现新代码长得很像老代码,代码评审的重点就自然落在业务逻辑上,而不是缩进和命名。做技术负责人应该深有体会,这个价值比生成代码本身还大。

1.3 t3code 的工作流:从实体配置到可运行代码

t3code 的核心流程并不复杂,我把它拆成五步:

  1. 读取配置:支持schema.yaml、schema.json和.ts三种格式。为的是让不同习惯的团队都能顺手用起来。
  2. 解析实体:把配置里的实体、字段、关系、约束解析成中间模型。这里不直接绑定任何 ORM 或框架,ORM 字段映射发生在渲染阶段。
  3. 模板渲染:遍历模板目录里的每个文件,把中间模型传给模板引擎,逐文件渲染。
  4. 文件写入:按输出目录写文件。支持overwrite开关和“保护区域”机制,后面实操部分会细说。
  5. 后处理:生成代码完成后自动执行prettier --write、eslint --fix,让产物直接达到团队的代码规范。

为什么要把 Schema 解析和模板渲染拆得这么开?因为模板引擎只关心“我拿到什么数据”,不关心数据从哪来。以后如果想支持 Swagger 文档导入、数据库反向生成 Schema,只需要扩展解析层,模板完全不用动。这是我踩过几次坑之后总结出来的教训:生成器里最容易拖垮项目的是模板和业务逻辑耦合,所以一开始就不要让它们耦在一起。

2. 目录设计、模板约定与配置要点

2.1 核心目录与文件职责说明

一个中型的 t3code 项目,目录结构大致长这样:

t3code/ ├── bin/ │ └── t3code.js # CLI 入口,负责解析命令参数 ├── src/ │ ├── commands/ │ │ ├── init.ts # 初始化项目模板 │ │ ├── generate.ts # 核心生成命令 │ │ └── doctor.ts # 检查当前环境是否满足要求 │ ├── core/ │ │ ├── schema.ts # Schema 文件解析与校验 │ │ ├── model.ts # 中间模型定义,实体的统一抽象 │ │ ├── render.ts # 模板渲染引擎封装 │ │ └── writer.ts # 文件写入与目录结构创建 │ ├── templates/ │ │ ├── server/ # 后端模板(按框架分子目录) │ │ └── web/ # 前端模板(列表页、表单页) │ └── utils/ │ ├── logger.ts # 命令行日志输出 │ └── file.ts # 路径与文件操作工具 ├── package.json └── README.md

core/schema.ts负责把 YAML 或 JSON 解析成中间结构,同时做字段类型、必填项、唯一约束的校验。core/model.ts里的中间模型是整个生成器的“语言”,模板不直接面向原始配置,而是面向这个模型。core/render.ts封装模板引擎,提供函数注册、过滤器、自定义标签能力。templates目录是整个项目的灵魂,也最容易烂尾,后面我会专门说模板维护的经验。

2.2 实体定义 Schema 怎么写:字段、类型、关系

一个最简单的用户实体配置大概长这样:

entities: - name: User table: users fields: - name: id type: uuid primary: true - name: name type: string length: 32 required: true - name: email type: string unique: true format: email - name: status type: enum values: [active, disabled] default: active relations: - type: one-to-many target: Order field: orders foreignKey: userId

每个配置项的用意,我按实际经验解释一下:

  • name是实体名,默认用驼峰命名,生成代码时复用。
  • table是数据库表名,和实体名分开,因为经常出现实体叫User表名却是sys_user的情况。
  • type用的是语义类型(uuid、string、enum),不是数据库原生类型。这是刻意的,为的是让一套 Schema 能同时生成 PostgreSQL、MySQL 等不同数据库的迁移脚本。
  • primary、unique、required这些约束会被模板翻译成数据库索引、TypeScript 类型、校验规则三份代码,一处定义,三处同步。
  • relations定义实体关系,生成服务层时会自动生成按关联字段查询的方法,比如根据用户ID查询订单列表。

我自己刚开始设计时犯过一个错误:直接在配置里写死varchar(32),结果后来切换到另一个数据库时配置全废了。改成语义类型之后就稳多了,数据库方言交给模板层处理。

2.3 模板里面的门道:变量、循环、条件与文件命名

模板引擎我选择的是类 Nunjucks 风格,因为它的语法对后端出身的人很友好。常见语法整理成一张表:

场景模板写法说明
输出变量{{ entity.name }}输出实体的名字
循环字段{% for field in fields %}遍历字段生成对应代码
条件判断{% if field.primary %}只有主键字段才走这段逻辑
引入子模板{% include "header.ts" %}公共部分抽到子模板
宏定义{% macro input(field) %}定义可复用的代码片段
转义原始花括号{{ "{{" }}生成器自身语法与 JS 模板语法冲突时用

文件命名也是一个容易忽略的细节。字段name值是User,默认输出user.controller.ts、user.service.ts、user.repository.ts。这里我特意用小写开头、点号分隔的风格,避免在 Windows 和 Linux 之间出现大小写问题。模板文件的命名规则和输出文件一一对应,例如{entity}.controller.ts对应控制器模板。

有个经验值得单独提:模板里不要做复杂运算。早期我在模板里写各种join、filter,导致模板又臭又难调。现在只保留循环、条件、简单判断,复杂的逻辑都在模型解析阶段算好,模板只负责输出。模板越是“无脑”,越不容易出错。

2.4 配置文件中的关键选项

t3code 的全局配置放在t3code.config.ts里:

export default { output: 'src', framework: 'nestjs', orm: 'prisma', language: 'ts', template: 'default', style: { indent: 2, quote: 'single', semi: false }, overwrite: false, hooks: { beforeGenerate: ['npm run lint'], afterGenerate: ['npx prettier --write', 'npx eslint --fix'] } }

framework和orm决定使用哪套模板,style控制生成代码的风格参数,overwrite控制是否覆盖已有文件,hooks可以在生成前后执行命令。我最常用的是overwrite: false配合 afterGenerate 里的代码格式化。有了 hooks,生成出来的代码可以直接进入 Git 暂存,省掉手动整理格式这一步。

另外要强调一个点:配置文件不建议写得太多太碎。我看到有些团队把每个字段的命名风格都做成配置项,最后配置比代码还难维护。t3code 的配置遵循“默认可用、少数覆盖”原则,你只需改你真正关心的事,其他交给默认值。

3. 完整实操过程:从初始化到生成代码

3.1 环境准备与安装:Node.js 版本、全局安装与 npx 使用

t3code 要求 Node.js 18 及以上版本,主要是因为 18 之后内置了fetch,生成前的环境诊断和一些后续扩展功能用得上。安装方式有两种:

npm install -g t3code # 或者不全局安装,直接用 npx npx t3code --help

建议第一次试用时用npx t3code --help,不用污染全局环境。确认命令能用之后,我再建议决定是否全局安装。如果你更习惯用 pnpm,那再强调一下pnpm dlx t3code的用法也是一样的,我这边就不再赘述。

装完之后跑一下环境诊断:

npx t3code doctor

它会检查 Node 版本、Git 是否可用、模板里依赖的 CLI 工具是否存在。别小看这个检查,生成一个项目最怕环境不一致,提前诊断能省去后面一半的排错时间。

3.2 用 init 命令初始化项目骨架

不用手工建目录,直接让 t3code 自己搭骨架:

npx t3code init my-app cd my-app

init会生成宝宝级的起始结构:

my-app/ ├── schema/ │ └── user.yaml # 示例实体配置 ├── templates/ # 自定义模板目录(需要覆盖默认模板时才用) ├── t3code.config.ts └── package.json

生成出来的user.yaml就是前文那个基础版本,你可以直接拿它跑通流程。这一步我会立刻跑一下npm install,防止后面生成后处理命令找不到本地依赖。因为afterGenerate里经常会调用eslint和prettier,这些命令必须能执行。

3.3 编写第一个业务实体配置

我在schema/order.yaml里加入订单实体:

entities: - name: Order table: orders fields: - name: id type: uuid primary: true - name: orderNo type: string length: 64 unique: true - name: userId type: uuid required: true - name: totalAmount type: decimal precision: 10 scale: 2 required: true - name: status type: enum values: [pending, paid, shipped, completed, cancelled] default: pending relations: - type: many-to-one target: User field: user foreignKey: userId

字段类型用了decimal和enum,而不是直接对应某个数据库类型,这样模板在生成 PostgreSQL 的numeric、MySQL 的decimal时就能各自处理。orderNo设置成unique,生成服务层时会一并生成“根据订单号查询”的方法。我把这个配置放到schema/yaml后,还要在t3code.config.ts里指定启用的 Schema 文件,这一步不要漏。

3.4 运行生成命令并检查产物

执行核心命令:

npx t3code generate --config schema/order.yaml --output src

正常情况下你会看到类似这样的日志输出(具体文本取决于模板):

[1/5] 读取配置完成,发现 1 个实体 [2/5] 解析模型完成,生成 10 个字段、1 个关系 [3/5] 渲染模板完成,处理 8 个模板文件 [4/5] 写入文件完成,新增 6 个文件 [5/5] 格式化代码完成,调用 prettier 与 eslint 处理成功

生成后的src目录会出现对应模块结构:

src/ ├── modules/ │ └── order/ │ ├── order.controller.ts │ ├── order.service.ts │ ├── order.repository.ts │ ├── order.model.ts │ ├── order.schema.ts │ └── order.module.ts

用编辑器打开order.service.ts,你会看到已经包含分页查询、主键查询、新增、更新、删除、状态流转方法,并且每个方法都带了一个// TODO(扩展区): 这里放自定义业务逻辑的标记。这个标记很关键,它表示这个区块可以被手动修改,而其他区域在重新生成时会被覆盖。一定要把自定义代码写在扩展区里,否则下次一生成全被冲掉。

3.5 生成的代码怎么接入已有项目

生成代码只是第一步,真正接入项目还需要做三件事:

  1. 数据库迁移:如果项目用 Prisma,模板可能已生成prisma schema片段,但迁移操作prisma migrate dev仍然需要自己执行。t3code 会生成一个折中的提示,不会自动执行迁移,防止它擅自改动数据库结构。
  2. 路由注册:NestJS 模块会自动 import,Fastify 模板可能需要在路由文件里注册新模块。我个人的经验是,不管模板是否支持自动注册,生成之后都要跑一遍项目启动,确认模块能被正确加载。
  3. 补充鉴权与权限:生成器会生成基础的 CRUD 方法,但不会也不应该猜你的权限模型。把@RequirePermissions('order:create')这类装饰器放在扩展区,等团队自己处理。

接入过程中我还会顺手看一眼生成的代码和团队现有代码风格是否一致。如果不一致,优先调模板和配置,而不是人肉改每一份新代码。这也是用生成器最大的隐藏收益:让代码风格问题在生成阶段解决,而不是在 review 阶段还来回讨论。

4. 常见问题与排查技巧实录

4.1 模板渲染报错:转义与缩进问题

最经典的一个报错是模板渲染时直接提示语法错误。原因绝大多数出在模板里混用了生成器语法和目标代码语法。例如你想在生成的 TypeScript 代码里输出一个模板字符串:

const sql = `SELECT * FROM users WHERE id = ${id}`;

模板引擎解析时会把${id}和{{ }}一起处理,结果报错。解决办法很简单,把花括号转义:

const sql = `SELECT * FROM users WHERE id = ${{"{"}}{{ id }}}`;

这段写法看起来很丑,但确实管用。我更推荐的做法是把这类动态代码改成模板可以识别的形式,例如生成器中传入queryTemplate字段,模板里直接输出,避免在模板里硬写花括号。

4.2 Windows 与 macOS 的路径兼容性问题

在 Windows 上生成文件时,偶尔会出现路径错乱,例如生成到了src\modules\order但实际目录是src/modules/order。原因是路径拼接用了字符串相加,而不是path.join。t3code 的作者群初次测试时,这个坑出现在文件写入器里。修复方式是把所有路径统一交给path.join处理。

如果遇到换行符问题,尤其是不同开发机之间 Git diff 满屏飘红,建议让模板统一使用\n,同时写一个.gitattributes强制文本文件换行符。这一点我在团队协作时深有体会,Windows 生成的代码和 Mac 上生成的代码因此对比时非常混乱。

4.3 重复生成覆盖代码怎么避免

默认情况下 t3code 会全量生成。如果不小心跑错了配置或改错了模板,新代码可能把已经改好的文件覆盖掉。我处理这个问题的方式有三道防线:

  • 配置里开启overwrite: false,默认跳过已有文件,只新增不覆盖。
  • 每次生成前用 Git 检查工作区状态,确认没有未提交的手写代码。
  • 把自定义逻辑严格放进模板标记的TODO(扩展区)区块,工具在重新生成时会保留扩展区内容。扩展区要做到“生成器无论如何都绕开”,这是最容易被忽略但最重要的特性。

如果扩展区没了,唯一的恢复办法是从 Git 历史捞回来。我自己经历过一次把整个 service 文件覆盖的惨案,从那以后我再也没有关过overwrite: false。

4.4 生成结果格式混乱怎么处理

模板写多了之后,代码缩进经常会乱,尤其是for循环和if嵌套多的时候。整个文件看起来能用,但 review 时特别难受。如果你遇到生成结果乱成一团,先别急着改模板缩进,直接用配置文件里的后处理命令跑一遍格式化,通常能解决 90% 的缩进问题:

hooks: { afterGenerate: ['npx prettier --write', 'npx eslint --fix'] }

关键是模板本身要尽量减少嵌套逻辑。如果一个模板里出现三层以上的if,基本可以判定模板需要重构,该拆分的拆到子模板或者提前在模型里算好结果。

4.5 高频问题速查表

问题现象可能原因解决办法
命令找不到t3codeNode 版本过低或没全局安装升级 Node.js,或使用npx t3code
生成后模块不识别路由或模块没有被注册手动注册模块,或检查模板的注册逻辑
数据迁移报字段类型错误Schema 字段语义类型与目标数据库不兼容检查模板中的数据库方言映射表
生成乱码或横杠问题文件编码或换行符不一致统一 UTF-8,设置.gitattributes
端口或服务被占用项目里已有的服务实例冲突排查本地进程,或更换测试端口
生成代码里出现undefinedSchema 中字段缺失或被错误命名用doctor校验配置,逐步删除字段排查
中文注释乱码文件编码不是 UTF-8在模板文件头声明charset utf-8,并确保编辑保存为 UTF-8

5. 个人实操心得与后续扩展方向

5.1 几个值得记住的使用经验

第一个经验是模板比生成器本身更难维护。t3code 的引擎一旦稳定下来,很少需要改。真正三天两头变动的是模板。如果团队决定长期使用代码生成器,一定要指定一个人当模板维护者,所有模板改动走统一的代码评审。否则模板会很快变成一堆没人敢动的历史包袱。

第二个经验是先定规范,再定生成。公司内部的命名规范、分层方式、错误处理逻辑都没定下来之前,不要急着写模板。否则生成一次换一批模板,代码风格还是乱的。我见过最典型的例子是团队先让生成器生成了所有模块,再回头补分层规范,最后大部分模块只能手写返工。更好的顺序是拿一个模块做试点,反复打磨模板,确认规范没问题后再推广到全部模块。

第三个经验是生成器不需要解决 100% 的问题。有一些奇怪的需求,比如动态字段、多租户隔离、复杂权限流,就不应该硬塞进模板。对于这些情况,把内容放在扩展区里,或者直接不用 t3code,手写部分代码。我见过追求“所有代码都生成”导致模板复杂度爆炸,最后谁都不敢动模板的翻车案例。保持生成器的克制,它的长期价值反而更高。

5.2 代码生成器还可以往哪些方向演进

t3code 目前的形态已经能解决大部分模板化工作,但后续还可以在几个方向上继续演进。

第一是可视化 Schema 设计。直接在网页上拖拽实体、画关系,导出 YAML 配置,这对团队里的非后端同学会更友好。第二是生成单元测试。既然 CRUD 方法都能生成,那对应的单元测试框架也应该能自动生成,至少把create、findById、delete这些方法的测试骨架铺好。第三是与 CI/CD 集成。在流水线里自动检测 Schema 变更,自动生成代码并跑编译,这样把“生成”这件事进一步固化到开发流程中。

还有一个很实用的方向是反向生成 Schema。有时候团队已经有成熟数据库,直接在数据库管理工具里点几下拉出配置,再做正向生成。这个功能我自己额外写了一个小插件,原理就是读取数据库系统表的字段定义,转换成中间模型,再走标准渲染流程。这个思路非常值得试一试。

最后再分享一个我自己实际验证过的做法。新模块一律从 t3code 生成,生成完先不急着写业务,先快速看一遍生成出来的 controller 和 service 是否符合预期,不符合就先改模板再重新生成,符合了才开始在扩展区里加逻辑。坚持大半年之后,整个项目的 CRUD 代码就像一个人写的一样,代码评审的速度明显变快,新人接手也不会迷路。工具终究不是万能的,但把重复劳动彻底交给它之后,省下来的时间拿来做业务创新和架构打磨,这笔账怎么算都划算。

RELATED READING

延伸阅读

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