过去几年里,我创建并使用过很多 API。在此过程中,我遇到过各种好的和坏的实践,也在开发和调用 API 时碰到过不少棘手的问题,但也有很多顺利的时刻。

网上有很多介绍最佳实践的文章,但在我看来,其中不少都缺乏实用性。只懂理论、没几个实例固然有一定价值,但我总是会想:在更真实的场景中,这些理论该如何落地?

简单的示例能帮助我们理解概念本身,避免过多复杂性干扰,但实际开发中事情往往没那么简单。我相信你肯定懂这种感受 😁

这就是我决定写这篇教程的原因。我把自己的所有经验(好的、坏的都有)整合到这篇通俗易懂的文章里,同时提供了可跟着操作的实战案例。最终,我们会一步步落实最佳实践,搭建出一个完整的 API。

开始前需要明确几点: 所谓“最佳实践”,并非必须严格遵守的法律或规则,而是经过时间检验、被证明有效的约定或建议。其中一些如今已成为标准,但这并不意味着你必须原封不动地照搬。

它们的核心目的是为你提供方向,帮助你从用户体验(包括调用者和开发者)、安全性、性能三个维度优化 API。

但请记住:不同项目需要不同的解决方案。有些情况下,你可能无法或不应该遵循某条约定。因此,最终需要开发者自己或与团队共同判断。

好了,废话不多说,我们开始吧!

目录

  1. 示例项目介绍
  2. 前置要求
  3. 架构设计
  4. 基础搭建
  5. REST API 最佳实践
    1. 版本控制
    2. 资源命名使用复数形式
    3. 接收与返回数据采用 JSON 格式
    4. 用标准 HTTP 错误码响应
    5. 端点名称避免使用动词
    6. 关联资源分组(逻辑嵌套)
    7. 集成过滤、排序与分页
    8. 用数据缓存提升性能
    9. 良好的安全实践
    10. 完善 API 文档
  6. 总结

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 中添加版本标识(如v1v2):

// 版本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目录,controllersservices等仍在src根目录——这对小型 API 来说没问题,可以让多个版本共享这些通用逻辑。

但如果 API 规模扩大,比如 v2 需要特定的控制器或服务(修改通用逻辑可能影响旧版本),则建议将controllersservices也按版本拆分到对应目录,实现版本内逻辑的完全封装。

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 文件”的工具方法:

  1. 创建工具方法:在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 };
  1. 更新服务层:安装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(), // 唯一IDcreatedAt: 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, 
};
  1. 更新控制器:修改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:完善“创建训练计划”的错误处理

修改数据访问层、服务层和控制器,添加错误抛出和捕获:

  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, 
};
测试错误处理
  1. 重复创建同名训练计划:发送相同名称的 POST 请求,返回 400 错误和“训练计划已存在”的信息;
  2. 缺失必填字段:请求体不包含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-jsdocswagger-ui-express
  • 在代码中添加 JSDoc 风格的注释(描述端点、参数、响应等);
  • 配置 Swagger 路由,访问/api-docs即可查看交互式文档。

6. 总结

REST API 的最佳实践并非一成不变的规则,而是基于“提升可用性、安全性和性能”的设计原则。本文通过一个 CrossFit 训练应用的示例,落地了以下核心实践:

  1. 版本控制:URL 添加版本标识,支持新旧版本并行;
  2. 资源命名:用复数形式命名资源,避免歧义;
  3. 数据格式:统一使用 JSON 接收和返回数据;
  4. 错误处理:用标准 HTTP 错误码+清晰信息,便于调试;
  5. 端点设计:URL 指向资源,不包含动词;
  6. 关联资源:用嵌套路由分组关联资源。

实际开发中,需根据项目规模和需求灵活调整(如小型 API 可简化版本控制,大型 API 需严格区分版本内逻辑)。掌握这些实践,能让你的 API 更易于维护和使用。

扩展链接

数据同步功能

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/bicheng/98148.shtml
繁体地址,请注明出处:http://hk.pswp.cn/bicheng/98148.shtml
英文地址,请注明出处:http://en.pswp.cn/bicheng/98148.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

MyCat

