☰
从 Prisma Schema 到类型安全 CRUD 代码:t3code 命令行工具开发实践
2026/10/26 23:32:47 网站建设 项目流程

拿到 t3code 这个名字的时候,我正被 T3 Stack 的样板代码折磨得够呛。用过 create-t3-app 的人应该懂:项目能起步,但一涉及真实的业务 CRUD,就得反复手写 tRPC router、zod schema、类型安全的请求体校验,以及前端那一套 useQuery 调用代码。这些代码彼此高度相似,只是字段名和类型不同,写多了既伤手又伤神。t3code 最开始就是冲着这个痛点去的——我想让"数据库 schema 定义完,业务接口代码自动生成"这件事变得足够可靠。

这篇文章不是介绍一个遥不可及的大型框架,而是完整复盘我做 t3code 的过程:它解决什么问题、内部如何设计、怎么从零搭出一个能用的命令行工具,以及在开发过程中真实踩过的几个坑。如果你也正在做代码生成器、CLI 工具,或者单纯想了解"从 Schema 到类型安全 CRUD 代码"的完整链路,这篇应该能给你不少能直接落地的参考。

1. 从痛点出发:为什么我会动手写 t3code

1.1 T3 Stack 的吸引力与"样板代码焦虑"

T3 Stack 走红不是没道理的:TypeScript 全栈、类型安全、API 层和服务端逻辑高度耦合,这套组合拳打下来,应用的健壮性确实比传统 Express + 任意前端要稳得多。但稳是有代价的,代价之一就是重复劳动。

举个例子,你有一张Article表,字段是title、content、published、authorId。在一个标准 T3 项目里,你需要做这些事:

  • 在prisma/schema.prisma里定义 model;
  • 在src/server/api/routers/article.ts里手写create、read、update、delete四个 procedure;
  • 为每个 procedure 定义输入校验(zod object),并且把select或include写清楚;
  • 在前端对应页面里,写useQuery、useMutation的调用,再把类型小心翼翼地对齐。

遇到第二个表Comment,再来一遍;第三个表Tag,继续。项目还没复杂到需要抽象的程度,人已经麻了。

1.2 "t3code" 到底想解决什么(核心定位)

t3code 不是要替代 T3 Stack,它只是一个"样板代码生成器"。核心定位可以用一句话概括:

输入一个 Prisma Schema,输出一组类型安全、可直接运行、可继续手动修改的 tRPC 路由代码和前端的 API 调用代码。

我给它定了几条硬性目标:生成的代码必须是 TypeScript 严格模式下的"干净代码",不能有any;生成的 tRPC procedure 必须能在create-t3-app的默认项目里直接放进src/server/api/routers/下运行;前端生成的调用代码必须和 tRPC 的类型推断完全打通,不让用户手动去写泛型。

第一版我没有做得很复杂,只做了一件事:单表 CRUD 生成。也就是一张表对应一个 router 文件,里面包含create、getById、update、delete、list五个操作,外加一个简单的分页listByCursor。复杂业务里用户肯定会改代码,但至少拿到的是一个正确的、能跑的起点。

1.3 项目范围:第一版只做一件事

有人说"代码生成器最终会变成一个大而全的框架",我不认同。项目范围不控制住,很容易在还没解决核心问题之前就死在配置项上。所以 t3code 第一版只做一条链路:

prisma.schema → t3code generate → tRPC router + zod schema + React hooks

其他比如鉴权、多对多关系、字段级别的权限策略,全都留到后续版本。不是不需要,而是"让核心用例跑通、跑稳"比"功能多到让人不敢用"重要得多。事实也证明,这个项目管理决策帮我省掉了至少七百行无效代码。

2. t3code 的工作原理:模板、AST 与类型映射

2.1 整体架构:配置驱动 + 模板渲染

如果你做过代码生成器,大概率会采用两种路径:一种是直接拼字符串,简单粗暴但难维护;另一种是模板引擎 + 数据模型,写起来像"套 T 恤",灵活度和可维护性都更高。t3code 走的显然是后者。

