所有文章 > API设计 > 如何使用JavaScript、Node.js和Express.js构建REST API
如何使用JavaScript、Node.js和Express.js构建REST API

如何使用JavaScript、Node.js和Express.js构建REST API

作者:Jean-Marc Möckel

在过去的几年里,我创建并使用了许多 API。在那段时间里,我遇到了好的和坏的做法,并且在使用和构建 API 时经历了一些令人沮丧的情况。然而,也有一些伟大的时刻。

虽然网上有很多有用的文章介绍了许多最佳实践,但我发现其中很多缺乏实用性。通过几个例子来了解理论是好的,但我一直想知道在更真实的例子中实现会是什么样子。

提供简单的示例有助于理解概念本身,而不会带来很多复杂性,但在实践中,事情并不总是那么简单。我相信你明白我在说什么😁。

这就是我决定编写本教程的原因。我将所有这些经验(好的和坏的)合并成一篇易于理解的文章,同时提供了一个可以遵循的实际示例。最后,我们将构建一个完整的 API,同时实施一个又一个的最佳实践。

在我们开始之前要记住几件事:

您可能已经猜到了,最佳实践并不是必须遵循的特定法律或规则。它们是随着时间的推移而演变并被证明是有效的惯例或提示。有些现在已经成为标准。但这并不意味着您必须 1:1 调整它们。

它们应该为您提供一个方向,使您的 API 在用户体验(对于消费者和构建者)、安全性和性能方面变得更好。

请记住,项目是不同的,需要不同的方法。在某些情况下,你不能或不应该遵循某个约定。因此,每个工程师都必须自己决定或与他们一起决定。

现在我们已经解决了这些问题,事不宜迟,让我们开始工作吧!

我们的示例项目

在我们开始将最佳实践应用到示例项目之前,我想简要介绍一下我们将构建的内容。

我们将为 CrossFit Training 应用程序构建一个 REST API。如果您对CrossFit不太了解,它是一种结合高强度锻炼和多项运动(如奥运会举重、体操等)元素的健身方法和竞技运动。

在我们的应用程序中,我们希望创建、读取、更新和删除 WOD (Workouts othe Day)。这将帮助我们的用户(即健身房所有者)制定锻炼计划,并在一个应用程序中维护自己的训练计划。最重要的是,他们还可以为每次锻炼添加一些关键的训练技巧。

我们的工作将要求我们为该应用程序设计和实现一个 API。

先决条件

为了确保您能够顺利跟上本教程的学习进度,建议您具备一定的JavaScript、Node.js、Express.js以及后端架构的相关知识和实践经验。此外,对于REST和API等概念,您应该有所了解,并熟悉客户端-服务器模型(Client-Server-Model)。

虽然我们并不要求您是这些领域的专家,但拥有相关的基础知识和一定的实践经验将有助于您更轻松地掌握教程内容。如果您在某些方面还不够熟悉,也请不要气馁,因为这里仍然有很多宝贵的知识等待您去探索和学习。

尽管此 API 是用 JavaScript 和 Express 编写的,但最佳实践并不局限于这些工具。它们也可以应用于其他编程语言或框架。

架构

如上所述,我们将 Express.js 用于我们的 API。我不想提出一个复杂的架构,所以我想坚持使用 3 层架构:

在 Controller 中,我们将处理所有与 HTTP 相关的事情。包括接收请求和发送响应。这一层之上,Express的路由器会将请求分发到相应的控制器。

整个业务逻辑将位于 Service Layer 中,该层提供了一些服务方法供控制器调用。

数据访问层位于最底层,负责与数据库进行交互。我们在这一层定义了一些数据库操作方法,例如创建可供我们的服务层使用的 WOD。

在我们的示例中,我们没有使用真正的数据库,例如 MongoDB 或 PostgreSQL,因为我想更多地关注最佳实践本身。因此,我们使用一个模拟 Database 的本地 JSON 文件。当然,这些逻辑可以轻松迁移到其他类型的数据库上。

基本设置

现在我们已经准备好为我们的 API 创建一个基本设置。为了不让事情过于复杂,我们将构建一个简单但有条理的项目结构。

首先,让我们创建包含所有必要文件和依赖项的整体文件夹结构。之后,我们将进行快速测试以检查是否一切正常:

# Create project folder & navigate into it
mkdir crossfit-wod-api && cd crossfit-wod-api
# Create a src folder & navigate into it
mkdir src && cd src
# Create sub folders
mkdir controllers && mkdir services && mkdir database && mkdir routes
# Create an index file (entry point of our API)
touch index.js
# We're currently in the src folder, so we need to move one level up first 
cd ..

# Create package.json file
npm init -y

安装基本设置的依赖项:

# Dev Dependencies 
npm i -D nodemon

# Dependencies
npm i express

在Text Editor 中打开项目并配置 Express:

// In src/index.js 
const express = require("express");

const app = express();
const PORT = process.env.PORT || 3000;

// For testing purposes
app.get("/", (req, res) => {
res.send("<h2>It's Working!</h2>");
});

app.listen(PORT, () => {
console.log(`API is listening on port ${PORT}`);
});

在 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"
}
}

该脚本确保开发服务器在我们进行更改时进行自动重启。

启动开发服务器:

npm run dev

查看您的终端,应该有一条消息 “API is listening on port 3000” 。

在浏览器中访问 localhost:3000。当一切都设置正确后,您应该会看到以下内容:

太棒了!现在我们已经准备好实施最佳实践。

REST API 最佳实践

是的!现在我们已经有一个非常基本的 Express 设置,我们可以使用以下最佳实践来扩展我们的 API。

让我们从基本的 CRUD 终端节点开始。之后,我们将使用每个最佳实践来扩展 API。

版本控制

在编写任何特定于 API 的代码之前,我们应该了解版本控制。就像在其他应用程序中一样,会有改进、新功能和类似的东西。因此,对 API 进行版本控制也很重要。

最大的优势在于,我们能够在不影响客户端正常使用当前版本的情况下,同时在新版本上开发新功能或进行改进。

我们也不会强迫客户立即使用新版本。他们可以使用当前版本,并在新版本稳定时自行迁移。

当前版本和新版本基本上是并行运行的,不会相互影响。

但是我们如何区分这些版本呢?一种好的做法是将 v1 或 v2 等路径段添加到 URL 中。

// Version 1 
"/api/v1/workouts"

// Version 2
"/api/v2/workouts"

// ...

这就是我们向外界公开的内容,也是其他开发人员可以使用的内容。但是我们还需要构建我们自己的项目,以便区分每个版本。

在 Express API 中,有许多不同的方法可以处理版本控制。在我们的示例中,我们打算为 src 目录中的每个版本创建一个名为 v1 的子文件夹。

mkdir src/v1

现在,我们将 routes 文件夹移动到新的 v1 目录中。

# Get the path to your current directory (copy it) 
pwd

# Move "routes" into "v1" (insert the path from above into {pwd})
mv {pwd}/src/routes {pwd}/src/v1

新目录 /src/v1/routes 将存储版本 1 的所有路由。我们稍后会添加内容。但现在让我们添加一个简单的 index.js 文件来测试一下。

# In /src/v1/routes 
touch index.js

在里面,我们启动了一个简单的路由器。

// In src/v1/routes/index.js
const express = require("express");
const router = express.Router();

router.route("/").get((req, res) => {
res.send(`<h2>Hello from ${req.baseUrl}</h2>`);
});

module.exports = router;

现在我们必须在 src/index.js 的根入口点内连接我们的 v1 路由器。

// In src/index.js
const express = require("express");
// *** ADD ***
const v1Router = require("./v1/routes");

const app = express();
const PORT = process.env.PORT || 3000;

// *** REMOVE ***
app.get("/", (req, res) => {
res.send("<h2>It's Working!</h2>");
});

// *** ADD ***
app.use("/api/v1", v1Router);

app.listen(PORT, () => {
console.log(`API is listening on port ${PORT}`);
});

现在在浏览器中访问 localhost:3000/api/v1,您应该会看到以下内容:

祝贺!您刚刚构建了用于处理不同版本的工程。现在,我们将带有 “/api/v1” 的传入请求传递给我们的版本 1 路由器,该路由器稍后会将每个请求路由到相应的控制器方法。

在我们继续之前,我想指出一些事情。

我们刚刚将 routes 文件夹移动到了 v1 目录中。其他文件夹(如 controllers 或 services)仍保留在我们的 src 目录中。这目前没有问题,因为我们正在构建一个相对较小的 API。我们可以在全球每个版本中使用相同的控制器和服务。

例如,当 API 增长并需要特定于 v2 的不同控制器方法时,最好将 controllers 文件夹移动到 v2 目录中,以便封装该特定版本的所有特定逻辑。

另一个原因可能是我们可能会更改所有其他版本使用的服务。我们不想破坏其他版本中的内容。因此,将 services 文件夹也移动到特定版本文件夹中将是一个明智的决定。

但正如我所说,在我们的示例中,我可以只区分路由,让路由器处理其余部分。尽管如此,重要的是要记住这一点,以便在 API 扩展和需要更改时有一个清晰的结构。

以复数形式命名资源

设置完所有内容后,我们现在可以深入了解 API 的真正实现。就像我说的,我想从我们的基本 CRUD 终端节点开始。

换句话说,让我们开始实现用于创建、读取、更新和删除锻炼的终端节点。

