
写业务系统写多了你会发现 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 的核心流程并不复杂我把它拆成五步读取配置支持schema.yaml、schema.json和.ts三种格式。为的是让不同习惯的团队都能顺手用起来。解析实体把配置里的实体、字段、关系、约束解析成中间模型。这里不直接绑定任何 ORM 或框架ORM 字段映射发生在渲染阶段。模板渲染遍历模板目录里的每个文件把中间模型传给模板引擎逐文件渲染。文件写入按输出目录写文件。支持overwrite开关和“保护区域”机制后面实操部分会细说。后处理生成代码完成后自动执行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.mdcore/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-appinit会生成宝宝级的起始结构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 生成的代码怎么接入已有项目生成代码只是第一步真正接入项目还需要做三件事数据库迁移如果项目用 Prisma模板可能已生成prisma schema片段但迁移操作prisma migrate dev仍然需要自己执行。t3code 会生成一个折中的提示不会自动执行迁移防止它擅自改动数据库结构。路由注册NestJS 模块会自动 importFastify 模板可能需要在路由文件里注册新模块。我个人的经验是不管模板是否支持自动注册生成之后都要跑一遍项目启动确认模块能被正确加载。补充鉴权与权限生成器会生成基础的 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-85. 个人实操心得与后续扩展方向5.1 几个值得记住的使用经验第一个经验是模板比生成器本身更难维护。t3code 的引擎一旦稳定下来很少需要改。真正三天两头变动的是模板。如果团队决定长期使用代码生成器一定要指定一个人当模板维护者所有模板改动走统一的代码评审。否则模板会很快变成一堆没人敢动的历史包袱。第二个经验是先定规范再定生成。公司内部的命名规范、分层方式、错误处理逻辑都没定下来之前不要急着写模板。否则生成一次换一批模板代码风格还是乱的。我见过最典型的例子是团队先让生成器生成了所有模块再回头补分层规范最后大部分模块只能手写返工。更好的顺序是拿一个模块做试点反复打磨模板确认规范没问题后再推广到全部模块。第三个经验是生成器不需要解决 100% 的问题。有一些奇怪的需求比如动态字段、多租户隔离、复杂权限流就不应该硬塞进模板。对于这些情况把内容放在扩展区里或者直接不用 t3code手写部分代码。我见过追求“所有代码都生成”导致模板复杂度爆炸最后谁都不敢动模板的翻车案例。保持生成器的克制它的长期价值反而更高。5.2 代码生成器还可以往哪些方向演进t3code 目前的形态已经能解决大部分模板化工作但后续还可以在几个方向上继续演进。第一是可视化 Schema 设计。直接在网页上拖拽实体、画关系导出 YAML 配置这对团队里的非后端同学会更友好。第二是生成单元测试。既然 CRUD 方法都能生成那对应的单元测试框架也应该能自动生成至少把create、findById、delete这些方法的测试骨架铺好。第三是与 CI/CD 集成。在流水线里自动检测 Schema 变更自动生成代码并跑编译这样把“生成”这件事进一步固化到开发流程中。还有一个很实用的方向是反向生成 Schema。有时候团队已经有成熟数据库直接在数据库管理工具里点几下拉出配置再做正向生成。这个功能我自己额外写了一个小插件原理就是读取数据库系统表的字段定义转换成中间模型再走标准渲染流程。这个思路非常值得试一试。最后再分享一个我自己实际验证过的做法。新模块一律从 t3code 生成生成完先不急着写业务先快速看一遍生成出来的 controller 和 service 是否符合预期不符合就先改模板再重新生成符合了才开始在扩展区里加逻辑。坚持大半年之后整个项目的 CRUD 代码就像一个人写的一样代码评审的速度明显变快新人接手也不会迷路。工具终究不是万能的但把重复劳动彻底交给它之后省下来的时间拿来做业务创新和架构打磨这笔账怎么算都划算。