文章目录18.1 MySQL 读写分离概述18.1.1 工作原理18.1.2 为什么要读写分离18.1.3 实现方式18.2 什么是 MyCat18.3 MyCat 安装与配置1. 下载与解压2. 创建用户并修改权限3. 目录说明4. Java 环境要求18.4 MyCat 启动与配置1. 配置环境变量2. 配置 hosts&#xff08;多节点集群&a…

使用 Spring Boot 搭建和部署 Kafka 消息队列系统

使用 Spring Boot 搭建和部署 Kafka 消息队列系统 摘要 本文将引导您在 Kafka 上搭建一个消息队列系统&#xff0c;并整合到您的 Spring Boot 项目中。我们将逐步实现这一方案&#xff0c;探讨其中的关键原理&#xff0c;避开可能遇到的坑&#xff0c;并最终将其部署到 Kuberne…

daily notes[45]

文章目录basic knowledgereferencesbasic knowledge the variable in Rust is not changed. let x5; x6;Rust language promotes the concept that immutable variables are safer than variables in other programming language such as python and and are in favour of th…

技术奇点爆发周:2025 年 9 月科技突破全景扫描

技术奇点爆发周&#xff1a;2025 年 9 月科技突破全景扫描当中国 "祖冲之三号" 量子计算机在特定任务上超越经典超级计算机一千万亿倍的算力新闻&#xff0c;与 OpenAI 宣布 100 亿美元定制芯片量产协议的消息在同一周密集爆发时&#xff0c;我们真切感受到了技术革命…

分布式专题——10.3 ShardingSphere实现原理以及内核解析

1 ShardingSphere-JDBC 内核工作原理当往 ShardingSphere 提交一个逻辑SQL后&#xff0c;ShardingSphere 到底做了哪些事情呢&#xff1f;首先要从 ShardingSphere 官方提供的这张整体架构图说起&#xff1a;1.1 配置管控在 SQL 进入 ShardingSphere 内核处理&#xff08;如解析…

移动语义的里里外外:从 std::move 的幻象到性能的现实

我们都已经听过这样的建议&#xff1a;“使用 std::move 来避免昂贵的拷贝&#xff0c;提升性能。” 这没错&#xff0c;但如果你对它的理解仅止于此&#xff0c;那么你可能正在黑暗中挥舞着一把利剑&#xff0c;既可能披荆斩棘&#xff0c;也可能伤及自身。 移动语义是 C11 带…

selenium完整版一览

selenium 库驱动浏览器selenium库是一种用于Web应用程序测试的工具,它可以驱动浏览器执行特定操作,自动按照脚本代码做出单击、输入、打开、验证等操作,支持的浏览器包括IE、Firefox、Safari、Chrome、Opera等。而在办公领域中如果经常需要使用浏览器操作某些内容,就可以使用se…

[Linux]学习笔记系列 -- lib/kfifo.c 内核FIFO实现(Kernel FIFO Implementation) 高效的无锁字节流缓冲区

文章目录lib/kfifo.c 内核FIFO实现(Kernel FIFO Implementation) 高效的无锁字节流缓冲区历史与背景这项技术是为了解决什么特定问题而诞生的&#xff1f;它的发展经历了哪些重要的里程碑或版本迭代&#xff1f;目前该技术的社区活跃度和主流应用情况如何&#xff1f;核心原理与…

MFC_Install_Create

1. 安装MFC 编写MFC窗口应用程序需要用到Visual Studiohttps://visualstudio.microsoft.com/zh-hans/&#xff0c;然后安装&#xff0c;要选择使用C的桌面开发&#xff0c;再点击右边安装详细信息中的使用C的桌面开发&#xff0c;往下滑&#xff0c;有一个适用于最新的v143生成…

Langchain4j开发之AI Service

学习基于Langchain4j的大模型开发需要学习其中Ai Service的开发模式。里面对大模型做了一层封装&#xff0c;提供一些可以方便调用的api。其中有两种使用Ai Service的方式。一.编程式开发1.首先引入Langchain4的依赖。<dependency><groupId>dev.langchain4j</gr…

认识神经网络和深度学习

