第八章:使用 Prisma 构建数据驱动的 API
发表于更新于
字数总计:3.9k阅读时长:18分钟阅读量: 广东
第八章:使用 Prisma 构建数据驱动的 API
摘要: 我们已经拥有了一个结构化的 Express 应用、一个清晰的数据模型 (schema.prisma
) 和一个与之同步的云数据库。现在,我们将进入最激动人心的环节:将它们串联起来。本章将是纯粹的实战,我们将深入学习并运用 Prisma Client——那个由 prisma generate
命令为我们量身定制的、类型安全的数据库“遥控器”。我们将逐一实现用户的 CRUD (增删改查) API,学习如何处理数据关联、如何进行高级查询(过滤、排序、分页),并遵循最佳实践,将数据库逻辑封装在独立的“服务层”中,保持代码的整洁与高内聚。
在本章,我们将把理论彻底转化为可运行的、数据驱动的 API:
- 首先,我们将学习实例化
PrismaClient
的最佳实践——单例模式,以确保应用高效、稳定地管理数据库连接。 - 接着,我们将系统地实现用户的 CRUD API,逐一掌握
create
, findMany
, findUnique
, update
, delete
等核心方法。 - 然后,我们将探索 Prisma 的高级查询能力,学习如何实现服务端的数据过滤、排序和分页。
- 最后,我们将演练关系数据的处理,学习如何在一个操作中同时创建用户及其关联的文章,以及如何在查询时一并带出关联数据。
8.1. 实例化 PrismaClient:应用的数据库连接器
PrismaClient
是我们与数据库交互的入口。一个常见的疑问是:我应该在哪里、以及如何创建它的实例?
错误的做法是在每个需要数据库操作的函数中都 new PrismaClient()
。这会导致应用创建过多的数据库连接,迅速耗尽连接池资源,从而严重影响性能甚至导致服务崩溃。
最佳实践是遵循单例模式:在整个应用程序的生命周期中,只创建一个 PrismaClient
实例,并在所有需要它的地方共享这个实例。PrismaClient
实例内部已经为你管理好了高效的数据库连接池。
为了实现这一点,我们创建一个专门的文件来实例化并导出这个单例。
src/utils/prisma.js
(我们为此创建一个新文件)
1 2 3 4 5 6 7 8 9 10 11 12
| import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({ log: ['query', 'info', 'warn', 'error'], })
export default prisma;
|
现在,在应用的任何地方,我们只需要 import prisma from '../utils/prisma.js'
就可以安全地使用这个共享的数据库连接器了。
8.2. 实现 CRUD:用户的增删改查
现在,我们将重构第六章中定义的 users
路由,用真实的 Prisma 操作替换掉所有模拟响应。我们将遵循“关注点分离”的原则,将路由和请求/响应处理逻辑放在 Controller
层,将纯粹的数据库交互逻辑放在 Service
层。
(注:为保持简洁,我们将 Service 和 Controller 的代码暂时合并展示在路由文件中,在更大型的项目中,应将它们拆分到 controllers
和 services
目录。)
src/api/users.routes.js
(重构后)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
|
import { Router } from 'express'; import { z } from 'zod'; import prisma from '../utils/prisma.js'; import validate from '../middlewares/validate.js';
const createUserSchema = z.object({ body: z.object({ email: z.string().email(), name: z.string().optional(), }), query: z.object({}).optional(), params: z.object({}).optional(), });
const updateUserSchema = z.object({ body: z.object({ email: z.string().email().optional(), name: z.string().optional(), }), params: z.object({ id: z.string().regex(/^\d+$/), }), });
const router = Router();
router.post("/", validate(createUserSchema), async (req, res, next) => { try { const newUser = await prisma.user.create({ data: req.body }) res.status(201).json(newUser); } catch (error) { next(error) } })
router.get("/", async (req, res, next) => { try { const users = await prisma.user.findMany(); res.json(users) } catch (error) { next(error) } })
router.get("/:id", async (req, res, next) => { try { const userId = parseInt(req.params.id); const user = await prisma.user.findUnique({ where: { id: userId } }) if (!user) { return res.status(404).json({ error: "用户未找到" }) } res.json(user) } catch (error) { next(error) } })
router.put("/:id", validate(updateUserSchema), async (req, res, next) => { try { const userId = parseInt(req.params.id); const updatedUser = await prisma.user.update({ where: { id: userId }, data: req.body }) res.json(updatedUser) } catch (error) { next(error) } })
router.delete("/:id", async (req, res, next) => { try { const userId = parseInt(req.params.id); await prisma.user.delete({ where: { id: userId } }) res.json({ message: "用户删除成功" }) } catch (error) { next(error) } })
export default router;
|
验证环节 (CRUD)
请复制如下代码快速进行验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268
| <!DOCTYPE html> <html lang="zh-CN">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>极简 API 验证器 (已修正)</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f0f2f5; color: #1c1e21; padding: 20px; max-width: 800px; margin: 0 auto; } .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); margin-bottom: 20px; } h1, h2 { border-bottom: 1px solid #ddd; padding-bottom: 10px; margin-top: 0; } form { display: flex; flex-direction: column; gap: 10px; } input { padding: 8px; border: 1px solid #ccc; border-radius: 4px; } button { padding: 10px 15px; border: none; border-radius: 4px; cursor: pointer; color: white; font-weight: bold; } .btn-primary { background-color: #007bff; } .btn-secondary { background-color: #6c757d; } .btn-danger { background-color: #dc3545; } .user-list { list-style: none; padding: 0; } .user-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #eee; } .user-item:last-child { border-bottom: none; } .user-actions button { margin-left: 5px; padding: 5px 8px; font-size: 12px; } #notification { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: 5px; color: white; display: none; } #notification.success { background-color: #28a745; } #notification.error { background-color: #dc3545; } .hidden { display: none; } </style> </head>
<body>
<div id="notification"></div>
<h1>🚀 Prisma API 验证器</h1>
<div class="container"> <h2 id="form-title">✨ 添加新用户</h2> <form id="user-form"> <input type="hidden" id="user-id"> <input type="email" id="user-email" placeholder="邮箱 (Email)" required> <input type="text" id="user-name" placeholder="姓名 (Name)"> <div> <button type="submit" id="submit-button" class="btn-primary">创建用户</button> <button type="button" id="cancel-edit-button" class="btn-secondary hidden">取消编辑</button> </div> </form> </div>
<div class="container"> <h2>👥 用户列表</h2> <button id="refresh-button" class="btn-primary" style="margin-bottom: 10px;">刷新列表</button> <ul id="user-list" class="user-list"> </ul> </div>
<script> document.addEventListener('DOMContentLoaded', () => { const API_URL = 'http://localhost:3000/api/v1/users';
const userList = document.getElementById('user-list'); const userForm = document.getElementById('user-form'); const formTitle = document.getElementById('form-title'); const submitButton = document.getElementById('submit-button'); const cancelEditButton = document.getElementById('cancel-edit-button'); const refreshButton = document.getElementById('refresh-button'); const notification = document.getElementById('notification'); const userIdInput = document.getElementById('user-id'); const userEmailInput = document.getElementById('user-email'); const userNameInput = document.getElementById('user-name');
const showNotification = (message, type = 'success') => { notification.textContent = message; notification.className = type; notification.style.display = 'block'; setTimeout(() => { notification.style.display = 'none'; }, 3000); };
const fetchUsers = async () => { try { const response = await fetch(API_URL); if (!response.ok) throw new Error('网络响应错误');
const users = await response.json();
userList.innerHTML = '';
if (users.length === 0) { userList.innerHTML = '<li>没有用户数据。</li>'; return; }
users.forEach(user => { const li = document.createElement('li'); li.className = 'user-item'; li.innerHTML = ` <div> <strong>ID: ${user.id}</strong> - ${user.email} (${user.name || 'N/A'}) </div> <div class="user-actions"> <button class="btn-secondary edit-btn" data-id="${user.id}">编辑</button> <button class="btn-danger delete-btn" data-id="${user.id}">删除</button> </div> `; userList.appendChild(li); }); } catch (error) { showNotification(`获取用户失败: ${error.message}`, 'error'); } };
const resetForm = () => { userForm.reset(); userIdInput.value = ''; formTitle.textContent = '✨ 添加新用户'; submitButton.textContent = '创建用户'; cancelEditButton.classList.add('hidden'); };
userForm.addEventListener('submit', async (e) => { e.preventDefault(); const id = userIdInput.value; const isEditing = !!id;
const userData = { email: userEmailInput.value, name: userNameInput.value, };
try { const url = isEditing ? `${API_URL}/${id}` : API_URL; const method = isEditing ? 'PUT' : 'POST';
const response = await fetch(url, { method: method, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(userData), });
if (!response.ok) { const errData = await response.json(); throw new Error(errData.message || errData.error || '操作失败'); }
showNotification(`用户${isEditing ? '更新' : '创建'}成功!`); resetForm(); fetchUsers();
} catch (error) { showNotification(`操作失败: ${error.message}`, 'error'); } });
userList.addEventListener('click', async (e) => { const target = e.target; const id = target.dataset.id;
if (target.classList.contains('delete-btn')) { if (confirm(`确定要删除 ID 为 ${id} 的用户吗?`)) { try { const response = await fetch(`${API_URL}/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('删除失败'); showNotification('用户删除成功!'); fetchUsers(); } catch (error) { showNotification(error.message, 'error'); } } }
if (target.classList.contains('edit-btn')) { try { const response = await fetch(`${API_URL}/${id}`); if (!response.ok) throw new Error('获取用户信息失败'); const user = await response.json();
userIdInput.value = user.id; userEmailInput.value = user.email; userNameInput.value = user.name || '';
formTitle.textContent = `✏️ 编辑用户 (ID: ${user.id})`; submitButton.textContent = '更新用户'; cancelEditButton.classList.remove('hidden'); window.scrollTo({ top: 0, behavior: 'smooth' }); } catch(error) { showNotification(error.message, 'error'); } } });
cancelEditButton.addEventListener('click', resetForm);
refreshButton.addEventListener('click', fetchUsers);
fetchUsers(); }); </script> </body> </html>
|
8.3. 深入查询:过滤、排序与分页
细致讲解:
简单的 CRUD 远不能满足真实业务的需求。Prisma 提供了极其丰富和强大的查询选项对象,让我们能用声明式的方式构建复杂的 SQL 查询。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
|
router.get('/', async (req, res, next) => { try { const { name, sort, page = 1, limit = 10 } = req.query; const where = {}; if (name) { where.name = { contains: name, mode: 'insensitive', }; }
const orderBy = {}; if (sort) { const [field, direction] = sort.split('_'); orderBy[field] = direction; } else { orderBy.createdAt = 'desc'; }
const skip = (parseInt(page) - 1) * parseInt(limit); const take = parseInt(limit); const [users, total] = await prisma.$transaction([ prisma.user.findMany({ where, orderBy, skip, take }), prisma.user.count({ where }) ]);
res.json({ data: users, meta: { total, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(total / limit) } }); } catch (error) { next(error); } });
|
验证环节 (高级查询)
1
| curl "http://localhost:3000/api/v1/users?name=Alice&sort=createdAt_desc&page=1&limit=5"
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| { "data": [ { "id": 1, "email": "alice@prisma.io", "name": "Alice", "createdAt": "2025-09-15T02:25:28.123Z", "updatedAt": "2025-09-15T02:25:28.123Z" } ], "meta": { "total": 1, "page": 1, "limit": 5, "totalPages": 1 } }
|
8.4. 处理关系:创建与查询
细致讲解:
Prisma 最强大的功能之一就是对关系数据的直观处理。
场景一:创建用户时,同时创建他的文章 (Nested Write)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| const newUserWithPosts = await prisma.user.create({ data: { email: 'bob@prisma.io', name: 'Bob', posts: { create: [ { title: '关注 Prisma', content: 'Prisma 太棒了!' }, { title: '学习 Express', content: 'Express 和 Prisma 是绝配' }, ], }, }, include: { posts: true, }, }); res.status(201).json(newUserWithPosts);
|
场景二:查询用户时,同时带出他的所有文章 (Eager Loading)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const userWithPosts = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true, name: true,
posts: { orderBy: { title: 'asc' } } } });
|
这种 include
的方式,正是解决上一章面试题中提到的 “N+1 查询问题” 的最佳方案。
8.5. 本章核心速查总结
分类 | 关键项 | 核心描述 |
---|
核心客户端 | new PrismaClient() | 创建一个可管理连接池的数据库客户端实例。应作为单例使用。 |
写操作 | prisma.model.create({ data }) | 创建一条新记录。 |
| prisma.model.update({ where, data }) | 根据 where 条件更新一条记录。 |
| prisma.model.delete({ where }) | 根据 where 条件删除一条记录。 |
读操作 | prisma.model.findMany({ ... }) | 查询多条记录。 |
| prisma.model.findUnique({ where }) | 根据唯一约束(如 id 或 @unique 字段)查询单条记录。 |
查询选项 | where | 定义查询的过滤条件。 |
| orderBy | 定义查询结果的排序方式,如 { createdAt: 'desc' } 。 |
| skip / take | 用于实现分页,take 是每页数量,skip 是跳过的记录数。 |
关系查询 | include | (关键) 预加载关联模型的数据,解决 N+1 问题。 |
| select | 精确选择要返回的字段,可用于裁剪响应数据。 |
事务 | prisma.$transaction([...]) | 执行多个操作,确保它们要么全部成功,要么全部失败。 |
8.6. 高频面试题与陷阱
面试官深度追问
2025-09-15
在一个复杂的业务场景中,比如用户下单,通常需要执行多个数据库写操作:1. 扣减库存;2. 创建订单;3. 更新用户积分。这三个操作必须是一个原子操作,要么全部成功,要么全部失败。如果使用 Prisma,你会如何来保证这种数据一致性?
我
对于这种场景,我必须使用数据库事务 (Transaction) 来处理。Prisma 提供了 prisma.$transaction()
API 来实现这一点。
我
我会将这三个独立的写操作——prisma.product.update(...)
、prisma.order.create(...)
和 prisma.user.update(...)
——放进一个数组中,然后将这个数组作为参数传递给 prisma.$transaction()
。
我
就像这样:const [updatedProduct, newOrder, updatedUser] = await prisma.$transaction([ ... ])
。
我
Prisma 会将这个数组中的所有操作包装在单个数据库事务中执行。如果其中任何一个操作失败,比如在扣减库存后,创建订单时因为某个约束失败了,那么整个事务就会被回滚 (Rollback)。之前已经成功执行的扣减库存操作也会被撤销,数据库将恢复到事务开始之前的状态。这样就完美地保证了业务操作的原子性和数据的一致性。
非常好。这正是在生产环境中处理关键业务逻辑的正确方式。