首先,让我们为我们的锻炼连接一个特定的控制器、服务和路由器。

touch src/controllers/workoutController.js 

touch src/services/workoutService.js

touch src/v1/routes/workoutRoutes.js

让我们考虑一下如何命名我们的终端节点。这对我们的最佳实践至关重要。

我们可以将创建端点命名为 /api/v1/workout,因为我们想添加一个锻炼,对吧?虽然这种方法没有错,但可能会导致误解。

请记住:您的 API 由其他人使用,因此应该准确无误。这也适用于命名您的资源。

我总是把资源想象成一个盒子。在我们的示例中,该框是存储不同锻炼的集合。

以复数形式命名您的资源有一个很大的好处,即其他人很清楚,这是一个由不同锻炼组成的集合。

那么,让我们在锻炼路由器中定义我们的端点。

// In src/v1/routes/workoutRoutes.js
const express = require("express");
const router = express.Router();

router.get("/", (req, res) => {
res.send("Get all workouts");
});

router.get("/:workoutId", (req, res) => {
res.send("Get an existing workout");
});

router.post("/", (req, res) => {
res.send("Create a new workout");
});

router.patch("/:workoutId", (req, res) => {
res.send("Update an existing workout");
});

router.delete("/:workoutId", (req, res) => {
res.send("Delete an existing workout");
});

module.exports = router;

你可以删除 src/v1/routes 中的测试文件index.js

现在让我们跳到我们的入口点并连接我们的 v1 锻炼路由器。

// In src/index.js
const express = require("express");
// *** REMOVE ***
const v1Router = require("./v1/routes");
// *** ADD ***
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");

const app = express();
const PORT = process.env.PORT || 3000;

// *** REMOVE ***
app.use("/api/v1", v1Router);

// *** ADD ***
app.use("/api/v1/workouts", v1WorkoutRouter);

app.listen(PORT, () => {
console.log(`API is listening on port ${PORT}`);
});

现在,我们使用 v1WorkoutRouter 捕获所有发送到 /api/v1/workouts 的请求。

在我们的路由器中,我们将为每个不同的端点调用一个由控制器处理的不同方法。

让我们为每个终端节点创建一个方法。现在只发回一条消息应该没问题。

// In src/controllers/workoutController.js
const getAllWorkouts = (req, res) => {
res.send("Get all workouts");
};

const getOneWorkout = (req, res) => {
res.send("Get an existing workout");
};

const createNewWorkout = (req, res) => {
res.send("Create a new workout");
};

const updateOneWorkout = (req, res) => {
res.send("Update an existing workout");
};

const deleteOneWorkout = (req, res) => {
res.send("Delete an existing workout");
};

module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};

现在是时候稍微重构我们的锻炼路由器并使用控制器方法了。

// In 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/2342 来测试我们的 GET /api/v1/workouts/:workoutId 端点。您应该看到如下内容:

我们成功了!我们架构的第一层已经完成。让我们通过实施下一个最佳实践来创建我们的服务层。

接受并使用 JSON 格式的数据进行响应

与 API 交互时,您始终随请求发送特定数据,或者随响应接收数据。有许多不同的数据格式,但 JSON(Javascript 对象表示法)是一种标准化格式。

尽管 JSON 中有 JavaScript 一词,但它并不仅限于 JavaScript。您还可以使用 Java、Python 等语言编写 API,这些 API 同样可以处理 JSON。

由于 JSON 的标准化特性,API 应该接受 JSON 格式的数据并做出相应的响应。

首先,创建我们的服务层。

// In 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,
};

最好将服务方法命名为与控制器方法相同的名称,以便在它们之间建立连接。

在我们的锻炼控制器中,我们可以使用这些方法。

// In src/controllers/workoutController.js
// *** ADD ***
const workoutService = require("../services/workoutService");

const getAllWorkouts = (req, res) => {
// *** ADD ***
const allWorkouts = workoutService.getAllWorkouts();
res.send("Get all workouts");
};

const getOneWorkout = (req, res) => {
// *** ADD ***
const workout = workoutService.getOneWorkout();
res.send("Get an existing workout");
};

const createNewWorkout = (req, res) => {
// *** ADD ***
const createdWorkout = workoutService.createNewWorkout();
res.send("Create a new workout");
};

const updateOneWorkout = (req, res) => {
// *** ADD ***
const updatedWorkout = workoutService.updateOneWorkout();
res.send("Update an existing workout");
};

const deleteOneWorkout = (req, res) => {
// *** ADD ***
workoutService.deleteOneWorkout();
res.send("Delete an existing workout");
};

module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};

目前,我们的回答应该没有任何变化。但在后台,我们的控制器层现在与服务层进行对话。

在我们的服务方法中,我们将处理业务逻辑,例如转换数据结构和与数据库层通信。

为此,我们需要一个数据库和一组实际处理数据库交互的方法。我们的数据库将是一个预先填充了一些锻炼数据的简单 JSON 文件。

# Create a new file called db.json inside src/database 
touch src/database/db.json

# Create a Workout File that stores all workout specific methods in /src/database
touch src/database/Workout.js

将以下内容复制到 db.json:

{
"workouts": [
{
"id": "61dbae02-c147-4e28-863c-db7bd402b2d6",
"name": "Tommy V",
"mode": "For Time",
"equipment": [
"barbell",
"rope"
],
"exercises": [
"21 thrusters",
"12 rope climbs, 15 ft",
"15 thrusters",
"9 rope climbs, 15 ft",
"9 thrusters",
"6 rope climbs, 15 ft"
],
"createdAt": "4/20/2022, 2:21:56 PM",
"updatedAt": "4/20/2022, 2:21:56 PM",
"trainerTips": [
"Split the 21 thrusters as needed",
"Try to do the 9 and 6 thrusters unbroken",
"RX Weights: 115lb/75lb"
]
},
{
"id": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
"name": "Dead Push-Ups",
"mode": "AMRAP 10",
"equipment": [
"barbell"
],
"exercises": [
"15 deadlifts",
"15 hand-release push-ups"
],
"createdAt": "1/25/2022, 1:15:44 PM",
"updatedAt": "3/10/2022, 8:21:56 AM",
"trainerTips": [
"Deadlifts are meant to be light and fast",
"Try to aim for unbroken sets",
"RX Weights: 135lb/95lb"
]
},
{
"id": "d8be2362-7b68-4ea4-a1f6-03f8bc4eede7",
"name": "Heavy DT",
"mode": "5 Rounds For Time",
"equipment": [
"barbell",
"rope"
],
"exercises": [
"12 deadlifts",
"9 hang power cleans",
"6 push jerks"
],
"createdAt": "11/20/2021, 5:39:07 PM",
"updatedAt": "11/20/2021, 5:39:07 PM",
"trainerTips": [
"Aim for unbroken push jerks",
"The first three rounds might feel terrible, but stick to it",
"RX Weights: 205lb/145lb"
]
}
]
}

如您所见,插入了三个锻炼。一个锻炼包括 id、name、mode、equipment、exercises、createdAt、updatedAt 和 trainerTips。

让我们从最简单的开始,返回所有存储的锻炼,然后开始在我们的数据访问层 (src/database/Workout.js) 中实现相应的方法。

同样,我选择将此处的方法命名为与服务和控制器中的方法相同的名称,这完全是可选的。

// In src/database/Workout.js
const DB = require("./db.json");

const getAllWorkouts = () => {
return DB.workouts;
};

module.exports = { getAllWorkouts };

直接跳回到我们的锻炼服务并实现 getAllWorkouts 的逻辑。

// In src/database/workoutService.js
// *** ADD ***
const Workout = require("../database/Workout");
const getAllWorkouts = () => {
// *** ADD ***
const allWorkouts = Workout.getAllWorkouts();
// *** ADD ***
return allWorkouts;
};

const getOneWorkout = () => {
return;
};

const createNewWorkout = () => {
return;
};

const updateOneWorkout = () => {
return;
};

const deleteOneWorkout = () => {
return;
};

module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};

返回所有锻炼非常简单,我们不必进行转换,因为它已经是一个 JSON 文件。我们现在也不需要接受任何争论,所以这个实现非常简单。但我们稍后会回到这个问题。

回到我们的锻炼控制器中,我们从中接收返回值,并将其作为响应发送给客户端。我们已经通过我们的服务将数据库响应循环到控制器。

// In src/controllers/workoutControllers.js
const workoutService = require("../services/workoutService");

const getAllWorkouts = (req, res) => {
const allWorkouts = workoutService.getAllWorkouts();
// *** ADD ***
res.send({ status: "OK", data: allWorkouts });
};

const getOneWorkout = (req, res) => {
const workout = workoutService.getOneWorkout();
res.send("Get an existing workout");
};

const createNewWorkout = (req, res) => {
const createdWorkout = workoutService.createNewWorkout();
res.send("Create a new workout");
};

const updateOneWorkout = (req, res) => {
const updatedWorkout = workoutService.updateOneWorkout();
res.send("Update an existing workout");
};

const deleteOneWorkout = (req, res) => {
workoutService.deleteOneWorkout();
res.send("Delete an existing workout");
};

module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};

在浏览器中转到 localhost:3000/api/v1/workouts,您应该会看到响应 JSON。

太好了!我们将以 JSON 格式发回数据。但是如何接收数据呢?让我们考虑一个终端节点,我们需要从客户端接收 JSON 数据。用于创建或更新锻炼的终端节点需要来自客户端的数据。