我把它拆成三个相对独立的阶段:

  1. 解析 Prisma Schema,转成内部模型(ModelDefinition);
  2. 根据内部模型,结合配置文件(比如字段排除、路由名前缀),生成一个"上下文对象";
  3. 把上下文对象交给模板引擎渲染,最终写入文件。

这三个阶段对应的目录结构大致如下:

t3code/ ├── src/ │ ├── parser/ # Prisma schema 解析 │ ├── model/ # 内部数据模型定义 │ ├── generator/ # 模板渲染与文件生成 │ ├── cli/ # 命令行入口 │ └── templates/ # .hbs 模板文件

这样做的好处是,每个环节可以单独测试。解析器出错,不影响模板;模板语法调整,不影响解析逻辑。后面我在写"黄金文件"测试时也轻松很多。

2.2 Prisma schema 如何被解析成中间模型

Prisma schema 的语法看着简单,但真正写起来会有很多变体:@relation、@default、@unique、@@index、enum、decimal、Json类型……这些都必须完整处理,否则生成的代码就会在用户的项目里"炸雷"。

为了稳妥,我没有直接用正则去啃 schema 文件,而是用了 Prisma 官方提供的@prisma/internals里的getDMMFAPI。这个 API 可以把 schema 转成 DMMF(Data Model Meta Format),里面包含了所有 model、字段、类型、索引、默认值等结构化信息。

import { getDMMF } from '@prisma/internals'; const dmmf = await getDMMF({ datamodel: schemaContent });

拿到dmmf.datamodel.models之后,我会把它重新整理成自己定义的中间模型:

interface FieldDefinition { name: string; kind: 'scalar' | 'object' | 'enum' | 'unsupported'; isList: boolean; isRequired: boolean; hasDefault: boolean; type: string; relationName?: string; } interface ModelDefinition { name: string; dbName: string | null; fields: FieldDefinition[]; primaryKey: string | null; uniqueFields: string[][]; isGeneratable: boolean; }

这一步看起来有点"多此一举",直接把 DMMF 塞给模板不就行了吗?实际上不行。DMMF 为了兼容 Prisma 的各种内部场景,结构非常深,里面有大量字段业务上根本用不到;而且 DMMF 的字段名不稳定,不同 Prisma 版本之间变过好几次。自己定一个稳定的中间模型,能保证模板层永远不受底层 Prisma 版本变化的影响。

2.3 类型映射规则:Prisma 类型到 TypeScript/JSON Schema

Prisma 的字段类型跟 TypeScript 类型不是一一对应的。比如 Prisma 的DateTime在 TS 里一般是Date,但在 API 输入校验里,客户端传过来的通常是 ISO 字符串;Decimal在 TS 里可能对应Prisma.Decimal,但 JSON 传输时又得处理序列化。

我在 t3code 里定义了一张类型映射表:

Prisma 类型生成的 TS 类型(服务端)zod 校验器前端 API 入参类型
Stringstringz.string()string
Intnumberz.number().int()number
Floatnumberz.number()number
Booleanbooleanz.boolean()boolean
DateTimeDatez.coerce.date()string(ISO)
JsonPrisma.InputJsonValuez.unknown()unknown
Enum对应枚举类型z.nativeEnum(...)枚举值
DecimalPrisma.Decimalz.any()(特殊处理)string

这里有一个很容易被忽视的坑:z.coerce.date()比z.date()好用得多。因为前端组件传日期的时候,多半是一个YYYY-MM-DD的字符串或者Date对象,直接用z.date()会导致服务端收到字符串时校验失败。用z.coerce.date()可以自动把 ISO 字符串转成Date实例,兼容性更好。

