☰
从手写CRUD到代码生成器:T3全栈项目类型安全的自动化实践
2026/10/27 16:03:13 网站建设 项目流程

1. 我为什么在年初把“t3code”从一个脚本攒成一个正经项目

先说结论:t3code不是某个开源框架,也不是什么AI大模型的代号,它是我自己维护的一个代码生成CLI工具,专门解决在T3技术栈项目里反复手写CRUD样板代码的问题。如果你用的是Next.js + TypeScript + Prisma + tRPC这套组合,你大概率经历过这种场景:数据库里加一张表,页面、接口、校验逻辑、查询条件全都得跟着动,一张表还好,十张表下来人就是复读机。

我一开始也没想过把它做成独立项目。最初就是在公司项目里写了个批量替换脚本,后来发现团队成员每个人都在用各自的方式补样板代码,有的人从GitHub找现成的CRUD模板,有的人用编辑器快捷键,有的人干脆复制粘贴上一个模块再改字段名。结果就是代码风格乱七八糟,类型定义漏一处,前端调用立刻报错。我花了两个晚上把脚本重构成了一个真正意义上的生成器,输入一段结构描述,输出的是一整套可以直接跑的增删改查代码,这就是t3code的第一个可用版本。

这篇文章不是来推销工具的,我也不打算贴一堆宣传语。我会尽量把t3code背后的设计思路和踩坑过程讲清楚:它解决什么问题、内部管线怎么搭、哪些地方差点把项目带沟里,以及如果我再来一次,哪些决定会保持不变,哪些决定一定会改。想自己做代码生成器的朋友可以当一份实战笔记看,想在现有项目里引入类似自动化流程的朋友也可以参考我的取舍。

2. T3全栈项目里那三件没人愿意做第二遍的重复劳动

2.1 加一张表等于碰四个文件,第五个文件还得看人脸色

T3 Stack的核心竞争力是类型安全,意思是前端调用后端接口时,参数类型和返回类型是能被TypeScript编译器验证的。这当然好,但代价是:每新增一个数据模型,你得手工维护四份相互关联的代码。Prisma schema里定义数据源,tRPC router里写查询和变更逻辑,页面Router Handler里加API路由,前端再封装一个调用函数。如果项目里用了React Hook Form做表单,还要单独写一套校验规则。

这四个文件之间没有自动同步机制。你改了Prisma schema里的字段名,tRPC router不会帮你改,页面组件里的表单字段也不会帮你改。更麻烦的是,它们之间的类型引用是隐式的,tRPC会通过inferRouterOutputs自动推导类型,页面组件里的useQuery拿到的是推导结果,但推导依赖router里的实际代码,不是你脑子里的“预期代码”。结果就是,改一个字段名,必须人工搜索所有引用点,漏一个就是运行时才暴露的错误。

t3code的第一个目标就是把这个链条变成单点更新。我只维护一份描述模型的文件,生成器自动产出这四类文件。新表上线的时间从半小时缩短到五分钟,前端联调时类型直接通过,不用再对着报错信息猜哪里漏改了。

2.2 “类型安全”的前提是每一环都守规矩,人恰恰是最不守规矩的

你可能觉得,团队里的人都写过TypeScript,不至于犯低级错误。现实是,每个人对“命名规范”的理解就不一样。有人习惯布尔字段叫isActive,有人习惯叫active;有人喜欢在Prisma模型里用createdAt,但页面组件里写的是created_at。这些不一致在单个文件里看不出来,一旦跨文件引用,就是一场灾难。

我自己见过最离谱的一次,是同事在Prisma里把一个外键字段命名为authorId,tRPC router的select里却写成了user_id,前端页面又用authorId传参。三个地方三个名字,TypeScript没报错,因为router层用了any。直到线上数据查不出来,才一层层扒到根因。这种问题用人工检查效率极低,代码审查的时候没人会逐一核对字段名。

t3code把“命名规则”内置到生成器里,字段名、关系名、查询参数名都从同一个源派生。不是靠文档约束,而是靠生成逻辑保证。只要源描述是合法的,输出就一定是自洽的。这是我想清楚的最重要的一个点:所谓类型安全,不光靠TypeScript编译器,更靠代码的产生方式本身就杜绝不一致。

2.3 心智模型转变:把表结构当成唯一的真相源

很多人写CRUD的时候,习惯从页面出发——“我要一个用户列表页,点击行跳转到详情,详情页有编辑按钮”。这个思路本身没问题,但落到代码上就乱了:页面要的字段和数据库结构往往不完全对齐,于是你得在router里做映射,页面里做转换,一来二去,中间层就成了藏污纳垢的地方。

