REST API 设计最佳实践指南 - 如何用 JavaScript、Node.js 和 Express.js 构建 REST API
本文分享API开发实战经验,通过构建一个CrossFit训练应用的REST API,讲解10个核心最佳实践。不同于纯理论文章,作者结合真实案例,从版本控制、资源命名、数据格式到缓存、安全等维度,提供可落地的解决方案。教程采用Express.js三层架构,包含完整代码示例,特别强调"最佳实践"应作为指导而非教条,开发者需根据项目实际需求灵活调整。适合有一定Node.js/Expr
过去几年里,我创建并使用过很多 API。在此过程中,我遇到过各种好的和坏的实践,也在开发和调用 API 时碰到过不少棘手的问题,但也有很多顺利的时刻。
网上有很多介绍最佳实践的文章,但在我看来,其中不少都缺乏实用性。只懂理论、没几个实例固然有一定价值,但我总是会想:在更真实的场景中,这些理论该如何落地?
简单的示例能帮助我们理解概念本身,避免过多复杂性干扰,但实际开发中事情往往没那么简单。我相信你肯定懂这种感受 😁
这就是我决定写这篇教程的原因。我把自己的所有经验(好的、坏的都有)整合到这篇通俗易懂的文章里,同时提供了可跟着操作的实战案例。最终,我们会一步步落实最佳实践,搭建出一个完整的 API。
开始前需要明确几点: 所谓“最佳实践”,并非必须严格遵守的法律或规则,而是经过时间检验、被证明有效的约定或建议。其中一些如今已成为标准,但这并不意味着你必须原封不动地照搬。
它们的核心目的是为你提供方向,帮助你从用户体验(包括调用者和开发者)、安全性、性能三个维度优化 API。
但请记住:不同项目需要不同的解决方案。有些情况下,你可能无法或不应该遵循某条约定。因此,最终需要开发者自己或与团队共同判断。
好了,废话不多说,我们开始吧!
目录
- 示例项目介绍
- 前置要求
- 架构设计
- 基础搭建
- REST API 最佳实践
- 版本控制
- 资源命名使用复数形式
- 接收与返回数据采用 JSON 格式
- 用标准 HTTP 错误码响应
- 端点名称避免使用动词
- 关联资源分组(逻辑嵌套)
- 集成过滤、排序与分页
- 用数据缓存提升性能
- 良好的安全实践
- 完善 API 文档
- 总结
1. 示例项目介绍
在将最佳实践落地到示例项目前,先简单介绍一下我们要做什么:
我们将为一个 CrossFit 训练应用搭建 REST API。如果你不了解 CrossFit,它是一种结合了高强度训练与奥林匹克举重、体操等多种运动元素的健身方式和竞技运动。
在这个应用中,用户(健身房经营者)可以创建、查询、更新和删除 WOD(每日训练计划,Workout of the Day),制定训练方案并统一管理;此外,还能为每个训练计划添加重要的训练提示。
我们的任务就是为这个应用设计并实现 API。
2. 前置要求
要跟上本教程的节奏,你需要具备以下基础:
- JavaScript、Node.js、Express.js 的使用经验
- 后端架构的基础认知
- 了解 REST、API 的概念,理解客户端-服务器模型
当然,你不必是这些领域的专家,只要熟悉基本用法、有过实操经验即可。
如果暂时不满足这些要求,也不用跳过这篇教程——里面仍有很多值得学习的内容,只是有基础会更容易跟上步骤。
另外,虽然本 API 用 JavaScript 和 Express 编写,但这些最佳实践并不局限于这两种工具,同样适用于其他编程语言或框架。
3. 架构设计
如前所述,我们将用 Express.js 搭建 API。为避免过度复杂,我们采用三层架构:
- 控制器层:处理所有 HTTP 相关逻辑,负责请求与响应的处理;上层通过 Express 的路由将请求分发到对应的控制器方法。
- 服务层:包含所有业务逻辑,通过导出方法供控制器调用。
- 数据访问层:负责与数据库交互,导出数据库操作方法(如创建 WOD)供服务层调用。
本示例中,我们不会使用 MongoDB、PostgreSQL 等真实数据库(以便聚焦最佳实践本身),而是用一个本地 JSON 文件模拟数据库。当然,这里的逻辑也可以无缝迁移到真实数据库中。
4. 基础搭建
现在我们开始搭建 API 的基础框架。不用搞得太复杂,重点是结构清晰。
首先创建项目文件夹、子目录及必要文件,然后安装依赖并测试是否能正常运行:
4.1 创建目录结构
# 创建项目文件夹并进入
mkdir crossfit-wod-api && cd crossfit-wod-api
# 创建src文件夹并进入
mkdir src && cd src
# 创建子文件夹
mkdir controllers && mkdir services && mkdir database && mkdir routes
# 创建入口文件index.js
touch index.js
# 返回项目根目录
cd ..
# 创建package.json文件
npm init -y
4.2 安装依赖
# 开发依赖(热重载)
npm i -D nodemon
# 核心依赖(Express框架)
npm i express
4.3 配置 Express
打开src/index.js
,写入以下代码:
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;
// 测试接口
app.get("/", (req, res) => {
res.send("<h2>运行正常!</h2>");
});
// 启动服务
app.listen(PORT, () => {
console.log(`API正在监听 ${PORT} 端口`);
});
4.4 配置开发脚本
在package.json
中添加dev
脚本(实现代码修改后自动重启服务):
{
"name": "crossfit-wod-api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon src/index.js" // 新增这行
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"nodemon": "^2.0.15"
},
"dependencies": {
"express": "^4.17.3"
}
}
4.5 测试基础搭建
启动开发服务器:
npm run dev
终端会显示“API 正在监听 3000 端口”,此时在浏览器中访问localhost:3000
,若看到“运行正常!”则说明基础搭建完成。
5. REST API 最佳实践
有了 Express 的基础框架后,我们就可以结合以下最佳实践来扩展 API 了。
先从最基础的 CRUD 端点开始,再逐步集成各项最佳实践。
5.1 版本控制(Versioning)
在编写任何 API 特定代码前,必须先考虑版本控制。和其他应用一样,API 也会不断迭代、新增功能,因此版本控制至关重要。
版本控制的优势:
- 开发新版本时,旧版本仍可正常使用,不会因破坏性变更影响现有用户;
- 无需强制用户立即升级到新版本,用户可在新版本稳定后自行迁移;
- 新旧版本并行运行,互不干扰。
如何实现版本控制?
一个常用的最佳实践是在 URL 中添加版本标识(如v1
、v2
):
// 版本1
"/api/v1/workouts"
// 版本2
"/api/v2/workouts"
这是对外暴露的 URL 格式,供其他开发者调用。同时,项目结构也需要区分不同版本:
步骤 1:创建版本目录
在src
下创建v1
文件夹,用于存放版本 1 的代码:
mkdir src/v1
将之前创建的routes
文件夹移动到v1
目录下:
# 先查看当前目录路径并复制(例如/Users/xxx/crossfit-wod-api)
pwd
# 移动routes文件夹到v1目录(将{pwd}替换为复制的路径)
mv {pwd}/src/routes {pwd}/src/v1
步骤 2:创建版本路由测试文件
在src/v1/routes
下创建index.js
,编写简单的路由测试代码:
touch src/v1/routes/index.js
// src/v1/routes/index.js
const express = require("express");
const router = express.Router();
// 测试路由
router.route("/").get((req, res) => {
res.send(`<h2>来自 ${req.baseUrl} 的响应</h2>`);
});
module.exports = router;
步骤 3:关联根入口文件与版本路由
修改src/index.js
,引入 v1 路由并配置访问路径:
const express = require("express");
// 引入v1路由
const v1Router = require("./v1/routes");
const app = express();
const PORT = process.env.PORT || 3000;
// 移除旧的测试接口
// app.get("/", (req, res) => {
// res.send("<h2>运行正常!</h2>");
// });
// 配置v1路由的访问路径
app.use("/api/v1", v1Router);
app.listen(PORT, () => {
console.log(`API正在监听 ${PORT} 端口`);
});
步骤 4:测试版本路由
访问localhost:3000/api/v1
,若看到“来自 /api/v1 的响应”则说明版本路由配置成功。
注意事项:
目前我们只将routes
放入v1
目录,controllers
、services
等仍在src
根目录——这对小型 API 来说没问题,可以让多个版本共享这些通用逻辑。
但如果 API 规模扩大,比如 v2 需要特定的控制器或服务(修改通用逻辑可能影响旧版本),则建议将controllers
、services
也按版本拆分到对应目录,实现版本内逻辑的完全封装。
5.2 资源命名使用复数形式(Name resources in plural)
接下来开始实现 API 的核心功能——为 WOD 设计 CRUD 端点。首先要解决的是资源命名问题。
为什么用复数?
资源可以理解为“一个存放数据的集合”(比如“workouts”是所有训练计划的集合)。用复数命名能让调用者一目了然地知道这是一个“集合”,而非单个资源,避免歧义。
步骤 1:创建 WOD 相关文件
创建控制器、服务和路由文件,分别对应三层架构:
# 控制器(处理HTTP请求/响应)
touch src/controllers/workoutController.js
# 服务(处理业务逻辑)
touch src/services/workoutService.js
# 路由(分发请求)
touch src/v1/routes/workoutRoutes.js
步骤 2:编写 WOD 路由(复数命名)
在src/v1/routes/workoutRoutes.js
中定义 CRUD 端点,注意 URL 使用复数/workouts
:
// src/v1/routes/workoutRoutes.js
const express = require("express");
const router = express.Router();
// 获取所有训练计划
router.get("/", (req, res) => {
res.send("获取所有训练计划");
});
// 获取单个训练计划(通过ID)
router.get("/:workoutId", (req, res) => {
res.send("获取单个训练计划");
});
// 创建训练计划
router.post("/", (req, res) => {
res.send("创建训练计划");
});
// 更新训练计划
router.patch("/:workoutId", (req, res) => {
res.send("更新训练计划");
});
// 删除训练计划
router.delete("/:workoutId", (req, res) => {
res.send("删除训练计划");
});
module.exports = router;
删除之前用于测试的src/v1/routes/index.js
(已不再需要)。
步骤 3:关联根入口文件与 WOD 路由
修改src/index.js
,替换旧的 v1 路由,改用 WOD 路由:
const express = require("express");
// 移除旧的v1路由引入
// const v1Router = require("./v1/routes");
// 引入WOD路由
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");
const app = express();
const PORT = process.env.PORT || 3000;
// 移除旧的v1路由配置
// app.use("/api/v1", v1Router);
// 配置WOD路由的访问路径(复数)
app.use("/api/v1/workouts", v1WorkoutRouter);
app.listen(PORT, () => {
console.log(`API正在监听 ${PORT} 端口`);
});
步骤 4:编写控制器方法
在src/controllers/workoutController.js
中定义与路由对应的控制器方法:
// src/controllers/workoutController.js
// 获取所有训练计划
const getAllWorkouts = (req, res) => {
res.send("获取所有训练计划");
};
// 获取单个训练计划
const getOneWorkout = (req, res) => {
res.send("获取单个训练计划");
};
// 创建训练计划
const createNewWorkout = (req, res) => {
res.send("创建训练计划");
};
// 更新训练计划
const updateOneWorkout = (req, res) => {
res.send("更新训练计划");
};
// 删除训练计划
const deleteOneWorkout = (req, res) => {
res.send("删除训练计划");
};
module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};
步骤 5:路由关联控制器
修改src/v1/routes/workoutRoutes.js
,将路由与控制器方法绑定:
// src/v1/routes/workoutRoutes.js
const express = require("express");
// 引入控制器
const workoutController = require("../../controllers/workoutController");
const router = express.Router();
// 绑定路由与控制器方法
router.get("/", workoutController.getAllWorkouts);
router.get("/:workoutId", workoutController.getOneWorkout);
router.post("/", workoutController.createNewWorkout);
router.patch("/:workoutId", workoutController.updateOneWorkout);
router.delete("/:workoutId", workoutController.deleteOneWorkout);
module.exports = router;
测试路由
访问localhost:3000/api/v1/workouts/123
,若看到“获取单个训练计划”则说明路由配置成功。
5.3 接收与返回数据采用 JSON 格式
调用 API 时,请求和响应都需要传递数据。JSON(JavaScript 对象表示法) 是通用的标准化格式,不受编程语言限制(Java、Python 等都能处理 JSON),因此 API 应统一使用 JSON 接收和返回数据。
步骤 1:编写服务层基础代码
服务层负责业务逻辑,先在src/services/workoutService.js
中创建与控制器对应的方法:
// src/services/workoutService.js
const getAllWorkouts = () => {
return;
};
const getOneWorkout = () => {
return;
};
const createNewWorkout = () => {
return;
};
const updateOneWorkout = () => {
return;
};
const deleteOneWorkout = () => {
return;
};
module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};
步骤 2:创建模拟数据库(JSON 文件)
在src/database
下创建db.json
(模拟数据库)和Workout.js
(数据访问方法):
# 模拟数据库
touch src/database/db.json
# 数据访问层方法
touch src/database/Workout.js
在db.json
中添加测试数据(3 个训练计划):
{
"workouts": [
{
"id": "61dbae02-c147-4e28-863c-db7bd402b2d6",
"name": "Tommy V",
"mode": "计时完成",
"equipment": ["杠铃", "绳梯"],
"exercises": [
"21次火箭推",
"12次15英尺绳爬",
"15次火箭推",
"9次15英尺绳爬",
"9次火箭推",
"6次15英尺绳爬"
],
"createdAt": "2022-04-20 14:21:56",
"updatedAt": "2022-04-20 14:21:56",
"trainerTips": [
"21次火箭推可拆分完成",
"9次和6次火箭推尽量不间断完成",
"标准重量:115磅/75磅"
]
},
{
"id": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
"name": "Dead Push-Ups",
"mode": "10分钟内尽可能多组",
"equipment": ["杠铃"],
"exercises": [
"15次硬拉",
"15次释放式俯卧撑"
],
"createdAt": "2022-01-25 13:15:44",
"updatedAt": "2022-03-10 08:21:56",
"trainerTips": [
"硬拉重量宜轻,速度宜快",
"尽量不间断完成一组",
"标准重量:135磅/95磅"
]
},
{
"id": "d8be2362-7b68-4ea4-a1f6-03f8bc4eede7",
"name": "Heavy DT",
"mode": "5轮计时完成",
"equipment": ["杠铃", "绳梯"],
"exercises": [
"12次硬拉",
"9次悬挂式力量抓举",
"6次推挺"
],
"createdAt": "2021-11-20 17:39:07",
"updatedAt": "2021-11-20 17:39:07",
"trainerTips": [
"推挺尽量不间断",
"前3轮可能很痛苦,但坚持住",
"标准重量:205磅/145磅"
]
}
]
}
步骤 3:编写数据访问层方法(获取所有训练计划)
在src/database/Workout.js
中实现从 JSON 文件读取数据的方法:
// src/database/Workout.js
// 引入模拟数据库
const DB = require("./db.json");
// 获取所有训练计划
const getAllWorkouts = () => {
return DB.workouts;
};
module.exports = { getAllWorkouts };
步骤 4:服务层调用数据访问层
修改src/services/workoutService.js
,调用数据访问层方法获取数据:
// src/services/workoutService.js
// 引入数据访问层
const Workout = require("../database/Workout");
// 获取所有训练计划
const getAllWorkouts = () => {
const allWorkouts = Workout.getAllWorkouts();
return allWorkouts;
};
// 其他方法暂不修改
const getOneWorkout = () => { return; };
const createNewWorkout = () => { return; };
const updateOneWorkout = () => { return; };
const deleteOneWorkout = () => { return; };
module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};
步骤 5:控制器返回 JSON 数据
修改src/controllers/workoutController.js
,通过服务层获取数据并以 JSON 格式返回:
// src/controllers/workoutController.js
// 引入服务层
const workoutService = require("../services/workoutService");
// 获取所有训练计划(返回JSON)
const getAllWorkouts = (req, res) => {
const allWorkouts = workoutService.getAllWorkouts();
// 以JSON格式返回数据(包含状态和数据)
res.send({ status: "成功", data: allWorkouts });
};
// 其他方法暂不修改
const getOneWorkout = (req, res) => { res.send("获取单个训练计划"); };
const createNewWorkout = (req, res) => { res.send("创建训练计划"); };
const updateOneWorkout = (req, res) => { res.send("更新训练计划"); };
const deleteOneWorkout = (req, res) => { res.send("删除训练计划"); };
module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};
测试返回 JSON
访问localhost:3000/api/v1/workouts
,浏览器会显示 JSON 格式的训练计划数据,说明返回 JSON 配置成功。
步骤 6:配置 API 接收 JSON 请求
创建或更新训练计划时,需要接收客户端发送的 JSON 数据。需安装body-parser
解析请求体:
npm i body-parser
修改src/index.js
,配置解析 JSON 请求体:
const express = require("express");
// 引入body-parser
const bodyParser = require("body-parser");
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");
const app = express();
const PORT = process.env.PORT || 3000;
// 配置解析JSON请求体
app.use(bodyParser.json());
app.use("/api/v1/workouts", v1WorkoutRouter);
app.listen(PORT, () => {
console.log(`API正在监听 ${PORT} 端口`);
});
步骤 7:实现“创建训练计划”(接收并存储 JSON)
要实现创建功能,需先添加“保存数据到 JSON 文件”的工具方法:
- 创建工具方法:在
src/database
下创建utils.js
,实现写入 JSON 文件的逻辑:
touch src/database/utils.js
// src/database/utils.js
const fs = require("fs");
// 保存数据到JSON文件
const saveToDatabase = (DB) => {
fs.writeFileSync("./src/database/db.json", JSON.stringify(DB, null, 2), {
encoding: "utf-8",
});
};
module.exports = { saveToDatabase };
2.更新数据访问层:修改src/database/Workout.js
,添加创建训练计划的方法:
// src/database/Workout.js
const DB = require("./db.json");
// 引入保存工具
const { saveToDatabase } = require("./utils");
// 获取所有训练计划
const getAllWorkouts = () => {
return DB.workouts;
};
// 创建训练计划
const createNewWorkout = (newWorkout) => {
// 检查是否已存在同名训练计划
const isAlreadyExists = DB.workouts.findIndex(w => w.name === newWorkout.name) > -1;
if (isAlreadyExists) {
return; // 已存在则返回空
}
// 新增训练计划并保存
DB.workouts.push(newWorkout);
saveToDatabase(DB);
return newWorkout;
};
module.exports = { getAllWorkouts, createNewWorkout };
- 更新服务层:安装
uuid
生成唯一 ID,修改src/services/workoutService.js
:
npm i uuid
// src/services/workoutService.js
const { v4: uuid } = require("uuid"); // 生成唯一ID
const Workout = require("../database/Workout");
// 获取所有训练计划(不变)
const getAllWorkouts = () => {
const allWorkouts = Workout.getAllWorkouts();
return allWorkouts;
};
// 创建训练计划(添加ID、时间戳)
const createNewWorkout = (newWorkout) => {
// 补充必要字段(ID、创建时间、更新时间)
const workoutToAdd = {
...newWorkout,
id: uuid(), // 唯一ID
createdAt: new Date().toLocaleString("zh-CN", { timeZone: "UTC" }),
updatedAt: new Date().toLocaleString("zh-CN", { timeZone: "UTC" }),
};
const createdWorkout = Workout.createNewWorkout(workoutToAdd);
return createdWorkout;
};
// 其他方法暂不修改
const getOneWorkout = () => { return; };
const updateOneWorkout = () => { return; };
const deleteOneWorkout = () => { return; };
module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};
- 更新控制器:修改
src/controllers/workoutController.js
,接收 JSON 请求并验证:
// src/controllers/workoutController.js
const workoutService = require("../services/workoutService");
// 获取所有训练计划(不变)
const getAllWorkouts = (req, res) => {
const allWorkouts = workoutService.getAllWorkouts();
res.send({ status: "成功", data: allWorkouts });
};
// 其他方法暂不修改
const getOneWorkout = (req, res) => { res.send("获取单个训练计划"); };
// 创建训练计划(接收JSON并验证)
const createNewWorkout = (req, res) => {
const { body } = req;
// 验证必填字段
if (!body.name || !body.mode || !body.equipment || !body.exercises || !body.trainerTips) {
res.status(400).send({
status: "失败",
data: { error: "请求体缺少以下必填字段:'name'、'mode'、'equipment'、'exercises'、'trainerTips'" }
});
return;
}
// 调用服务层创建训练计划
const createdWorkout = workoutService.createNewWorkout(body);
// 返回201(创建成功)和新训练计划
res.status(201).send({ status: "成功", data: createdWorkout });
};
const updateOneWorkout = (req, res) => { res.send("更新训练计划"); };
const deleteOneWorkout = (req, res) => { res.send("删除训练计划"); };
module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};
测试接收 JSON
用 Postman 或 Apifox 发送POST
请求到localhost:3000/api/v1/workouts
,请求体为 JSON:
{
"name": "核心爆发",
"mode": "20分钟内尽可能多组",
"equipment": ["架子", "杠铃", "腹肌垫"],
"exercises": [
"15次举腿触杠",
"10次火箭推",
"30次腹肌垫卷腹"
],
"trainerTips": [
"举腿触杠最多分两组完成",
"火箭推尽量不间断",
"卷腹时调整呼吸节奏"
]
}
若返回状态 201 和包含 ID、时间戳的新训练计划,则说明 API 成功接收并存储了 JSON 数据。再访问localhost:3000/api/v1/workouts
,可看到新增的训练计划。
5.4 用标准 HTTP 错误码响应
实际开发中,API 难免出现错误(如参数缺失、资源不存在等)。使用标准 HTTP 错误码并返回清晰的错误信息,能帮助调用者快速定位问题。
常见 HTTP 错误码及场景:
- 400:请求错误(如参数缺失、格式错误)
- 404:资源不存在(如查询的训练计划 ID 不存在)
- 500:服务器内部错误(如数据库操作失败)
步骤 1:完善“创建训练计划”的错误处理
修改数据访问层、服务层和控制器,添加错误抛出和捕获:
- 数据访问层(抛出错误):修改
src/database/Workout.js
:
// src/database/Workout.js
const DB = require("./db.json");
const { saveToDatabase } = require("./utils");
// 获取所有训练计划(添加错误捕获)
const getAllWorkouts = () => {
try {
return DB.workouts;
} catch (error) {
throw { status: 500, message: "获取训练计划失败:" + error.message };
}
};
// 创建训练计划(抛出错误)
const createNewWorkout = (newWorkout) => {
try {
const isAlreadyExists = DB.workouts.findIndex(w => w.name === newWorkout.name) > -1;
if (isAlreadyExists) {
throw { status: 400, message: `训练计划"${newWorkout.name}"已存在` };
}
DB.workouts.push(newWorkout);
saveToDatabase(DB);
return newWorkout;
} catch (error) {
throw { status: error.status || 500, message: error.message || "创建训练计划失败" };
}
};
module.exports = { getAllWorkouts, createNewWorkout };
2.服务层(捕获并抛出错误):修改src/services/workoutService.js
:
// src/services/workoutService.js
const { v4: uuid } = require("uuid");
const Workout = require("../database/Workout");
// 获取所有训练计划(错误处理)
const getAllWorkouts = () => {
try {
const allWorkouts = Workout.getAllWorkouts();
return allWorkouts;
} catch (error) {
throw error;
}
};
// 创建训练计划(错误处理)
const createNewWorkout = (newWorkout) => {
try {
const workoutToAdd = {
...newWorkout,
id: uuid(),
createdAt: new Date().toLocaleString("zh-CN", { timeZone: "UTC" }),
updatedAt: new Date().toLocaleString("zh-CN", { timeZone: "UTC" }),
};
const createdWorkout = Workout.createNewWorkout(workoutToAdd);
return createdWorkout;
} catch (error) {
throw error;
}
};
// 其他方法暂不修改
const getOneWorkout = () => { return; };
const updateOneWorkout = () => { return; };
const deleteOneWorkout = () => { return; };
module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};
3.控制器(捕获错误并返回错误码):修改src/controllers/workoutController.js
:
// src/controllers/workoutController.js
const workoutService = require("../services/workoutService");
// 获取所有训练计划(错误处理)
const getAllWorkouts = (req, res) => {
try {
const allWorkouts = workoutService.getAllWorkouts();
res.send({ status: "成功", data: allWorkouts });
} catch (error) {
res.status(error.status || 500).send({
status: "失败",
data: { error: error.message }
});
}
};
// 其他方法暂不修改
const getOneWorkout = (req, res) => { res.send("获取单个训练计划"); };
// 创建训练计划(错误处理)
const createNewWorkout = (req, res) => {
const { body } = req;
// 验证必填字段(400错误)
if (!body.name || !body.mode || !body.equipment || !body.exercises || !body.trainerTips) {
res.status(400).send({
status: "失败",
data: { error: "请求体缺少以下必填字段:'name'、'mode'、'equipment'、'exercises'、'trainerTips'" }
});
return;
}
try {
const createdWorkout = workoutService.createNewWorkout(body);
res.status(201).send({ status: "成功", data: createdWorkout });
} catch (error) {
res.status(error.status || 500).send({
status: "失败",
data: { error: error.message }
});
}
};
const updateOneWorkout = (req, res) => { res.send("更新训练计划"); };
const deleteOneWorkout = (req, res) => { res.send("删除训练计划"); };
module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};
测试错误处理
- 重复创建同名训练计划:发送相同名称的 POST 请求,返回 400 错误和“训练计划已存在”的信息;
- 缺失必填字段:请求体不包含
name
,返回 400 错误和“缺少必填字段”的信息。
5.5 端点名称避免使用动词
端点 URL 应指向资源,而非描述“动作”——因为 HTTP 方法(GET/POST/PATCH/DELETE)已经明确了动作含义,再在 URL 中加动词会显得冗余且混乱。
错误示例(含动词):
GET "/api/v1/getAllWorkouts" // 冗余:GET已表示“获取”
POST "/api/v1/createWorkout" // 冗余:POST已表示“创建”
DELETE "/api/v1/deleteWorkout/123" // 冗余:DELETE已表示“删除”
正确示例(无动词,仅资源):
GET "/api/v1/workouts" // 获取所有训练计划(GET+复数资源)
POST "/api/v1/workouts" // 创建训练计划(POST+复数资源)
DELETE "/api/v1/workouts/123" // 删除ID为123的训练计划(DELETE+资源+ID)
我们之前的实现已经遵循了这个最佳实践,无需修改——核心原则是:HTTP 方法描述动作,URL 描述资源。
5.6 关联资源分组(逻辑嵌套)
当资源之间存在关联关系时(如“训练计划”与“训练记录”),可通过 URL 嵌套实现逻辑分组,让 API 结构更清晰。
例如,我们要为每个训练计划添加“会员记录”(记录会员完成该训练的时间),可设计嵌套 URL:
// 获取ID为123的训练计划的所有记录
GET "/api/v1/workouts/123/records"
步骤 1:扩展模拟数据库(添加会员数据)
修改src/database/db.json
,添加members
(会员)和records
(记录)字段:
{
"workouts": [
// 原有训练计划数据不变...
],
"members": [
{
"id": "12a410bc-849f-4e7e-bfc8-4ef283ee4b19",
"name": "Jason Miller",
"gender": "男",
"dateOfBirth": "1990-04-23",
"email": "jason@mail.com",
"password": "666349420ec497c1dc890c45179d44fb13220239325172af02d1fb6635922956"
},
{
"id": "2b9130d4-47a7-4085-800e-0144f6a46059",
"name": "Tiffany Brookston",
"gender": "女",
"dateOfBirth": "1996-06-09",
"email": "tiffy@mail.com",
"password": "8a1ea5669b749354110dcba3fac5546c16e6d0f73a37f35a84f6b0d7b3c22fcc"
}
],
"records": [
{
"id": "r1",
"workoutId": "61dbae02-c147-4e28-863c-db7bd402b2d6",
"memberId": "12a410bc-849f-4e7e-bfc8-4ef283ee4b19",
"time": "12:30",
"date": "2022-04-21"
},
{
"id": "r2",
"workoutId": "61dbae02-c147-4e28-863c-db7bd402b2d6",
"memberId": "2b9130d4-47a7-4085-800e-0144f6a46059",
"time": "14:15",
"date": "2022-04-21"
}
]
}
步骤 2:创建记录相关文件
# 记录控制器
touch src/controllers/recordController.js
# 记录服务
touch src/services/recordService.js
# 记录路由
touch src/v1/routes/recordRoutes.js
步骤 3:编写记录数据访问层方法
在src/database
下创建Record.js
:
touch src/database/Record.js
// src/database/Record.js
const DB = require("./db.json");
// 根据训练计划ID获取记录
const getRecordsByWorkoutId = (workoutId) => {
return DB.records.filter(record => record.workoutId === workoutId);
};
module.exports = { getRecordsByWorkoutId };
步骤 4:编写记录服务层
// src/services/recordService.js
const Record = require("../database/Record");
// 根据训练计划ID获取记录
const getRecordsByWorkoutId = (workoutId) => {
return Record.getRecordsByWorkoutId(workoutId);
};
module.exports = { getRecordsByWorkoutId };
步骤 5:编写记录控制器
// src/controllers/recordController.js
const recordService = require("../services/recordService");
// 根据训练计划ID获取记录
const getRecordsByWorkoutId = (req, res) => {
const { workoutId } = req.params;
if (!workoutId) {
res.status(400).send({ status: "失败", data: { error: "workoutId不能为空" } });
return;
}
try {
const records = recordService.getRecordsByWorkoutId(workoutId);
res.send({ status: "成功", data: records });
} catch (error) {
res.status(500).send({ status: "失败", data: { error: error.message } });
}
};
module.exports = { getRecordsByWorkoutId };
步骤 6:编写嵌套路由
// src/v1/routes/recordRoutes.js
const express = require("express");
const recordController = require("../../controllers/recordController");
const router = express.Router({ mergeParams: true }); // 允许访问父路由参数
// 嵌套路由:/api/v1/workouts/:workoutId/records
router.get("/", recordController.getRecordsByWorkoutId);
module.exports = router;
步骤 7:关联训练计划路由与记录路由
修改src/v1/routes/workoutRoutes.js
,引入记录路由并配置嵌套:
// src/v1/routes/workoutRoutes.js
const express = require("express");
const workoutController = require("../../controllers/workoutController");
// 引入记录路由
const recordRouter = require("./recordRoutes");
const router = express.Router();
// 嵌套路由:将/records挂载到/workouts/:workoutId下
router.use("/:workoutId/records", recordRouter);
// 原有CRUD路由不变
router.get("/", workoutController.getAllWorkouts);
router.get("/:workoutId", workoutController.getOneWorkout);
router.post("/", workoutController.createNewWorkout);
router.patch("/:workoutId", workoutController.updateOneWorkout);
router.delete("/:workoutId", workoutController.deleteOneWorkout);
module.exports = router;
测试嵌套路由
访问localhost:3000/api/v1/workouts/61dbae02-c147-4e28-863c-db7bd402b2d6/records
,可获取该训练计划的所有会员记录,说明嵌套路由配置成功。
5.7 其他最佳实践(简要说明)
由于篇幅限制,以下最佳实践简要介绍核心思路,可参考上述方法自行实现:
1. 集成过滤、排序与分页
当资源数量庞大时,需支持过滤、排序和分页,减轻服务器压力:
- 过滤:
GET /api/v1/workouts?mode=计时完成
(筛选“计时完成”的训练计划); - 排序:
GET /api/v1/workouts?sort=createdAt&order=desc
(按创建时间倒序); - 分页:
GET /api/v1/workouts?page=1&limit=10
(第 1 页,每页 10 条)。
实现思路:在服务层解析req.query
中的参数,对数据进行过滤、排序或切片处理。
2. 用数据缓存提升性能
对频繁访问且更新不频繁的数据(如热门训练计划),可使用 Redis 缓存,减少数据库查询次数:
- 首次请求:从数据库获取数据,存入 Redis;
- 后续请求:直接从 Redis 获取数据,若数据过期则重新从数据库加载。
3. 良好的安全实践
- 身份验证:用 JWT 或 OAuth2.0 验证用户身份(如仅登录用户可创建训练计划);
- 权限控制:区分管理员和普通用户权限(如仅管理员可删除训练计划);
- 输入验证:用
express-validator
验证请求参数,防止 SQL 注入或 XSS 攻击; - HTTPS:生产环境强制使用 HTTPS,加密传输数据;
- 限流:用
express-rate-limit
限制接口调用频率,防止恶意请求。
4. 完善 API 文档
API 文档是调用者的使用指南,推荐用 Swagger/OpenAPI 自动生成文档:
- 安装
swagger-jsdoc
和swagger-ui-express
; - 在代码中添加 JSDoc 风格的注释(描述端点、参数、响应等);
- 配置 Swagger 路由,访问
/api-docs
即可查看交互式文档。
6. 总结
REST API 的最佳实践并非一成不变的规则,而是基于“提升可用性、安全性和性能”的设计原则。本文通过一个 CrossFit 训练应用的示例,落地了以下核心实践:
- 版本控制:URL 添加版本标识,支持新旧版本并行;
- 资源命名:用复数形式命名资源,避免歧义;
- 数据格式:统一使用 JSON 接收和返回数据;
- 错误处理:用标准 HTTP 错误码+清晰信息,便于调试;
- 端点设计:URL 指向资源,不包含动词;
- 关联资源:用嵌套路由分组关联资源。
实际开发中,需根据项目规模和需求灵活调整(如小型 API 可简化版本控制,大型 API 需严格区分版本内逻辑)。掌握这些实践,能让你的 API 更易于维护和使用。
扩展链接
更多推荐
所有评论(0)