第5篇:基於 blog-demo 寫出一條完整業務鏈路(Prisma 進階實踐)

上一篇,我們為 blog-demo 設計了一套關係模型:User / Profile / Post / Comment / Category。它已經可以跑起來了,但是還停留在「結構層面」。
這一節,我想換一個角度:
不再按「概念」來講,而是按「完整業務鏈路」來講。
我們假設有這樣一個需求:
使用者註冊之後,自動產生檔案;
第一次登入時,引導他寫下第一篇部落格;
文章可以選擇分類;
以後查看個人首頁,能看到:基本資訊 + 檔案 + 最近文章 + 每篇文章的留言數。
下面我們用 Prisma,一步一步把這個需求打通。
一、準備工作:我們手裡有什麼?
先複習一下 blog-demo 的核心資料結構(只保留關鍵欄位):
model User {
id Int @id @default(autoincrement())
email String @unique
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile Profile?
posts Post[]
comments Comment[]
}
model Profile {
id Int @id @default(autoincrement())
bio String?
avatarUrl String?
userId Int @unique
user User @relation(fields: [userId], references: [id])
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId Int
author User @relation(fields: [authorId], references: [id])
comments Comment[]
categories PostCategory[]
}
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
postId Int
post Post @relation(fields: [postId], references: [id])
authorId Int
author User @relation(fields: [authorId], references: [id])
}
model Category {
id Int @id @default(autoincrement())
name String
slug String @unique
posts PostCategory[]
}
model PostCategory {
postId Int
categoryId Int
assignedAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id])
category Category @relation(fields: [categoryId], references: [id])
@@id([postId, categoryId])
}有了這些表,我們就可以開始從「業務流程」的角度組織程式碼了。
下面所有 TypeScript 程式碼,都假設你已經有:
import { PrismaClient } from '../generated/blog'
const prisma = new PrismaClient()二、註冊使用者:一次寫完 User + Profile
第一步是註冊。
很多專案會在「註冊成功」後,為使用者自動建立一條檔案記錄,哪怕內容先是空的。
這個需求,用 Prisma 的巢狀寫入剛好很適合:官方文件強調,巢狀寫入會在一個事務裡完成,任何一步失敗會整條回滾。
2.1 最簡單的寫法
我們先寫出一個最小版本的「註冊」函式:
async function registerUser(input: {
email: string
name: string
}) {
const user = await prisma.user.create({
data: {
email: input.email,
name: input.name,
profile: {
create: {
bio: '',
avatarUrl: null,
},
},
},
include: {
profile: true,
},
})
return user
}這段程式碼在做三件事:
往
User表格插入新使用者。往
Profile表格插入一條檔案,userId 外鍵由 Prisma 自動填好。回傳值中帶上
profile欄位。
你不用自己管 userId,這就是巢狀寫入帶來的好處。
2.2 加一點穩健性:避免重複註冊
真實專案裡,email 通常必須唯一。我們已經在 schema 裡寫了 @unique,這會讓資料庫層面有約束。一旦你嘗試插入重複郵箱,Prisma 會拋出錯誤(程式碼 P2002)。
可以稍微包裝一下:
async function safeRegisterUser(input: { email: string; name: string }) {
try {
return await registerUser(input)
} catch (e: any) {
// 簡化處理:真實專案可以用更精細的錯誤映射
if (e.code === 'P2002') {
throw new Error('電子郵件已被註冊')
}
throw e
}
}到這裡,我們完成了「註冊 + 自動建立檔案」的第一段鏈路。
三、第一次登入:建立歡迎帖 + 選分類
第二步:使用者第一次登入時,我們想給他一個「寫第一篇部落格」的引導。
這個流程,大致有兩種實作方式:
前端先讓他填完表單,再一次性地送給後端;
後端自己產生一個「歡迎帖」草稿,使用者後面再編輯。
這裡我們寫一種最常見的情況:
「使用者在第一次登入時,直接提交一個標題 / 內容 / 分類選擇,後端一次性地寫完 Post + 關聯 Category」。
3.1 先準備分類:Category 初始資料
在真正寫邏輯前,我們通常會預置幾條分類,例如「技術」「生活」。這可以用腳本或在啟動時執行一次。
async function seedCategories() {
await prisma.category.createMany({
data: [
{ name: '技術', slug: 'tech' },
{ name: '生活', slug: 'life' },
{ name: '隨筆', slug: 'essay' },
],
skipDuplicates: true,
})
}skipDuplicates: true 可以避免重複執行時報錯。
3.2 寫一篇帶分類的文章:用事務打包
現在使用者提交了一個表單:
標題:
title內容:
content分類 slug 列表:
['tech', 'life']
我們希望:
建立一篇新文章;
找出對應的 Category;
在
PostCategory中插入關聯記錄。
這可以寫成一段事務($transaction):Prisma 文件建議,用事務打包多步寫操作,避免半成功半失敗。
async function createFirstPostForUser(params: {
userId: number
title: string
content: string
categorySlugs: string[]
}) {
return await prisma.$transaction(async (tx) => {
// 1. 建立文章
const post = await tx.post.create({
data: {
title: params.title,
content: params.content,
published: true,
author: {
connect: { id: params.userId },
},
},
})
// 2. 查出使用者選中的分類
const categories = await tx.category.findMany({
where: { slug: { in: params.categorySlugs } },
})
// 3. 把文章和這些分類關聯起來
if (categories.length > 0) {
await tx.postCategory.createMany({
data: categories.map((c) => ({
postId: post.id,
categoryId: c.id,
})),
skipDuplicates: true,
})
}
// 4. 最後回傳文章和分類
const postWithCategories = await tx.post.findUnique({
where: { id: post.id },
include: {
categories: {
include: { category: true },
},
},
})
return postWithCategories
})
}這一段有幾點值得注意:
我們使用了
prisma.$transaction(async (tx) => { ... }),裡面所有tx.xxx呼叫會作為一組事務提交;postCategory.createMany自帶事務語意(bulk 操作),再包上一層$transaction,能保證「文章 + 分類關聯」整體的一致性;最後再讀一次帶分類的 Post,作為回傳值。
到這裡,第二段鏈路完成:使用者第一次登入時,可以一口氣寫出自己的第一篇部落格,並選好分類。
四、個人首頁:一次查出「所有關鍵資訊」
第三步:個人首頁。
需求是:
給定一個使用者 id,回傳這樣一份「個人首頁資料」:
使用者基本資訊(name / email)
Profile(bio / avatar)
最近 N 篇已發布文章(title + 建立時間)
每篇文章的留言數
這個需求可以拆成兩種寫法:
用一個 Prisma 查詢搞定(巢狀 include + 聚合);
分成多次查詢,程式語言裡組裝。
我們先嘗試「一次搞定」的方法,然後再講為什麼有時需要退一步。
4.1 先寫一個「看起來很直觀」的 include
我們可以先這樣寫:
async function getUserHomePage(userId: number, limitPosts = 5) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
profile: {
select: {
bio: true,
avatarUrl: true,
},
},
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
take: limitPosts,
select: {
id: true,
title: true,
createdAt: true,
_count: {
select: { comments: true },
},
},
},
},
})
return user
}這裡用到了兩個概念:
巢狀
select:對 profile、posts 只取需要的欄位;_count聚合:對每篇文章的comments做計數;
_count.comments 的寫法是 Prisma 對 relation count 的內建支援,官方文件稱之為「count relation」。
這樣一來,回傳結果的結構大概會是:
{
id: 1,
name: 'Alice',
email: 'alice@example.com',
profile: {
bio: 'I love Prisma',
avatarUrl: '...'
},
posts: [
{
id: 123,
title: 'Hello Blog',
createdAt: '...',
_count: { comments: 3 }
},
...
]
}個人首頁所需的資訊,全在裡面了。
4.2 為什麼這裡要用 select 而不是 include
官方最佳實踐裡有一條建議:只取必要欄位,可以減少資料傳輸,提高效能。
如果你寫成:
include: { profile: true, posts: true }那 Prisma 會把 Post 的所有欄位、Comment 的所有欄位都塞進來,非常浪費,而且容易把「內部實作細節」暴露給前端。
select 的優勢在於你可以精確控制回傳格式,甚至訂製出專門給前端用的「視圖模型」。
五、留言功能:寫得安全,又不重複查詢
留言功能看起來簡單:「文章下新增一則留言」。
但是稍微嚴謹一點,就有幾個問題要考慮:
文章是否存在?
使用者是否存在?
留言是否應該寫在事務裡(防止並發下的髒資料)?
這裡我們做一個折中、也易於理解的實作。
5.1 簡單但不夠安全的寫法(僅示範,不推薦)
最簡單的 Prisma 寫法是:
async function addComment(params: {
postId: number
userId: number
content: string
}) {
return await prisma.comment.create({
data: {
content: params.content,
post: {
connect: { id: params.postId },
},
author: {
connect: { id: params.userId },
},
},
})
}這段程式碼有兩個隱含假設:
Post 一定存在;
User 一定存在。
如果其中一個 id 錯了,會在資料庫層面因為外鍵約束失敗而報錯。
在某些內部服務裡,這種寫法可以接受;但如果對外 API,就應該提前做驗證,讓錯誤資訊更友善。
5.2 用事務順便做存在性檢查
我們可以用 $transaction 寫一版稍微嚴謹一點的:
async function safeAddComment(params: {
postId: number
userId: number
content: string
}) {
return await prisma.$transaction(async (tx) => {
const [post, user] = await Promise.all([
tx.post.findUnique({ where: { id: params.postId }, select: { id: true } }),
tx.user.findUnique({ where: { id: params.userId }, select: { id: true } }),
])
if (!post) {
throw new Error('文章不存在')
}
if (!user) {
throw new Error('使用者不存在')
}
return await tx.comment.create({
data: {
content: params.content,
postId: params.postId,
authorId: params.userId,
},
})
})
}這裡有一個設計選擇:
我們沒有用關聯欄位 +
connect,而是直接寫postId和authorId;這是因為我們剛剛已經自己做了存在性檢查,不希望再觸發可能的「源自 connect 的外鍵錯誤」。
這算是「安全性和簡潔性」的折中。你也可以完全依賴外鍵錯誤,然後統一在上層捕獲並翻譯成業務錯誤。
六、一點效能與實踐經驗:從「小專案」一開始就可以用上的
到這裡,我們已經把 blog-demo 的一條完整路徑跑通了:
註冊(User + Profile)
設定分類(Category)
寫第一篇文章並關聯分類(Post + PostCategory)
展示個人首頁(User + Profile + posts + comments count)
留言功能(Comment + 安全性檢查)
最後,可以藉這個機會講幾個和 Prisma 實務相關、但不會太「玄學」的小經驗。官方文件也有一篇「Best practices」,可以對照著看。
6.1 永遠只要必要欄位
select 和 _count 是兩個非常重要的最佳化手段:
對外介面不要直接回傳全欄位模型;
你可以為「個人首頁視圖」「文章列表視圖」等定義各自的查詢函式;
每個函式嚴格 select 需要的欄位。
這樣一來:
資料傳輸更小;
型別更精確;
一旦 schema 變化(加欄位、刪欄位),不至於突然多出一堆不想暴露的欄位。
6.2 巢狀寫入能做的事,就不要拆成多條 SQL
Prisma 的巢狀寫入,本質上是一種「簡化版事務」:官方文件強調,它保證多表格寫操作要麼全部成功,要麼全部回滾。
對於像「建立使用者 + 檔案」、「建立文章 + 關聯分類」這種操作:
如果你用多條獨立的
create,一旦中途出錯,你還要手動回滾;用巢狀寫入,可以用一條語句搞定;
如果巢狀結構太深/複雜,再考慮
$transaction拆步。
6.3 一個 PrismaClient 就夠了
官方 best practices 提醒過:整個應用只需要一個 PrismaClient 實例,否則會產生多個連線池,導致連線數耗盡。
在 Node/Next.js 專案裡,把
PrismaClient封裝到lib/prisma.ts,用全域單例模式;在 Serverless 環境(Vercel)下,也要小心不要在 handler 內反覆 new。
這屬於「架構衛生問題」,越早養成習慣越好。
七、結語:從「寫 CRUD」到「寫業務」
到這篇為止,我們已經:
用 blog-demo 設計了一套關係結構;
用一條具體的業務鏈路,把巢狀寫入、事務、relation filter、
_count、select/include全都串起來了。
你會發現一件事:
當 Schema 設計清晰以後,Prisma 的程式碼會越來越像是在寫業務,而不是在和資料庫較勁。
在 Google 上持續關注
把 HeyBinyang 加入 Google 首選來源
如果你希望之後在 Google 上更容易看到我的更新,可以把這個站點加入 preferred source,讓它在相關閱讀情境裡更容易被找到。
SHARE
分享
分享這篇文章。