过去几年里,我创建并使用过很多 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(), // 唯一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, 
};
  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 更易于维护和使用。

扩展链接

数据同步功能

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务

更多推荐