技術5 閱讀

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

上一篇,我們為 blog-demo 設計了一套關係模型:User / Profile / Post / Comment / Category。它已經可以跑起來了,但是還停留在「結構層面」。

這一節,我想換一個角度:
不再按「概念」來講,而是按「完整業務鏈路」來講。

我們假設有這樣一個需求:

使用者註冊之後,自動產生檔案;
第一次登入時,引導他寫下第一篇部落格;
文章可以選擇分類;
以後查看個人首頁,能看到:基本資訊 + 檔案 + 最近文章 + 每篇文章的留言數。

下面我們用 Prisma,一步一步把這個需求打通。


一、準備工作:我們手裡有什麼?

先複習一下 blog-demo 的核心資料結構(只保留關鍵欄位):

text
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 程式碼,都假設你已經有:

text
import { PrismaClient } from '../generated/blog'

const prisma = new PrismaClient()

二、註冊使用者:一次寫完 User + Profile

第一步是註冊。
很多專案會在「註冊成功」後,為使用者自動建立一條檔案記錄,哪怕內容先是空的。

這個需求,用 Prisma 的巢狀寫入剛好很適合:官方文件強調,巢狀寫入會在一個事務裡完成,任何一步失敗會整條回滾。

2.1 最簡單的寫法

我們先寫出一個最小版本的「註冊」函式:

text
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
}

這段程式碼在做三件事:

  1. User 表格插入新使用者。

  2. Profile 表格插入一條檔案,userId 外鍵由 Prisma 自動填好。

  3. 回傳值中帶上 profile 欄位。

你不用自己管 userId,這就是巢狀寫入帶來的好處。

2.2 加一點穩健性:避免重複註冊

真實專案裡,email 通常必須唯一。我們已經在 schema 裡寫了 @unique,這會讓資料庫層面有約束。一旦你嘗試插入重複郵箱,Prisma 會拋出錯誤(程式碼 P2002)。

可以稍微包裝一下:

text
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
  }
}

到這裡,我們完成了「註冊 + 自動建立檔案」的第一段鏈路。


三、第一次登入:建立歡迎帖 + 選分類

第二步:使用者第一次登入時,我們想給他一個「寫第一篇部落格」的引導。

這個流程,大致有兩種實作方式:

  1. 前端先讓他填完表單,再一次性地送給後端;

  2. 後端自己產生一個「歡迎帖」草稿,使用者後面再編輯。

這裡我們寫一種最常見的情況:
「使用者在第一次登入時,直接提交一個標題 / 內容 / 分類選擇,後端一次性地寫完 Post + 關聯 Category」。

3.1 先準備分類:Category 初始資料

在真正寫邏輯前,我們通常會預置幾條分類,例如「技術」「生活」。這可以用腳本或在啟動時執行一次。

text
async function seedCategories() {
  await prisma.category.createMany({
    data: [
      { name: '技術', slug: 'tech' },
      { name: '生活', slug: 'life' },
      { name: '隨筆', slug: 'essay' },
    ],
    skipDuplicates: true,
  })
}

skipDuplicates: true 可以避免重複執行時報錯。

3.2 寫一篇帶分類的文章:用事務打包

現在使用者提交了一個表單:

我們希望:

  1. 建立一篇新文章;

  2. 找出對應的 Category;

  3. PostCategory 中插入關聯記錄。

這可以寫成一段事務($transaction):Prisma 文件建議,用事務打包多步寫操作,避免半成功半失敗。

text
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
  })
}

這一段有幾點值得注意:

到這裡,第二段鏈路完成:使用者第一次登入時,可以一口氣寫出自己的第一篇部落格,並選好分類


四、個人首頁:一次查出「所有關鍵資訊」

第三步:個人首頁。
需求是:

給定一個使用者 id,回傳這樣一份「個人首頁資料」:

  • 使用者基本資訊(name / email)

  • Profile(bio / avatar)

  • 最近 N 篇已發布文章(title + 建立時間)

  • 每篇文章的留言數

這個需求可以拆成兩種寫法:

  1. 用一個 Prisma 查詢搞定(巢狀 include + 聚合);

  2. 分成多次查詢,程式語言裡組裝。

我們先嘗試「一次搞定」的方法,然後再講為什麼有時需要退一步。

4.1 先寫一個「看起來很直觀」的 include

我們可以先這樣寫:

text
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
}

這裡用到了兩個概念:

  1. 巢狀 select:對 profile、posts 只取需要的欄位;

  2. _count 聚合:對每篇文章的 comments 做計數;

_count.comments 的寫法是 Prisma 對 relation count 的內建支援,官方文件稱之為「count relation」。

這樣一來,回傳結果的結構大概會是:

text
{
  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

官方最佳實踐裡有一條建議:只取必要欄位,可以減少資料傳輸,提高效能。

如果你寫成:

text
include: { profile: true, posts: true }

那 Prisma 會把 Post 的所有欄位、Comment 的所有欄位都塞進來,非常浪費,而且容易把「內部實作細節」暴露給前端。

select 的優勢在於你可以精確控制回傳格式,甚至訂製出專門給前端用的「視圖模型」。


五、留言功能:寫得安全,又不重複查詢

留言功能看起來簡單:「文章下新增一則留言」。
但是稍微嚴謹一點,就有幾個問題要考慮:

  1. 文章是否存在?

  2. 使用者是否存在?

  3. 留言是否應該寫在事務裡(防止並發下的髒資料)?

這裡我們做一個折中、也易於理解的實作。

5.1 簡單但不夠安全的寫法(僅示範,不推薦)

最簡單的 Prisma 寫法是:

text
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 },
      },
    },
  })
}

這段程式碼有兩個隱含假設:

如果其中一個 id 錯了,會在資料庫層面因為外鍵約束失敗而報錯。

在某些內部服務裡,這種寫法可以接受;但如果對外 API,就應該提前做驗證,讓錯誤資訊更友善。

5.2 用事務順便做存在性檢查

我們可以用 $transaction 寫一版稍微嚴謹一點的:

text
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,
      },
    })
  })
}

這裡有一個設計選擇:

這算是「安全性和簡潔性」的折中。你也可以完全依賴外鍵錯誤,然後統一在上層捕獲並翻譯成業務錯誤。


六、一點效能與實踐經驗:從「小專案」一開始就可以用上的

到這裡,我們已經把 blog-demo 的一條完整路徑跑通了:

最後,可以藉這個機會講幾個和 Prisma 實務相關、但不會太「玄學」的小經驗。官方文件也有一篇「Best practices」,可以對照著看。

6.1 永遠只要必要欄位

select_count 是兩個非常重要的最佳化手段:

這樣一來:

6.2 巢狀寫入能做的事,就不要拆成多條 SQL

Prisma 的巢狀寫入,本質上是一種「簡化版事務」:官方文件強調,它保證多表格寫操作要麼全部成功,要麼全部回滾。

對於像「建立使用者 + 檔案」、「建立文章 + 關聯分類」這種操作:

6.3 一個 PrismaClient 就夠了

官方 best practices 提醒過:整個應用只需要一個 PrismaClient 實例,否則會產生多個連線池,導致連線數耗盡。

這屬於「架構衛生問題」,越早養成習慣越好。


七、結語:從「寫 CRUD」到「寫業務」

到這篇為止,我們已經:

你會發現一件事:

當 Schema 設計清晰以後,Prisma 的程式碼會越來越像是在寫業務,而不是在和資料庫較勁。

SHARE

分享

分享這篇文章。