在我们的锻炼控制器中,我们提取请求正文中用于创建新锻炼的数据,并将其传递给锻炼服务。在锻炼服务中,我们会将其插入到我们的 DB.json 中,并将新创建的锻炼发送回给客户。

为了能够解析请求正文中发送的 JSON,我们需要先安装 body-parser 并对其进行配置。

npm i body-parser
// In src/index.js 
const express = require("express");
// *** ADD ***
const bodyParser = require("body-parser");
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");

const app = express();
const PORT = process.env.PORT || 3000;

// *** ADD ***
app.use(bodyParser.json());
app.use("/api/v1/workouts", v1WorkoutRouter);

app.listen(PORT, () => {
console.log(`API is listening on port ${PORT}`);
});

现在我们能够在控制器中的 req.body 下接收 JSON 数据。

为了正确测试它,只需打开您最喜欢的 HTTP 客户端(我正在使用 Postman),创建对 localhost:3000/api/v1/workouts 的 POST 请求和JSON格式的请求正文,如下所示:

{
"name": "Core Buster",
"mode": "AMRAP 20",
"equipment": [
"rack",
"barbell",
"abmat"
],
"exercises": [
"15 toes to bars",
"10 thrusters",
"30 abmat sit-ups"
],
"trainerTips": [
"Split your toes to bars into two sets maximum",
"Go unbroken on the thrusters",
"Take the abmat sit-ups as a chance to normalize your breath"
]
}

你可能已经注意到了,缺少一些属性,比如 “id”、“createdAt” 和 “updatedAt”。这就是我们的 API 的工作,即在插入之前添加这些属性。我们稍后会在我们的锻炼服务中处理它。

在锻炼控制器的 createNewWorkout 方法中,我们可以从 request 对象中提取 body,进行一些验证,并将其作为参数传递给我们的锻炼服务。

// In src/controllers/workoutController.js
...

const createNewWorkout = (req, res) => {
const { body } = req;
// *** ADD ***
if (
!body.name ||
!body.mode ||
!body.equipment ||
!body.exercises ||
!body.trainerTips
) {
return;
}
// *** ADD ***
const newWorkout = {
name: body.name,
mode: body.mode,
equipment: body.equipment,
exercises: body.exercises,
trainerTips: body.trainerTips,
};
// *** ADD ***
const createdWorkout = workoutService.createNewWorkout(newWorkout);
// *** ADD ***
res.status(201).send({ status: "OK", data: createdWorkout });
};

...

为了改进请求验证,您通常会使用第三方包,例如 express-validator。

让我们进入我们的锻炼服务,并在 createNewWorkout 方法中接收数据。

之后,我们将缺少的属性添加到对象中,并将其传递给数据访问层中的新方法,以将其存储在数据库中。

首先,我们创建一个简单的 Util 函数来覆盖我们的 JSON 文件以保留数据。

# Create a utils file inside our database directory 
touch src/database/utils.js
// In src/database/utils.js
const fs = require("fs");

const saveToDatabase = (DB) => {
fs.writeFileSync("./src/database/db.json", JSON.stringify(DB, null, 2), {
encoding: "utf-8",
});
};

module.exports = { saveToDatabase };

然后我们可以在我们的 Workout.js 文件中使用这个函数。

// In src/database/Workout.js
const DB = require("./db.json");
// *** ADD ***
const { saveToDatabase } = require("./utils");


const getAllWorkouts = () => {
return DB.workouts;
};

// *** ADD ***
const createNewWorkout = (newWorkout) => {
const isAlreadyAdded =
DB.workouts.findIndex((workout) => workout.name === newWorkout.name) > -1;
if (isAlreadyAdded) {
return;
}
DB.workouts.push(newWorkout);
saveToDatabase(DB);
return newWorkout;
};

module.exports = {
getAllWorkouts,
// *** ADD ***
createNewWorkout,
};

下一步是使用我们 workout 服务中的数据库方法。

# Install the uuid package 
npm i uuid
// In src/services/workoutService.js
// *** ADD ***
const { v4: uuid } = require("uuid");
// *** ADD ***
const Workout = require("../database/Workout");

const getAllWorkouts = () => {
const allWorkouts = Workout.getAllWorkouts();
return allWorkouts;
};

const getOneWorkout = () => {
return;
};

const createNewWorkout = (newWorkout) => {
// *** ADD ***
const workoutToInsert = {
...newWorkout,
id: uuid(),
createdAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
};
// *** ADD ***
const createdWorkout = Workout.createNewWorkout(workoutToInsert);
return createdWorkout;
};

const updateOneWorkout = () => {
return;
};

const deleteOneWorkout = () => {
return;
};

module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};

现在,您可以转到 HTTP 客户端,再次发送 POST 请求,您应该会收到一个以JSON格式返回的新创建的锻炼信息。

如果您再次尝试添加相同的锻炼,您仍会收到 201 状态代码,但不会收到新插入的锻炼。

这意味着我们的 database 方法暂时取消了插入,并且只返回任何内容。这是因为我们的数据库方法暂时取消了插入操作,并且只返回了现有内容。这是由于我们的if语句用于检查是否已经存在同名的锻炼。接下来,我们将在下一个最佳实践中处理这种情况。

现在,请向localhost:3000/api/v1/workouts发送GET请求以读取所有锻炼信息。我选择使用浏览器进行此操作。您应该会看到我们的锻炼已成功插入并持久存在。

您可以自己实现其他方法,也可以直接复制。

首先,锻炼控制器(您可以复制整个内容):

// In src/controllers/workoutController.js
const workoutService = require("../services/workoutService");

const getAllWorkouts = (req, res) => {
const allWorkouts = workoutService.getAllWorkouts();
res.send({ status: "OK", data: allWorkouts });
};

const getOneWorkout = (req, res) => {
const {
params: { workoutId },
} = req;
if (!workoutId) {
return;
}
const workout = workoutService.getOneWorkout(workoutId);
res.send({ status: "OK", data: workout });
};

const createNewWorkout = (req, res) => {
const { body } = req;
if (
!body.name ||
!body.mode ||
!body.equipment ||
!body.exercises ||
!body.trainerTips
) {
return;
}
const newWorkout = {
name: body.name,
mode: body.mode,
equipment: body.equipment,
exercises: body.exercises,
trainerTips: body.trainerTips,
};
const createdWorkout = workoutService.createNewWorkout(newWorkout);
res.status(201).send({ status: "OK", data: createdWorkout });
};

const updateOneWorkout = (req, res) => {
const {
body,
params: { workoutId },
} = req;
if (!workoutId) {
return;
}
const updatedWorkout = workoutService.updateOneWorkout(workoutId, body);
res.send({ status: "OK", data: updatedWorkout });
};

const deleteOneWorkout = (req, res) => {
const {
params: { workoutId },
} = req;
if (!workoutId) {
return;
}
workoutService.deleteOneWorkout(workoutId);
res.status(204).send({ status: "OK" });
};

module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};

然后,锻炼服务(您可以复制整个内容):

// In src/services/workoutServices.js
const { v4: uuid } = require("uuid");
const Workout = require("../database/Workout");

const getAllWorkouts = () => {
const allWorkouts = Workout.getAllWorkouts();
return allWorkouts;
};

const getOneWorkout = (workoutId) => {
const workout = Workout.getOneWorkout(workoutId);
return workout;
};

const createNewWorkout = (newWorkout) => {
const workoutToInsert = {
...newWorkout,
id: uuid(),
createdAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
};
const createdWorkout = Workout.createNewWorkout(workoutToInsert);
return createdWorkout;
};

const updateOneWorkout = (workoutId, changes) => {
const updatedWorkout = Workout.updateOneWorkout(workoutId, changes);
return updatedWorkout;
};

const deleteOneWorkout = (workoutId) => {
Workout.deleteOneWorkout(workoutId);
};

module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};

最后是我们在数据访问层中的数据库方法(你可以复制整个内容):

// In src/database/Workout.js
const DB = require("./db.json");
const { saveToDatabase } = require("./utils");

const getAllWorkouts = () => {
return DB.workouts;
};

const getOneWorkout = (workoutId) => {
const workout = DB.workouts.find((workout) => workout.id === workoutId);
if (!workout) {
return;
}
return workout;
};

const createNewWorkout = (newWorkout) => {
const isAlreadyAdded =
DB.workouts.findIndex((workout) => workout.name === newWorkout.name) > -1;
if (isAlreadyAdded) {
return;
}
DB.workouts.push(newWorkout);
saveToDatabase(DB);
return newWorkout;
};

const updateOneWorkout = (workoutId, changes) => {
const indexForUpdate = DB.workouts.findIndex(
(workout) => workout.id === workoutId
);
if (indexForUpdate === -1) {
return;
}
const updatedWorkout = {
...DB.workouts[indexForUpdate],
...changes,
updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
};
DB.workouts[indexForUpdate] = updatedWorkout;
saveToDatabase(DB);
return updatedWorkout;
};

const deleteOneWorkout = (workoutId) => {
const indexForDeletion = DB.workouts.findIndex(
(workout) => workout.id === workoutId
);
if (indexForDeletion === -1) {
return;
}
DB.workouts.splice(indexForDeletion, 1);
saveToDatabase(DB);
};

module.exports = {
getAllWorkouts,
createNewWorkout,
getOneWorkout,
updateOneWorkout,
deleteOneWorkout,
};

