MongoDB: RESTful API全实战:完整CRUD设计
最后更新:2026-08-26
RESTful API 是 Web 服务的标准——掌握它能构建规范、可维护的 API 体系。
REST 的核心理念与常见误解:REST(Representational State Transfer)的核心是"资源导向"——URL 表示资源,HTTP 方法表示操作。常见误解——1. REST ≠ CRUD:REST 不只是增删改查,还可以表达业务动作(如 POST /orders/{id}/cancel),关键是 URL 名词化;2. REST ≠ 无状态:无状态是指每次请求包含所有必要信息(不依赖服务端 session),但业务数据当然有状态;3. REST ≠ 必须用 JSON:REST 不限制格式,JSON 只是最流行的选择;4. REST ≠ 完美:REST 对实时推送/复杂查询/批量操作支持不佳,这些场景 gRPC/GraphQL 更合适。理解 REST 的边界比背诵规则更重要。
RESTful API 的成熟度模型:Richardson 成熟度模型将 REST API 分为 4 级——0 级(HTTP 隧道):只用 POST,所有操作通过一个 URL(如 SOAP);1 级(资源):引入资源概念,不同资源用不同 URL,但只用 GET/POST;2 级(HTTP 动词):正确使用 GET/POST/PUT/DELETE + 状态码,这是大多数项目达到的级别;3 级(超媒体/HATEOAS):响应包含相关资源的链接(如订单响应中包含取消链接),实现自发现。从 1 级到 2 级的提升最大(规范了操作语义),3 级在实践中较少使用(增加复杂度但前端收益有限)。本课程目标是 2 级——正确使用 HTTP 语义构建清晰的 API。
1. 你将学到
- RESTful API 设计原则
- 完整 CRUD 端点(GET/POST/PUT/DELETE)
- 请求体验证(joi / express-validator)
- 分页、排序、筛选
- HTTP 状态码规范
- 统一响应格式
2. RESTful 设计原则
REST 成熟度模型:Leonard Richardson 定义了 REST API 的四个成熟度级别——Level 0:单一 URL + POST(如 SOAP);Level 1:引入资源概念(不同 URL 代表不同资源);Level 2:HTTP 方法语义化(GET/POST/PUT/DELETE 表达操作);Level 3:HATEOAS(响应中包含下一步操作的链接)。大多数生产 API 停在 Level 2,Level 3 虽最符合 REST 理念但实践成本高。
API 安全性设计:RESTful API 的安全需要多层防御——1. 传输层:强制 HTTPS,防止中间人攻击;2. 认证层:JWT Bearer Token 验证用户身份;3. 授权层:RBAC 角色权限控制(customer/admin/moderator);4. 输入层:joi 验证 + 请求体大小限制(express.json({limit:'1mb'}));5. 速率层:express-rate-limit 防暴力攻击;6. 跨域层:CORS 白名单限制来源域名。
| 安全层 | 防御目标 | 实现方式 |
|---|---|---|
| HTTPS | 窃听/篡改 | TLS 证书 |
| JWT | 身份伪造 | Bearer Token |
| RBAC | 越权操作 | 角色+权限矩阵 |
| joi | 注入/脏数据 | Schema 验证 |
| rate-limit | DDoS | IP 限速 |
| CORS | 跨域滥用 | 白名单域名 |
什么是 REST? REST(Representational State Transfer)是一种架构风格,核心思想是:一切皆资源,用 URL 标识资源,用 HTTP 方法表达操作。REST 不是协议而是约束——遵循约束的 API 就叫 RESTful API。Roy Fielding 在 2000 年的博士论文中定义了 6 个约束:客户端-服务器、无状态、缓存、统一接口、分层系统、按需代码。
RESTful 设计核心原则:
| 原则 | 说明 | 示例 |
|---|---|---|
| 资源用名词 | URL 表示资源,不是动作 | /products ✅ /getProducts ❌ |
| 复数名词 | 集合用复数 | /products ✅ /product ❌ |
| HTTP 方法语义 | GET 读 / POST 写 / PUT 换 / PATCH 改 / DELETE 删 | DELETE /products/:id |
| 嵌套资源 | 用路径表达从属关系 | /products/:id/reviews |
| 幂等性 | GET/PUT/DELETE 多次调用结果相同 | 重复 DELETE 不报错 |
| 无状态 | 每个请求包含所有必要信息 | JWT Token 每次携带 |
REST vs RPC vs GraphQL:
| 维度 | REST | RPC | GraphQL |
|---|---|---|---|
| 核心思想 | 资源 + HTTP 语义 | 动作调用 | 按需查询 |
| URL 风格 | 名词 | 动词 | 单端点 |
| 数据获取 | 固定结构 | 固定结构 | 客户端定义 |
| 过度获取 | 常见 | 常见 | 避免 |
| 学习成本 | 低 | 低 | 中 |
| 缓存友好 | HTTP 原生缓存 | 需自行实现 | 复杂 |
| 适用场景 | CRUD API | 内部服务 | 复杂前端 |
REST 的约束与自由:REST 的 6 个约束(客户端-服务器、无状态、缓存、统一接口、分层系统、按需代码)并非强制要求——Level 2 REST(资源 + HTTP 方法语义)已满足 90% 的 API 需求。过度追求 RESTful 纯粹性(如 Level 3 HATEOAS)反而增加开发成本。实际项目中,核心原则只有三个:1. URL 是名词(资源),HTTP 方法是动词(操作);2. 用状态码表达结果,不在响应体中重复;3. 无状态认证(JWT 每次携带,不依赖 session)。
何时不用 REST:REST 不适合所有场景——1. 实时通信(WebSocket 更好,REST 的请求-响应模式不支持服务端推送);2. 批量操作(REST 的逐资源操作效率低,批量导入/导出用 RPC 风格的 /batch 端点更实际);3. 复杂查询(多条件组合筛选用 GraphQL 更灵活,REST 的 URL 查询参数表达力有限);4. 文件上传(multipart/form-data 不是 REST 的标准交互方式,但实践中可以接受)。选型的核心是务实而非教条。
graph LR
Client[客户端] -->|GET| R1[GET /products<br/>列表]
Client -->|GET| R2[GET /products/:id<br/>详情]
Client -->|POST| R3[POST /products<br/>创建]
Client -->|PUT| R4[PUT /products/:id<br/>完整更新]
Client -->|PATCH| R5[PATCH /products/:id<br/>部分更新]
Client -->|DELETE| R6[DELETE /products/:id<br/>删除]
style R1 fill:#d4edda
style R3 fill:#cce5ff
style R6 fill:#f8d7da
Alice 的 RESTful 设计实践:Alice 为 ShopHub 设计商品 API,她把 URL 当作资源路径:/api/products 是商品集合,/api/products/PHONE-001 是具体商品,/api/products/PHONE-001/reviews 是该商品的评论——URL 本身就是自解释的文档。
幂等性的实战意义:幂等性是 API 可靠性的基础——幂等操作可以安全重试而不产生副作用。实战场景:1. 网络超时重试——PUT/PATCH/DELETE 请求超时后前端可自动重试(结果与第一次相同),POST 请求不能自动重试(可能创建重复数据);2. 移动端弱网——用户点击"删除"后网络断开,恢复后重试不会删除另一条数据;3. 微服务重试——服务间调用失败后重试,幂等操作不会产生副作用。非幂等操作(如 POST 创建)需要用幂等键(idempotency key)实现幂等性——客户端生成唯一 ID,服务端检查是否已处理。
HTTP 状态码的选择指南:RESTful API 的状态码应精确表达结果——2xx 成功(200 正常、201 已创建、204 无内容),4xx 客户端错误(400 验证失败、401 未认证、403 无权限、404 不存在、409 冲突),5xx 服务端错误(500 内部错误、502 网关错误、503 服务不可用)。常见错误:1. 所有错误都返回 200 + {error: "...}"(违反 HTTP 语义,前端无法用状态码判断结果);2. 401 和 403 混用(401 是"未认证"需要登录,403 是"已认证但无权限");3. 用 400 代替所有 4xx(前端无法区分"验证失败"和"资源不存在")。精确的状态码让前端代码更简洁——axios 的拦截器按 status code 分支处理。
5xx 错误的处理原则:5xx 错误意味着服务端出了问题——1. 500 Internal Server Error:未捕获的异常(如 TypeError、数据库连接断开),应记录完整堆栈到日志,返回通用错误消息给客户端(不暴露堆栈信息);2. 502 Bad Gateway:反向代理(Nginx)无法连接到 Node.js 应用(应用崩溃或未启动),触发告警;3. 503 Service Unavailable:应用过载或维护中,返回 Retry-After 头告诉客户端何时重试;4. 5xx 的统一处理:Express 错误中间件统一捕获,记录日志 + 发送告警 + 返回通用消息。生产环境的 5xx 错误率应 < 0.1%,超过此阈值触发 PagerDuty 告警。
| HTTP 方法 | 操作 | 幂等 |
|---|---|---|
| GET | 读取 | ✅ |
| POST | 创建 | ❌ |
| PUT | 完整更新 | ✅ |
| PATCH | 部分更新 | ✅ |
| DELETE | 删除 | ✅ |
| URL 模式 | 含义 |
|---|---|
GET /api/products |
列表 |
GET /api/products/:id |
详情 |
POST /api/products |
创建 |
PUT /api/products/:id |
完整更新 |
PATCH /api/products/:id |
部分更新 |
DELETE /api/products/:id |
删除 |
3. HTTP 状态码规范
为什么状态码很重要? HTTP 状态码是 API 的"信号灯"——客户端通过状态码判断请求结果,不需要解析响应体。2xx 表示成功,4xx 表示客户端错误,5xx 表示服务端错误。滥用 200(如错误也返回 200 + 错误消息)会破坏 HTTP 语义,导致客户端无法正确处理。
最常用的 6 个状态码:生产环境 90% 的 API 响应只需 6 个状态码——200 OK(成功+有数据)、201 Created(创建成功)、204 No Content(删除成功/更新成功无返回)、400 Bad Request(验证失败)、404 Not Found(资源不存在)、500 Internal Server Error(服务端异常)。其他状态码(401/403/409/422)在特定场景使用。原则:能用 6 个基础码表达的不用生僻码,一致性比完整性更重要。
4xx 状态码的精确选择:4xx 状态码表示客户端错误,精确选择帮助前端定位问题——400(请求格式错误/验证失败,前端需修改输入重试)、401(未认证,前端跳转登录页)、403(已认证但无权限,前端显示"无权限"页面)、404(资源不存在,前端显示"未找到")、409(冲突/重复,前端提示"已存在")、422(语义错误,验证通过但业务规则不满足)。常见错误:用 400 代替所有 4xx(前端无法区分"未登录"和"参数错误")、用 404 代替 403(隐藏资源存在性但有安全争议——404 说"不存在"实际是"不让看")。
5xx 错误的处理原则:5xx 表示服务端错误——客户端无法修复,只能重试或等待。关键原则:1. 永远不暴露内部错误详情(数据库错误、文件路径、堆栈信息对用户无用且是安全漏洞);2. 返回 request ID(UUID),用户反馈时提供 ID,开发者在日志中搜索定位);3. 500 用于未知错误,502 用于上游网关错误(如 MongoDB 连接断开),503 用于服务过载(配合 Retry-After 响应头告知客户端何时重试);4. 触发告警(5xx 错误率 > 1% 触发 PagerDuty 告警)。
graph TD
Start[请求结果] --> Success{成功?}
Success -->|是| Code2xx[2xx 状态码]
Success -->|否| WhoFault{谁的错?}
Code2xx --> HasBody{有返回体?}
HasBody -->|是| OK[200 OK]
HasBody -->|新建资源| Created[201 Created]
HasBody -->|无内容| NoContent[204 No Content]
WhoFault -->|客户端| Code4xx[4xx 状态码]
WhoFault -->|服务端| Code5xx[5xx 状态码]
Code4xx --> What4xx{什么问题?}
What4xx -->|参数错误| BR[400 Bad Request]
What4xx -->|未认证| UA[401 Unauthorized]
What4xx -->|无权限| FB[403 Forbidden]
What4xx -->|不存在| NF[404 Not Found]
What4xx -->|资源冲突| CF[409 Conflict]
Code5xx --> SE[500 Internal Server Error]
style OK fill:#d4edda
style Created fill:#d4edda
style BR fill:#f8d7da
style UA fill:#fff3cd
| 状态码 | 含义 | 场景 |
|---|---|---|
| 200 | OK | 成功(GET/PUT/PATCH) |
| 201 | Created | 创建成功(POST) |
| 204 | No Content | 成功无内容(DELETE) |
| 400 | Bad Request | 请求参数错误 |
| 401 | Unauthorized | 未认证 |
| 403 | Forbidden | 无权限 |
| 404 | Not Found | 资源不存在 |
| 409 | Conflict | 资源冲突(如重复) |
| 500 | Server Error | 服务器错误 |
4. 统一响应格式
为什么要统一响应格式? 没有统一格式时,一个端点返回 { product },另一个返回 { data: product },错误时又变成 { message: "error" }——前端需要为每个端点写不同的解析逻辑。统一格式后,前端只需一套逻辑:if (response.success) 走成功,else 读 response.error。
标准响应结构设计:
| 字段 | 类型 | 成功时 | 错误时 |
|---|---|---|---|
| success | boolean | true | false |
| data | any | 业务数据 | 不存在 |
| meta | object | 分页信息 | 不存在 |
| error | object | 不存在 | 错误详情 |
错误码设计原则:错误码(error.code)用大写下划线格式(如 VALIDATION_ERROR),不暴露技术细节(如不要返回 MongooseError),提供可操作信息(如 DUPLICATE_KEY + 重复的字段名)。
HATEOAS 与 API 自发现:REST 成熟度模型的 Level 3 是 HATEOAS(Hypermedia As The Engine Of Application State)——响应中不仅包含数据,还包含相关操作的链接。例如,订单详情响应中包含 links: {pay: '/orders/123/pay', cancel: '/orders/123/cancel'}。HATEOAS 让 API 自描述——客户端不需要硬编码 URL,而是从响应中动态发现可用操作。大多数项目达到 Level 2 即可,HATEOAS 适用于开放 API 平台(如 Stripe、GitHub API)。
分页元数据的设计:列表接口的分页元数据(meta)必须包含足够信息让前端渲染分页器——total(总记录数)、page(当前页)、limit(每页条数)、totalPages(总页数 = ceil(total/limit))。额外可选字段:hasNext(是否有下一页)、hasPrev(是否有上一页)。前端用这些字段决定分页器的显示逻辑:totalPages > 1 才显示分页器,currentPage == totalPages 隐藏下一页按钮。
API 性能的量化指标:RESTful API 的性能需要量化——1. 响应时间 P50/P95/P99(50%/95%/99% 请求的响应时间,P95 < 200ms 是常见目标);2. 吞吐量 QPS(每秒处理请求数,单实例 Express + MongoDB 通常 500-2000 QPS);3. 错误率(5xx 错误占总请求的比例,< 0.1% 是健康标准)。监控工具:Prometheus + Grafana 收集和展示指标,Alertmanager 告警。性能优化优先级:先优化最慢的 API(P95 最高),再优化最高频的 API(QPS 最大的列表接口)。
API 的缓存策略:读多写少的 API(如商品列表、文章详情)适合缓存——1. HTTP 缓存(Cache-Control: max-age=300,5 分钟内浏览器直接用缓存,不发请求);2. CDN 缓存(Cloudflare/CloudFront 边缘缓存,全球用户就近获取);3. 应用缓存(Redis 缓存热门数据,TTL 5-10 分钟);4. 数据库缓存(MongoDB 的 wiredTiger 缓存,自动管理)。缓存失效策略:1. TTL 过期自动失效;2. 写入时主动失效(文章更新后删除对应缓存键);3. 版本号控制(缓存键含版本号,更新时递增版本号)。
缓存穿透与雪崩的防护:缓存系统有两个经典故障模式——1. 缓存穿透:查询不存在的数据(如 productId=999999),缓存无数据→查数据库→也无数据→不缓存→下次还查数据库。防护:对查询结果为 null 的也缓存(TTL 60 秒),或用 Bloom Filter 过滤不存在的 ID;2. 缓存雪崩:大量缓存同时过期(如凌晨 3 点批量过期),请求全部打到数据库。防护:TTL 加随机偏移(300 ± 30 秒),使过期时间分散;3. 缓存击穿:热点数据过期瞬间大量请求打到数据库。防护:热点数据用分布式锁(同一时间只允许一个请求重建缓存),或永不过期 + 异步更新。三种故障的防护策略不同,需要分别实现。
// 成功响应
{
"success": true,
"data": { ... },
"meta": { "page": 1, "limit": 20, "total": 100 }
}
// 错误响应
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": { "email": "Required" }
}
}
// 中间件:统一响应
const sendSuccess = (res, data, meta = null) => {
res.json({ success: true, data, meta });
};
const sendError = (res, code, message, status = 400, details = null) => {
res.status(status).json({
success: false,
error: { code, message, details }
});
};
5. 请求体验证(joi)
为什么需要请求验证? 永远不信任客户端输入——空字符串、超长文本、非法字符、缺失必填字段都可能导致:① 数据库写入脏数据;② 查询出错;③ 安全漏洞(注入攻击)。验证层是 API 的第一道防线,在数据进入 Controller 之前拦截非法请求。
验证层在请求管道中的位置:验证中间件应紧贴 Controller 之前——在认证/授权之后(先确认身份再验证输入),在业务逻辑之前(脏数据不进入 Controller)。验证通过后,将清洗后的数据挂载到 req.validated,Controller 只使用 validated 数据而非原始 req.body——这确保 Controller 拿到的一定是合法数据。
joi vs express-validator 深度对比:joi 是独立的验证库(不依赖 Express),express-validator 是 Express 专用中间件。joi 的优势:1. Schema 可导出复用(前后端共用同一套验证规则);2. 复杂验证(跨字段校验、条件验证)表达力更强;3. 不绑定框架(Koa/Hapi 同样适用)。express-validator 的优势:1. 原生 Express 中间件,零配置集成;2. 基于 validator.js,验证规则丰富;3. 链式语法直观。新项目推荐 joi(复用性强),已有 express-validator 的项目不必迁移。
sequenceDiagram
participant Client
participant Route as Express Route
participant Validate as joi 验证
participant Controller
participant DB as MongoDB
Client->>Route: POST /api/products
Route->>Validate: validate(req.body)
alt 验证失败
Validate-->>Client: 400 VALIDATION_ERROR
else 验证通过
Validate->>Controller: req.validated = value
Controller->>DB: Product.create(validated)
DB-->>Client: 201 Created
end
输入验证的纵深防御策略:API 输入验证应在多个层实施——1. 路由层(Joi/express-validator):最早拦截,格式/类型/范围校验,返回 400 + 详细错误信息;2. Controller 层:业务规则校验(如"分类必须存在"、"SKU 不能重复"),需要查询数据库;3. Model 层(mongoose Schema):最后的防线,保证写入 MongoDB 的数据一定满足结构约束。三层验证各有价值——路由层过滤 90% 的无效输入(快速失败),Controller 层处理业务逻辑(需要数据库查询),Model 层兜底(防绕过前两层)。
Joi vs express-validator 深度对比:两者是最常用的 Node.js 输入验证库——Joi:独立验证库,与框架无关,Schema 定义优雅(链式 API),错误消息详细,但需要手动集成到 Express(中间件包装);express-validator:基于 validator.js,与 Express 深度集成(req.check() 直接在路由中使用),但 Schema 定义不如 Joi 直观(用 check() 链式而非对象定义)。选择依据:1. 新项目用 Joi(Schema 可复用、可测试、可生成 Swagger 文档);2. 已有 express-validator 的项目继续用(迁移成本不值得);3. 复杂验证(条件依赖、跨字段校验)用 Joi 更方便。
验证中间件的设计模式:将验证逻辑封装为中间件是 Express 的最佳实践——1. 验证中间件接收 Schema(validate(createProductSchema)),验证 req.body,通过则 next(),失败则 400 + 错误详情;2. 中间件将验证后的值挂载到 req.validated(而非继续用 req.body),避免后续 Controller 处理未验证的数据;3. 验证 Schema 按 CRUD 操作分离(createSchema 有 required 字段,updateSchema 全部可选),不混用。这种模式让 Controller 完全不需要关心验证逻辑——只处理已验证的数据。
// validators/productValidator.js
const Joi = require('joi');
const createProductSchema = Joi.object({
sku: Joi.string().required().pattern(/^[A-Z0-9-]+$/),
title: Joi.string().required().min(1).max(200),
price: Joi.number().required().min(0),
category: Joi.string().required().valid('Electronics', 'Books', 'Clothing'),
stock: Joi.number().integer().min(0).default(0)
});
const updateProductSchema = Joi.object({
title: Joi.string().min(1).max(200),
price: Joi.number().min(0),
category: Joi.string().valid('Electronics', 'Books', 'Clothing'),
stock: Joi.number().integer().min(0)
}).min(1);
const validate = (schema) => (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
error: { code: 'VALIDATION_ERROR', details: error.details }
});
}
next();
};
module.exports = { createProductSchema, updateProductSchema, validate };
// routes/products.js
const { createProductSchema, updateProductSchema, validate } = require('../validators/productValidator');
router.post('/', validate(createProductSchema), ctrl.createProduct);
router.put('/:sku', validate(updateProductSchema), ctrl.updateProduct);
6. 完整 CRUD 端点
CRUD 端点设计模式:每个资源遵循统一的 5 个端点模式——列表(GET /)、详情(GET /:id)、创建(POST /)、更新(PUT /:id)、删除(DELETE /:id)。关键决策点:① 列表用分页还是游标?② 更新用 PUT 还是 PATCH?③ 删除用物理删除还是软删除?
CRUD 端点的权限矩阵:每个端点的权限要求不同——1. GET /(列表):公开或需认证(视业务而定),电商商品列表公开,订单列表需认证;2. GET /:id(详情):同列表策略,但可能需权限过滤(用户只能查看自己的订单详情);3. POST /(创建):必须认证,部分资源需授权(如管理员才能创建商品);4. PUT /:id(更新):必须认证 + 资源所有者或管理员(用户只能编辑自己的评论);5. DELETE /:id(删除):必须认证 + 资源所有者或管理员。权限检查应在中间件中完成(authenticate + authorize),Controller 只做业务逻辑。
CRUD 端点设计决策:
| 决策点 | 选项A | 选项B | 推荐 |
|---|---|---|---|
| 分页方式 | page + limit(偏移) | cursor(游标) | 浅分页用 page,深分页用 cursor |
| 更新方式 | PUT(全量替换) | PATCH(部分更新) | PATCH(更安全,不会清空未传字段) |
| 删除方式 | 物理删除(remove) | 软删除(isDeleted 标记) | 软删除(可恢复,数据合规) |
| ID 格式 | ObjectId | 自定义 SKU/Slug | 面向用户用 SKU,内部用 ObjectId |
| 列表排序 | 单字段 | 多字段组合 | 多字段(sort + order) |
Bob 的性能优化:在 ShopHub,Bob 发现列表查询同时执行 find 和 countDocuments,他将它们用 Promise.all 并行执行——查询时间从 200ms+200ms=400ms 降到 max(200ms, 180ms)=200ms。
// controllers/productController.js
const Product = require('../models/Product');
// GET /api/products
exports.list = async (req, res) => {
const { page = 1, limit = 20, sort = 'createdAt', order = 'desc', category, search } = req.query;
const query = { isActive: true };
if (category) query.category = category;
if (search) query.title = new RegExp(search, 'i');
const [products, total] = await Promise.all([
Product.find(query)
.select('sku title price thumbnail rating')
.sort({ [sort]: order === 'desc' ? -1 : 1 })
.limit(limit * 1)
.skip((page - 1) * limit)
.lean(),
Product.countDocuments(query)
]);
res.json({
success: true,
data: products,
meta: { page: +page, limit: +limit, total, pages: Math.ceil(total / limit) }
});
};
// GET /api/products/:sku
exports.get = async (req, res) => {
const product = await Product.findOne({ sku: req.params.sku }).lean();
if (!product) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND' } });
res.json({ success: true, data: product });
};
// POST /api/products
exports.create = async (req, res) => {
const product = await Product.create(req.body);
res.status(201).json({ success: true, data: product });
};
// PUT /api/products/:sku
exports.update = async (req, res) => {
const product = await Product.findOneAndUpdate(
{ sku: req.params.sku },
req.body,
{ new: true, runValidators: true }
);
if (!product) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND' } });
res.json({ success: true, data: product });
};
// DELETE /api/products/:sku
exports.remove = async (req, res) => {
const product = await Product.findOneAndDelete({ sku: req.params.sku });
if (!product) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND' } });
res.status(204).send();
};
7. 错误处理
API 错误处理分层策略:错误处理不是"加个 try-catch",而是分层的防御体系——验证层拦截非法输入(4xx),业务层拦截逻辑错误(如商品不存在 → 404),数据库层拦截系统错误(如连接断开 → 5xx),最外层兜底所有未预期错误。
错误分类与处理:
| 错误来源 | 示例 | 状态码 | 处理方式 |
|---|---|---|---|
| 验证层 | joi 验证失败 | 400 | 返回字段级错误详情 |
| 业务层 | 商品不存在 | 404 | 返回资源不存在 |
| 数据库层 | 唯一键冲突 | 409 | 返回冲突字段 |
| 认证层 | Token 无效 | 401 | 返回未认证 |
| 权限层 | 角色不足 | 403 | 返回无权限 |
| 系统层 | 未知异常 | 500 | 返回通用错误(不暴露细节) |
错误处理中间件的设计原则:Express 错误处理中间件是 4 参数函数 (err, req, res, next),必须放在所有路由之后。设计要点:1. 错误分类优先级——按 ValidationError → MongoError → JsonWebTokenError → 兜底 500 的顺序判断,因为具体错误类型能给出更精确的状态码;2. 生产环境不暴露堆栈——err.stack 只在开发环境返回,生产环境返回通用消息;3. 日志分级——4xx 记录 warn(客户端错误),5xx 记录 error(服务端错误);4. 请求上下文——错误日志应包含 requestId、userId、path 方便排查。
错误响应的安全防护:API 错误响应是信息泄露的重灾区——1. 数据库错误不要返回原始 message(可能包含集合名、查询语句);2. 堆栈信息仅在开发环境返回;3. Mongoose ValidationError 可以直接返回(字段级错误是安全的),但 MongoError 需要过滤(唯一键冲突可返回,其他需脱敏);4. 统一包装错误格式 {success: false, error: {code, message, details?}},前端不需要判断响应结构。这条原则的底线:任何 5xx 错误都不应向客户端暴露内部实现细节。
// middlewares/errorHandler.js
const errorHandler = (err, req, res, next) => {
console.error(err);
if (err.name === 'ValidationError') {
return res.status(400).json({
success: false,
error: { code: 'VALIDATION_ERROR', message: err.message, details: err.errors }
});
}
if (err.code === 11000) {
return res.status(409).json({
success: false,
error: { code: 'DUPLICATE_KEY', message: 'Duplicate key', details: err.keyValue }
});
}
res.status(500).json({
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }
});
};
8. 实战:评论 API
嵌套资源路由设计:评论从属于商品,URL 设计为 /products/:productId/reviews 而非 /reviews?productId=xxx——前者语义更清晰,REST 约定更规范。但直接操作评论(更新/删除)用 /reviews/:reviewId,因为此时不需要商品上下文。
评论系统 API 设计:
| 端点 | 方法 | 认证 | 说明 |
|---|---|---|---|
| /products/:productId/reviews | GET | 否 | 查看评论列表 |
| /products/:productId/reviews | POST | 是 | 发表评论 |
| /reviews/:reviewId | PUT | 是(作者) | 编辑评论 |
| /reviews/:reviewId | DELETE | 是(作者) | 删除评论 |
| /reviews/:reviewId/like | POST | 是 | 点赞/取消 |
// routes/reviews.js
router.get('/products/:productId/reviews', ctrl.listReviews);
router.post('/products/:productId/reviews', authenticate, ctrl.createReview);
router.put('/reviews/:reviewId', authenticate, ctrl.updateReview);
router.delete('/reviews/:reviewId', authenticate, ctrl.deleteReview);
router.post('/reviews/:reviewId/like', authenticate, ctrl.likeReview);
API 端点的设计原则:上表展示了电商评论系统的 API 端点设计——1. 资源命名用复数名词(/products 非 /product);2. 嵌套资源表达从属关系(/products/:productId/reviews 表示"某商品的评论");3. 认证要求明确标注——读取操作公开(GET),写入操作需认证(POST/PUT/DELETE);4. 操作型端点用动词子路径(/reviews/:reviewId/like 而非 /likes 集合);5. 版本化前缀统一(/api/v1/...,表中省略)。
API 版本化的三种策略:1. URL 前缀(/api/v1/products):最直观最常用,版本切换清晰,但 URL 变长;2. 请求头(Header: Api-Version: 1):URL 不变但客户端需设置请求头,调试不便;3. 内容协商(Accept: application/vnd.api+json; version=1):最 RESTful 但最复杂,实际项目极少用。推荐策略 1——开发友好、测试方便、Nginx 路由天然支持。版本升级时:v1 保持不变,v2 新增路由文件,逐步迁移客户端,v1 设定废弃日期后下线。
▶ 示例 1:商品 CRUD + 分页 + 验证
// === 完整商品 CRUD API(含验证+分页+错误处理)===
const express = require('express');
const Joi = require('joi');
const mongoose = require('mongoose');
const app = express();
app.use(express.json());
// Schema
const ProductSchema = new mongoose.Schema({
sku: { type: String, required: true, unique: true },
title: { type: String, required: true },
price: { type: Number, required: true, min: 0 },
category: { type: String, required: true, enum: ['Electronics', 'Books', 'Clothing'] },
stock: { type: Number, default: 0, min: 0 }
}, { timestamps: true });
const Product = mongoose.model('Product', ProductSchema);
// Joi 验证
const createSchema = Joi.object({
sku: Joi.string().required().pattern(/^[A-Z0-9-]+$/),
title: Joi.string().required().min(1).max(200),
price: Joi.number().required().min(0),
category: Joi.string().required().valid('Electronics', 'Books', 'Clothing'),
stock: Joi.number().integer().min(0).default(0)
});
const validate = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', details: error.details } });
req.validated = value;
next();
};
// CRUD
app.get('/api/products', async (req, res) => {
const { page = 1, limit = 20, category } = req.query;
const query = {};
if (category) query.category = category;
const [products, total] = await Promise.all([
Product.find(query).select('sku title price').skip((page-1)*limit).limit(+limit).lean(),
Product.countDocuments(query)
]);
res.json({ success: true, data: products, meta: { page: +page, limit: +limit, total, pages: Math.ceil(total/limit) } });
});
app.post('/api/products', validate(createSchema), async (req, res) => {
const product = await Product.create(req.validated);
res.status(201).json({ success: true, data: product });
});
app.get('/api/products/:sku', async (req, res) => {
const product = await Product.findOne({ sku: req.params.sku }).lean();
if (!product) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND' } });
res.json({ success: true, data: product });
});
app.use((err, req, res, next) => {
if (err.code === 11000) return res.status(409).json({ success: false, error: { code: 'DUPLICATE_KEY' } });
res.status(500).json({ success: false, error: { code: 'INTERNAL_ERROR' } });
});
mongoose.connect('mongodb://localhost:27017/shopdb').then(() => app.listen(3000));
输出:完整商品 CRUD API,含 joi 验证、分页查询、错误处理、统一响应格式。
▶ 示例 2:完整 RESTful 评论 API + joi 验证
// === 1. validators/reviewValidator.js - joi 验证 ===
const Joi = require('joi');
const createReviewSchema = Joi.object({
productId: Joi.string().required().pattern(/^[0-9a-fA-F]{24}$/), // ObjectId
content: Joi.string().required().min(10).max(1000),
rating: Joi.number().integer().required().min(1).max(5),
parentId: Joi.string().pattern(/^[0-9a-fA-F]{24}$/).allow(null)
});
const updateReviewSchema = Joi.object({
content: Joi.string().min(10).max(1000),
rating: Joi.number().integer().min(1).max(5)
}).min(1); // 至少有一个字段
const validate = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
details: error.details.map(d => ({ field: d.path.join('.'), message: d.message }))
}
});
}
req.validated = value; // 验证后的数据
next();
};
module.exports = { createReviewSchema, updateReviewSchema, validate };
// === 2. controllers/reviewController.js ===
const Review = require('../models/Review');
const Product = require('../models/Product');
exports.list = async (req, res, next) => {
try {
const { productId } = req.params;
const { page = 1, limit = 20, sort = '-createdAt' } = req.query;
const reviews = await Review.find({ productId, parentId: null })
.populate('userId', 'username avatar')
.populate({
path: 'replies',
populate: { path: 'userId', select: 'username avatar' }
})
.sort(sort)
.skip((page - 1) * limit)
.limit(+limit)
.lean();
const total = await Review.countDocuments({ productId, parentId: null });
res.json({
success: true,
data: reviews,
meta: { page: +page, limit: +limit, total, pages: Math.ceil(total / limit) }
});
} catch (err) { next(err); }
};
exports.create = async (req, res, next) => {
try {
// 1. 验证商品存在
const product = await Product.findById(req.validated.productId).lean();
if (!product) {
return res.status(404).json({
success: false,
error: { code: 'PRODUCT_NOT_FOUND' }
});
}
// 2. 验证父评论存在(如果是回复)
if (req.validated.parentId) {
const parent = await Review.findById(req.validated.parentId);
if (!parent) {
return res.status(404).json({
success: false,
error: { code: 'PARENT_REVIEW_NOT_FOUND' }
});
}
}
// 3. 创建评论
const review = await Review.create({
...req.validated,
userId: req.user._id
});
// 4. 更新商品评分
await updateProductRating(req.validated.productId);
await review.populate('userId', 'username avatar');
res.status(201).json({ success: true, data: review });
} catch (err) { next(err); }
};
exports.update = async (req, res, next) => {
try {
const review = await Review.findOneAndUpdate(
{ _id: req.params.reviewId, userId: req.user._id }, // 仅作者可改
{ $set: { ...req.validated, isEdited: true } },
{ new: true, runValidators: true }
).lean();
if (!review) {
return res.status(404).json({
success: false,
error: { code: 'NOT_FOUND_OR_NO_PERMISSION' }
});
}
res.json({ success: true, data: review });
} catch (err) { next(err); }
};
exports.remove = async (req, res, next) => {
try {
const review = await Review.findOneAndDelete({
_id: req.params.reviewId,
userId: req.user._id
});
if (!review) {
return res.status(404).json({
success: false,
error: { code: 'NOT_FOUND_OR_NO_PERMISSION' }
});
}
await updateProductRating(review.productId);
res.status(204).send();
} catch (err) { next(err); }
};
// === 3. routes/reviews.js ===
const router = require('express').Router();
const ctrl = require('../controllers/reviewController');
const { authenticate } = require('../middlewares/auth');
const { createReviewSchema, updateReviewSchema, validate } = require('../validators/reviewValidator');
router.get('/products/:productId/reviews', ctrl.list);
router.post('/products/:productId/reviews',
authenticate, validate(createReviewSchema), ctrl.create);
router.put('/reviews/:reviewId',
authenticate, validate(updateReviewSchema), ctrl.update);
router.delete('/reviews/:reviewId',
authenticate, ctrl.remove);
module.exports = router;
// === 4. 测试 API ===
// curl http://localhost:3000/api/products/507f1f77bcf86cd799439021/reviews
// curl -X POST http://localhost:3000/api/products/507f1f77bcf86cd799439021/reviews \
// -H "Authorization: Bearer <token>" \
// -H "Content-Type: application/json" \
// -d '{"content":"Great product!","rating":5}'
输出:完整 RESTful 评论 API,支持列表、创建、更新、删除,joi 验证确保数据合法,认证保护作者权限。
Controller 层的设计模式:上述代码展示了 Controller 的标准设计模式——1. 每个导出函数对应一个路由端点(exports.list → GET,exports.create → POST);2. try-catch 包裹所有异步操作,catch 统一交给 next(err) 传递给错误处理中间件;3. 验证失败提前返回(404/403),避免进入业务逻辑;4. 查询条件中加入权限过滤(userId: req.user._id 确保只能操作自己的评论);5. lean() 减少 Mongoose 文档开销(只读场景不需要 Mongoose 的修改追踪)。这些模式让 Controller 保持简洁——验证→查询→响应,每个函数 10-20 行。
路由与中间件的组合模式:路由定义展示了中间件的组合策略——1. 公共中间件(router.use(authenticate)):所有路由都需要认证;2. 路由级中间件(validate(createReviewSchema)):仅特定路由需要验证;3. 中间件执行顺序:authenticate → validate → controller,按依赖关系排列(验证依赖认证结果 req.user);4. 条件中间件:有些路由不需要认证(如 GET 列表),放在 authenticate 前面或用可选认证中间件。这种组合模式让每个路由的中间件栈清晰可读。
▶ 示例 3:API 版本管理 + HATEOAS 超媒体链接
RESTful API 随业务演进需要版本管理——v1 和 v2 可能共存,新版本不能破坏旧客户端。同时,HATEOAS(超媒体作为应用状态引擎)让 API 响应包含相关操作链接,客户端无需硬编码 URL。本示例实现 URL 路径版本控制 + HATEOAS 链接生成。
const express = require('express');
// === 1. URL 路径版本控制 ===
const v1Router = express.Router();
const v2Router = express.Router();
// v1: 简单商品列表(向后兼容)
v1Router.get('/products', async (req, res) => {
const { page = 1, limit = 10 } = req.query;
const products = await Product.find()
.skip((page - 1) * limit).limit(Number(limit)).lean();
const total = await Product.countDocuments();
res.json({
products,
page: Number(page),
total
});
});
// v2: 商品列表 + HATEOAS 链接 + 元数据
v2Router.get('/products', async (req, res) => {
const { page = 1, limit = 10 } = req.query;
const skip = (page - 1) * limit;
const products = await Product.find().skip(skip).limit(Number(limit)).lean();
const total = await Product.countDocuments();
const totalPages = Math.ceil(total / limit);
const baseUrl = `/api/v2/products?page=${page}&limit=${limit}`;
res.json({
data: products.map(p => ({
...p,
_links: {
self: { href: `/api/v2/products/${p._id}` },
reviews: { href: `/api/v2/products/${p._id}/reviews` }
}
})),
_meta: { page: Number(page), limit: Number(limit), total, totalPages },
_links: {
self: { href: baseUrl },
first: { href: `/api/v2/products?page=1&limit=${limit}` },
last: { href: `/api/v2/products?page=${totalPages}&limit=${limit}` },
next: page < totalPages ? { href: `/api/v2/products?page=${page + 1}&limit=${limit}` } : null,
prev: page > 1 ? { href: `/api/v2/products?page=${page - 1}&limit=${limit}` } : null
}
});
});
// === 2. 商品详情 HATEOAS ===
v2Router.get('/products/:id', async (req, res) => {
const product = await Product.findById(req.params.id).lean();
if (!product) return res.status(404).json({ error: '商品不存在' });
res.json({
data: product,
_links: {
self: { href: `/api/v2/products/${product._id}` },
collection: { href: '/api/v2/products' },
reviews: { href: `/api/v2/products/${product._id}/reviews` },
create_review: {
href: `/api/v2/products/${product._id}/reviews`,
method: 'POST',
schema: { content: 'string', rating: 'number(1-5)' }
}
}
});
});
// === 3. 版本弃用中间件 ===
function deprecated(version, sunset) {
return (req, res, next) => {
res.set({
'Deprecation': version,
'Sunset': new Date(sunset).toUTCString(),
'Link': `</api/v2${req.path}>; rel="successor-version"`
});
next();
};
}
v1Router.use(deprecated('v1', '2026-12-31'));
// === 4. 注册路由 ===
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// 版本重定向:默认版本重定向到最新版
app.get('/api/products*', (req, res) => {
res.redirect(301, `/api/v2${req.path.replace('/api', '')}`);
});
输出:v1 返回简单商品列表(标记弃用),v2 返回商品数据 + HATEOAS 链接(self、reviews、分页导航)。版本弃用通过 HTTP 头通知客户端迁移,默认路径重定向到最新版本。
API 版本管理的策略对比:1. URL 路径版本(/api/v1/)——最直观,但 URL 变了等于资源地址变了,严格来说不够 RESTful;2. Header 版本(Accept: application/vnd.api.v2+json)——URL 不变,但客户端实现复杂;3. 查询参数版本(?version=2)——简单但不规范;4. 实践建议——大多数团队选择 URL 路径版本,虽然不够 RESTful 但运维友好;5. HATEOAS 的价值——让 API 自发现,客户端通过链接导航而非硬编码 URL,减少版本升级时的客户端改动。
❓ 常见问题
📖 小节
- RESTful 6 个 HTTP 方法语义
- HTTP 状态码:200/201/204/400/401/403/404/409/500
- 统一响应格式:success + data + meta + error
- joi 请求体验证
- 完整 CRUD 端点(GET/POST/PUT/PATCH/DELETE)
- 错误处理中间件
📝 作业
- 基础题(⭐):实现 6 个 HTTP 方法的路由(products CRUD)。
- 基础题(⭐):用 joi 实现请求体验证(必填、长度、枚举)。
- 进阶题(⭐⭐):实现统一响应格式(success/data/meta/error)。
- 进阶题(⭐⭐):实现完整评论 API(CRUD + 点赞 + 错误处理)。
- 挑战题(⭐⭐⭐):完整电商 API(products + reviews + orders + users + JWT 认证)。