t3code使用完全相反的思路:一切以数据模型为起点。先定义模型,包括字段、类型、关系、索引约束,生成器再推导出Router查询、页面表单、校验规则。这样做的好处是逻辑链条简单,数据库里有什么,前端就能看到什么;数据库里没有的派生字段,生成代码里也不会出现。缺点是需要你提前规划好模型,模型设计错了,生成的代码就全错。但比起在代码层反复打补丁,提前把模型想清楚显然是更划算的。

这个心智模型也是t3code这个项目名里“t3”的由来——它不只是一个工具名,更代表一种工作方式:TypeScript技术栈、类型安全的生成链路、以数据表为唯一真源的约定。后面我写的所有生成器逻辑,都是在围着这个心智模型转。

3. t3code的模型描述语法:我把DSL设计成“让写的人不犯语法错误”

3.1 一个模型描述长什么样,以及为什么不用JSON

最开始我确实试过用JSON写模型描述,因为JSON在TypeScript生态里解析太方便了,一行JSON.parse就搞定。但用了两周就发现,一旦模型多起来,JSON的噪音非常大。每个字段要写引号、冒号、逗号,关系定义嵌套两层以后,人眼根本分不清括号到底是不是配对的。加上我还有一个私心:希望这个描述文件能直接被业务同事看懂,他们不少人一看到JSON就头大。

于是我用了一种很朴素的缩进式语法,类似Prisma schema的简化版。举个例子,定义一个用户模型是这样的:

model User { id String @id @default(cuid()) email String @unique displayName String? @map("display_name") age Int? role String @default("member") posts Post[] @relation("UserPosts") } model Post { id String @id @default(cuid()) title String content String? author User @relation("UserPosts", fields: [authorId], references: [id]) authorId String }

每行都是一个“字段名 + 类型 + 可选的修饰符”。修饰符用空格分隔,含义按顺序排列:@id表示主键,@unique表示唯一约束,?表示可选,@default(...)表示默认值,@map(...)表示数据库列名,@relation(...)表示关系。解析这种语法比解析JSON麻烦,但文件的可读性高了一个量级,业务同事花十分钟就能看懂。

DSL设计里还有一个很关键的细节:字段顺序是固定的,类型必须在第二列,修饰符必须在类型之后。这样做的目的是降低解析复杂度——我不需要处理“类型写前面还是后面”的歧义。省下的解析逻辑,都用来做错误提示了。解析失败时,生成器会明确指出第几行、第几个字段、期望什么类型,而不是抛一个含糊的SyntaxError。

3.2 从DSL到五种产出物的映射规则

生成器的作用不是简单把DSL翻译成Prisma schema,而是要推导出前后端一整套代码。每一类产出物都有明确的映射规则,我列一下目前稳定的版本:

产出物输入依据生成要点
Prisma schemamodel定义、字段修饰符原样映射字段类型,关系字段按@relation生成外键,索引约束保留
tRPC routermodel定义 + 操作清单(默认有list/get/create/update/delete)每个操作生成对应的protectedProcedure,list支持分页与关键词过滤
React Hook Form校验字段的可选性、类型、长度约束数字字段转valueAsNumber,可选字段不加重置规则,默认值统一处理
页面组件(列表/详情/表单)字段的展示类型与关系列表页自动生成表格列与搜索框,表单页按字段类型渲染输入控件
API调用层(fetch封装)router里的操作名生成类型安全的方法,参数类型直接取自tRPC inferred types

这套映射规则是整个项目里最核心的部分,因为每一条都隐含了很多业务判断。比如Boolean字段在列表页用Switch展示,日期字段用DateTimePicker,这是我在多个项目里沉淀出的默认交互,不是凭空拍脑袋。生成器允许覆盖默认映射,但我不建议一上来就覆盖,先跑通再调整是最稳妥的方式。

3.3 为什么只提供五个配置项

做工具的人容易控制不住加配置项的冲动。什么输出目录、命名风格、缩进宽度、引号风格、是否生成注释,全都想留给用户自定义。t3code差点也走上这条路,但我在写文档的时候做了个表格统计配置项与使用频率的关联,发现80%的配置改动都集中在五个点上:项目根目录、Prisma schema路径、tRPC router目录、页面目录、是否覆盖现有文件。