让我们继续下一个最佳实践,看看我们如何正确处理错误。

使用标准 HTTP 错误代码进行响应

我们已经取得了很大的进展,但我们的任务还没有完成。我们的 API 已经能够处理基本的 CRUD 操作,这很好,但还有改进的空间。

为什么?让我解释一下。

在一个完美的世界里,一切都很顺利,没有任何错误。然而,在现实世界中,无论是人为因素还是技术问题,都可能导致错误发生。

当一切顺利时,你可能会感到奇怪。这确实很棒,也很有趣,但作为开发人员,我们更习惯于面对问题和错误。😁

我们的 API 也是如此。我们应该处理某些可能出错或引发错误的情况。这也将强化我们的 API。

当出现问题时(来自请求或我们的 API 内部),我们会发回 HTTP 错误代码。我见过并使用过 API,当请求有问题时,它们总是返回 400 错误代码,而没有任何关于此错误发生原因或错误是什么的具体消息。这使得调试变得非常困难。

因此,针对不同情况返回正确的 HTTP 错误代码始终是一个好习惯。这有助于使用者或构建 API 的工程师更轻松地识别问题。

为了改善用户体验,我们还可以将简短的错误消息与错误响应一起发送。然而,正如我在引言中提到的那样,这并不总是明智的做法,工程师应该自己权衡利弊。

例如,返回类似 “The username is already signup” 的内容应该经过深思熟虑,因为提供有关用户的信息实际上可能涉及隐私问题。

在我们的 Crossfit API 中,我们将查看创建端点,看看可能会出现哪些错误以及我们如何处理它们。在本提示的末尾,您将再次找到其他终端节点的完整实现。

让我们开始看看锻炼控制器中的 createNewWorkout 方法:

// In src/controllers/workoutController.js
...

const createNewWorkout = (req, res) => {
const { body } = req;
if (
!body.name ||
!body.mode ||
!body.equipment ||
!body.exercises ||
!body.trainerTips
) {
return;
}
const newWorkout = {
name: body.name,
mode: body.mode,
equipment: body.equipment,
exercises: body.exercises,
trainerTips: body.trainerTips,
};
const createdWorkout = workoutService.createNewWorkout(newWorkout);
res.status(201).send({ status: "OK", data: createdWorkout });
};

...

我们已经捕获到请求正文未正确构建并丢失我们期望的键的情况。

这是一个很好的示例,可以发回 400 HTTP 错误并带有相应的错误消息。

// In src/controllers/workoutController.js
...

const createNewWorkout = (req, res) => {
const { body } = req;
if (
!body.name ||
!body.mode ||
!body.equipment ||
!body.exercises ||
!body.trainerTips
) {
res
.status(400)
.send({
status: "FAILED",
data: {
error:
"One of the following keys is missing or is empty in request body: 'name', 'mode', 'equipment', 'exercises', 'trainerTips'",
},
});
return;
}
const newWorkout = {
name: body.name,
mode: body.mode,
equipment: body.equipment,
exercises: body.exercises,
trainerTips: body.trainerTips,
};
const createdWorkout = workoutService.createNewWorkout(newWorkout);
res.status(201).send({ status: "OK", data: createdWorkout });
};

...

如果我们尝试添加新的锻炼,但忘记在请求正文中提供 “mode” 属性,我们应该会看到错误消息以及 400 HTTP 错误代码。

现在,使用 API 的开发人员可以更好地理解他们需要查找的内容。他们立刻明白要检查请求正文,看看是否遗漏了某个必需的属性。

现在,对于所有属性来说,返回通用的错误消息是可行的。通常,你会使用 schema 验证器来处理这个问题。

让我们更深入地了解我们的锻炼服务,看看可能发生哪些潜在错误。

// In src/services/workoutService.js
...

const createNewWorkout = (newWorkout) => {
const workoutToInsert = {
...newWorkout,
id: uuid(),
createdAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
};
const createdWorkout = Workout.createNewWorkout(workoutToInsert);
return createdWorkout;
};

...

可能出错的一件事是数据库插入 Workout.createNewWorkout()。我喜欢将这个东西包装在 try/catch 块中,以便在错误发生时捕获它。

// In src/services/workoutService.js
...

const createNewWorkout = (newWorkout) => {
const workoutToInsert = {
...newWorkout,
id: uuid(),
createdAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
};
try {
const createdWorkout = Workout.createNewWorkout(workoutToInsert);
return createdWorkout;
} catch (error) {
throw error;
}
};

...

在我们的 Workout.createNewWorkout() 方法中抛出的每个错误都会被捕获在我们的 catch 块中。我们只是将其扔回去,以便我们稍后可以在控制器中调整我们的响应。

让我们在 Workout.js 中定义我们的错误:

// In src/database/Workout.js
...

const createNewWorkout = (newWorkout) => {
const isAlreadyAdded =
DB.workouts.findIndex((workout) => workout.name === newWorkout.name) > -1;
if (isAlreadyAdded) {
throw {
status: 400,
message: `Workout with the name '${newWorkout.name}' already exists`,
};
}
try {
DB.workouts.push(newWorkout);
saveToDatabase(DB);
return newWorkout;
} catch (error) {
throw { status: 500, message: error?.message || error };
}
};

...

如您所见,错误由两部分组成:状态和消息。我在这里只使用 throw 关键字来发送与字符串不同的数据结构,这在 throw new Error() 中是必需的。

仅 throwing 的一个缺点使我们无法获得堆栈跟踪。但通常,此错误引发将由我们选择的第三方库(例如,如果您使用 MongoDB 数据库,则为 Mongoose)处理。但对于本教程的目的,这应该没问题。

现在,我们能够在服务和数据访问层中引发和捕获错误。接下来,我们将进入锻炼控制器,并捕获其中的错误,然后做出相应的响应。

// In src/controllers/workoutController.js
...

const createNewWorkout = (req, res) => {
const { body } = req;
if (
!body.name ||
!body.mode ||
!body.equipment ||
!body.exercises ||
!body.trainerTips
) {
res
.status(400)
.send({
status: "FAILED",
data: {
error:
"One of the following keys is missing or is empty in request body: 'name', 'mode', 'equipment', 'exercises', 'trainerTips'",
},
});
return;
}
const newWorkout = {
name: body.name,
mode: body.mode,
equipment: body.equipment,
exercises: body.exercises,
trainerTips: body.trainerTips,
};
// *** ADD ***
try {
const createdWorkout = workoutService.createNewWorkout(newWorkout);
res.status(201).send({ status: "OK", data: createdWorkout });
} catch (error) {
res
.status(error?.status || 500)
.send({ status: "FAILED", data: { error: error?.message || error } });
}
};

...

您可以通过添加两次具有相同名称的锻炼或不在请求正文中提供 required 属性来测试内容。您应该会收到相应的 HTTP 错误代码以及错误消息。

要总结此内容并转到下一个提示,您可以将其他实现的方法复制到以下文件中,也可以自行尝试:

// In src/controllers/workoutController.js
const workoutService = require("../services/workoutService");

const getAllWorkouts = (req, res) => {
try {
const allWorkouts = workoutService.getAllWorkouts();
res.send({ status: "OK", data: allWorkouts });
} catch (error) {
res
.status(error?.status || 500)
.send({ status: "FAILED", data: { error: error?.message || error } });
}
};

const getOneWorkout = (req, res) => {
const {
params: { workoutId },
} = req;
if (!workoutId) {
res
.status(400)
.send({
status: "FAILED",
data: { error: "Parameter ':workoutId' can not be empty" },
});
}
try {
const workout = workoutService.getOneWorkout(workoutId);
res.send({ status: "OK", data: workout });
} catch (error) {
res
.status(error?.status || 500)
.send({ status: "FAILED", data: { error: error?.message || error } });
}
};

const createNewWorkout = (req, res) => {
const { body } = req;
if (
!body.name ||
!body.mode ||
!body.equipment ||
!body.exercises ||
!body.trainerTips
) {
res
.status(400)
.send({
status: "FAILED",
data: {
error:
"One of the following keys is missing or is empty in request body: 'name', 'mode', 'equipment', 'exercises', 'trainerTips'",
},
});
return;
}
const newWorkout = {
name: body.name,
mode: body.mode,
equipment: body.equipment,
exercises: body.exercises,
trainerTips: body.trainerTips,
};
try {
const createdWorkout = workoutService.createNewWorkout(newWorkout);
res.status(201).send({ status: "OK", data: createdWorkout });
} catch (error) {
res
.status(error?.status || 500)
.send({ status: "FAILED", data: { error: error?.message || error } });
}
};

const updateOneWorkout = (req, res) => {
const {
body,
params: { workoutId },
} = req;
if (!workoutId) {
res
.status(400)
.send({
status: "FAILED",
data: { error: "Parameter ':workoutId' can not be empty" },
});
}
try {
const updatedWorkout = workoutService.updateOneWorkout(workoutId, body);
res.send({ status: "OK", data: updatedWorkout });
} catch (error) {
res
.status(error?.status || 500)
.send({ status: "FAILED", data: { error: error?.message || error } });
}
};

const deleteOneWorkout = (req, res) => {
const {
params: { workoutId },
} = req;
if (!workoutId) {
res
.status(400)
.send({
status: "FAILED",
data: { error: "Parameter ':workoutId' can not be empty" },
});
}
try {
workoutService.deleteOneWorkout(workoutId);
res.status(204).send({ status: "OK" });
} catch (error) {
res
.status(error?.status || 500)
.send({ status: "FAILED", data: { error: error?.message || error } });
}
};

