Node.js: REST API 设计
最后更新:2026-08-26
Alice 的团队正为一个任务管理系统开发前后端。前端抱怨:不知道有哪些接口、用什么 HTTP 方法、返回什么格式。后端也很无奈——同一个"更新任务",有人用 POST,有人用 PUT,有人用 PATCH,返回格式五花八门。协作陷入混乱。
Alice 决定引入 REST 规范。团队统一了资源命名、方法映射、状态码和响应格式后,API 变得清晰可预测,前端不再反复确认接口文档,协作效率翻倍。
1. 你将学到
- REST 架构的四大核心原则
- CRUD 操作与 HTTP 方法的正确映射
- RESTful URL 设计的 do 与 don't
- HTTP 状态码的选择策略
- 请求与响应的 JSON 规范
- API 版本化的三种策略
- REST 成熟度模型与 HATEOAS
2. 1 REST 架构原则
(1) 什么是 REST
REST(Representational State Transfer)是一种软件架构风格,由 Roy Fielding 在 2000 年提出。它定义了一组约束,用于设计网络应用的接口。REST 不是协议,不是标准,而是一种设计理念。
(2) 四大核心原则
| 原则 | 含义 | 示例 |
|---|---|---|
| 资源(Resource) | 一切皆资源,用 URL 标识 | /tasks, /users/42 |
| 表现层(Representation) | 资源的表现形式,如 JSON | {"id": 1, "title": "Learn REST"} |
| 无状态(Stateless) | 每次请求包含所有必要信息 | 请求携带 token,不依赖 session |
| 统一接口(Uniform Interface) | 用标准 HTTP 方法操作资源 | GET 读取,POST 创建,DELETE 删除 |
▶ 示例:无状态 vs 有状态
// 有状态:依赖服务器 session(非 RESTful)
app.post('/login', (req, res) => {
req.session.userId = 42; // 服务器保存状态
res.send('logged in');
});
app.get('/profile', (req, res) => {
const userId = req.session.userId; // 依赖服务器状态
res.json({ id: userId, name: 'Alice' });
});
// 无状态:每次请求携带认证信息(RESTful)
app.get('/profile', (req, res) => {
const userId = verifyToken(req.headers.authorization);
res.json({ id: userId, name: 'Alice' });
});
3. 2 CRUD 与 HTTP 方法映射
(1) 标准映射关系
REST 的核心思想是用 HTTP 方法表达对资源的操作意图,而不是在 URL 中嵌入动作词。
| CRUD 操作 | HTTP 方法 | 路径 | 幂等性 | 安全性 |
|---|---|---|---|---|
| Create | POST | /tasks |
否 | 否 |
| Read(列表) | GET | /tasks |
是 | 是 |
| Read(单个) | GET | /tasks/42 |
是 | 是 |
| Update(全量) | PUT | /tasks/42 |
是 | 否 |
| Update(局部) | PATCH | /tasks/42 |
否 | 否 |
| Delete | DELETE | /tasks/42 |
是 | 否 |
(2) 幂等性详解
幂等性指同一请求执行一次与执行多次的效果相同。GET、PUT、DELETE 是幂等的,POST 和 PATCH 不是。
▶ 示例:PUT vs POST 的幂等性差异
// POST:每次调用创建新资源(非幂等)
// 第1次 POST /tasks → 创建 id=1
// 第2次 POST /tasks → 创建 id=2
app.post('/tasks', (req, res) => {
const task = { id: nextId++, ...req.body };
tasks.push(task);
res.status(201).json(task);
});
// PUT:每次调用替换同一资源(幂等)
// 第1次 PUT /tasks/1 → 替换 id=1
// 第2次 PUT /tasks/1 → 替换 id=1(结果相同)
app.put('/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) return res.status(404).json({ error: 'Not found' });
tasks[idx] = { id: parseInt(req.params.id), ...req.body };
res.json(tasks[idx]);
});
▶ 示例:PATCH 局部更新
// PATCH:只修改提供的字段
app.patch('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) return res.status(404).json({ error: 'Not found' });
Object.assign(task, req.body);
res.json(task);
});
// 请求:只修改 status 字段
// PATCH /tasks/1 {"status": "done"}
// 原数据:{"id":1,"title":"Learn REST","status":"pending"}
// 结果:{"id":1,"title":"Learn REST","status":"done"}
4. 3 URL 设计规范
(1) 核心规则
RESTful URL 设计遵循一组约定,让 API 直觉可读。
| 规则 | 正确 ✅ | 错误 ❌ |
|---|---|---|
| 用名词不用动词 | GET /tasks |
GET /getTasks |
| 用复数不用单数 | /tasks |
/task |
| 用嵌套表示关系 | /users/42/tasks |
/tasksByUser?userId=42 |
| 层级不超过3层 | /users/42/tasks/1 |
/orgs/1/teams/2/users/42/tasks |
| 用查询参数过滤 | /tasks?status=done |
/doneTasks |
| 用 kebab-case | /task-items |
/taskItems |
(2) 嵌套资源的设计
嵌套资源表示从属关系。当子资源离不开父资源独立存在时,使用嵌套路径。
▶ 示例:任务管理系统的 URL 设计
# 任务资源
GET /tasks # 获取任务列表
POST /tasks # 创建新任务
GET /tasks/42 # 获取单个任务
PUT /tasks/42 # 全量更新任务
PATCH /tasks/42 # 局部更新任务
DELETE /tasks/42 # 删除任务
# 任务的评论(嵌套资源)
GET /tasks/42/comments # 获取任务42的评论列表
POST /tasks/42/comments # 为任务42添加评论
GET /tasks/42/comments/7 # 获取任务42的评论7
DELETE /tasks/42/comments/7 # 删除评论7
# 过滤与分页
GET /tasks?status=done&page=2&limit=20
GET /tasks?sort=-created_at # 按创建时间倒序
▶ 示例:URL 查询参数的常见用法
app.get('/tasks', (req, res) => {
let result = [...tasks];
// 过滤
if (req.query.status) {
result = result.filter(t => t.status === req.query.status);
}
// 排序
if (req.query.sort) {
const field = req.query.sort.startsWith('-')
? req.query.sort.slice(1)
: req.query.sort;
const order = req.query.sort.startsWith('-') ? -1 : 1;
result.sort((a, b) => (a[field] > b[field] ? order : -order));
}
// 分页
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const start = (page - 1) * limit;
result = result.slice(start, start + limit);
res.json({
data: result,
page,
limit,
total: tasks.length
});
});
5. 4 HTTP 状态码选择
(1) 状态码分类与选择
HTTP 状态码是 REST API 与客户端沟通的关键信号。选对状态码,客户端能准确理解请求结果。
| 场景 | 状态码 | 含义 | 说明 |
|---|---|---|---|
| 成功获取资源 | 200 OK | 请求成功 | GET/PUT/PATCH 成功时返回 |
| 成功创建资源 | 201 Created | 资源已创建 | POST 成功时返回,应含 Location 头 |
| 成功删除资源 | 204 No Content | 无内容 | DELETE 成功时返回,无响应体 |
| 请求参数错误 | 400 Bad Request | 客户端请求语法错误 | 缺少必填字段、格式错误 |
| 未认证 | 401 Unauthorized | 未提供认证信息 | 缺少或无效的 token |
| 无权限 | 403 Forbidden | 认证但无权限 | 普通用户访问管理员接口 |
| 资源不存在 | 404 Not Found | 请求的资源不存在 | ID 对应的资源未找到 |
| 服务器错误 | 500 Internal Server Error | 服务器内部错误 | 未捕获的异常 |
(2) 常见错误:误用状态码
▶ 示例:正确的状态码使用
// 创建资源 → 201 + Location
app.post('/tasks', (req, res) => {
const task = { id: nextId++, ...req.body };
tasks.push(task);
res.status(201)
.location(`/tasks/${task.id}`)
.json(task);
});
// 删除资源 → 204(无响应体)
app.delete('/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) return res.status(404).json({ error: 'Not found' });
tasks.splice(idx, 1);
res.status(204).end();
});
// 验证失败 → 400 + 错误详情
app.post('/tasks', (req, res) => {
if (!req.body.title) {
return res.status(400).json({
error: 'Validation failed',
details: [{ field: 'title', message: 'Title is required' }]
});
}
// ...
});
6. 5 请求与响应格式
(1) JSON 规范约定
| 约定 | 规范 | 示例 |
|---|---|---|
| 字段命名 | camelCase | createdAt, taskId |
| 日期格式 | ISO 8601 | 2025-07-03T10:30:00Z |
| 列表响应 | 包含 data + 分页信息 | {"data": [...], "total": 100} |
| 错误响应 | 包含 error + details | {"error": "Not found", "details": [...]} |
| 空值处理 | 用 null 不省略字段 | {"description": null} |
| ID 类型 | 字符串(避免精度问题) | {"id": "42"} |
▶ 示例:标准化的列表响应
app.get('/tasks', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const start = (page - 1) * limit;
const data = tasks.slice(start, start + limit);
res.json({
data,
pagination: {
page,
limit,
total: tasks.length,
totalPages: Math.ceil(tasks.length / limit)
}
});
});
// 响应示例
{
"data": [
{
"id": "1",
"title": "Learn REST",
"status": "pending",
"createdAt": "2025-07-03T10:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"totalPages": 1
}
}
▶ 示例:标准化的错误响应
// 统一错误处理中间件
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message || 'Internal Server Error',
details: err.details || [],
requestId: req.id,
timestamp: new Date().toISOString()
});
});
// 自定义错误类
class ApiError extends Error {
constructor(status, message, details = []) {
super(message);
this.status = status;
this.details = details;
}
}
// 使用
app.get('/tasks/:id', (req, res, next) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
return next(new ApiError(404, 'Task not found', [
{ field: 'id', message: `No task with id ${req.params.id}` }
]));
}
res.json(task);
});
// 错误响应示例
{
"error": "Task not found",
"details": [
{ "field": "id", "message": "No task with id 999" }
],
"requestId": "req-a1b2c3",
"timestamp": "2025-07-03T10:30:00Z"
}
7. 6 API 版本化策略
(1) 三种主流策略对比
| 策略 | 示例 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| URL 路径 | /api/v1/tasks |
直观,浏览器可测试 | URL 变长,非 RESTful 纯粹派争议 | 大多数公开 API |
| 请求头 | Accept: application/vnd.myapi.v1+json |
URL 干净,RESTful 纯粹 | 不直观,调试复杂 | 追求 REST 纯粹性 |
| 查询参数 | /api/tasks?version=1 |
最简单 | 易被忽略,缓存策略复杂 | 内部 API、简单项目 |
(2) 版本化最佳实践
- 从 v1 开始,不要无版本
- 只在破坏性变更时升级大版本
- 旧版本至少维护 6 个月
- 在响应头中标注当前版本
▶ 示例:URL 路径版本化实现
// 路由结构
// /api/v1/tasks → v1 逻辑
// /api/v2/tasks → v2 逻辑
const express = require('express');
const app = express();
// v1 路由
const v1Router = express.Router();
v1Router.get('/tasks', (req, res) => {
res.json({ data: tasks, version: 'v1' }); // v1 返回格式
});
// v2 路由(响应格式升级)
const v2Router = express.Router();
v2Router.get('/tasks', (req, res) => {
res.json({ // v2 返回格式(含分页)
data: tasks,
pagination: { page: 1, total: tasks.length }
});
});
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// 响应头标注版本
app.use('/api/v2', (req, res, next) => {
res.setHeader('X-API-Version', '2.0');
next();
});
8. 7 REST 成熟度模型
(1) Richardson 成熟度模型
Leonard Richardson 提出了一个模型,衡量 API 的 RESTful 成熟度:
graph TD
L0["Level 0: 单一端点<br/>HTTP 作为隧道<br/>例: POST /api {action: getTasks}"]
L1["Level 1: 引入资源<br/>每个资源一个 URL<br/>例: POST /tasks, POST /users"]
L2["Level 2: HTTP 方法<br/>GET/POST/PUT/DELETE<br/>例: GET /tasks, DELETE /tasks/1"]
L3["Level 3: HATEOAS<br/>响应包含超媒体链接<br/>例: 响应中有 next, self 链接"]
L0 --> L1 --> L2 --> L3
style L0 fill:#ff6b6b,color:#fff
style L1 fill:#ffa502,color:#fff
style L2 fill:#2ed573,color:#fff
style L3 fill:#1e90ff,color:#fff
| 级别 | 特征 | 示例请求 | 示例响应 |
|---|---|---|---|
| Level 0 | HTTP 隧道,单一 URL | POST /api {"action":"getTasks"} |
{"tasks": [...]} |
| Level 1 | 资源分离,方法不限 | POST /tasks |
{"tasks": [...]} |
| Level 2 | HTTP 语义正确 | GET /tasks |
200 {"data": [...]} |
| Level 3 | HATEOAS 超媒体 | GET /tasks/1 |
含 _links 导航 |
(2) HATEOAS 详解
HATEOAS(Hypermedia as the Engine of Application State)要求响应中包含相关操作的链接,客户端无需硬编码 URL。
▶ 示例:Level 3 — 带 HATEOAS 链接的响应
app.get('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) return res.status(404).json({ error: 'Not found' });
res.json({
...task,
_links: {
self: { href: `/tasks/${task.id}`, method: 'GET' },
update: { href: `/tasks/${task.id}`, method: 'PUT' },
delete: { href: `/tasks/${task.id}`, method: 'DELETE' },
assign: { href: `/tasks/${task.id}/assignee`, method: 'POST' },
comments: { href: `/tasks/${task.id}/comments`, method: 'GET' }
}
});
});
// 响应
{
"id": "1",
"title": "Learn REST",
"status": "pending",
"createdAt": "2025-07-03T10:30:00Z",
"_links": {
"self": { "href": "/tasks/1", "method": "GET" },
"update": { "href": "/tasks/1", "method": "PUT" },
"delete": { "href": "/tasks/1", "method": "DELETE" },
"assign": { "href": "/tasks/1/assignee", "method": "POST" },
"comments": { "href": "/tasks/1/comments", "method": "GET" }
}
}
9. 8 综合示例:任务管理 API 完整设计
Alice 的团队为任务管理系统设计了完整的 RESTful API,从资源定义到错误处理,一应俱全。
▶ 示例:完整的任务管理 API
(1) 资源定义
| 资源 | 路径 | 说明 |
|---|---|---|
| 任务集合 | /api/v1/tasks |
所有任务 |
| 单个任务 | /api/v1/tasks/:id |
指定任务 |
| 任务评论 | /api/v1/tasks/:id/comments |
指定任务的评论 |
| 任务标签 | /api/v1/tasks/:id/tags |
指定任务的标签 |
(2) 方法映射与请求/响应
const express = require('express');
const app = express();
app.use(express.json());
let tasks = [
{ id: 1, title: 'Design database schema', status: 'done', priority: 'high', createdAt: '2025-07-01T08:00:00Z' },
{ id: 2, title: 'Implement REST API', status: 'in-progress', priority: 'high', createdAt: '2025-07-02T09:00:00Z' }
];
let nextId = 3;
// GET /api/v1/tasks — 获取任务列表
app.get('/api/v1/tasks', (req, res) => {
const { status, priority, page = 1, limit = 20 } = req.query;
let result = [...tasks];
if (status) result = result.filter(t => t.status === status);
if (priority) result = result.filter(t => t.priority === priority);
const start = (page - 1) * limit;
const data = result.slice(start, start + Number(limit));
res.json({
data,
pagination: {
page: Number(page),
limit: Number(limit),
total: result.length,
totalPages: Math.ceil(result.length / Number(limit))
}
});
});
// GET /api/v1/tasks/:id — 获取单个任务
app.get('/api/v1/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
res.json({
data: task,
_links: {
self: { href: `/api/v1/tasks/${task.id}` },
update: { href: `/api/v1/tasks/${task.id}`, method: 'PUT' },
delete: { href: `/api/v1/tasks/${task.id}`, method: 'DELETE' },
comments: { href: `/api/v1/tasks/${task.id}/comments` }
}
});
});
// POST /api/v1/tasks — 创建任务
app.post('/api/v1/tasks', (req, res) => {
const { title, priority } = req.body;
if (!title) {
return res.status(400).json({
error: 'Validation failed',
details: [{ field: 'title', message: 'Title is required' }],
timestamp: new Date().toISOString()
});
}
const task = {
id: nextId++,
title,
status: 'pending',
priority: priority || 'medium',
createdAt: new Date().toISOString()
};
tasks.push(task);
res.status(201).location(`/api/v1/tasks/${task.id}`).json({ data: task });
});
// PUT /api/v1/tasks/:id — 全量更新
app.put('/api/v1/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
const { title, status, priority } = req.body;
if (!title || !status) {
return res.status(400).json({
error: 'Validation failed',
details: [
...(!title ? [{ field: 'title', message: 'Title is required' }] : []),
...(!status ? [{ field: 'status', message: 'Status is required' }] : [])
],
timestamp: new Date().toISOString()
});
}
tasks[idx] = { id: tasks[idx].id, title, status, priority: priority || 'medium', createdAt: tasks[idx].createdAt };
res.json({ data: tasks[idx] });
});
// PATCH /api/v1/tasks/:id — 局部更新
app.patch('/api/v1/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
Object.assign(task, req.body);
res.json({ data: task });
});
// DELETE /api/v1/tasks/:id — 删除任务
app.delete('/api/v1/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
tasks.splice(idx, 1);
res.status(204).end();
});
app.listen(3000, () => console.log('Task API running on port 3000'));
(3) 请求与响应速查
# 创建任务
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Write documentation","priority":"high"}'
# 获取任务列表(过滤 + 分页)
curl http://localhost:3000/api/v1/tasks?status=pending&page=1&limit=10
# 局部更新
curl -X PATCH http://localhost:3000/api/v1/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status":"done"}'
# 删除任务
curl -X DELETE http://localhost:3000/api/v1/tasks/2
// POST 创建成功 → 201
Status: 201 Created
Location: /api/v1/tasks/3
{ "data": { "id": 3, "title": "Write documentation", "status": "pending", "priority": "high", "createdAt": "2025-07-03T10:30:00Z" } }
// PATCH 更新成功 → 200
{ "data": { "id": 1, "title": "Design database schema", "status": "done", "priority": "high", "createdAt": "2025-07-01T08:00:00Z" } }
// DELETE 成功 → 204
Status: 204 No Content
(empty body)
// 404 错误
{ "error": "Task not found", "details": [{ "field": "id", "message": "No task with id 999" }], "timestamp": "2025-07-03T10:30:00Z" }
// 400 验证错误
{ "error": "Validation failed", "details": [{ "field": "title", "message": "Title is required" }], "timestamp": "2025-07-03T10:30:00Z" }
❓ 常见问题
/api/v1/)最直观,浏览器可直接测试,是大多数公开 API 的选择。请求头版本化更 RESTful 但调试复杂。查询参数最简单但易被忽略。新手推荐 URL 路径。Content-Type 头协商格式即可。/users/42/tasks/1/comments/5 太深,可改为 /comments/5 或 /tasks/1/comments/5。/tasks/batch 传数组创建;PATCH /tasks 传数组批量更新;DELETE /tasks?ids=1,2,3 批量删除。自定义端点需在文档中明确说明。📖 小节
- 你将学到的核心概念与使用方法
- 1 REST 架构原则的核心概念与使用方法
- 2 CRUD 与 HTTP 方法映射的核心概念与使用方法
- 3 URL 设计规范的核心概念与使用方法
- 4 HTTP 状态码选择的核心概念与使用方法
- 5 请求与响应格式的核心概念与使用方法
- 6 API 版本化策略的核心概念与使用方法
- 7 REST 成熟度模型的核心概念与使用方法
📝 作业
- 完成本课所有代码示例,确保每个示例都能正确运行
- 修改综合示例,添加自己的扩展功能
- 查阅官方文档,找出本课未涉及的1-2个API并编写测试代码
- 思考:在实际项目中,你会如何应用本课学到的知识?
- 尝试将本课知识与前面课程的内容结合,构建一个小项目