剩下的需求都是伪需求。比如有人问要不要支持单引号还是双引号,我当时的想法是:生成器输出的是代码,你完全可以在生成之后跑一次eslint --fix,没必要让生成器替编辑器干这活。配置项少还有一个隐藏好处:使用者不容易配错。配置界面的产品逻辑是“选择越多越自由”,但代码生成器的产品逻辑是“选择越少越不容易偏离主流程”。最终我保留了五个配置项,跑通一个项目只需要改前三项,后两项都有合理的默认值。

4. 生成管线的四个核心阶段:解析、校验、渲染、落盘

4.1 解析阶段:我用手写parser,没上通用解析库

严格来说,t3code的模型描述语法非常接近Prisma schema,所以理论上可以直接用Prisma的schema解析器。但我不想引入那么大的依赖,而且Prisma schema的完整语法比我需要的DSL复杂得多——它支持多数据源、多Generator、视图、枚举、原生类型函数等一大堆东西。我只关心模型定义的一个子集,引入完整解析器只会让错误信息变得不直观。

所以我手工写了一个大约四百行的递归下降解析器。Token的类型很简单:标识符、类型名、@修饰符、括号、逗号、问号、换行、注释。每解析到一个model块,就往模型列表里塞一个对象。字段的修饰符不立即做语义校验,只先挂到原始AST上,等下一步统一处理。

这个过程有个经验可以参考:不要把解析和校验混在一起写。很多初学者会把“发现类型错误”直接丢在解析逻辑里,结果是一个解析器里塞了十几条错误分支。我把解析器限制为只做语法结构识别,任何语义上的问题(比如关系字段没有对应外键)都延后到校验阶段,代码的可读性和可测试性立刻提升了不少。

4.2 校验阶段:关系完整性检查比语法检查更能救命

语法解析通过之后,生成器进入校验阶段。这一步在生成任何文件之前执行,目的是拦截那些“语法没问题但业务上必然出错”的模型描述。校验规则目前有六条,我挑几条典型的说:

第一,关系必须成对出现。如果A模型声明了到B模型的一对多关系,B模型必须声明反向关系字段,且反向关系的@relation括号里的名字要一致。这个约束来源于Prisma的严格要求,我的生成器直接沿用。第二,主键不能是可选字段,且必须是String或Int类型。第三,外键字段的类型必须与被引用模型的主键类型一致。第四,@default的常数值要跟字段类型匹配,字符串默认值不能给到Int字段上。

校验通过以后,生成器会构建一个“标准化模型对象”,所有字段的默认值、可选性、关系信息都以统一结构保存。后面的渲染阶段只认这个标准对象,不直接认AST。这一步很重要,因为渲染模板需要频繁查询字段属性,如果每次解析AST,代码会慢而且难维护。标准化之后,模板里的数据访问路径变成了model.fields.map(f => f.name),非常直观。

4.3 渲染阶段:手写模板引擎还是拼接字符串

渲染阶段我有过两个选择:用现成的模板引擎,比如Handlebars或EJS,或者自己拼字符串。选模板引擎的理由是社区成熟、转义安全;自己拼的理由是生成器输出的是结构化代码,每个文件的结构差异很大,模板引擎的语法反而成了另一种心智负担。

最终我选了折中方案。列表页、表单页这种结构非常模板化的代码,用Handlebars模板渲染;Prisma schema和tRPC router这种逻辑分支多、需要条件判断的文件,用手写拼字符串。后来又重构了一次,把拼字符串的部分抽成了一个个renderXxx函数,每个函数负责渲染文件里的一块内容,比如renderListQuery、renderCreateInput、renderRelationField。这样做的结果是,我可以对单个函数做单元测试,后面加字段类型支持时,只需要动一个render函数,不用担心破坏整个模板。

渲染阶段还有一个细节,就是缩进和空行。生成的代码不是给人手改的,但调试时人一定会打开看。如果缩进混乱、空行随意,排查起来很难受。我在render函数里统一维护一个indentLevel变量,所有输出方法都基于它做前缀,确保生成的代码格式一致。实测下来,这个细节虽然不起眼,但团队引入t3code的意愿高了很多,没人愿意看“能跑但很丑”的代码。

4.4 落盘阶段:覆盖策略和文件备份才是真正的大坑

生成代码本身不难,难的是落盘时怎么处理现有文件。假设你的项目已经存在一个手写的userRouter,生成器是默认覆盖还是跳过?如果覆盖,手工加的定制逻辑就没了;如果跳过,新加字段可能不会同步到既有文件。