module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
getRecordsForWorkout,
};
// In 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 getOneWorkout = (workoutId) => {
try {
const workout = Workout.getOneWorkout(workoutId);
return workout;
} catch (error) {
throw error;
}
};

const createNewWorkout = (newWorkout) => {
const workoutToInsert = {
...newWorkout,
id: uuid(),
createdAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
};
try {
const createdWorkout = Workout.createNewWorkout(workoutToInsert);
return createdWorkout;
} catch (error) {
throw error;
}
};

const updateOneWorkout = (workoutId, changes) => {
try {
const updatedWorkout = Workout.updateOneWorkout(workoutId, changes);
return updatedWorkout;
} catch (error) {
throw error;
}
};

const deleteOneWorkout = (workoutId) => {
try {
Workout.deleteOneWorkout(workoutId);
} catch (error) {
throw error;
}
};

module.exports = {
getAllWorkouts,
getOneWorkout,
createNewWorkout,
updateOneWorkout,
deleteOneWorkout,
};
// In 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 };
}
};

const getOneWorkout = (workoutId) => {
try {
const workout = DB.workouts.find((workout) => workout.id === workoutId);
if (!workout) {
throw {
status: 400,
message: `Can't find workout with the id '${workoutId}'`,
};
}
return workout;
} catch (error) {
throw { status: error?.status || 500, message: error?.message || error };
}
};

const createNewWorkout = (newWorkout) => {
try {
const isAlreadyAdded =
DB.workouts.findIndex((workout) => workout.name === newWorkout.name) > -1;
if (isAlreadyAdded) {
throw {
status: 400,
message: `Workout with the name '${newWorkout.name}' already exists`,
};
}
DB.workouts.push(newWorkout);
saveToDatabase(DB);
return newWorkout;
} catch (error) {
throw { status: error?.status || 500, message: error?.message || error };
}
};

const updateOneWorkout = (workoutId, changes) => {
try {
const isAlreadyAdded =
DB.workouts.findIndex((workout) => workout.name === changes.name) > -1;
if (isAlreadyAdded) {
throw {
status: 400,
message: `Workout with the name '${changes.name}' already exists`,
};
}
const indexForUpdate = DB.workouts.findIndex(
(workout) => workout.id === workoutId
);
if (indexForUpdate === -1) {
throw {
status: 400,
message: `Can't find workout with the id '${workoutId}'`,
};
}
const updatedWorkout = {
...DB.workouts[indexForUpdate],
...changes,
updatedAt: new Date().toLocaleString("en-US", { timeZone: "UTC" }),
};
DB.workouts[indexForUpdate] = updatedWorkout;
saveToDatabase(DB);
return updatedWorkout;
} catch (error) {
throw { status: error?.status || 500, message: error?.message || error };
}
};

const deleteOneWorkout = (workoutId) => {
try {
const indexForDeletion = DB.workouts.findIndex(
(workout) => workout.id === workoutId
);
if (indexForDeletion === -1) {
throw {
status: 400,
message: `Can't find workout with the id '${workoutId}'`,
};
}
DB.workouts.splice(indexForDeletion, 1);
saveToDatabase(DB);
} catch (error) {
throw { status: error?.status || 500, message: error?.message || error };
}
};

module.exports = {
getAllWorkouts,
createNewWorkout,
getOneWorkout,
updateOneWorkout,
deleteOneWorkout,
};

避免在终端节点名称中使用动词

在端点内使用动词没有多大意义,事实上,它毫无用处。通常,每个 URL 都应该指向一个资源(记住上面的 box 示例)。

在 URL 中使用动词表示资源本身不能具有的特定行为。

我们已经成功地实现了端点,而没有在 URL 中使用动词。现在让我们看看如果我们使用动词,URL 会是什么样子。

// Current implementations (without verbs)
GET "/api/v1/workouts"
GET "/api/v1/workouts/:workoutId"
POST "/api/v1/workouts"
PATCH "/api/v1/workouts/:workoutId"
DELETE "/api/v1/workouts/:workoutId"

// Implementation using verbs
GET "/api/v1/getAllWorkouts"
GET "/api/v1/getWorkoutById/:workoutId"
CREATE "/api/v1/createWorkout"
PATCH "/api/v1/updateWorkout/:workoutId"
DELETE "/api/v1/deleteWorkout/:workoutId"

您是否注意到了区别?为每种行为提供完全不同的 URL 可能会很快变得令人困惑和不必要的复杂。

假设我们有 300 个不同的终端节点。为每个 URL 使用单独的 URL 可能是一个开销(和文档)地狱。

我想指出的另一个原因是,在 URL 中不使用动词的原因是 HTTP 动词本身已经表示了操作。

像 “GET /api/v1/getAllWorkouts” 或 “DELETE api/v1/deleteWorkout/workoutId” 这样的内容是不必要的。

当你看一下我们当前的实现时,它变得更加清晰,因为我们只使用了两个不同的 URL,并且实际行为是通过 HTTP 动词和相应的请求有效负载来处理的。

我总是想象 HTTP 动词描述操作(我们想要做什么),而 URL 本身(指向资源)是目标。“GET /api/v1/workouts” 的人类语言也更流利。

将关联的资源分组在一起(逻辑嵌套)

在设计 API 时,可能会出现您拥有与其他资源关联的资源的情况。最好将它们分组到一个终端节点中并正确嵌套它们。

让我们考虑一下,在我们的 API 中,我们还有一个在 CrossFit 框中注册的会员列表(“box”是 CrossFit 健身房的名称)。为了激励我们的会员,我们会跟踪每次锻炼的总体记录。

例如,有一项锻炼,要求会员尽快按照一定顺序完成一系列动作。我们记录所有成员的时间,以列出每个完成此锻炼的成员的时间。

现在,前端需要一个终端节点,该节点能够响应特定锻炼的所有记录,以便在用户界面中显示这些数据。

锻炼、成员和记录存储在数据库中的不同位置。所以我们需要的是另一个盒子(锻炼)里面的盒子(记录),对吧?

该终端节点的 URI 将为 /api/v1/workouts/:workoutId/records。这是允许 URL 的逻辑嵌套的好做法。URL 本身不一定必须镜像数据库结构。

让我们开始实现该端点。

首先,将一个名为 “members” 的新表添加到您的db.json中。将其放在 “workouts” 下。

{
"workouts": [ ...
],
"members": [
{
"id": "12a410bc-849f-4e7e-bfc8-4ef283ee4b19",
"name": "Jason Miller",
"gender": "male",
"dateOfBirth": "23/04/1990",
"email": "jason@mail.com",
"password": "666349420ec497c1dc890c45179d44fb13220239325172af02d1fb6635922956"
},
{
"id": "2b9130d4-47a7-4085-800e-0144f6a46059",
"name": "Tiffany Brookston",
"gender": "female",
"dateOfBirth": "09/06/1996",
"email": "tiffy@mail.com",
"password": "8a1ea5669b749354110dcba3fac5546c16e6d0f73a37f35a84f6b0d7b3c22fcc"
},
{
"id": "11817fb1-03a1-4b4a-8d27-854ac893cf41",
"name": "Catrin Stevenson",
"gender": "female",
"dateOfBirth": "17/08/2001",
"email": "catrin@mail.com",
"password": "18eb2d6c5373c94c6d5d707650d02c3c06f33fac557c9cfb8cb1ee625a649ff3"
},
{
"id": "6a89217b-7c28-4219-bd7f-af119c314159",
"name": "Greg Bronson",
"gender": "male",
"dateOfBirth": "08/04/1993",
"email": "greg@mail.com",
"password": "a6dcde7eceb689142f21a1e30b5fdb868ec4cd25d5537d67ac7e8c7816b0e862"
}
]
}

在您开始询问之前 – 是的,密码已经过哈希处理。😉

之后,在 “members” 下添加一些 “records”。

{
"workouts": [ ...
],
"members": [ ...
],
"records": [
{
"id": "ad75d475-ac57-44f4-a02a-8f6def58ff56",
"workout": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
"record": "160 reps"
},
{
"id": "0bff586f-2017-4526-9e52-fe3ea46d55ab",
"workout": "d8be2362-7b68-4ea4-a1f6-03f8bc4eede7",
"record": "7:23 minutes"
},
{
"id": "365cc0bb-ba8f-41d3-bf82-83d041d38b82",
"workout": "a24d2618-01d1-4682-9288-8de1343e53c7",
"record": "358 reps"
},
{
"id": "62251cfe-fdb6-4fa6-9a2d-c21be93ac78d",
"workout": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
"record": "145 reps"
}
],
}

为了确保您获得与我使用相同的 ID 相同的锻炼,请同时复制锻炼:

{
"workouts": [
{
"id": "61dbae02-c147-4e28-863c-db7bd402b2d6",
"name": "Tommy V",
"mode": "For Time",
"equipment": [
"barbell",
"rope"
],
"exercises": [
"21 thrusters",
"12 rope climbs, 15 ft",
"15 thrusters",
"9 rope climbs, 15 ft",
"9 thrusters",
"6 rope climbs, 15 ft"
],
"createdAt": "4/20/2022, 2:21:56 PM",
"updatedAt": "4/20/2022, 2:21:56 PM",
"trainerTips": [
"Split the 21 thrusters as needed",
"Try to do the 9 and 6 thrusters unbroken",
"RX Weights: 115lb/75lb"
]
},
{
"id": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
"name": "Dead Push-Ups",
"mode": "AMRAP 10",
"equipment": [
"barbell"
],
"exercises": [
"15 deadlifts",
"15 hand-release push-ups"
],
"createdAt": "1/25/2022, 1:15:44 PM",
"updatedAt": "3/10/2022, 8:21:56 AM",
"trainerTips": [
"Deadlifts are meant to be light and fast",
"Try to aim for unbroken sets",
"RX Weights: 135lb/95lb"
]
},
{
"id": "d8be2362-7b68-4ea4-a1f6-03f8bc4eede7",
"name": "Heavy DT",
"mode": "5 Rounds For Time",
"equipment": [
"barbell",
"rope"
],
"exercises": [
"12 deadlifts",
"9 hang power cleans",
"6 push jerks"
],
"createdAt": "11/20/2021, 5:39:07 PM",
"updatedAt": "4/22/2022, 5:49:18 PM",
"trainerTips": [
"Aim for unbroken push jerks",
"The first three rounds might feel terrible, but stick to it",
"RX Weights: 205lb/145lb"
]
},
{
"name": "Core Buster",
"mode": "AMRAP 20",
"equipment": [
"rack",
"barbell",
"abmat"
],
"exercises": [
"15 toes to bars",
"10 thrusters",
"30 abmat sit-ups"
],
"trainerTips": [
"Split your toes to bars in two sets maximum",
"Go unbroken on the thrusters",
"Take the abmat sit-ups as a chance to normalize your breath"
],
"id": "a24d2618-01d1-4682-9288-8de1343e53c7",
"createdAt": "4/22/2022, 5:50:17 PM",
"updatedAt": "4/22/2022, 5:50:17 PM"
}
],
"members": [ ...
],
"records": [ ...
]
}

好的,让我们花几分钟时间考虑一下我们的实现。

我们一边是名为 “workouts” 的资源,另一边是 “records” 的资源。

为了继续完善我们的架构,建议新增一个控制器、一个服务,以及一套负责处理记录的数据库方法。

考虑到未来可能需要添加、更新或删除记录,实现记录的 CRUD 端点也是很有可能必要的,但这还不是目前的主要任务。

此外,我们还需要创建一个记录路由器来捕获针对记录的特定请求,尽管现在可能还用不到它。这可以是一个利用自定义路由实现记录的 CRUD 操作并进行一些实践的好机会。

# Create records controller 
touch src/controllers/recordController.js

# Create records service
touch src/services/recordService.js

# Create records database methods
touch src/database/Record.js

这很容易。让我们继续前进,从实现我们的数据库方法开始。

// In src/database/Record.js
const DB = require("./db.json");

const getRecordForWorkout = (workoutId) => {
try {
const record = DB.records.filter((record) => record.workout === workoutId);
if (!record) {
throw {
status: 400,
message: `Can't find workout with the id '${workoutId}'`,
};
}
return record;
} catch (error) {
throw { status: error?.status || 500, message: error?.message || error };
}
};
module.exports = { getRecordForWorkout };

很简单,对吧?我们从 query 参数中筛选出与锻炼 ID 相关的所有记录。

下一个是我们的记录服务:

// In src/services/recordService.js
const Record = require("../database/Record");

const getRecordForWorkout = (workoutId) => {
try {
const record = Record.getRecordForWorkout(workoutId);
return record;
} catch (error) {
throw error;
}
};
module.exports = { getRecordForWorkout };

现在,我们可以在锻炼路由器中创建新路线,并将请求定向到我们的记录服务。

// In src/v1/routes/workoutRoutes.js
const express = require("express");
const workoutController = require("../../controllers/workoutController");
// *** ADD ***
const recordController = require("../../controllers/recordController");

const router = express.Router();

router.get("/", workoutController.getAllWorkouts);

router.get("/:workoutId", workoutController.getOneWorkout);

// *** ADD ***
router.get("/:workoutId/records", recordController.getRecordForWorkout);

router.post("/", workoutController.createNewWorkout);

router.patch("/:workoutId", workoutController.updateOneWorkout);

router.delete("/:workoutId", workoutController.deleteOneWorkout);

module.exports = router;

让我们在浏览器中测试一下。

首先,我们获取所有锻炼以获取锻炼 ID。

让我们看看是否可以获取该记录的所有记录:

如您所见,当您拥有可以组合在一起的资源时,逻辑嵌套是有意义的。理论上,您可以根据需要将其嵌套任意深度,但根据经验,你最多可以嵌套三层。

如果您希望进一步嵌套,可以在数据库记录中进行一些调整。让我给您展示一个简单的例子。

想象一下,前端还需要一个终端节点来获取有关哪个成员确切持有当前记录并希望接收有关它们的元数据的信息。

当然,我们可以实现以下 URI:

GET /api/v1/workouts/:workoutId/records/members/:memberId

现在,我们向终端节点添加的嵌套越多,它就越难管理。因此,最好将 URI 存储起来,以便将有关成员的信息直接接收到记录中。

请考虑数据库中的以下内容:

{
"workouts": [ ...
],
"members": [ ...
],
"records": [ ... {
"id": "ad75d475-ac57-44f4-a02a-8f6def58ff56",
"workout": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
"record": "160 reps",
"memberId": "11817fb1-03a1-4b4a-8d27-854ac893cf41",
"member": "/members/:memberId"
},
]
}

如您所见,我们已经在数据库中的记录里添加了两个属性:“memberId”和“member”。这样做有一个显著的优势:我们无需进一步嵌套现有的终端节点。

前端只需要调用 GET /api/v1/workouts/:workoutId/records 即可自动接收与该 Workout 相关的所有记录。

最重要的是,它获取成员 ID 和终端节点以获取有关该成员的信息。因此,我们避免了端点的更深嵌套。

当然,这只有在我们可以处理对 “/members/:memberId” 的请求时才有效。😁这听起来像是你实施这种情况的一个很好的培训机会!

集成过滤、排序和分页

目前,我们的API已经能够执行许多操作,这无疑是一个巨大的进步。然而,我们还有更多工作要做。

在最后几节中,我们专注于改善开发人员体验以及如何与我们的 API 进行交互。但是,我们 API 的整体性能是我们应该努力的另一个关键因素。

因此,集成过滤、排序和分页功能也是我计划中的一个重要部分。

想象一下,我们的数据库中存储了 2,000 次锻炼、450 条记录和 500 个成员。在调用我们的终端节点以获取所有锻炼时,我们不希望一次发送所有 2,000 个锻炼。当然,这将是一个非常缓慢的响应,或者它会使我们的系统宕机(可能有 200,000 😁 )。

过滤和分页在我们的API中非常重要。过滤允许我们从整个集合中获取特定数据,例如,所有具有 “For Time” 模式的锻炼。

而分页是另一种将我们的整个锻炼集合拆分为多个“页面”的机制,例如,每个页面仅包含 20 个锻炼。这种技术可以帮助我们确保我们不会在回复客户时同时发送超过 20 个锻炼。

排序也是一个复杂的任务,但在API中执行此操作并将排序后的数据发送到客户端会更加高效。

让我们从集成一些过滤机制开始,通过接受filter参数来升级我们的API端点。通常,在 GET 请求中,我们将筛选条件添加为查询参数。

当我们只想获取处于“AMRAP”模式的锻炼时,我们的新 URI 将如下所示 (AMany Rounds APossible):/api/v1/workouts?mode=amrap。

为了让这一切更有趣,我们需要增加一些锻炼。将这些锻炼粘贴到 db.json 中的“workouts”集合中:

{
"name": "Jumping (Not) Made Easy",
"mode": "AMRAP 12",
"equipment": [
"jump rope"
],
"exercises": [
"10 burpees",
"25 double-unders"
],
"trainerTips": [
"Scale to do 50 single-unders, if double-unders are too difficult"
],
"id": "8f8318f8-b869-4e9d-bb78-88010193563a",
"createdAt": "4/25/2022, 2:45:28 PM",
"updatedAt": "4/25/2022, 2:45:28 PM"
},
{
"name": "Burpee Meters",
"mode": "3 Rounds For Time",
"equipment": [
"Row Erg"
],
"exercises": [
"Row 500 meters",
"21 burpees",
"Run 400 meters",
"Rest 3 minutes"
],
"trainerTips": [
"Go hard",
"Note your time after the first run",
"Try to hold your pace"
],
"id": "0a5948af-5185-4266-8c4b-818889657e9d",
"createdAt": "4/25/2022, 2:48:53 PM",
"updatedAt": "4/25/2022, 2:48:53 PM"
},
{
"name": "Dumbbell Rower",
"mode": "AMRAP 15",
"equipment": [
"Dumbbell"
],
"exercises": [
"15 dumbbell rows, left arm",
"15 dumbbell rows, right arm",
"50-ft handstand walk"
],
"trainerTips": [
"RX weights for women: 35-lb",
"RX weights for men: 50-lb"
],
"id": "3dc53bc8-27b8-4773-b85d-89f0a354d437",
"createdAt": "4/25/2022, 2:56:03 PM",
"updatedAt": "4/25/2022, 2:56:03 PM"
}

