戰(zhàn):從零構(gòu)建代碼片段協(xié)作平臺的關(guān)鍵決策與踩坑記錄)
這個標(biāo)題t3code乍一看有點(diǎn)抽象但如果你最近常在技術(shù)社區(qū)晃悠應(yīng)該能嗅到一絲熟悉的味道——T3那個被Theo帶火的全棧技術(shù)??s寫再加上code基本就能猜到這項(xiàng)目是干嘛的了。先說清楚t3code是我最近一個月從零肝完的一個代碼片段管理與協(xié)作平臺。名字聽起來敷衍實(shí)際上是我拿T3 Stack這套技術(shù)組合Next.js、TypeScript、tRPC、Tailwind CSS、Prisma、NextAuth做的全棧實(shí)戰(zhàn)項(xiàng)目定位是給開發(fā)者團(tuán)隊(duì)用的內(nèi)部代碼片段庫支持片段上傳、按團(tuán)隊(duì)分組、多人評論、帶權(quán)限的收藏與分享。整個項(xiàng)目從前端頁面到數(shù)據(jù)庫約束全部走TypeScript類型打通算是我近年做得最通透的一次全棧開發(fā)。這篇東西不是教程是我做完之后想把關(guān)鍵決策、踩坑鏈路、以及那些文檔里不會寫的細(xì)節(jié)整理出來給正準(zhǔn)備上T3這套組合的人當(dāng)參考。如果你也在糾結(jié)要不要用tRPC替代REST、Prisma和NextAuth到底怎么協(xié)作、權(quán)限到底在層控制才不出事那這篇應(yīng)該能幫你省掉不少試錯時(shí)間。1. 選型階段T3 Stack到底解決了什么問題1.1 項(xiàng)目需求一個開發(fā)者的代碼片段協(xié)作工具先說需求來源。我們團(tuán)隊(duì)之前用的是一個很古老的Wiki代碼片段靠頁面歸檔找代碼靠CtrlF權(quán)限基本等于擺設(shè)有人誤刪了片段只能翻備份。我當(dāng)時(shí)的想法很簡單搞一個內(nèi)部工具讓團(tuán)隊(duì)能結(jié)構(gòu)化地存代碼片段并且能控制誰能看、誰能改、誰能刪。核心需求拆下來大概這么幾條片段要支持多語言標(biāo)簽、多標(biāo)簽分類能按團(tuán)隊(duì)篩選每個片段要有獨(dú)立詳情頁支持評論和收藏權(quán)限分三級公開全團(tuán)隊(duì)可看、受保護(hù)僅指定團(tuán)隊(duì)可看、私有僅創(chuàng)建者可看操作要留痕刪除了不能徹底消失得能恢復(fù)整個東西要部署簡單不能搞一堆微服務(wù)出來給自己找事說來慚愧這個需求如果放三五年前我大概率會選VueDjangoMySQL寫一堆REST接口再手動維護(hù)一份前端類型定義。但這幾年TypeScript用久了真的受不了后端改了字段、前端跑崩了才知道的狀態(tài)。所以一開始我就鎖定了全棧TypeScript的路線然后很自然就看到了T3 Stack。1.2 T3 Stack的組成與取舍邏輯T3 Stack不是一個新的框架而是一套被社區(qū)驗(yàn)證過的技術(shù)組合核心哲學(xué)是大膽假設(shè)、小心求證、按需引入。它默認(rèn)推薦Next.js作為應(yīng)用框架、tRPC做API層、Prisma做ORM、NextAuth做認(rèn)證、Tailwind CSS做樣式然后在這基礎(chǔ)上把TypeScript的類型安全貫穿始終。我實(shí)際落地時(shí)這幾個選型的理由是這樣的組件選了它理由Next.jsApp Router版路由即文件、Server Component天然適配服務(wù)端渲染團(tuán)隊(duì)熟悉ReacttRPC最新版不寫REST、不寫GraphQL直接調(diào)用函數(shù)級別的API類型自動推斷Prisma最新版Schema即文檔遷移工具完善多對多關(guān)系寫起來舒服NextAuthv4和Next.js生態(tài)深度綁定支持CredentialsGitHub雙登錄Tailwindv4寫完不用再寫CSS文件迭代快團(tuán)隊(duì)已經(jīng)在用需要說明的是tRPC這套方案在國內(nèi)團(tuán)隊(duì)里普及率還不算高很多人聽都沒聽過。它最核心的底氣是只要你的前端和后端都跑在TypeScript世界里讓編譯器幫你把API契約管好就不需要像OpenAPI那樣額外維護(hù)一份接口文檔。你可以理解成前后端共享一份類型定義調(diào)用API的時(shí)候像調(diào)用本地函數(shù)一樣這種體驗(yàn)我用了之后是回不去的。2. t3code的架構(gòu)落地頁層與數(shù)據(jù)層如何切分2.1 工程初始化與目錄規(guī)范初始化T3項(xiàng)目很簡單裝好Node環(huán)境后一行命令npx create-t3-applatest t3code腳手架會問你選哪些模塊我勾了Next.js、tRPC、Prisma、NextAuth、Tailwind。有一點(diǎn)提醒一下它默認(rèn)會把ESLint、Prettier一起配好別手賤刪掉后面寫代碼真的會省心很多。初始化完之后的目錄是腳手架替你做好的核心結(jié)構(gòu)長這樣src/ app/ # Next.js App Router的頁面目錄 pages/ # 舊版Pages Router腳手架默認(rèn)保留 server/ # tRPC路由、Prisma實(shí)例、認(rèn)證配置 styles/ # 全局樣式我在這個基礎(chǔ)上做了兩處調(diào)整。第一刪掉了src/pages因?yàn)槲胰坑肁pp Router不需要Pages Router混著來。第二在src/server下面按模塊切了文件夾而不是一個router.ts寫到底src/server/ api/ # tRPC的rootRouter與子路由 db/ # Prisma Client單例 auth/ # NextAuth配置 middlewares/ # 權(quán)限中間件說實(shí)話這種目錄劃分沒什么玄學(xué)純粹是后面代碼量大了好找人。tRPC的router文件如果只有一兩個幾百行還能忍到我這種有片段、評論、收藏、團(tuán)隊(duì)、用戶五六個維度的項(xiàng)目不拆就是事故現(xiàn)場。2.2 tRPC路由設(shè)計(jì)聚合還是拆分這里是我整個項(xiàng)目里思考最久的部分。tRPC有兩種組織方式聚合式所有邏輯寫進(jìn)一個rootRouter用嵌套命名區(qū)分模塊拆分式每個業(yè)務(wù)域一個子路由procudure分類掛在對應(yīng)子路由下我選了第二種原因很實(shí)際聚合式在項(xiàng)目初期寫起來飛快但一旦多個模塊要共享中間件或者互相調(diào)用文件會膨脹得極其恐怖。拆分式每個子路由獨(dú)立維護(hù)中間件可以在子路由級別單獨(dú)掛載避免全員都要過登錄校驗(yàn)這種粗粒度權(quán)限。實(shí)際的rootRouter長這樣簡化版export const appRouter router({ snippet: snippetRouter, comment: commentRouter, collection: collectionRouter, team: teamRouter, user: userRouter, health: healthRouter, }); export type AppRouter typeof appRouter;前端調(diào)用的時(shí)候自動有一個類型完備的trpc對象幫你做代理比如創(chuàng)建片段const mutation trpc.snippet.create.useMutation(); await mutation.mutateAsync({ title: 防抖函數(shù), language: typescript, code: export function debounce(fn, delay) {...}, visibility: TEAM, });注意這里language、visibility這些字段都是字面量聯(lián)合類型寫錯了編譯器直接紅。我在開發(fā)中期加了一個字段的時(shí)候改完schema之后TS立刻把所有用到的地方全標(biāo)紅這種全鏈路可控感是REST方案給不了的。2.3 類型安全的鏈接方式從頁面到數(shù)據(jù)流的閉環(huán)T3這套組合最爽的一環(huán)是不用再手寫請求層。傳統(tǒng)REST開發(fā)里前端拿數(shù)據(jù)要寫一個fetch封裝、定義返回類型、再寫一個loading狀態(tài)。在Next.js App Router時(shí)代更麻煩因?yàn)轫撁婕仁强蛻舳擞质欠?wù)端你得反復(fù)斟酌這個數(shù)據(jù)在Server Component里拿還是掛在tRPC的procedure里。我用下來的結(jié)論是能走Server Component的查詢就不要走tRPC客戶端查詢tRPC專注在頁面需要動態(tài)交互的場景。比如片段列表頁首屏數(shù)據(jù)我直接放到Server Component里用Prisma查// app/snippets/page.tsx import { db } from /server/db; export default async function SnippetsPage() { const snippets await db.snippet.findMany({ where: { visibility: { not: PRIVATE } }, include: { tags: true, author: true }, orderBy: { createdAt: desc }, }); return SnippetList initialData{snippets} /; }而搜索、篩選、收藏這些用戶點(diǎn)了才發(fā)生的操作才掛在tRPC的query/mutation上。好處很明顯首屏沒有先loading再請求的等待SEO友好同時(shí)動態(tài)交互那塊又保持著全類型安全。這套搭配我強(qiáng)烈推薦比全部走客戶端請求的體驗(yàn)好一個檔次。3. Prisma與NextAuth最容易翻車的兩個模塊3.1 Prisma schema與多表關(guān)聯(lián)設(shè)計(jì)Prisma最舒服的是Schema即數(shù)據(jù)庫設(shè)計(jì)寫起來像寫配置文件但遷移能力是真實(shí)生產(chǎn)級的。t3code的核心Schema大概是這個畫風(fēng)model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] snippets Snippet[] comments Comment[] collections Collection[] teams TeamMember[] } model Team { id String id default(cuid()) name String unique members TeamMember[] snippets Snippet[] } model TeamMember { teamId String userId String role Role default(MEMBER) user User relation(fields: [userId], references: [id]) team Team relation(fields: [teamId], references: [id]) id([teamId, userId]) } model Snippet { id String id default(cuid()) title String code String language String visibility Visibility default(PUBLIC) authorId String author User relation(fields: [authorId], references: [id]) teamId String? team Team? relation(fields: [teamId], references: [id]) tags Tag[] comments Comment[] collections CollectionSnippet[] deletedAt DateTime? createdAt DateTime default(now()) updatedAt DateTime updatedAt }有幾個設(shè)計(jì)決策想細(xì)說為什么User只有email沒有password字段因?yàn)槲业卿浻玫腃redentials方案密碼存在Account表里由NextAuth負(fù)責(zé)。這個責(zé)任邊界很清楚Prisma只管用戶資料認(rèn)證本身的會話歸NextAuth管。為什么軟刪除用deletedAt而不是直接刪記錄需求里有誤刪要能恢復(fù)直接物理刪除后面想救人就得從備份里撈太痛苦。Prisma支持updatedAt自動更新配合查詢時(shí)過濾deletedAt: null恢復(fù)邏輯就是把deletedAt設(shè)為null成本極低。多對多關(guān)系注意點(diǎn)Tag和Snippet是隱式多對多即只要兩邊都定義了數(shù)組字段Prisma會自動生成中間表不用手動建model。但我建議如果中間表要存額外字段比如收藏時(shí)間就得顯式建模像CollectionSnippet那樣。否則后面想記錄收藏時(shí)間就得改表麻煩得很。3.2 NextAuth會話集成session策略與JWTNextAuth是T3棧里文檔看著簡單、用起來滿地坑的典型。一個最經(jīng)典的坑就是默認(rèn)的session策略是JWT還是database建議直接用JWT別碰數(shù)據(jù)庫session。原因很簡單database session需要在數(shù)據(jù)庫建Session表每次查詢都多一次IOJWT則把用戶信息塞進(jìn)簽名的token里無狀態(tài)、輕量。代價(jià)是登出后token在有效期內(nèi)依然能用但對于內(nèi)部協(xié)作工具這完全不是問題。我在集成時(shí)遇到過一個特別隱晦的坑NextAuth的session回調(diào)里拿不到j(luò)wt回調(diào)里塞進(jìn)去的自定義字段。比如我想把用戶的是否管理員塞進(jìn)session在jwt回調(diào)里改了token前端Session卻依然是舊結(jié)構(gòu)。根因是回調(diào)執(zhí)行順序jwt回調(diào)先執(zhí)行然后session回調(diào)才能從token里取數(shù)據(jù)。你需要同時(shí)改兩個地方// src/server/auth.ts callbacks: { async jwt({ token, user }) { if (user) token.role (user as any).role; return token; }, async session({ session, token }) { session.user.role token.role; return session; }, }另外別忘了在src/types/next-auth.d.ts里擴(kuò)展Session和JWT的類型不然TS又給你一路標(biāo)紅。3.3 種子數(shù)據(jù)權(quán)限初始化的坑開發(fā)環(huán)境需要種子數(shù)據(jù)我寫了一個prisma/seed.ts創(chuàng)建測試用戶、團(tuán)隊(duì)、片段。這里有一個非常容易被忽略的混亂源頭NextAuth的Credentials Provider在創(chuàng)建用戶時(shí)和種子腳本必須走同一套賬號密碼hash方案。我們先看Credentials Provider的校驗(yàn)它需要你自己比對明文CredentialsProvider({ name: credentials, credentials: { email: { label: 郵箱, type: email }, password: { label: 密碼, type: password }, }, async authorize(credentials) { const user await db.user.findUnique({ where: { email: credentials.email }, }); if (!user) return null; // 因?yàn)檠菔卷?xiàng)目我直接明文比較生產(chǎn)環(huán)境必須 hash const isValid credentials.password user.password; return isValid ? user : null; }, })但Prisma的User model我上面說了沒有password字段種子腳本怎么為用戶設(shè)置密碼我的做法是專門加了一個passwordHash字段放在User表種子腳本生成時(shí)用bcryptjs加密authorize校驗(yàn)時(shí)用bcrypt.compare比對。這樣兩邊邏輯統(tǒng)一。千萬不要圖省事在數(shù)據(jù)庫里存明文密碼后面接入真實(shí)用戶時(shí)隱患極大。哪怕是一個內(nèi)部工具也得按生產(chǎn)標(biāo)準(zhǔn)做這是職業(yè)習(xí)慣。4. 權(quán)限鏈路從數(shù)據(jù)庫約束到UI控制的層層設(shè)防4.1 tRPC中間件體系權(quán)限是我這項(xiàng)目里最上心的部分。因?yàn)樾枨罄镉腥壙梢娦砸坏┛刂撇缓幂p則越權(quán)查看重則數(shù)據(jù)泄露。我是這樣層層設(shè)防的。tRPC的中間件和Express類似可以在每個procedure執(zhí)行前插入邏輯。我定義了一個基礎(chǔ)中間件必須登錄const isAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, userId: ctx.session.user.id }, }); }); export const protectedProcedure t.procedure.use(isAuthed);這是第一層。第二層是針對團(tuán)隊(duì)資源的團(tuán)隊(duì)成員校驗(yàn)const isTeamMember (teamId: () string) t.middleware(async ({ ctx, next }) { const user ctx.session?.user; const membership await db.teamMember.findUnique({ where: { teamId_userId: { teamId: teamId(), userId: user?.id as string, }, }, }); if (!membership) { throw new TRPCError({ code: FORBIDDEN }); } return next({ ctx: { ...ctx, membership } }); });這套組合拳下來所有受保護(hù)片段的讀寫都必須先過兩道關(guān)卡。順序很重要先鑒權(quán)再鑒權(quán)到具體資源。4.2 服務(wù)端校驗(yàn) vs 客戶端提示很多教程里會寫前端根據(jù)用戶角色顯示按鈕但那只解決了用戶看不看得到?jīng)]解決用戶能不能調(diào)接口。我始終堅(jiān)持服務(wù)端校驗(yàn)才是真校驗(yàn)前端只是體驗(yàn)優(yōu)化。實(shí)現(xiàn)方式是在tRPC的router里把權(quán)限判斷和業(yè)務(wù)邏輯分兩段。例如編輯片段update: protectedProcedure .input(z.object({ id: z.string(), title: z.string().min(1), code: z.string().min(1), })) .mutation(async ({ ctx, input }) { const snippet await db.snippet.findUnique({ where: { id: input.id } }); if (!snippet) { throw new TRPCError({ code: NOT_FOUND }); } // 核心權(quán)限校驗(yàn)要么是作者要么是團(tuán)隊(duì)管理員 if (snippet.authorId ! ctx.userId !ctx.membership?.isAdmin) { throw new TRPCError({ code: FORBIDDEN }); } return db.snippet.update({ where: { id: input.id }, data: ... }); })而前端的體驗(yàn)就是能拿到數(shù)據(jù)但按鈕置灰或者調(diào)用時(shí)報(bào)錯后彈提示。最怕的就是前端很自信地以為沒顯示按鈕就是沒權(quán)限后面測試時(shí)用Postman一捅就漏了。4.3 審計(jì)日志與軟刪除設(shè)計(jì)上面提到軟刪除這里展開聊。軟刪除最大的坑是所有查詢都要記得過濾deletedAt稍不注意就會把已刪除數(shù)據(jù)查出來。我這里的做法是封裝了一個Prisma擴(kuò)展const extendedDb prisma.$extends({ query: { snippet: { async findMany({ args, query }) { return query({ ...args, where: { deletedAt: null, ...args.where }, }); }, }, }, });這樣所有對外查詢默認(rèn)帶上未刪除條件只有審計(jì)和管理員還原時(shí)才顯式查詢deletedAt。這類約束如果只靠開發(fā)人員自覺遲早出幺蛾子建議在數(shù)據(jù)訪問層統(tǒng)一處理。審計(jì)日志方面我建了一張AuditLog表在tRPC的mutation成功后寫入誰在什么時(shí)間干了什么。不搞復(fù)雜的消息隊(duì)列就是同步寫一條日志。對于內(nèi)部工具來說這才是真正能拿來追溯的東西。5. 部署前夜性能優(yōu)化與那些被忽視的配置5.1 打包體積分析與字體本地化項(xiàng)目功能都跑通后部署前最關(guān)心的就是體積和速度。我用next/bundle-analyzer看了一眼bundle發(fā)現(xiàn)問題最大的不是業(yè)務(wù)代碼而是第三方庫中的日期處理包和圖標(biāo)庫被整包引入了。Next.js 13默認(rèn)會做tree-shaking但有些CJS庫就是改造不動。解決辦法要么切換成按需加載要么換庫我最后把幾個大的純工具庫合并成了一個自己寫的工具集體積直接砍掉30%。另外一個容易被忽視的點(diǎn)開發(fā)時(shí)用了Google Fonts構(gòu)建時(shí)Next.js默認(rèn)會在運(yùn)行時(shí)去拉字體導(dǎo)致首屏涉及的接口請求多了一層外部依賴。部署到內(nèi)網(wǎng)環(huán)境沒有外網(wǎng)權(quán)限直接白屏拉不上來字體。解決方式是構(gòu)建時(shí)把字體文件下載到本地用next/font/local引用徹底斷了運(yùn)行時(shí)外部請求。5.2 tRPC批量查詢的N1問題tRPC用起來爽但遠(yuǎn)程函數(shù)調(diào)用就像遠(yuǎn)程銀行轉(zhuǎn)賬——單筆沒感覺循環(huán)100筆就慢到懷疑人生。我踩的一個典型坑是在片段列表頁查評論數(shù)前端對每個片段發(fā)一次comment.count的query列表15個片段就是15個請求白白多了15個RTT。排查鏈路是這樣的打開Network面板發(fā)現(xiàn)首頁有16個tRPC請求第一反應(yīng)是怎么這么多組件里看代碼發(fā)現(xiàn)每個SnippetCard都獨(dú)立調(diào)用了trpc.comment.getCount改成在列表頁的查詢里用Prisma的_count直接把評論數(shù)帶出來const snippets await db.snippet.findMany({ include: { _count: { select: { comments: true } }, author: true, }, });請求數(shù)從16降成1。寫tRPC的同學(xué)務(wù)必養(yǎng)成的習(xí)慣凡是可以聚合的查詢在服務(wù)端一次性查出結(jié)構(gòu)話數(shù)據(jù)再拋給前端。別把tRPC當(dāng)成端到端直連數(shù)據(jù)庫的魔法它只是幫你少寫類型定義沒有免掉網(wǎng)絡(luò)開銷。5.3 環(huán)境變量與CI的一次配置部署前我還處理了一個看似不起眼但折騰了一晚上的問題CI環(huán)境里跑prisma migrate deploy失敗原因是CI環(huán)境沒有數(shù)據(jù)庫連接權(quán)限。工程配置里.env文件默認(rèn)沒有提交到git而CI機(jī)器上沒有這個文件Prisma CLI就找不到DATABASE_URL。解決辦法是在CI的yml里顯式傳遞環(huán)境變量并在prisma generate之前先跑prisma migrate deploy。如果你用的是GitHub Actions在Settings - Secrets里配置好DATABASE_URL然后yml里這樣寫- name: Apply migrations run: npx prisma migrate deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }}順手再給Prisma Client加一個generator配置里的previewFeatures這取決于你用的版本。反正CI這塊大多數(shù)莫名其妙失敗都是環(huán)境變量沒傳干凈先查這步。6. 上線一個月后的復(fù)盤哪些設(shè)計(jì)扛住了哪些還要改說實(shí)話項(xiàng)目上線后并沒有一勞永逸。一個月下來有幾個觀察挺有意思??缸〉牟糠诸愋桶踩钦娴南?。改了一次Snippet表加字段前端所有用到的地方標(biāo)紅逐個修完一次通過。軟刪除方案沒有拖垮查詢性能配合默認(rèn)過濾規(guī)則也沒出現(xiàn)過已刪除內(nèi)容復(fù)活的異常。tRPC的中間件體系讓我加團(tuán)隊(duì)管理權(quán)限時(shí)幾乎沒動業(yè)務(wù)代碼很輕松地插入了一層校驗(yàn)。沒扛住的部分全文檢索。當(dāng)時(shí)圖省事用了LIKE %keyword%數(shù)據(jù)量過萬之后查一次能感知到慢。后面得換Postgres的全文索引或者接一個搜索服務(wù)。團(tuán)隊(duì)權(quán)限模型還是太簡單了。上線后用戶反饋我需要讓某個成員只能看不能改、看能不能按標(biāo)簽限制可見性這是當(dāng)時(shí)沒細(xì)想的部分。NextAuth的Credentials方案對于內(nèi)部工具夠用但如果以后要接企業(yè)微信或飛書登錄可能得換一套認(rèn)證中間層。如果讓我重來一遍我會在權(quán)限模型上多花一倍時(shí)間。代碼片段管理工具看起來是CRUD 搜索真正做起來你會發(fā)現(xiàn)權(quán)限才是它的靈魂。最后分享一個小技巧上線之后我保留了調(diào)試用的health router每次排查部署問題時(shí)訪問一下就能極快地確認(rèn)服務(wù)在線、數(shù)據(jù)庫連接正常、認(rèn)證模塊可用比看日志大海撈針舒服多了。t3code這個項(xiàng)目做到這里算告一段落但T3這套組合的探索還在繼續(xù)后面有新的體會再來分享。