我最后实现的策略是:生成器落盘前先逐个检查目标文件是否存在,如果不存在直接创建;如果存在,比较生成内容和现有内容的哈希,相同则跳过,不同则生成一个.bak.t3code后缀的备份文件,然后用新内容覆盖。这样既不会静默覆盖手写改动,又给了反悔的机会。备份文件名带上后缀而不是直接改名成.old,是为了让Git的diff能一眼看出哪些文件是生成器动过的。

还有一个容易忽视的问题是行尾符。在Windows上开发的人,Git默认可能会把CRLF转成LF,如果不统一处理,每次重新生成代码都会制造大量无效diff。我在落盘前统一把行尾符转成LF,同时确保文件末尾有且只有一个换行。这个细节很小,但如果你在团队里用,能避免无数次“为什么这次diff里全是整行变化”的困惑。

5. 跑完一轮生成测试后,我面对的真实翻车现场

5.1 Prisma字段名和前端字段名的默认值不一致

第一版t3code生成的Prisma schema里,字段类型映射完全照搬DSL,但字段名没有做任何转换。也就是说DSL里写了displayName,Prisma schema里也是displayName。这本身没错,但我忘了数据库列名在团队现有项目里是snake_case。同事的数据库列是display_name,DSL里按TypeScript习惯写了displayName,生成器直接映射过去,Prisma启动时就报字段找不到,得靠@map显式映射。

这个问题的根因不是技术,而是对“TypeScript字段命名”和“数据库列命名”两套规则的混淆。修复方案是在DSL的修饰符里加@map支持,但没有@map时,我会根据一个配置项自动把驼峰转成snake_case作为数据库列名。默认开启自动转换,避免了绝大多数手写命名冲突。

5.2 生成的ESM模块在Node环境里加载失败

项目里的服务端代码是ESM模块规范,生成tRPC router时应该用import语法。我第一版渲染函数里偷懒,写死了相对路径前缀import { z } from 'zod'。单文件看起来没问题,但生成多个文件后,某个router引用了另一个schema文件,路径写成user/schema导致模块解析失败。这类问题在开发环境不一定暴露,因为Next.js的编译层可能帮你兜住了,但单独跑tsx或jest测试时就原形毕露。

解决方式是我在渲染阶段增加了路径计算逻辑:每个输出文件都知道自己的输出目录,引用其他文件时,统一用path.relative计算相对路径,再以.posix方式处理分隔符。这听起来简单,但很容易被忽略,尤其是你只在macOS上开发时,永远不会踩到Windows路径分隔符的坑。后来我专门写了一个路径测试用例,在CI里把生成结果放到临时目录,用Node的ESM loader实际加载一遍,确保所有import都能解析。

5.3 二次生成时,手工加的定制代码被“优化”掉了

项目上线第三周,团队负责通知模块的同事跑来问我:他手工在生成的router里加了一个customSendNotification方法,重新跑生成器以后这个方法没了。我当时的逻辑是:如果目标文件已存在,直接备份后覆盖,所以手写代码确实会被冲掉。这个设计在原则上是安全的,因为备份还在,但体验极差,同事得手动从.bak文件里把方法抄回来。

