arrow_back返回文章列表
技术

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

这段代码在做三件事:

  1. User 表插入新用户。

  2. Profile 表插入一条档案,userId 外键由 Prisma 自动填好。

  3. 返回值中带上 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
  }
}

到这里,我们完成了“注册 + 自动创建档案”的第一段链路。


三、第一次登录:创建欢迎帖 + 选分类

第二步:用户第一次登录时,我们想给他一个“写第一篇博客”的引导。

这个流程,大致有两种实现方式:

  1. 前端先让他填完表单,再一次性发给后端;

  2. 后端自己生成一个“欢迎帖”草稿,用户后面再编辑。

这里我们写一种最常见的情况:
“用户在第一次登录时,直接提交一个标题 / 内容 / 分类选择,后端一次性写完 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 写一篇带分类的文章:用事务打包

现在用户提交了一个表单:

我们希望:

  1. 创建一篇新文章;

  2. 找出对应的 Category;

  3. 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
  })
}

这一段有几点值得注意:

到这里,第二段链路完成:用户第一次登录时,可以一口气写出自己的第一篇博客,并选好分类


四、个人主页:一次查出“所有关键信息”

第三步:个人主页。
需求是:

给定一个用户 id,返回这样一份“个人主页数据”:

  • 用户基本信息(name / email)

  • Profile(bio / avatar)

  • 最近 N 篇已发布文章(title + 创建时间)

  • 每篇文章的评论数

这个需求可以拆成两种写法:

  1. 用一个 Prisma 查询搞定(嵌套 include + 聚合);

  2. 分成多次查询,编程语言里组装。

我们先尝试“一次搞定”的方法,然后再讲为什么有时需要退一步。

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
}

这里用到了两个概念:

  1. 嵌套 select:对 profile、posts 只取需要的字段;

  2. _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 的优势在于你可以精确控制返回格式,甚至定制出专门给前端用的“视图模型”。


五、评论功能:写得安全,又不重复查询

评论功能看起来简单:“文章下添加一条评论”。
但是稍微严谨一点,就有几个问题要考虑:

  1. 文章是否存在?

  2. 用户是否存在?

  3. 评论是否应该写在事务里(防止并发下的脏数据)?

这里我们做一个折中、也易于理解的实现。

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

这段代码有两个隐含假设:

如果其中一个 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,
      },
    })
  })
}

这里有一个设计选择:

这算是“安全性和简洁性”的折中。你也可以完全依赖外键错误,然后统一在上层捕获并翻译成业务错误。


六、一点性能与实践经验:从“小项目”一开始就可以用上的

到这里,我们已经把 blog-demo 的一条完整路径跑通了:

最后,可以借这个机会讲几个和 Prisma 实践相关、但不会太“玄学”的小经验。官方文档也有一篇“Best practices”,可以对照着看。

6.1 永远只要必要字段

select_count 是两个非常重要的优化手段:

这样一来:

6.2 嵌套写入能做的事,就不要拆成多条 SQL

Prisma 的嵌套写入,本质上是一种“简化版事务”:官方文档强调,它保证多表写操作要么全部成功,要么全部回滚。

对于像 “创建用户 + 档案”、“创建文章 + 关联分类” 这种操作:

6.3 一个 PrismaClient 就够了

官方 best practices 提醒过:整个应用只需要一个 PrismaClient 实例,否则会产生多个连接池,导致连接数耗尽。

这属于“架构卫生问题”,越早养成习惯越好。


七、结束语:从“写 CRUD”到“写业务”

到这篇为止,我们已经:

你会发现一件事:

当 Schema 设计清晰以后,Prisma 的代码会越来越像是在写业务,而不是在和数据库较劲。