Json类型是另一个大坑。Prisma 的Json字段写入时要求是Prisma.InputJsonValue,但很多前端同学喜欢直接传 null 或数组,这会导致类型不匹配。我最终选择了"宽松入参 + 服务端二次序列化"的策略:前端 zod 用z.unknown(),然后在 tRPC procedure 内部用JSON.parse(JSON.stringify(value))把它转成 Prisma 能识别的普通 JSON 结构。这个过程可能丢失部分Date类型,但业务场景中 JSON 列本来就该以 JSON 格式为准。

3. 从零搭建 t3code 的命令行与核心流程

3.1 工程初始化:为什么选 pnpm + tsup

t3code 是用 TypeScript 写的 CLI 工具,发布到 npm 上供人通过npx使用。工程化选型我比较保守:包管理用 pnpm,构建用 tsup,Node 版本要求>=18。

pnpm 的好处不必多说,依赖安装快、磁盘省。tsup 则是我对比过tsc和rollup之后的选择:它是基于esbuild的,打包速度非常快,而且支持直接生成esm和cjs双格式。CLI 工具最好两个格式都出,因为用户项目可能是 ESM 也可能是 CommonJS,npm 包的exports字段要同时兼顾。

项目里的tsup.config.ts大概长这样:

import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/cli.ts'], format: ['esm', 'cjs'], dts: true, clean: true, sourcemap: true, target: 'node18', });

其实如果只是内部用,cjs就够了。但发布开源工具的话,dts也要开,否则别人想 import 你的核心 API 时没有类型提示,体验会大打折扣。

3.2 入口参数设计:init / generate / watch

CLI 设计上,我参考了create-t3-app的命令风格,也参考了 Prisma 自己的 CLI 感觉。t3code 提供三个子命令:

t3code init t3code generate t3code watch
  • init:在项目根目录生成t3code.config.ts配置文件,里面可以指定 schema 路径、输出目录、路由前缀等。
  • generate:核心命令,解析 schema,渲染模板,写入代码。
  • watch:监听 schema 文件,改动后自动重新生成。

init看起来简单,但其实是"最提升好感度"的功能。很多开发者不喜欢记配置,你把一个模板文件给他,他只需要改几个路径就行。配置文件默认长这样:

import { defineConfig } from 't3code'; export default defineConfig({ schema: 'prisma/schema.prisma', output: 'src/generated', routerPrefix: 'api', excludeModels: ['User'], });

generate的执行顺序是:读配置 → 校验 schema 文件存在 → 解析 DMMF → 遍历 model → 渲染模板 → 写入文件。每个模型独立生成一个.ts文件,这样用户做版本管理的时候能看到每个文件的 diff,比一个大文件友好得多。

3.3 模板渲染引擎的选择:Handlebars 还是 EJS

模板引擎是最容易引发争论的点。我先后试了 EJS和 Handlebars,最后选了 Handlebars。