我重新思考了“生成代码”和“手写代码”的边界问题。最后确定的方案是:生成器在tRPC router内部保留一个“手写扩展区”,用一对注释标记(// t3code:manual-start和// t3code:manual-end)。重新生成时,会先扫描现有文件,把这两个标记之间的内容原样保留,再和生成的代码合并。虽然这给渲染逻辑增加了不少复杂度,但值得。它把一个“生成器不能碰的文件”变成了“生成器可以反复更新的文件”,这才真正解决了二次生成的实际痛点。

5.4 列表页的搜索字段生成了,但搜索逻辑没生成

这是比较隐蔽的一个问题。列表页的搜索条件需要白名单,不是所有字段都适合搜索。比如ID字段用毫无意义的包含匹配,Boolean字段做“模糊搜索”更是无稽之谈。第一版生成器给每个字符串字段都生成了搜索条件,结果是页面搜索框下拉列表长得吓人,查询效率也差。数据库里一个文章表三四十个字段,一大半加了模糊搜索条件,直接导致索引失效。

后来我规定了一个默认策略:只有标了@searchable修饰符的字段,才会出现在列表页搜索区;没有标的一律不生成搜索逻辑。生成器会对没有搜索条件的模型自动取消list页的搜索区渲染,避免出现一个空空的搜索框。这个教训的核心是:代码生成器不能机械地“有字段就生成”——它也得懂一点业务语义,哪怕是用最笨的修饰符标记,也比全部字段一刀切好得多。

6. 如果你也想动手写代码生成器,我最想提前告诉你的三件事

6.1 先用手工整理核查表,再考虑自动化

很多程序员一上来就写工具,写完了自己都不敢用。原因往往不是技术不行,而是没想清楚工具到底要覆盖哪些情况。我建议你先用手工方式改十个模块,每改完一个就记录一次“我动了哪些文件、哪些命名规则、哪些边界条件”,三个模块之后,共性就浮现了。t3code最早期的设计文档就是一张Excel表,列着项目里常见的模型字段、默认交互、命名约定。拿着这张表再设计DSL,比凭空画架构图靠谱十倍。

6.2 生成代码一定要带“自描述信息”,否则三个月后没人敢碰

生成器最容易被抵触的一点是:代码不是人写的,出了问题没人愿意看。为了缓解这个问题,t3code在每个生成文件的顶部都放了一段固定的注释,写明这是由t3code自动生成的,并标明生成时间、源DSL文件路径、以及“请勿手动修改,手动修改会在重新生成时被覆盖,自定义逻辑请写在manual标记区域”。这行注释看似不起眼,但它能避免非常多的无效沟通。团队里有人想改生成文件时,看到注释就知道该去哪里改,不用来问我。

6.3 生成器本身也要有测试,而且是“生成→编译→运行”的完整测试

给代码生成器写测试,比普通业务代码更讲究。你不能只测渲染函数输出的字符串是否包含某段内容,因为字符串匹配也许没问题,但生成的代码编译不过、运行报错,等于白搭。我后来搭建了一套端到端测试:启动一个临时目录,跑t3code生成完整项目,然后执行tsc --noEmit做类型检查,再跑一次最小化的CRUD流程。CI里的每次改动都会触发这套流程。很多逻辑上的问题,比如某个字段没有导出、某个import路径写错,都是靠这套测试发现,而不是靠人工review发现的。

如果你打算写自己的生成器,我强烈建议从一开始就把这套测试建起来,哪怕初期只有一个模型、两条用例。等模型多了再补测试,会很痛苦,因为你不记得当初哪些边界场景是特殊处理的。

7. t3code目前的边界和接下来想解决的问题

7.1 它不擅长处理非标业务逻辑

这是所有代码生成器的天然边界。t3code能很好地处理标准的CRUD、分页、搜索、权限字段校验,但一旦某个页面有复杂的状态流转,比如订单的“待支付→已支付→已发货→已完成”,生成器只能生成最基础的status字段读写法,流程本身还得手写。我建议团队在使用时把握一个原则:生成器负责地基,手写代码负责差异化,两者用manual注释区隔开,谁也别越界。

7.2 多对多关系的生成还停留在“生成关联表”这个粗颗粒

目前t3code对多对多关系的处理比较简单,生成中间表模型,但不生成中间表对应的管理界面。如果你的项目里夹带了很多标签、分类、订阅这类多对多场景,中间表的管理界面往往需要定制。我在考虑给DSL增加relation的级联操作修饰符,比如onDelete: Cascade,这样至少能把依赖数据库层面的完整性约束扛住,界面部分再做一层自定义扩展。

7.3 可观测性:生成代码的执行日志太弱

生成器本身跑得快不快其实无所谓,但生成出来的业务接口有没有被人调用、响应慢不慢、错误集中在哪个输入上,这些在t3code生成的代码里目前没有自动埋点逻辑。这个我计划用一个可选的logger插件来实现,接入点位定在list和mutation的finally分支,输出每次操作的耗时、入参摘要和错误信息。等这个插件写完之后,我打算把t3code调整成“脚手架生成 + 运维可观测”的两段式结构,让生成代码的执行情况不再是个黑盒。

这些边界的存在,不代表t3code不好用,而是说明“代码生成器”这东西没法一劳永逸。它是你和项目之间的一层辅助决策,不可能替代人对业务的理解。每次把边界往前提一点,工具的价值就大一点,但提得过快,又会引入复杂度。我现在的节奏是一个季度动一次核心逻辑,中间只修bug和补测试。

如果你也在做类似的东西,或者你正在跟手写CRUD死磕,不妨试试把数据模型当成唯一真相源,把重复劳动交给生成器,然后腾出精力去处理那些真正需要人判断的部分。这是我做完t3code之后最大的体会:工具的产出不该是代码本身,而应该是让人不再纠结于“改一处漏三处”的确定性。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询