之后,我们必须接受并处理查询参数。我们的锻炼控制器将是正确的起点:

// In src/controllers/workoutController.js
...

const getAllWorkouts = (req, res) => {
// *** ADD ***
const { mode } = req.query;
try {
// *** ADD ***
const allWorkouts = workoutService.getAllWorkouts({ mode });
res.send({ status: "OK", data: allWorkouts });
} catch (error) {
res
.status(error?.status || 500)
.send({ status: "FAILED", data: { error: error?.message || error } });
}
};

...

我们从 req.query 对象中提取 “mode” 并定义 workoutService.getAllWorkouts 的参数。这将是一个包含我们的 filter 参数的对象。

我在这里使用速记语法,在对象内创建一个名为 “mode” 的新键,其值为 “req.query.mode” 中的任何内容。这可能是 truey 值,如果没有名为 “mode” 的查询参数,则为 undefined。我们想要接受的 filter 参数越多,就可以扩展这个对象。

在我们的 workout 服务中,将其传递给您的数据库方法:

// In src/services/workoutService.js
...

const getAllWorkouts = (filterParams) => {
try {
// *** ADD ***
const allWorkouts = Workout.getAllWorkouts(filterParams);
return allWorkouts;
} catch (error) {
throw error;
}
};

...

现在我们可以在数据库方法中使用它并应用过滤:

// In src/database/Workout.js
...

const getAllWorkouts = (filterParams) => {
try {
let workouts = DB.workouts;
if (filterParams.mode) {
return DB.workouts.filter((workout) =>
workout.mode.toLowerCase().includes(filterParams.mode)
);
}
// Other if-statements will go here for different parameters
return workouts;
} catch (error) {
throw { status: 500, message: error };
}
};

...

很简单,对吧?我们在这里所做的只是检查我们的 “filterParams” 中的键 “mode” 是否真的有一个真值。如果这是真的,我们就会过滤所有具有相同 “mode” 的锻炼。如果不是这样,则没有名为 “mode” 的查询参数,我们返回所有锻炼,因为我们不需要过滤。

我们在这里将 “workouts” 定义为 “let” 变量,因为当为不同的过滤器添加更多 if 语句时,我们可以覆盖 “workouts” 并链接过滤器。

在浏览器中,您可以访问 localhost:3000/api/v1/workouts?mode=amrap,您将收到存储的所有 “AMRAP” 锻炼:

如果省略 query 参数,则应像以前一样获取所有锻炼。您可以通过添加“for%20time”作为“mode”参数的值来进一步尝试(记住 –>“%20”表示“空白”),您应该会收到所有具有“For Time”模式的锻炼(如果有存储)。

键入未存储的值时,您应该会收到一个空数组。

排序和分页的参数遵循相同的原理。让我们看看我们可以实现的一些功能:

  • 接收所有需要杠铃的锻炼:/api/v1/workouts?equipment=barbell
  • 仅获取 5 次锻炼:/api/v1/workouts?length=5
  • 使用分页时,收到第二个页面:/api/v1/workouts?page=2
  • 按创建日期对响应中的锻炼进行降序排序:/api/v1/workouts?sort=-createdAt
  • 您还可以组合参数,以获取最近 10 次更新的锻炼,例如:/api/v1/workouts?sort=-updatedAt&length=10

使用数据缓存提高性能

使用数据缓存也是改善 API 整体体验和性能的好方法。

当数据是经常请求的资源时,使用缓存提供数据非常有意义,或者/或从数据库中查询该数据是一项繁重的工作,可能需要几秒钟。

您可以将此类数据存储在缓存中,并从那里提供数据,而不是每次都访问数据库查询数据。

在使用缓存提供数据时必须记住的一件重要事情是,这些数据可能会过时。因此,您必须确保缓存中的数据始终是最新的。

市面上有许多不同的解决方案。一个合适的示例是使用 redis 或快速中间件 apicache。

我想使用 apicache,但如果你想使用 Redis,我强烈建议你查看他们的优秀文档。

让我们考虑一下 API 中缓存有意义的场景。我认为请求接收所有锻炼将有效地从我们的缓存中提供。

首先,让我们安装我们的中间件:

npm i apicache

现在,我们必须将其导入我们的锻炼路由器并对其进行配置。

// In src/v1/routes/workoutRoutes.js
const express = require("express");
// *** ADD ***
const apicache = require("apicache");
const workoutController = require("../../controllers/workoutController");
const recordController = require("../../controllers/recordController");

const router = express.Router();
// *** ADD ***
const cache = apicache.middleware;

// *** ADD ***
router.get("/", cache("2 minutes"), workoutController.getAllWorkouts);

router.get("/:workoutId", workoutController.getOneWorkout);

router.get("/:workoutId/records", recordController.getRecordForWorkout);

router.post("/", workoutController.createNewWorkout);

router.patch("/:workoutId", workoutController.updateOneWorkout);

router.delete("/:workoutId", workoutController.deleteOneWorkout);

module.exports = router;

入门非常简单,对吧?我们可以通过调用 apicache.middleware 来定义一个新的缓存,并将其用作 get 路由中的中间件。您只需将其作为实际路径和我们的锻炼控制器之间的参数。

在那里,您可以定义数据应缓存多长时间。在本教程中,我选择了两分钟。该时间取决于缓存中数据更改的速度或频率。

现在,让我们进行测试!

Postman 或您选择的其他 HTTP 客户端中,定义一个获取所有锻炼的新请求。到目前为止,我一直在浏览器中完成此操作,但我想更好地为您可视化响应时间。这就是我现在通过 Postman 请求资源的原因。

让我们第一次调用我们的请求:

如您所见,我们的API响应时间为22.93毫秒。一旦缓存在两分钟后失效并需要重新填充时,第一个请求就会受到影响。

因此,在上面的例子中,数据不是从缓存中提供的,而是通过“常规”方式直接从数据库获取并填满了缓存。

对于第二个请求,我们收到的响应时间更短,因为它是直接从缓存中提供的。

我们的服务速度比之前的要求快了三倍!这一切都归功于我们的缓存。

在我们的示例中,我们只缓存了一个路由,但你也可以通过像这样实现它来缓存所有路由:

// In src/index.js
const express = require("express");
const bodyParser = require("body-parser");
// *** ADD ***
const apicache = require("apicache");
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");

const app = express();
// *** ADD ***
const cache = apicache.middleware;
const PORT = process.env.PORT || 3000;

app.use(bodyParser.json());
// *** ADD ***
app.use(cache("2 minutes"));
app.use("/api/v1/workouts", v1WorkoutRouter);

app.listen(PORT, () => {
console.log(`API is listening on port ${PORT}`);
});

当涉及到缓存时,我想强调一件重要的事情。尽管缓存似乎能解决很多问题,但它也可能给您的应用程序带来一些困扰。

使用缓存时必须注意以下几点:

  • 您始终必须确保缓存中的数据是最新的,因为您不想提供过时的数据
  • 当第一个请求正在处理中,缓存即将被填满并且有更多请求进入时,您必须决定是否延迟这些其他请求并从缓存中提供数据,或者它们是否也像第一个请求一样直接从数据库接收数据
  • 如果您选择像 Redis 这样的分布式缓存,它是基础设施中的另一个组件(因此您必须问自己使用它是否真的有意义)

以下是通常的做法:

我喜欢从我构建的所有内容尽可能简单和干净开始。API 也是如此。

当我开始构建 API 并且没有特别的理由立即使用缓存时,我会将其省略,看看随着时间的推移会发生什么。当有理由使用缓存时,我可以在那时实现它。

良好的安全实践

我们已经经历了一段非常充实的旅程,探讨了许多关键要点并相应地扩展了我们的API。

我们已经讨论了提高 API 的可用性和性能的最佳实践。安全性也是 API 的一个关键因素。您可以构建最好的 API,如果它在服务器上运行时容易受到攻击,那么它不仅会变得无用,还会带来安全风险。

使用SSL/TLS是绝对必要的,因为它已成为当今互联网通信的标准。对于API来说,保护客户端和API之间传输的私人数据尤为重要。

如果您的资源应该只对经过身份验证的用户可用,则应使用身份验证检查来保护它们。

例如,在 Express 中,您可以将其实现为中间件,就像我们对特定路由的缓存所做的那样,并在请求访问资源之前首先检查请求是否经过身份验证。

此外,可能存在一些API资源或交互,我们不希望每个用户都进行请求。在这种情况下,您应该为用户设计一个角色系统。因此,您必须向该路由添加额外的检查逻辑,并验证用户是否具有访问此资源的权限。

在我们的用例中,当我们只希望特定用户(如教练)创建、更新和删除我们的锻炼和记录时,用户角色也很有意义。阅读可以适合所有人(也包括“普通 ”成员)。

这可以在我们用于要保护的路由的另一个 middleware 中处理。例如,我们向 /api/v1/workouts 发送 POST 请求以创建新的锻炼。

在第一个 middleware 中,我们将检查用户是否经过身份验证。如果这是真的,我们将转到下一个 middleware,这将是检查用户角色的 middleware。如果用户具有访问此资源的适当角色,则请求将传递给相应的控制器。

在路由处理程序中,它看起来像这样:

// In src/v1/routes/workoutRoutes.js
...

// Custom made middlewares
const authenticate = require("../../middlewares/authenticate");
const authorize = require("../../middlewares/authorize");

router.post("/", authenticate, authorize, workoutController.createNewWorkout);

