手寫同步煩惱)
凌晨兩點(diǎn)我盯著屏幕上那條報錯心里只剩一個念頭再也不手寫樣板代碼了。當(dāng)時我正在維護(hù)一個基于 T3 代碼棧Next.js tRPC Prisma Zod的中型項目數(shù)據(jù)模型里的字段從字符串改成了枚舉Prisma 這邊改完忘了同步 tRPC 路由里的 Zod schema。結(jié)果前端提交訂單時服務(wù)端校驗直接把請求給攔了線上反饋接二連三。問題不算大但讓我意識到一個事實(shí)整個 T3 生態(tài)最引以為傲的端到端類型安全恰恰在最容易出錯的環(huán)節(jié)——模型、校驗、路由、前端類型之間的手工同步——把壓力全留給了開發(fā)者。我就是為了解決這件事寫的 t3code。它是一個面向 T3 代碼棧的 CLI 代碼生成與同步檢測工具核心思路是把 Prisma 數(shù)據(jù)模型當(dāng)作唯一事實(shí)來源自動生成 Zod 校驗 schema、tRPC 路由模板、前端類型定義和 API 調(diào)用封裝順帶在 CI 里幫你強(qiáng)制檢查模型改了但代碼沒跟上的情況。如果你正在用 Next.js 和 tRPC 寫全棧應(yīng)用又被重復(fù)的 CRUD 樣板拖得心煩這篇文章應(yīng)該能給你一點(diǎn)實(shí)際的參考。1. T3 代碼棧里的重復(fù)勞動從一次半夜事故說起T3 代碼棧用著確實(shí)爽路由、數(shù)據(jù)庫、校驗全在 TypeScript 的類型系統(tǒng)里閉環(huán)幾乎每個環(huán)節(jié)都有類型兜底。但閉環(huán)不等于自動鏈條里所有的連接件都需要你手寫去接Prisma 模型定義了數(shù)據(jù)庫結(jié)構(gòu)Zod 要單獨(dú)寫一份輸入校驗tRPC 路由里要寫查詢和變更邏輯前端還得自己掛 React Query 的 hook 才能消費(fèi)這些 API。四套代碼描述同一個實(shí)體任何一處不一致線上就會給你顏色看。1.1 那一次的線上故障一次字段改名引發(fā)的連鎖反應(yīng)說回開頭那次事故。業(yè)務(wù)上我需要把訂單的status字段從字符串改成聯(lián)合類型PENDING | PAID | SHIPPED | COMPLETED | CANCELLED。Prisma 模型里改枚舉幾分鐘的事數(shù)據(jù)庫遷移順手做了但 tRPC 路由的入?yún)⑿r灷飳?yīng)的 Zod schema 還是老樣子——z.string()。前端按新枚舉提交PAID服務(wù)端校驗自然通過問題出在另一個老接口某個回調(diào)邏輯還在傳舊字符串paid小寫Zod 直接拒絕訂單狀態(tài)更新全部失敗。排查過程不算難schema 不匹配的報錯一眼就能定位。但這件事讓我真正難受的是這類問題幾乎不可防。只要模型和校驗之間存在手工同步就永遠(yuǎn)存在改了一處、漏了一處的可能。TypeScript 能保證的是同一份代碼里類型一致可保證不了不同文件里手寫的類型定義和數(shù)據(jù)庫模型一致。一次字段改名的連鎖反應(yīng)大致是這樣修改schema.prisma中Order.status枚舉定義執(zhí)行遷移并重新生成 Prisma Client手改 Zod 校驗中的status字段手改 tRPC 路由中的輸出類型手改前端對應(yīng)的類型定義和枚舉常量五步里只要漏掉任何一步編譯期大概率不報錯運(yùn)行期才炸。這比編譯錯誤可怕得多因為線上反饋有延遲用戶已經(jīng)遭殃了你才知道。1.2 樣板代碼到底有多少統(tǒng)計一個典型 CRUD 模塊后來我做了一次統(tǒng)計更堅定了要寫工具的決心。一個典型的博客文章資源模塊只算基礎(chǔ) CRUD不加任何業(yè)務(wù)規(guī)則需要手寫哪些東西文件/位置大致內(nèi)容手寫行數(shù)Prisma modelPost模型的字段、關(guān)系、索引30-50 行Zod schema創(chuàng)建、更新、查詢參數(shù)的校驗80-120 行tRPC routercreatePost、getPost、updatePost、deletePost、列表查詢150-200 行前端類型從后端類型手動推導(dǎo)或重新聲明40-60 行API 封裝fetch/axios調(diào)用 錯誤處理60-100 行React Query hooksuseQuery、useMutation封裝80-120 行加起來一個模塊 440-650 行而且是最枯燥、最沒技術(shù)含量、最容易抄錯的那部分。我要說句實(shí)話這種代碼的價值低到幾乎為零但出錯的成本高到離譜。字段多一個、少一個類型不一致空值處理漏了都是這類手寫樣板的高發(fā)問題。t3code 的第一個目標(biāo)就是把這些低價值、高錯誤率的樣板代碼從日常開發(fā)里拿掉讓你把精力留給真正重要的業(yè)務(wù)邏輯。2. t3code 的設(shè)計思路與核心取舍寫工具之前我先把需求想清楚了。當(dāng)時市面上一堆低代碼平臺和可視化搭建看著很美好但你真用在生產(chǎn)項目里就知道生成的東西一復(fù)雜就不可維護(hù)。所以 t3code 的定位從一開始就很克制——它不是可視化平臺不跟你搶業(yè)務(wù)邏輯只是一個幫你把類型同步做好、把樣板補(bǔ)齊的代碼生成器。2.1 為什么是 CLI 而不是 VSCode 插件其實(shí)第一版我也考慮過做 VSCode 插件那樣保存文件自動生成體驗更順滑。但后來我放棄了原因有三CLI 可以進(jìn) CI。插件只能在本地用可模型改了代碼沒跟上這個問題最應(yīng)該攔截的地方是代碼評審和 CI 階段。CLI 加一個check命令誰改了 Prisma schema 沒跑生成CI 直接紅這才是真正的防護(hù)。CLI 不挑編輯器。團(tuán)隊里有人用 VSCode有人用 JetBrains有人用 Neovim插件方案會制造工具割裂。CLI 可以串流程。prisma generate t3code generate一條命令鏈解決所有生成問題插件做不到這種組合能力。所以最后我選擇了純 CLI 方案npx t3code搞定一切。這也讓工具天然具備腳本化的能力后續(xù)接 CI、接打包鉤子都很方便。2.2 一張表驅(qū)動一切以數(shù)據(jù)模型為唯一事實(shí)來源t3code 最核心的設(shè)計決策是把 Prisma schema 當(dāng)作唯一事實(shí)來源其他一切代碼都從這里推導(dǎo)出來。為什么不從 tRPC router 反推因為 router 里混著業(yè)務(wù)代碼解析起來不干凈為什么不搞一個獨(dú)立的數(shù)據(jù)字典文件因為多一個文件就多一份約束成本開發(fā)者遲早會忘記維護(hù)。Prisma schema 本身就是團(tuán)隊的數(shù)據(jù)契約你本來就要維護(hù)它拿它當(dāng)源頭最省事。整個生成鏈路是這樣的schema.prisma唯一事實(shí)來源 ↓ 解析模型字段、枚舉、關(guān)系 Zod schema 生成輸入校驗、輸出類型 ↓ tRPC router 模板生成自動包含 CRUD 基礎(chǔ)實(shí)現(xiàn) ↓ 前端類型 API 封裝 React Query hooks這條鏈路解決了一個核心問題同一個實(shí)體的描述只有一份其他全是推導(dǎo)結(jié)果。Post模型里的title: String生成了 Zod 的z.string()生成了 tRPC 的輸入類型生成了前端 hook 的入?yún)㈩愋吞烊灰恢略贌o手寫同步這回事。2.3 生成策略可覆蓋、可保留、可手動接管代碼生成工具最怕什么怕把你手寫的代碼覆蓋掉。t3code 的生成策略是三檔自動生成文件文件頂部帶// generated by t3code標(biāo)記再次生成時整體覆蓋。這類文件包括 Zod schema、前端類型、API 封裝它們本來就該是純推導(dǎo)結(jié)果你不應(yīng)該手改。半自動模板文件t3code 生成 tRPC router 的骨架但用// t3code-preserved-start和// t3code-preserved-end標(biāo)記出保留區(qū)。保留區(qū)里的業(yè)務(wù)代碼在重新生成時不會動保留區(qū)外的模板部分會被刷新。這樣既享受生成效率又不犧牲業(yè)務(wù)靈活性。手動接管任何文件只要去掉頂部生成標(biāo)記t3code 就再也不碰它。適合那種已經(jīng)被你重度改造、和模板差距太大的文件。這個設(shè)計參考了代碼生成領(lǐng)域常見的做法生成器只負(fù)責(zé)生成交接給使用時要有清晰的邊界。模板代碼和手寫代碼混在一個文件里靠保留區(qū)做隔離是我試過的最穩(wěn)定的方案。3. 上手實(shí)操從初始化到跑通一個 CRUD 模塊光聊設(shè)計理念沒有用真正好不好用得跑一遍才知道。下面我用一個具體的例子完整演示怎么用 t3code 從零生成一個帶 CRUD 的模塊。3.1 安裝與初始化t3code 可以直接通過 npx 使用也可以裝成項目依賴。我建議裝成 devDependencies保證團(tuán)隊里所有人版本一致CI 環(huán)境也好復(fù)現(xiàn)pnpm add -D t3code然后執(zhí)行初始化npx t3code init初始化向?qū)柲銕讉€問題項目路徑、是否啟用 React Query hooks 生成、包管理器類型等等。初始化完成后項目根目錄生成一個t3code.config.tsimport { defineConfig } from t3code; export default defineConfig({ project: ., prismaSchema: prisma/schema.prisma, output: { zod: src/server/schemas, trpc: src/server/routers, client: src/lib/api, hooks: src/hooks, }, conventions: { inputSuffix: Input, outputSuffix: Output, }, features: { reactQuery: true, swr: false, }, });注意一個容易被忽略的點(diǎn)配置文件里所有路徑都是相對于配置文件所在目錄的不是相對于你執(zhí)行命令的目錄。這個設(shè)計是我踩過坑之后特意確定的因為 CLI 工具最容易出的問題就是在子目錄里跑命令生成位置全錯了。后面我會在踩坑章節(jié)詳細(xì)說。3.2 第一個生成命令跑通一個 Post 模塊假設(shè)prisma/schema.prisma里有這樣一個模型model Post { id String id default(cuid()) title String content String published Boolean default(false) authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }執(zhí)行生成命令npx t3code generate默認(rèn)情況下t3code 會為每個模型生成一套完整的 CRUD 代碼。生成的目錄結(jié)構(gòu)大致是src/ ├── server/ │ ├── schemas/ │ │ └── post.schema.ts │ └── routers/ │ └── post.router.ts ├── lib/ │ └── api/ │ └── post.ts └── hooks/ └── use-post.ts其中post.schema.ts長這樣// generated by t3code - 手動修改此文件將被覆蓋 import { z } from zod; export const PostInput z.object({ title: z.string().min(1), content: z.string(), published: z.boolean().default(false), authorId: z.string().min(1), }); export const PostOutput z.object({ id: z.string(), title: z.string(), content: z.string(), published: z.boolean(), authorId: z.string(), createdAt: z.date(), updatedAt: z.date(), }); export type PostInput z.infertypeof PostInput; export type PostOutput z.infertypeof PostOutput;post.router.ts里的 CRUD 骨架大概是這樣import { router, publicProcedure } from ../trpc; import { PostInput } from ../schemas/post.schema; export const postRouter router({ create: publicProcedure .input(PostInput) .mutation(async ({ ctx, input }) { // t3code-preserved-start // 這里可以寫自定義業(yè)務(wù)邏輯比如權(quán)限校驗、事件發(fā)布等 // t3code-preserved-end return ctx.prisma.post.create({ data: input }); }), list: publicProcedure .query(async ({ ctx }) { return ctx.prisma.post.findMany(); }), detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { return ctx.prisma.post.findUnique({ where: { id: input.id } }); }), });可以看到生成的文件覆蓋了創(chuàng)建、列表、詳情這三個最常用的操作。更新和刪除默認(rèn)沒有生成因為更新涉及字段級別的權(quán)限控制刪除涉及級聯(lián)行為這些跟業(yè)務(wù)耦合太深模板化了反而不安全。需要的時候你在保留區(qū)里手寫就好或者把 router 文件頭部的生成標(biāo)記刪掉完全手動接管。3.3 增量更新與 watch 模式開發(fā)過程中最頻繁的場景是模型加了一個字段或者改了一個字段名。跑一遍t3code generate能全量刷新但每次都手動跑很煩所以我加了watch模式npx t3code watch這個命令會監(jiān)聽prisma/schema.prisma的變化每次保存自動增量生成。請注意它不是簡單地全量重跑而是做了字段級別的 diff只有新增或修改的字段對應(yīng)的 Zod、類型、API 封裝才會被重新生成其他文件保持不變。這樣 git diff 干凈評審時一眼就能看到改動影響范圍。舉個例子我把Post模型加上tags String[]保存schema.prisma的瞬間watch 會自動幫我把PostInput和PostOutput里的tags: z.array(z.string())加上同時src/lib/api/post.ts里的類型定義也跟著更新。整個過程不需要我碰任何一個手寫文件類型安全鏈條自動閉合。4. 把 t3code 接入真實(shí)項目工作流工具寫出來只是第一步讓它融入團(tuán)隊日常開發(fā)流程才是關(guān)鍵。我實(shí)際推進(jìn)的過程中發(fā)現(xiàn)代碼生成器最怕的不是功能不夠而是沒人記得跑。所以需要把生成動作嵌進(jìn)已有的工作流里形成習(xí)慣甚至形成強(qiáng)制約束。4.1 與 CI/CD 結(jié)合讓類型同步變成強(qiáng)制約束前面提到模型改了但代碼沒跟上這類問題最好的攔截點(diǎn)其實(shí)是 CI。t3code 專門提供了一個check命令它會重新生成一遍代碼然后和當(dāng)前倉庫里的文件做對比如果有差異就返回非零退出碼。在package.json里加一個腳本{ scripts: { typecheck: tsc --noEmit, codegen:check: t3code check } }然后在 CI 流程里加一步- name: Check type-safe codegen run: pnpm codegen:check有了這一步誰要是改了 Prisma schema 忘了跑 generateCI 直接紅PR 根本合不進(jìn)去。這比任何代碼評審建議都管用因為機(jī)器檢查是零成本、零遺漏的人審總會漏。我甚至見過團(tuán)隊把這個 check 和 lint 合并成一個 pre-push hook本地就能擋住大部分問題。4.2 與編輯器 Task 整合保存即生成無感使用CI 解決的是事后攔截但本地開發(fā)里字段改完馬上自動生成體驗更順。我不建議每個人手動開一個t3code watch終端更好的方式是交給編輯器任務(wù)。在 VSCode 里.vscode/tasks.json可以這樣配置{ version: 2.0.0, tasks: [ { label: t3code: watch, type: shell, command: npx t3code watch, isBackground: true, problemMatcher: [] } ] }配合.vscode/settings.json讓它在打開項目時自動啟動{ task.autoDetect: on }這樣團(tuán)隊里每個人 clone 完項目、打開 VSCodewatch 任務(wù)自動在后臺跑起來不需要任何人背命令。這套組合拳打下來生成器才算真正隱形了——你感覺不到它的存在但它的確在替你守著類型安全。4.3 生成的代碼到底要不要提交進(jìn)倉庫這個問題團(tuán)隊里爭論了很久我的結(jié)論是提交且必須提交。理由很簡單tRPC 路由、Zod schema 這些生成物直接參與運(yùn)行時邏輯如果只留在本地那么 CI 類型檢查、部署構(gòu)建、壞境復(fù)現(xiàn)全部依賴先跑一次生成。但凡哪一步漏了線上跑的就是和倉庫里不一致的代碼排查起來非常痛苦。生成物提交進(jìn)倉庫配合上面的check命令能保證倉庫狀態(tài)和生成器始終一致。這也是很多成熟代碼生成工具比如 Prisma Client 本身的生成產(chǎn)物的做法。你只需要在 README 里寫清楚改 schema 后請跑pnpm codegen并提交生成文件即可。生成的文件不用能工去考究格式反正都是機(jī)器產(chǎn)物git 歷史看起來丑一點(diǎn)也無所謂。5. 踩坑實(shí)錄我在真實(shí)項目中遇到的六個問題任何工具寫出來都要經(jīng)歷真槍實(shí)彈的考驗。下面是 t3code 在落地過程中遇到的最典型的六個問題每一個都是血淚教訓(xùn)寫出來幫你避坑。5.1 路徑解析的坑cwd 不等于配置文件目錄CLI 工具最常見的 bug 就是路徑錯亂。用戶可能在packages/web目錄下執(zhí)行npx t3code generate但配置文件在項目根目錄而 Prisma schema 可能又指向prisma/schema.prisma這個相對路徑。我第一版實(shí)現(xiàn)用的是process.cwd()來解析所有路徑立刻被隊友噴了——只要你在子目錄里跑命令生成位置就全亂了。后來改成所有相對路徑都以配置文件所在目錄為基準(zhǔn)解析命令行參數(shù)只能是絕對路徑或相對配置文件的路徑。順手加了一條校驗如果檢測到 cwd 和配置目錄不一致打印一行提示。這個改動之后再沒出現(xiàn)過生成到哪去了的疑惑。5.2 Prisma Client 生成順序生成的代碼引用了還不存在的 clientt3code 生成的 router 代碼會有ctx.prisma依賴而ctx.prisma來源于 Prisma Client。如果你剛 clone 完項目、還沒跑過prisma generate就直接跑t3code generate生成的 TypeScript 文件會因為找不到prisma/client類型而報錯。解決方式是在項目腳本里把兩條命令串起來{ scripts: { codegen: prisma generate t3code generate } }這里有一個小細(xì)節(jié)prisma generate必須在t3code generate之前跑不能反過來因為 t3code 解析 Prisma schema 時需要用到 Prisma 的某些內(nèi)部類型定義尤其是枚舉類型。順序反了會出現(xiàn)莫名的解析錯誤別問我怎么知道的。5.3 Zod 枚舉和 Prisma 枚舉的類型一致性Prisma 里的enum字段和 Zod 里的校驗看起來是一一對應(yīng)的但有個隱藏的坑Prisma 生成的是 TS 的enumZod 的z.enum()接受的是只讀字符串元組。兩者形態(tài)不一致導(dǎo)致你從 Prisma 枚舉類型直接構(gòu)造 Zod schema 時會遇到類型兼容問題。t3code 的默認(rèn)處理是把 Prisma 枚舉轉(zhuǎn)成as const的對象再用z.nativeEnum()來兼容import { PostStatus } from prisma/client; export const PostStatusSchema z.nativeEnum(PostStatus);這比z.enum([DRAFT, PUBLISHED])強(qiáng)在哪里強(qiáng)在當(dāng)你改 Prisma 枚舉時Zod 這里天然跟隨不需要手動維護(hù)字符串列表。如果你用z.enum手寫就等于在另一個地方復(fù)制了一份枚舉又回到了兩處同步的老問題。5.4 模板覆蓋手寫代碼保留區(qū)解析的邊界問題保留區(qū)機(jī)制好用但也不是萬無一失。最開始的版本我用正則匹配t3code-preserved-start和t3code-preserved-end來切分文件結(jié)果被隊友搞出過一個詭異 bug業(yè)務(wù)代碼里有人寫了一句注釋里面碰巧包含了保留區(qū)標(biāo)記文本導(dǎo)致生成時保留區(qū)范圍錯亂、手寫代碼被覆蓋。后來我把保留區(qū)的識別改成了基于模板引擎的結(jié)構(gòu)化注釋塊解析只認(rèn)單獨(dú)一行且格式完全匹配的標(biāo)記并且生成前先做一次手寫代碼被覆蓋檢查如果檢測到某個文件有未標(biāo)記的手寫改動就拒絕覆蓋并輸出 warning。這個策略雖然保守一點(diǎn)但安全第一寧可讓你手動合并也不能靜默丟代碼。5.5 watch 模式在 monorepo 里的表現(xiàn)如果你在 monorepopnpm workspace、turborepo 等里跑t3code watch第一反應(yīng)可能是每個子包都開一個 watch。這樣會有大問題schema 文件通常只有一個但多個 watch 實(shí)例會同時響應(yīng)同一個文件的變更可能互相覆蓋生成產(chǎn)物。我的建議是watch 任務(wù)只在擁有 Prisma schema 的那個包啟動其他包的配置文件通過extends復(fù)用根配置。t3code 配置支持繼承機(jī)制export default defineConfig({ extends: ../../t3code.config.ts, output: { zod: src/server/schemas, trpc: src/server/routers, }, });子包只覆蓋output路徑其他設(shè)置繼承根配置。這樣 monorepo 下只有一處 watch生成物分發(fā)到各包干凈利落。5.6 命名風(fēng)格自動轉(zhuǎn)換PascalCase 和 camelCase 的邊界數(shù)據(jù)庫字段和 TypeScript 代碼之間有個繞不過去的坎Prisma 模型名是PascalCase但變量名和文件名習(xí)慣用camelCase。t3code 做自動轉(zhuǎn)換看似簡單真的寫起來才發(fā)現(xiàn)邊界情況多到頭疼BlogPost→blogPost標(biāo)準(zhǔn)轉(zhuǎn)換沒問題APIKey→apikey還是apiKey縮寫詞到底拆不拆UserID→userId還是uid每個團(tuán)隊習(xí)慣不一樣這個問題的解決方案不是技術(shù)性的而是約定性的t3code 默認(rèn)只做大小寫首字母轉(zhuǎn)換不猜縮寫語義。同時暴露一個naming配置項允許你自定義轉(zhuǎn)換函數(shù)。畢竟命名這東西工具不該替團(tuán)隊做決定做錯了反而讓人難受。你只需要統(tǒng)一模型名必須 PascalCase、生成變量名必須 camelCase這一條基礎(chǔ)約定就夠了。結(jié)尾說一點(diǎn)個人體會。t3code 寫了半年最大的收獲不是我有個工具能自動生成代碼而是想明白了一個道理代碼生成器的價值不在生成本身而在它能強(qiáng)制維護(hù)單一事實(shí)來源。手寫代碼時模型改字段、校驗改規(guī)則、類型改聲明全是腦力負(fù)擔(dān)隨時可能漏有了生成器這些同步變成了機(jī)器的任務(wù)人只需要管業(yè)務(wù)。開發(fā)效率的提升不是一點(diǎn)點(diǎn)而是根本上少了一類問題的煩惱。如果你想試一下我建議從小處著手先拿一兩個模型跑通生成看看保留區(qū)的業(yè)務(wù)代碼和生成模板之間的配合是不是符合你們團(tuán)隊的習(xí)慣再決定要不要全面推開。工具的設(shè)計是我根據(jù)自身經(jīng)驗做的取舍不一定全適合你的項目但自動同步 強(qiáng)制檢查這個流程思路值得每個 T3 項目借鑒。