第 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 的代码会越来越像是在写业务,而不是在和数据库较劲。