Node.js: REST API 设计

最后更新:2026-08-26

Alice 的团队正为一个任务管理系统开发前后端。前端抱怨:不知道有哪些接口、用什么 HTTP 方法、返回什么格式。后端也很无奈——同一个"更新任务",有人用 POST,有人用 PUT,有人用 PATCH,返回格式五花八门。协作陷入混乱。

Alice 决定引入 REST 规范。团队统一了资源命名、方法映射、状态码和响应格式后,API 变得清晰可预测,前端不再反复确认接口文档,协作效率翻倍。

1. 你将学到



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 有状态

JAVASCRIPT
// 有状态:依赖服务器 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 的幂等性差异

JAVASCRIPT
// 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 局部更新

JAVASCRIPT
// 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 设计

TEXT 📖 仅展示
# 任务资源
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 查询参数的常见用法

JAVASCRIPT
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) 常见错误:误用状态码

▶ 示例:正确的状态码使用

JAVASCRIPT
// 创建资源 → 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"}

▶ 示例:标准化的列表响应

JAVASCRIPT
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)
    }
  });
});
▶ 试一试
TEXT 📖 仅展示
// 响应示例
{
  "data": [
    {
      "id": "1",
      "title": "Learn REST",
      "status": "pending",
      "createdAt": "2025-07-03T10:30:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 1,
    "totalPages": 1
  }
}

▶ 示例:标准化的错误响应

JAVASCRIPT
// 统一错误处理中间件
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);
});
▶ 试一试
TEXT 📖 仅展示
// 错误响应示例
{
  "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) 版本化最佳实践

▶ 示例:URL 路径版本化实现

JAVASCRIPT
// 路由结构
// /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 成熟度:

100%
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 链接的响应

JAVASCRIPT
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' }
    }
  });
});
▶ 试一试
TEXT 📖 仅展示
// 响应
{
  "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) 方法映射与请求/响应

JAVASCRIPT 📖 仅展示
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'));
逻辑代码 111 行(超过 40 行限制,仅展示)

(3) 请求与响应速查

BASH
# 创建任务
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
TEXT 📖 仅展示
// 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" }

❓ 常见问题

Q REST 和 GraphQL 有什么区别?
A REST 基于资源,每个 URL 对应一个资源,用 HTTP 方法操作;GraphQL 基于查询语言,客户端在一个端点按需获取字段。REST 适合资源明确的 CRUD 场景,GraphQL 适合复杂关联查询。
Q PUT 和 PATCH 有什么区别?
A PUT 是全量替换,必须提供资源的所有字段,缺失字段会被置为默认值;PATCH 是局部更新,只修改提供的字段,未提供的字段保持不变。PUT 是幂等的,PATCH 不保证幂等。
Q API 版本化用哪种方式最好?
A URL 路径版本化(/api/v1/)最直观,浏览器可直接测试,是大多数公开 API 的选择。请求头版本化更 RESTful 但调试复杂。查询参数最简单但易被忽略。新手推荐 URL 路径。
Q REST API 一定要返回 JSON 吗?
A 不一定。REST 不限制格式,可以用 XML、HTML、JSON 等。但 JSON 是目前最常用的格式,轻量、易解析、与 JavaScript 天然兼容。通过 Content-Type 头协商格式即可。
Q 什么是幂等性?
A 幂等性指同一请求执行一次与执行多次的效果相同。GET 幂等(读多次结果一样),PUT 幂等(替换多次结果一样),DELETE 幂等(删除已删除的资源仍返回成功),POST 非幂等(每次创建新资源)。
Q 嵌套资源层级太深怎么办?
A 超过 2 层嵌套时应考虑将子资源提升为顶级资源,用查询参数关联。例如 /users/42/tasks/1/comments/5 太深,可改为 /comments/5/tasks/1/comments/5
Q REST API 如何处理批量操作?
A REST 没有标准方案。常见做法:POST /tasks/batch 传数组创建;PATCH /tasks 传数组批量更新;DELETE /tasks?ids=1,2,3 批量删除。自定义端点需在文档中明确说明。

📖 小节


📝 作业

  1. 完成本课所有代码示例,确保每个示例都能正确运行
  2. 修改综合示例,添加自己的扩展功能
  3. 查阅官方文档,找出本课未涉及的1-2个API并编写测试代码
  4. 思考:在实际项目中,你会如何应用本课学到的知识?
  5. 尝试将本课知识与前面课程的内容结合,构建一个小项目
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