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. 你将学到


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 的标准交互方式,但实践中可以接受)。选型的核心是务实而非教条。

100%
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 告警)。

100%
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) 走成功,elseresponse.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. 缓存击穿:热点数据过期瞬间大量请求打到数据库。防护:热点数据用分布式锁(同一时间只允许一个请求重建缓存),或永不过期 + 异步更新。三种故障的防护策略不同,需要分别实现。

JAVASCRIPT
// 成功响应
{
  "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 的项目不必迁移。

100%
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 完全不需要关心验证逻辑——只处理已验证的数据。

JAVASCRIPT
// 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 };
JAVASCRIPT
// 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。

JAVASCRIPT
// 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 错误都不应向客户端暴露内部实现细节。

JAVASCRIPT
// 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 点赞/取消
JAVASCRIPT
// 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 + 分页 + 验证

JAVASCRIPT
// === 完整商品 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 验证

JAVASCRIPT
// === 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 链接生成。

JAVASCRIPT
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,减少版本升级时的客户端改动。

❓ 常见问题

Q PUT vs PATCH 怎么选?
A PUT 替换整个资源(未指定字段丢失),PATCH 部分更新。推荐 PATCH。
Q 分页用 page 还是 cursor?
A 浅分页用 page,深分页用 cursor(性能稳定)。
Q joi vs express-validator 哪个好?
A joi 更强大(Schema 风格),express-validator 集成更简单。

📖 小节


📝 作业

  1. 基础题(⭐):实现 6 个 HTTP 方法的路由(products CRUD)。
  2. 基础题(⭐):用 joi 实现请求体验证(必填、长度、枚举)。
  3. 进阶题(⭐⭐):实现统一响应格式(success/data/meta/error)。
  4. 进阶题(⭐⭐):实现完整评论 API(CRUD + 点赞 + 错误处理)。
  5. 挑战题(⭐⭐⭐):完整电商 API(products + reviews + orders + users + JWT 认证)。
Web-Tutorial.com

Web-Tutorial 技术团队

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

100%

🙏 帮我们做得更好

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

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