选择 Handlebars 的原因有两条。第一,它的语法"逻辑弱、表达强",{{#each}}、{{#if}}足够应付 90% 的场景,天然逼迫你不要在模板里塞太多业务逻辑;第二,它天然支持"元数据",比如 path、dash-case 等等,而 EJS 虽然写起来更像 JS,但也更容易写出"模板里跑循环嵌套三层"这种烂代码。

我没有给 Handlebars 加太多自定义 helper,只加了 4 个:

  • camelCase:把article_title变成articleTitle;
  • pascalCase:变成ArticleTitle;
  • pluralize:处理单复数(article→articles);
  • eq:方便在模板里做等值判断。

这里有个重要的经验:不要在模板里用{{#if}}判断复杂的"联合条件",比如(a > 0 && b < 1)。Handlebars 不支持直接写逻辑表达式,强行用 helper 会非常难读。我通常会在上下文对象里预先算好一个布尔值字段,比如isList、isSearchable,模板里只需要做{{#if isSearchable}},思路清晰得多。

3.4 文件写入策略:幂等生成与冲突处理

生成代码容易,难的是"重复生成不覆盖用户的修改"。t3code 的策略很简单:默认生成时采用"如果文件存在且内容相同就跳过,存在且不同则询问是否覆盖"。

这看起来土,但异常有效。第一版我直接"无脑覆盖",结果同事跑过一次生成,然后手动改了 router 里的业务逻辑,下次升级 schema 重新生成,改动全没了。从那以后我就记住了"面向重新生成场景设计文件写入"。

在实现上用到了prompts这个库做交互式确认。但 CLI 工具就怕交互,所以还提供了--force参数跳过确认。另外生成文件的时候,我会同时生成一个.t3code.sha1清单文件,记录每个生成文件的哈希值。这样下次生成时,只有上次由 t3code 生成且内容没被动过的文件,才会被自动覆盖;手动改过的文件,即使加了--force也会在终端先输出警告,给你一次反悔机会。

4. 一次完整的生成演示:从 schema 到 CRUD 代码

4.1 准备一个最小示例:博客文章模型

光说不练假把式。下面用一个极简的博客模型来实测一下 t3code 的生成结果。

假设prisma/schema.prisma里有这么一段:

model Article { id String @id @default(cuid()) title String content String published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

然后在项目根目录运行:

npx t3code generate --schema prisma/schema.prisma --output src/generated

默认情况下,t3code 会生成两个目录:

  • src/generated/server/:放 tRPC router 文件;
  • src/generated/hooks/:放前端 React hooks 调用文件。

4.2 生成后的目录结构与核心代码长什么样

实际生成的项目结构:

src/generated/ ├── server/ │ ├── article.ts │ └── index.ts └── hooks/ ├── useArticleQuery.ts └── useArticleMutation.ts

server/article.ts的内容大概是这样(我已经简化掉一部分注释和错误处理):

import { z } from 'zod'; import { createTRPCRouter, publicProcedure } from '~/server/api/trpc'; import { prisma } from '~/server/db'; const articleCreateSchema = z.object({ title: z.string().min(1), content: z.string(), published: z.boolean().optional(), }); const articleUpdateSchema = articleCreateSchema.partial(); export const articleRouter = createTRPCRouter({ create: publicProcedure .input(articleCreateSchema) .mutation(async ({ ctx, input }) => { return ctx.prisma.article.create({ data: input }); }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.article.findUnique({ where: { id: input.id }, }); }), update: publicProcedure .input(z.object({ id: z.string() }).merge(articleUpdateSchema)) .mutation(async ({ ctx, input }) => { const { id, ...data } = input; return ctx.prisma.article.update({ where: { id }, data }); }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.prisma.article.delete({ where: { id: input.id } }); }), list: publicProcedure .input(z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(10) })) .query(async ({ ctx, input }) => { const [items, total] = await Promise.all([ ctx.prisma.article.findMany({ skip: (input.page - 1) * input.pageSize, take: input.pageSize, orderBy: { createdAt: 'desc' }, }), ctx.prisma.article.count(), ]); return { items, total, page: input.page, pageSize: input.pageSize }; }), });

前端 hooks 文件则是基于 tRPC 的useQuery/useMutation封装,不需要手写泛型,因为 tRPC 的路由类型已经能被自动推断:

import { api } from '~/utils/api'; export function useArticleList(options: { page: number; pageSize: number }) { return api.article.list.useQuery(options); } export function useCreateArticle() { const utils = api.useUtils(); return api.article.create.useMutation({ onSuccess: () => utils.article.list.invalidate(), }); }

这里值得注意的一点是:生成代码时我刻意不引入任何自定义的axios或fetch,完全依赖 tRPC 的 client。为什么?因为 tRPC 的强项就是"类型从服务端自动流向客户端",如果另搞一套 HTTP 请求封装,等于把类型安全这条路走断了。

4.3 如何把生成结果接入 Next.js 与 tRPC

有了生成文件,集成到现有 T3 项目里只需要两步。

第一步,把src/generated/server/index.ts引入到src/server/api/root.ts里,像这样:

import { articleRouter } from '../generated/server/article'; import { createTRPCRouter } from '~/server/api/trpc'; export const appRouter = createTRPCRouter({ article: articleRouter, // 其他手动 router... });

注意,index.ts会自动导出所有生成 router 的集合,但我不建议直接解构展开,因为可能覆盖你已有的 router 名称。好的做法是显式引入每一个,见名知义。

第二步,确保trpc上下文里能拿到prisma实例。t3code 生成的代码依赖ctx.prisma,这个惯例在 create-t3-app 里默认就有,所以基本开箱即用。

集成完,npm run dev起来,在页面里调用useArticleList({ page: 1, pageSize: 10 }),类型提示、数据请求、缓存更新这些全都正常。到这里,"一份 schema 换一套 CRUD 接口"的目标就算实现了。

5. 开发过程中踩过的坑(排查链路实录)

5.1 模板中的{{ }}与嵌套模板冲突

这是所有使用 Handlebars 写代码生成器的人都会遇到的第一个坑。你想生成一个 zod schema,里面大量用到z.object({ ... }),如果模板里想要输出字面量的{{双大括号,就必须用\{{转义。

但问题是,有些场景下你会遇到"模板本身也是模板"的嵌套需求。比如生成代码里包含一个内联的handlebars模板字符串,或者某段代码里有{{ }}表示占位符。直接转义容易漏,漏掉一个,生成出来就是语法错误。

我在排查时用了很笨的办法:先全量搜索{{,然后把每一个都归入三类——需要渲染的、需要字面量输出的、需要部分渲染的。逐条检查后,我给 Handlebars 配了一个自定义 escape:

Handlebars.registerHelper('raw', (options) => { return options.fn(this); });

然后在模板里这样写:

{{#raw}}{{ userId }}{{/raw}}

这样{{ userId }}会原样输出成{{ userId }},不会被渲染成实际值。这个 helper 救了我很多次。

5.2 字段名为constructor时引发的类型覆盖问题

第一次用 t3code 生成代码后,有个同事反馈说某个模型的生成的代码里,字段总是拿不到正确的类型。排查了很久,发现问题出在模型里有个字段叫constructor。

我生成的模板里用了类似const { title, content, constructor } = input;这样的解构。而constructor在 JavaScript 里是对象原型上的保留属性,对象字面量解构时它不会从input自身取值,反而容易拿到原型链上的值。更坑的是,TypeScript 一般不会对这种写法报错,因为它觉得你是故意的。

最终的解决方案是在生成字段变量名时,遇到 JS 保留字,就自动添加后缀_,比如constructor_,并且在后续引用该字段的地方同步替换。这个逻辑我一开始没想到,是用户提 issue 之后才补上的。如果你自己写代码生成器,请务必维护一份"保留字列表"。

5.3 生成文件的路径别名解析:为什么需要二次格式化

T3 项目里普遍使用 tsconfig 路径别名~/*指向根目录,生成的代码大概率也要引用~/server/db这类模块。问题在于:生成的代码是从src/generated/server/这个相对较深的路径出发的,如果模板里写死~/server/db,那没问题,因为别名不区分文件位置;但如果用户期望用相对路径../../server/db,在模板里计算相对路径就成了灾难。

t3code 的立场是:既然用户用的是 T3 Stack,路径别名就默认~/*,在模板里直接写别名即可。同时还有一个隐形问题:换行和缩进。Handlebars 渲染出来的源代码往往格式不理想,比如空行过多、缩进混乱。所以生成的每个文件在写入前,我会用prettier过一次。

prettier的引入一开始我也觉得是过度设计,但实际使用后立刻明白了它的价值。在 t3code 的generate流程里,写完文件后统一执行一次格式化,不仅能保证代码风格一致,最关键的是能提前暴露语法错误。如果 prettier 格式化一个文件失败,说明生成结果大概率有语法问题,这时候直接停止并报错,比用户后续编译报错要友好得多。

5.4 watch 模式下的文件监听与重复生成的竞态

watch命令的开发比generate复杂一个量级。最开始我用chokidar监听 schema 文件,然后直接调用generate。结果发现一个诡异的现象:当 schema 文件被编辑器保存时,chokidar会触发多次事件,导致生成逻辑并发执行,输出的文件偶尔会出现半写状态。

排查过程其实很经典:

  1. 加日志,观察事件触发次数,确认是多次触发;
  2. 没加防抖,导致两个generate任务交叉跑;
  3. 修掉防抖问题后,又发现"删除旧生成文件"和"写入新文件"之间有时序差,如果删除和写入交错,就会有一瞬间文件不存在。

最终我写了这样一个 watch 处理逻辑:

const generateQueue = new PQueue({ concurrency: 1 }); watcher.on('change', async (file) => { if (file.endsWith('schema.prisma')) { debouncedRun(() => generateQueue.add(() => runGenerate(schemaPath))); } });

关键点是三个:单文件判断、防抖、队列串行。任何一个缺失都可能出问题。我还特意在每次生成前做一次"父目录存在性检查",生成后用fs.stat验证关键文件确实存在且非空,宁可多花几次磁盘 IO,也要避免静默失败。

6. 回归测试与后续演进方向

6.1 为什么最终写了一个"黄金文件"测试策略

代码生成器的测试比普通函数测试麻烦许多。你没法用传统单元测试去断言"模板正确性",因为生成的代码是文本,判断它好不好只能靠人工 review。但如果完全不测试,每次改个模板都有可能悄悄破坏别处的输出。

我采用了一个很实在的策略:黄金文件(golden file)测试。做法是先跑一轮generate,把生成结果中几个有代表性的文件存进test/golden/目录,然后测试的时候重新生成,和 golden 文件做逐字节 diff。如果 diff 不为空,说明模板逻辑变了,测试失败。

这个策略让 t3code 的模板改动变得非常安全。每次我改模板,只要运行npm test,就能立刻知道哪些模型生成结果受影响,然后人工确认 diff 是否符合预期。看起来粗暴,但效率极高。当然,做 golden file 测试的前提是"生成过程确定性足够强",所以我特意把所有模板里的时间戳、随机变量都剔除了。

6.2 接下来打算加入的能力:插件系统与关系处理

t3code 现在的状态是"单表 CRUD 够用",但真实业务里表之间一定有关系。比如Article和Tag是多对多,Comment依赖Article。下一步我想加入的能力有两个:一是自动识别 model 之间的外键关系,生成"嵌套查询"的预置逻辑;二是允许用户写自定义模板,也就是插件系统。

插件系统的思路是给模板渲染阶段加一个中间件钩子,用户在配置里提供beforeRender和afterRender方法,分别可以修改上下文对象和生成后的文本。这个功能做到位,t3code 就不只服务于 T3 Stack 用户,任何想拿 Prisma schema 生成自定义代码的团队都能复用它。

不过插件系统也是一把双刃剑。API 设计不好,用户会写出越来越怪的模板,最后回来骂你文档垃圾。所以我现在还在规划阶段,暂时不急着动工。工具类项目最怕的不是功能少,而是功能多到失去设计边界。


如果你也在考虑做类似的代码生成器,我个人的建议是:先把"输入一条 schema,生成一份可用的代码"这个闭环跑通,不要一开始就想着生成"完美的业务代码"。完美的业务代码里全是业务逻辑,而业务逻辑恰恰是人不应该让机器替你决定的。t3code 的使命是帮你省掉那些"复制粘贴再改名"的活,而不是让你变成一个只会执行生成命令的操作员。

最后分享一个小技巧:生成器项目的调试,一定要单独建一个"测试工程",而不是在自己真实业务项目里反复生成。测试工程可以很小,但要是正规的 create-t3-app 项目,这样每次修改生成逻辑后,直接在测试工程里跑npm run build检查类型,远比看模板语法正确性有意义。t3code 能走到现在,这个习惯帮了大忙。

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

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

立即咨询