第八章:使用 Prisma 构建数据驱动的 API

第八章:使用 Prisma 构建数据驱动的 API

摘要: 我们已经拥有了一个结构化的 Express 应用、一个清晰的数据模型 (schema.prisma) 和一个与之同步的云数据库。现在,我们将进入最激动人心的环节:将它们串联起来。本章将是纯粹的实战,我们将深入学习并运用 Prisma Client——那个由 prisma generate 命令为我们量身定制的、类型安全的数据库“遥控器”。我们将逐一实现用户的 CRUD (增删改查) API,学习如何处理数据关联、如何进行高级查询(过滤、排序、分页),并遵循最佳实践,将数据库逻辑封装在独立的“服务层”中,保持代码的整洁与高内聚。


在本章,我们将把理论彻底转化为可运行的、数据驱动的 API:

  1. 首先,我们将学习实例化 PrismaClient 的最佳实践——单例模式,以确保应用高效、稳定地管理数据库连接。
  2. 接着,我们将系统地实现用户的 CRUD API,逐一掌握 create, findMany, findUnique, update, delete 等核心方法。
  3. 然后,我们将探索 Prisma 的高级查询能力,学习如何实现服务端的数据过滤、排序和分页。
  4. 最后,我们将演练关系数据的处理,学习如何在一个操作中同时创建用户及其关联的文章,以及如何在查询时一并带出关联数据。

8.1. 实例化 PrismaClient:应用的数据库连接器

PrismaClient 是我们与数据库交互的入口。一个常见的疑问是:我应该在哪里、以及如何创建它的实例?

错误的做法是在每个需要数据库操作的函数中都 new PrismaClient()。这会导致应用创建过多的数据库连接,迅速耗尽连接池资源,从而严重影响性能甚至导致服务崩溃。

最佳实践是遵循单例模式:在整个应用程序的生命周期中,只创建一个 PrismaClient 实例,并在所有需要它的地方共享这个实例。PrismaClient 实例内部已经为你管理好了高效的数据库连接池。

为了实现这一点,我们创建一个专门的文件来实例化并导出这个单例。

src/utils/prisma.js (我们为此创建一个新文件)

1
2
3
4
5
6
7
8
9
10
11
12
// file: src/utils/prisma.js
import {
PrismaClient
} from '@prisma/client';

// 实例化 PrismaClient
const prisma = new PrismaClient({
// 在开发环境中,开启日志记录,方便我们观察 Prisma 生成的 SQL 语句
log: ['query', 'info', 'warn', 'error'],
})

export default prisma;

现在,在应用的任何地方,我们只需要 import prisma from '../utils/prisma.js' 就可以安全地使用这个共享的数据库连接器了。


8.2. 实现 CRUD:用户的增删改查

现在,我们将重构第六章中定义的 users 路由,用真实的 Prisma 操作替换掉所有模拟响应。我们将遵循“关注点分离”的原则,将路由和请求/响应处理逻辑放在 Controller 层,将纯粹的数据库交互逻辑放在 Service 层。

(注:为保持简洁,我们将 Service 和 Controller 的代码暂时合并展示在路由文件中,在更大型的项目中,应将它们拆分到 controllersservices 目录。)

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
// file: src/api/users.routes.js
// file: src/api/users.routes.js
import {
Router
} from 'express';
import {
z
} from 'zod';
import prisma from '../utils/prisma.js'; // 导入 Prisma 单例
import validate from '../middlewares/validate.js';


// 定义创建用户时的验证 schema
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+$/), // 确保 id 是数字字符串
}),
});

// 1. 创建一个新的路由实例
const router = Router();

// 1. CREATE: 创建一个新用户
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)
}
})

// 2. READ: 获取所有用户
router.get("/", async (req, res, next) => {
try {
const users = await prisma.user.findMany();
res.json(users)
} catch (error) {
next(error)
}
})


// 3. READ: 获取单个用户
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)
}
})



// 4. UPDATE: 更新一个用户
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)
}
})



// 5. DELETE: 删除一个用户
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">
<!-- 用户将通过 JS 动态插入这里 -->
</ul>
</div>

<script>
document.addEventListener('DOMContentLoaded', () => {
const API_URL = 'http://localhost:3000/api/v1/users';

// --- DOM Elements ---
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');

// --- Functions ---

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
// file: src/api/users.routes.js (在 GET / 路由中修改)

router.get('/', async (req, res, next) => {
try {
// 1. 过滤 (Filtering): ?name=Alice
const { name, sort, page = 1, limit = 10 } = req.query;
const where = {};
if (name) {
where.name = {
contains: name, // 使用 contains 实现模糊查询 (LIKE '%Alice%')
mode: 'insensitive', // 不区分大小写
};
}

// 2. 排序 (Sorting): ?sort=createdAt_desc
const orderBy = {};
if (sort) {
const [field, direction] = sort.split('_');
orderBy[field] = direction;
} else {
orderBy.createdAt = 'desc'; // 默认按创建时间倒序
}

// 3. 分页 (Pagination): ?page=1&limit=5
const skip = (parseInt(page) - 1) * parseInt(limit);
const take = parseInt(limit);

// 4. 同时查询总数以方便前端分页
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"

8.4. 处理关系:创建与查询

细致讲解:
Prisma 最强大的功能之一就是对关系数据的直观处理。

场景一:创建用户时,同时创建他的文章 (Nested Write)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 在 POST / 路由中...
const newUserWithPosts = await prisma.user.create({
data: {
email: 'bob@prisma.io',
name: 'Bob',
// 嵌套的 create 操作
posts: {
create: [
{ title: '关注 Prisma', content: 'Prisma 太棒了!' },
{ title: '学习 Express', content: 'Express 和 Prisma 是绝配' },
],
},
},
// 使用 include 将关联的文章一并返回
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
// 使用 select (只获取 User 的 id, email + posts)
const userWithPosts = await prisma.user.findUnique({
where: { id: userId },
select: {
// 1. 明确指定你想要的用户字段
id: true,
email: true,
name: true,

// 2. 在 select 内部指定关联模型,效果类似于 include
posts: {
// 3. 这里可以像之前一样,对关联数据进行排序、筛选等操作
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)。之前已经成功执行的扣减库存操作也会被撤销,数据库将恢复到事务开始之前的状态。这样就完美地保证了业务操作的原子性和数据的一致性。

非常好。这正是在生产环境中处理关键业务逻辑的正确方式。