什么是神经网络&#xff1f;什么又是深度学习&#xff1f;二者有什么关系&#xff1f;……带着这些疑问&#xff0c;进入本文的学习。什么是神经网络神经网络&#xff08;Neural Network&#xff09;是一种模仿生物神经系统&#xff08;如大脑神经元连接方式&#xff09;设计的…

医疗行业安全合规数据管理平台:构建高效协作与集中化知识沉淀的一体化解决方案

在医疗行业中&#xff0c;数据不仅是日常运营的基础&#xff0c;更是患者安全、服务质量和合规管理的核心载体。随着医疗业务的复杂化和服务模式的多元化&#xff0c;各类机构——从大型医院到科研中心——都面临着海量文档、报告、影像资料和政策文件的管理需求。这些资料往往…

Day25_【深度学习(3)—PyTorch使用(5)—张量形状操作】

reshape() squeeze()unsqueeze()transpose()permute()view() reshape() contiguous() reshape() 一、reshape() 函数保证张量数据不变的前提下改变数据的维度&#xff0c;将其转换成指定的形状。def reshape_tensor():data torch.tensor([[1, 2, 3], [4, 5, 6]])print(data…

第十八篇 开发网页教学:实现画布、绘画、简易 PS 方案

在网页开发领域&#xff0c;画布功能是实现交互创作的重要基础&#xff0c;无论是简单的绘画工具&#xff0c;还是具备基础修图能力的简易 PS 方案&#xff0c;都能为用户带来丰富的视觉交互体验。本篇教学将围绕 “学习 - 实践 - 实操” 的核心思路&#xff0c;从技术原理讲解…

封装形成用助焊剂:电子制造“隐形桥梁”的技术突围与全球产业重构

在5G通信、人工智能、新能源汽车等新兴技术驱动下&#xff0c;全球电子制造业正以年均6.8%的增速重构产业链。作为电子元件焊接的核心辅料&#xff0c;封装形成用助焊剂&#xff08;又称电子封装用助焊剂&#xff09;凭借其“优化焊接质量、提升可靠性、降低制造成本”的核心价…

【完整源码+数据集+部署教程】零件实例分割系统源码和数据集:改进yolo11-GhostHGNetV2

背景意义 研究背景与意义 随着工业自动化和智能制造的迅速发展&#xff0c;零件的高效识别与分割在生产线上的重要性日益凸显。传统的图像处理方法在处理复杂场景时往往面临着准确性不足和实时性差的问题&#xff0c;而深度学习技术的引入为这一领域带来了新的机遇。特别是基于…

墨色规则与血色节点:C++红黑树设计与实现探秘

前言​ 前几天攻克了AVL树&#xff0c;我们已然是平衡二叉树的强者。但旅程还未结束&#xff0c;下一个等待我们的&#xff0c;是更强大、也更传奇的**终极BOSS**——红黑树。它不仅是map和set的强大心脏&#xff0c;更是C STL皇冠上的明珠。准备好了吗&#xff1f;让我们一…

大数据时代时序数据库选型指南:为何 Apache IoTDB 成优选(含实操步骤)

在数字经济加速渗透的今天&#xff0c;工业物联网&#xff08;IIoT&#xff09;、智慧能源、金融交易、城市运维等领域每天产生海量 “带时间戳” 的数据 —— 从工业设备的实时温度、电压&#xff0c;到电网的负荷波动&#xff0c;再到金融市场的每秒行情&#xff0c;这类 “时…

MAZANOKE+cpolar让照片存储无上限

文章目录前言1. 关于MAZANOKE2. Docker部署3. 简单使用MAZANOKE4. 安装cpolar内网穿透5. 配置公网地址6. 配置固定公网地址总结当工具开始理解用户的需求痛点时&#xff0c;MAZANOKE与cpolar这对搭档给出了“轻量化”的解决方案。它不追求浮夸的功能堆砌&#xff0c;却用扎实的…

正则表达式 - 元字符

正则表达式中的元字符是具有特殊含义的字符&#xff0c;它们不表示字面意义&#xff0c;而是用于控制匹配模式。基本元字符. (点号)匹配除换行符(\n)外的任意单个字符示例&#xff1a;a.b 匹配 "aab", "a1b", "a b" 等^ (脱字符)匹配字符串的开始…