...

要进一步阅读并获得有关该主题的更多最佳实践,我建议您阅读本文。

正确记录您的 API

我知道文档绝对不是开发人员最喜欢的任务,但这是必须做的事情。尤其是在 API 方面

我认为这种说法有很多道理,因为如果 API 没有得到很好的记录,它就无法正确使用,因此变得毫无用处。该文档也有助于使开发人员的工作更轻松。

请始终记住,文档通常是使用者与您的 API 的第一次交互。用户理解文档的速度越快,他们使用 API 的速度就越快。

因此,我们的任务是实施良好且精确的文档。幸运的是,有一些出色的工具可以让我们的生活变得更加轻松。

与计算机科学的其他领域一样,也有某种用于记录 API 的标准,称为 OpenAPI 规范。

让我们看看如何创建一些文档来证明该规范的合理性。我们将使用 swagger-ui-express 和 swagger-jsdoc 包来完成此操作。您会在一秒钟内惊讶于这是多么棒!

首先,我们为文档设置裸体结构。因为我们计划使用不同版本的 API,所以文档也会有所不同。这就是为什么我想定义我们的 swagger 文件以在相应的版本文件夹中启动我们的文档的原因。

# Install required npm packages 
npm i swagger-jsdoc swagger-ui-express

# Create a new file to setup the swagger docs
touch src/v1/swagger.js
// In src/v1/swagger.js
const swaggerJSDoc = require("swagger-jsdoc");
const swaggerUi = require("swagger-ui-express");

// Basic Meta Informations about our API
const options = {
definition: {
openapi: "3.0.0",
info: { title: "Crossfit WOD API", version: "1.0.0" },
},
apis: ["./src/v1/routes/workoutRoutes.js", "./src/database/Workout.js"],
};

// Docs in JSON format
const swaggerSpec = swaggerJSDoc(options);

// Function to setup our docs
const swaggerDocs = (app, port) => {
// Route-Handler to visit our docs
app.use("/api/v1/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// Make our docs in JSON format available
app.get("/api/v1/docs.json", (req, res) => {
res.setHeader("Content-Type", "application/json");
res.send(swaggerSpec);
});
console.log(
`Version 1 Docs are available on http://localhost:${port}/api/v1/docs`
);
};

module.exports = { swaggerDocs };

所以,设置非常简单。我们已经定义了 API 的一些基本元数据,以 JSON 格式创建了文档,并创建了一个使我们的文档可用的函数。

为了控制一切是否正常运行,我们将一条简单的消息记录到控制台,我们可以在其中找到我们的文档。

这将是我们将在根文件中使用的函数,我们在其中创建了 Express 服务器以确保文档也启动。

// In src/index.js
const express = require("express");
const bodyParser = require("body-parser");
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");
// *** ADD ***
const { swaggerDocs: V1SwaggerDocs } = require("./v1/swagger");

const app = express();
const PORT = process.env.PORT || 3000;

app.use(bodyParser.json());
app.use("/api/v1/workouts", v1WorkoutRouter);

app.listen(PORT, () => {
console.log(`API is listening on port ${PORT}`);
/// *** ADD ***
V1SwaggerDocs(app, PORT);
});

现在你应该看到你的终端内部运行你的开发服务器的地方:

当你访问 localhost:3000/api/v1/docs 时,你应该已经看到我们的文档页面:

我每次都惊讶于它的效果如此之好。现在,基本结构已设置完毕,我们可以开始为端点实现文档。我们开始吧!

当您查看 swagger.js 文件中的 options.apis 时,您会发现我们已经在数据库文件夹中包含了锻炼路线和锻炼文件的路径。这是设置中最重要的部分,它将使整个奇迹发生。

在我们的 swagger 选项中定义这些文件将允许我们使用引用 OpenAPI 的注释,并且具有类似于 yaml 文件的语法,这是设置我们的文档所必需的。

现在我们准备好为我们的第一个端点创建文档了!让我们直接进入它。

// In src/v1/routes/workoutRoutes.js
...

/**
* @openapi
* /api/v1/workouts:
* get:
* tags:
* - Workouts
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: OK
* data:
* type: array
* items:
* type: object
*/
router.get("/", cache("2 minutes"), workoutController.getAllWorkouts);

...

这基本上就是向我们的 swagger 文档添加端点的全部魔术。您可以在他们的优秀文档中查找所有规范来描述终端节点。

当您重新加载文档页面时,您应该会看到以下内容:

如果您已经使用过具有 OpenAPI 文档的 API,那么这应该看起来非常熟悉。这是将列出所有终端节点的视图,您可以扩展每个终端节点以获取有关它的更多信息。

当你仔细查看我们的响应时,你会发现我们没有定义正确的返回值,因为我们只是说我们的 “data” 属性将是一个空对象的数组。

这就是 Schema 发挥作用的地方。

// In src/databse/Workout.js
...

/**
* @openapi
* components:
* schemas:
* Workout:
* type: object
* properties:
* id:
* type: string
* example: 61dbae02-c147-4e28-863c-db7bd402b2d6
* name:
* type: string
* example: Tommy V
* mode:
* type: string
* example: For Time
* equipment:
* type: array
* items:
* type: string
* example: ["barbell", "rope"]
* exercises:
* type: array
* items:
* type: string
* example: ["21 thrusters", "12 rope climbs, 15 ft", "15 thrusters", "9 rope climbs, 15 ft", "9 thrusters", "6 rope climbs, 15 ft"]
* createdAt:
* type: string
* example: 4/20/2022, 2:21:56 PM
* updatedAt:
* type: string
* example: 4/20/2022, 2:21:56 PM
* trainerTips:
* type: array
* items:
* type: string
* example: ["Split the 21 thrusters as needed", "Try to do the 9 and 6 thrusters unbroken", "RX Weights: 115lb/75lb"]
*/

...

在上面的示例中,我们创建了第一个 schema。通常,此定义将位于定义数据库模型的架构或模型文件中。

如您所见,它也非常简单。我们已经定义了构成锻炼的所有属性,包括类型和示例。

您可以再次访问我们的文档页面,我们将收到另一个包含我们架构的部分。

现在可以在终端节点的响应中引用此架构。

// In src/v1/routes/workoutRoutes.js
...

/**
* @openapi
* /api/v1/workouts:
* get:
* tags:
* - Workouts
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: OK
* data:
* type: array
* items:
* $ref: "#/components/schemas/Workout"
*/
router.get("/", cache("2 minutes"), workoutController.getAllWorkouts);

...

仔细查看我们在 “items” 下的评论底部。我们使用 “$ref” 创建引用,并引用我们在锻炼文件中定义的架构的路径。

现在,我们可以在回复中显示完整的锻炼。

很酷,对吧?您可能会认为“手动输入这些评论可能是一项乏味的任务”。

这或许是真的,但请这样想。代码库中的那些注释对于作为 API 开发人员的您自己来说也是一个很棒的文档。当您想知道特定终端节点的文档时,您不必一直访问文档。您可以在源代码中的一个位置查找它。

记录终端节点还可以帮助您更好地理解它们,并 “迫使” 您考虑您可能忘记实现的任何内容。

正如你所看到的,我确实忘记了一些事情。可能的错误响应和查询参数仍然缺失!

没问题,以下是按照您的要求进行整理后的内容:

让我们来解决这个问题:

// In src/v1/routes/workoutRoutes.js
...

/**
* @openapi
* /api/v1/workouts:
* get:
* tags:
* - Workouts
* parameters:
* - in: query
* name: mode
* schema:
* type: string
* description: The mode of a workout
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: OK
* data:
* type: array
* items:
* $ref: "#/components/schemas/Workout"
* 5XX:
* description: FAILED
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FAILED
* data:
* type: object
* properties:
* error:
* type: string
* example: "Some error message"
*/
router.get("/", cache("2 minutes"), workoutController.getAllWorkouts);

...

当您查看 “tags” 下注释的顶部时,您可以看到我添加了另一个名为 “parameters” 的键,我在其中定义了用于筛选的查询参数。

我们的文档现在可以正确显示它:

为了记录可能的错误情况,我们此时只抛出一个 5XX 错误。因此,在 “responses” 下,您可以看到我还为此定义了另一个文档。

在我们的文档页面上,它看起来像这样:

我们刚刚为一个终端节点创建了完整的文档。我强烈建议您自己实现其余的终端节点,以便自己动手操作。在这个过程中,你会学到很多东西!

你可能已经看到,记录 API 有时会让人头疼。我认为我向您介绍的工具可以减少您的整体工作量,并且设置它们非常简单。

因此,我们可以专注于重要的事情,即文档本身。在我看来,swagger/OpenAPI 的文档非常好,互联网上有很多很好的例子。

因为太多的 “额外” 工作而没有文档不应该再成为理由。

结论

这真是一次有趣的旅程。我非常喜欢为您撰写这篇文章,也从中学到了很多。

可能有一些最佳实践很重要,而另一些最佳实践似乎不适用于您当前的情况。这没关系,因为正如我之前所说,每个工程师都有责任挑选出可应用于他们当前情况的最佳实践。

我尽我所能将迄今为止所做的所有最佳实践整合在一起,同时在此过程中构建我们自己的 API。这对我来说很有趣!

下次见!

原文链接:https://www.freecodecamp.org/news/rest-api-design-best-practices-build-a-rest-api/

#你可能也喜欢这些API文章!