原文:REST API Design Best Practices – How to Build a REST API with JavaScript, Node.js, and Express.js,作者:Jean-Marc Möckel

在过去几年我创建和使用过不少 API,期间我遇到过优秀的实践方式,也遭遇过极其不好的实践方式,但曙光总是存在。

网上有许多最佳实践相关的文章,但是它们大多数都缺乏实用性。通过少量示例来了解理论是一个好办法,但是我一直都在思考如何通过更实际的例子来展现 API 的应用。

简单的例子确实可以帮助概念的理解,也省去了复杂度。但实际情况往往并不简单,我确信你对此也深有体会。😁

这就是我决定写这个教程的原因。我将过去好的坏的学习经验都融入了到这边文章之中,并提供例子,使文章易读易懂。我们会通过一个又一个最佳实践来创建一个完整的 API。

开始之前的注意事项:

如你所想,最佳实践并不是必须遵从的具体规则。它们是人们逐渐总结出来的有效的惯例,确实有一些成为现在的标准,但这并不意味着你需要百分之一百的采用这些实践。

最佳实践指导如何使 API 更加符合用户(使用 API 的人和其他工程师)的使用习惯、更加安全和提高性能。

请记住不同的项目有不同的实践方法,肯定存在一些情况下你无法遵守这些规范,每一个工程师都应该自己决定使用什么方法。

话不多说,让我们开始吧!

目录

示例项目

alvaro-reyes-qWwpHwip31M-unsplash--1-

图片作者Alvaro Reyes,来自Unsplash

在正式开始在示例中应用最佳实践前,我先简单介绍一下我们要创建什么。

我们将为交叉训练应用(CrossFit Training Application)创建 REST API。交叉训练是一种健身方式,融合了竞技类运动和高强度训练,以及各种各样的运动元素(奥林匹克举重、体操等)。

这个应用可以创建、读取、更新和删除WODWorkout of the Day,即每日训练),帮助用户(健身馆主)指定和维护已有的健身计划。除此之外,还可以在一些重要的训练旁批注一些训练建议。

我们的工作就是设计和实现这个应用的 API。

前提条件

在学习这门教程之前,你必须使用过 JavaScript、Node.js、Express.js 以及后端架构,熟悉 REST 和 API 这类术语,并且了解主从式架构(客户端/服务器架构)

当然你不需要成为这些话题的专家,熟悉并且有这些实际操作经验就足够了。

即便你不符合上述条件,也不是跳过这篇教程的理由。你还是可以从这篇文章中学到很多东西,具备上述条件可以帮助你更轻松地阅读这篇文章。

虽然这里的 API 是用 JavaScript 和 Express 写的,但不表示这些最佳实践仅适用于此。你也可以在其他的编程语言和框架中应用这些最佳实践。

架构

如前所述,我会是用 Express.js 来搭建 API。我不想弄得太复杂,所以我会使用 3 层结构

Bildschirmfoto-2022-04-25-um-14.33.24-1

控制器,我们将处理所有 HTTP 相关的内容,也就是说我们在这里处理端点的请求和响应。在这层之上是 Express 的路由把请求传递给相应的控制器。

所有业务逻辑都在服务层,服务层导出特定服务(方法)供控制层器用。

第三层是数据访问层,在这里处理数据库。我们将导出一些处理数据的方法,如创建 WOD,供服务层使用。

在我们的教学示例中,我们不会使用 真实的 数据库,如 MongoDB 或者 PostgreSQL,因为我想专注于最佳实践本身。因此我们会使用本地 JSON 文件来模拟数据库,但是使用逻辑可以迁移到其他的数据库。

基础设置

现在我们开始配置 API 的基础设置。不会太复杂,我们只创建一个简单、有组织的结构。

首先,我们创建一个总文件目录结构,包含所有必需的文件和依赖项。创建完了之后,我们将快速地检查一下一切是否运行正常。

# 创建项目目录并且打开这个目录
mkdir crossfit-wod-api && cd crossfit-wod-api
# 创建 src 目录并打开这个目录
mkdir src && cd src
# 创建子目录
mkdir controllers && mkdir services && mkdir database && mkdir routes
# 创建 index 文件(API 接入点)
touch index.js
# 我们现在位于 src 目录,所以要返回一级
cd .. 

# 创建 package.json 文件
npm init -y

安装基础设置的所有依赖项:

# 开发依赖项
npm i -D nodemon 

# 依赖项 
npm i express

在你最喜欢使用的文字处理器中打开我们的项目,然后配置 Express:

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

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

// 供测试用代码
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"
  }
}

nodemon 可以确保每次你保存更改的时候,重新启动开发服务器。

启动开发服务器:

npm run dev

查看控制台,会收到消息 "API is listening on port 3000"

在浏览器中打开 localhost:3000。如果一切设置正确,你会看到下面内容:

Bildschirmfoto-2022-04-30-um-11.09.44

太好了!我们已经设置好应用最佳实践的环境。

REST API 最佳实践

constantin-wenning-idDvA4jPBO8-unsplash--1-

图片作者Constantin Wenning,来自Unsplash

很好!我们已经完成了 Express 的基础设置,现在我们可以根据最佳实践来扩展 API 了。

我们从最简单的基础 CRUD 端点开始,之后我们将使用最佳实践来扩展 API。

版本

稍等一下,在我们编写具体的 API 代码之前,我们要关注一下版本。和其他所有应用一样,我们的 API 也需要迭代、更新功能,所以给我们的 API 制定版本十分重要。

这样做最大的优势是当我们在创建新功能的时候并不影响客户端继续运行旧版本。

我们并不强迫用户直接使用我们的新版本,用户可以继续使用旧的版本,直到新版本稳定后再迁移到新版本。

当下版本和新版本并行运行,互不干扰。

那我们如何区分不同的版本呢?一种不错的做法是在 URL 添加 v1、v2 这样的路径段。

// 版本1 
"/api/v1/workouts" 

// 版本2 
"/api/v2/workouts" 

// ...

这就是我们暴露给外部,其他开发者也可以使用的部分。同时,我们也需要调整项目结构来区分不同的版本。

管理 Express API 版本的方法各式各样。本教程中我将在 src 目录下创建一个版本目录,如 v1

mkdir src/v1

现在我们将路由目录移动到新的 v1 目录下:

# 获取当前路径(复制)
pwd 

# 将 “routes” 添加到 “v1” (使用 {pwd} 插入新的路径)
mv {pwd}/src/routes {pwd}/src/v1

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

# 在 /src/v1/routes 中
touch index.js

我们开启一个简单的路由:

// 在 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;

现在我们将在 v1 内部的根入口点 src/index.js 接上路由:

// 在src/index.js
const express = require("express");
// *** 添加 ***
const v1Router = require("./v1/routes");

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

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

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

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

再登陆浏览器浏览 localhost:3000/api/v1,你会看到以下画面:

Bildschirmfoto-2022-04-30-um-11.22.28

祝贺你!你已经调整好了项目结构以适应不同版本。现在我们通过版本 1 的路由来传入请求,之后每一个请求会连接相应的控制器方式。

再继续下一步之前,我想强调一些内容。

我们把路由目录迁移到了 v1 目录下,其他目录如控制器和服务器仍在 src 目录下。因为我们搭建的 API 比较小,所以这么做没有问题,每一个版本我们使用相同的控制器和服务器。

当 API 逐渐壮大,比方说 2 版本需要使用不同的控制方法的话,最好还是把控制器目录放在 v2 目录下,这样就打包了这个版本所有的特定逻辑。

另一个这样做的原因是,我们可能在其他版本中想要改变某个服务器,但我们并不想要中断除此之外的版本。所以推荐把服务器目录也迁移到特定版本目录。

在我们的例子当中仅区分路由的版本是可行的。尽管如此。切记当 API 壮大需要改变的时候,拥有一个清晰的目录结构十分重要。

用复数形式命名资源

设置完结构后我们就进入了真正的 API 搭建了。我希望从基础的 CRUD 端点开始。

也就是说,我们从实现创建、读取、更新和删除交叉训练端点开始。

首先,让我们为训练连接控制器、服务器和路由。

touch src/controllers/workoutController.js 

touch src/services/workoutService.js 

touch src/v1/routes/workoutRoutes.js

我通常喜欢从编写路由开始。让我们思考一下如何给端点命名。这里就会运用到最佳实践。

我们可以将端点命名为 /api/v1/workout,因为我们只添加一个交叉训练,对不对?虽说这样做基本上没什么问题,但是这样可能会造成误解。

谨记:你的 API 会被其他的人类使用,所以必须精准。这一规则也适用于给资源命名。

我通常会把资源看作一个盒子。在我们的例子中,这个盒子存储了训练集合。

将资源以复数形式命名最大的好处是这对于其他人类来说也清晰易懂,复数意味着这是一个包含了各种各样训练的合集。

所以,让我们定义一下路由中端点:

// 在 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 文件。

现在让我们回到入口接点连接版本 1.0 的路由。

// 在 src/index.js
const express = require("express");
// *** 删除 ***
const v1Router = require("./v1/routes");
// *** 添加 ***
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");

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

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

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

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

进展的很顺利!现在我们就可以通过版本 1 的训练路由捕捉到来自 /api/v1/workouts 的所有请求。

在路由当中,我们将调用控制器的方法来处理不同的端点。

让我们为每一个端点创建一个方法。现阶段只需要返回一个信息。

// 在 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;

现在可以测试 GET /api/v1/workouts/:workoutId 端点,在浏览器输入 localhost:3000/api/v1/workouts/2342,你会看到以下信息:

Bildschirmfoto-2022-04-30-um-11.29.19

我们成功了!API 结构的第一层就搭建完毕。让我们用另个最佳实践来创建服务层。

以 JSON 格式接受和响应数据

和 API 交互的时候,我们会通过请求发送特定数据,或者通过响应接受数据。市面上有各种各样的数据格式,但是 JSON(JavaScript Object Notation)是一个标准格式。

虽然在 JSON 的全称中有 JavaScript ,但两者并没有绑定。你也可以使用 Java 或者 Python 来编写你的 API,它们也可以处理 JSON。

由于这样的标准化,API 应该接受和响应 JSON 格式的数据。

让我们回到我们的代码,看看如何把这一点融入到我们的最佳实践。

首先,我们创建服务层。

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

将服务方法和控制器方法命名为一样的名字也是一种最佳实践,这样可以让两者保持关联。让我们先不返回任何东西。

在训练的控制器中,调用服务层的这些方法:

// 在src/controllers/workoutController.js
// *** 添加 ***
const workoutService = require("../services/workoutService");

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

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

我们暂且不需要改变控制器响应中的任何内容,但是控制器已经可以和服务层联通了。

在服务的方法中,我们处理了业务逻辑,如改变数据结构以及和数据层交互。

为此我们需要创建一个数据层和一组处理与数据库交互的方法。我们的数据库将为简单的训练 JSON 文件。

# 在src/database中创建一个新的名为 db.json的文件
touch src/database/db.json 

# 在/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)。

在这里我也使用和服务层、控制器的相同的命名,不过是否这样命名完全取决于你的选择。

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

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

module.exports = { getAllWorkouts };

回到训练计划服务层,实现 getAllWorkouts 的逻辑。

// 在src/database/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,
};

返回所有的训练十分简单,我们不需要改变数据格式,因为数据已经是一个 JSON 文件了。我们暂时也不需要传入参数,现在做的事情非常简单直白,待会儿我们会重新回到这里。

在我们的训练控制器中,已经接受到了 workoutService.getAllWorkouts() 的返回值,并作为响应发送给客户端。我们完成了数据库从服务层到控制器的响应循环。

// 在src/controllers/workoutControllers.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 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。

Bildschirmfoto-2022-04-30-um-11.38.14

一切都进展得很顺利,我们将数据以 JSON 的形式返回。但如何接受来自客户端的数据呢?假设我们需要一个端点来接受来自客户端的 JSON,在这个端点客户端创建和更新训练数据。

在控制器中,我们提取了请求体来创建一个新的训练,并传入训练服务层。在训练服务层,我们插入了 DB.json 并且将新创建的训练返回到客户端。

要想在请求体中解析 JSON,我们需要首先安装 body-parser 并配置。

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

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}`);
});

现在我们就可以在控制器的 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 方法中,我们可以在请求体中提取 body,并做一些验证,并作为参数传入训练服务层。

// 在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 });
};

...

通常会使用第三方包来来提升请求验证性能,如:express-validator

训练服务层接受来自 createdNewWorkout 方法传入的数据。

之后我们将缺失的属性传入对象,并将这个对象作为新的方法传入数据访问层,再存入 DB 中。

首先我们要创建一个简单的 Util 函数,来覆盖 JSON 文件以实时更新数据:

# 在 data 目录下创建 util 文件
touch src/database/utils.js
// 在 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 文件中使用这个函数:

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


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

// *** 添加 ***
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,
  // *** 添加 ***
  createNewWorkout,
};

一切进展得很顺利。下一步是调用训练服务层中的数据库方法。

# 安装 uuid 包
npm i uuid
// 在src/services/workoutService.js
// *** 添加 ***
const { v4: uuid } = require("uuid");
// *** 添加 ***
const Workout = require("../database/Workout");

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

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

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 = () => {
  return;
};

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

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

一切还不错,对不对?现在你可以去 HTTP 客户端,重新发送 POST 请求,就会接受到新的 JSON 格式的训练。

如果你尝试再次添加同样的训练,你仍会得到 201 状态码,但是不会插入新的内容。

也就是说我们的数据库方法取消了插入,什么都不返回。这是因为 if 声明检查了是否已经存在同样名称的内容,暂时这么处理,我们会在下一个最佳实践中讲解如何优化。

现在向 localhost:3000/api/v1/workouts 发出 GET 请求,读取所有的训练。我选择使用浏览器来操作,你会看到我们的训练成功地插入了:
Bildschirmfoto-2022-04-30-um-11.57.23

你可以选择自行编写其他的方法,或者直接复制我的。

首先是训练控制器(你可以直接复制所有内容)。

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

然后是训练服务层(你可以直接复制所有内容)。

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

最后是数据访问层的数据库方法(你可以直接复制所有内容)。

// 在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 的工程师更轻松地识别问题。

为了提升体验,我们还可以在返回错误的同时快速发送一个错误信息。但正如在文章开头说的那样,这一做法并不是万精油,还需要工程师自己来权衡。

例如,是否应该向用户返回 “该用户名已经注册” 这类信息是需要深思熟虑的,因为或许这样就给用户提供了本该隐藏的数据。

可以浏览一遍交叉训练 API 中的创建(CRUD 中的 C)端点,看看会出现什么问题,我们能怎么解决。在这一部分最后部分有其他端点的完整实现。

我们先从训练控制器的 createNewWorkout 方法开始:

// 在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 时,附带一条返回错误信息是一个不错的选择。

// 在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 报错的同时看到错误信息。

Bildschirmfoto-2022-04-30-um-15.17.21

这样的话,使用这个 API 的开发者就更知道自己需要什么。他们马上就知道应该在请求体中找答案,并且看看他们缺失了哪一个必须的属性。

在我们的例子中使用通用的错误信息没有问题。一般情况下可以使用一个模式验证器来处理这个问题。

让我们再深入一层看看服务层有什么潜在的错误:

// 在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 代码块中,来捕获错误。

// 在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 中定义错误:

// 在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() 必须这么写。

使用 throw 的缺点是无法得到栈追踪。但基本上抛出错误由第三方库来处理(如果你使用 MongoDB 数据库的话就是 Mongoose),在本教程中,我们现在做的就足够了。

现在我们就可以在服务和数据访问层来抛出和捕获错误了。我们现在进入训练控制层,来编写抛出错误和对应的消息。

// 在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,
  };
  // *** 添加 ***
  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 } });
  }
};

...

你可以通过添加同样名字的训练,或者不在请求体中提供必需的属性来测试。你会接受对应的 HTTP 错误代码以及错误信息。

在结束这一篇并且进入下一个最佳实践之前,让我们复制其他的实现代码,或者你可以尝试自己编写:

// 在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,
};
// 在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 和资源(想想我们前文提到的“盒子”)是一一对应的。

在 URL 中使用动词,相当于展示了资源本身并没有的行为。

我们已经在不使用动词的情况下正确地编写好了 URL,但让我们看看,如果使用动词,URL 会是什么样。

// 现在的样子(没有动词)
GET "/api/v1/workouts" 
GET "/api/v1/workouts/:workoutId" 
POST "/api/v1/workouts" 
PATCH "/api/v1/workouts/:workoutId" 
DELETE "/api/v1/workouts/:workoutId"  

// 使用动词
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 中使用动词的原因是,HTTP 动词已经表明了响应的动作。

“GET /api/v1/getAllWorkouts”“DELETE api/v1/deleteWorkout/workoutId” 就很没有必要。

你会发现我们的实现非常清晰,因为我们只使用两个不同的 URL,而实际的行为是通过 HTTP 动词以及对应的请求有效载荷来实现。

我认为 HTTP 动词是来定义行为的(我们也希望这样),而 URL(指向资源)是目标。“GET /api/v1/workouts” 这句话即便是人类的语言中也更通顺。

把相关的资源放在一起(逻辑嵌套)

当你在设计 API 的时候,会出现资源之间相互关联的情况。一个好的实践方式是将资源整合和嵌套到一个端点。

在我们的 API 中,有一系列的会员注册了交叉训练盒子(此处的“盒子”是交叉训练健身房的名字),为了鼓励会员,我们记录了每一次训练的所有记录。

假设有一组训练包含一定顺序的练习,你想要尽快做完。我们记录了所有会员完成这项训练的时间。

这时,前端就需要一个端点来响应一个特定训练的所有时间记录,并且在 UI 上呈现。

训练、会员还有训练记录存储在不同的数据库里。所以在这里我们需要使用盒中盒(训练中的记录),对不对?

这个端点的 URI 会是 /api/v1/workouts/:workoutId/records。这便是一个在 URL 中实现逻辑嵌套的好实践。URL 本身不需要反应数据结构。

让我们来实现这个端点。

首先我们要在 db.json 中添加一组叫 “memebers” 的数据,放在 “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"
    }
  ]
}

在你问之前,我先回答——是的,密码是哈希加密的。😉

然后我们在 “records” 下面添加 “members”:

{
  "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 中:

{
  "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 的绝好机会。

# 创建记录控制器
touch src/controllers/recordController.js 

# 创建记录服务层
touch src/services/recordService.js 

# 创建记录数据处理方法 
touch src/database/Record.js

很简单!让我们从后往前,从实现数据方法开始编写。

// 在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 };

很直接对不对,我们通过查询参数过滤出和训练 id 相关的记录数据

接下来是记录的服务层:

// 在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 };

这里也没有新的知识点。

现在就可以在训练路由创建新的路由,并且导向记录服务请求。

// 在src/v1/routes/workoutRoutes.js
const express = require("express");
const workoutController = require("../../controllers/workoutController");
// *** 添加 ***
const recordController = require("../../controllers/recordController");

const router = express.Router();

router.get("/", 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;

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

首先我们抓取所有训练记录,来获得一个训练 id。

Bildschirmfoto-2022-04-30-um-15.36.48

让我们来看看能不能获得这个 id 下的所有记录。

Bildschirmfoto-2022-04-30-um-15.36.32

如你所见,逻辑嵌套可以使资源捆绑在一起。理论上你可以想嵌套多少层就嵌套多少层,但建议至多使用三层嵌套。

如果你想嵌套得更深,可以稍微调整一下数据库的记录。我给你看一个小例子。

想象一下,前端还需要一个端点来获取到底是哪个会员持有当前记录的信息,并希望接受这个会员的所有原始信息。

你当然可以使用下面的 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 便可以获得所有和训练相关的数据。

除此之外,我们可以由会员 id 来获取会员的信息,就可以避免更深入的嵌套。

当然,这一切实现的前提是处理 “/members/:memberId” 请求。😁 这听上去是锻炼你自己实现能力的好机会!

集成过滤、排序和分页功能

现在我们的 API 已经可以完成很多工作,取得了相当大的进展,但是这还不够。

在上一部分我们聚焦在如何提高开发者的体验,以及我们的 API 如何交互。但是 API 的整体性能也是一个关键部分,需要我们努力提高。

这就是为什么在我的待办清单中集成过滤、排序和分页功能也是非常关键的。

假设我们的 DB 中有 2000 个训练,450 条记录和 500 个会员。当我们调用端点来获取训练的时候,我们不希望一次性获得所有 2000 个训练。这样的响应速度会比较慢,导致系统崩溃(崩溃可能需要 200000 条记录😁 )。

这就是为什么过滤和分页十分重要。过滤正如这个名称一样,可以帮助我们在整个数据集中获取我们需要的数据。例如所有具备“时间”模式的训练。

分页是另一种可以拆分数据集的机制,比方说我们可以把数据分成每页二十个训练的“页面”。这个技术确保我们一次返回不超过 20 个训练。

排序可以变得非常复杂,所以直接在我们的 API 排序后,再向用户发送数据更高效。

我们首先在 API 中整合一些过滤机制。我们将发送所有训练的这个端点升级,让这个端点接受过滤参数。通常在 GET 请求中,我们使用查询参数来添加过滤条件。

当我们只获取训练状态(mode)为 “AMRAP”(尽可能多地训练 As Many Rounds As Possible)时,我们新的 URI 会是这样:/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"
}

当我们处理好接受和处理查询参数后,就可以编写训练的控制层:

// 在src/controllers/workoutController.js
...

const getAllWorkouts = (req, res) => {
  // *** 添加 ***
  const { mode } = req.query;
  try {
    // *** 添加 ***
    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 的参数。这个对象包含了所有过滤参数。

这里我使用了简写语法,来创建一个名为 “mode” 的新键,这个键位于对象内部,其值可以是任意 “req.query.mode” 的值。可以为一个真值或者如果没有一个参数为 “mode” 的参数则为 undefined。我们可以在对象内扩充更多过滤参数。

在workoutService中传入数据处理方法:

// 在src/services/workoutService.js
...

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

...

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

// 在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)
      );
    }
    // 如果有其他的参数,可以在这里编写其他的 if 表达式
    return workouts;
  } catch (error) {
    throw { status: 500, message: error };
  }
};

...

简单明了!我们在这里做的工作就是检查 “filterParams” 中是否存在键 “mode” 的真值,如果存在,则过滤出所有包含同样 “mode” 的训练,如果不存在,则返回所有训练,

此处我们使用 “let” 来定义 “workouts” 变量是因为如果我们使用 if 表达式来添加更多过滤器的话,会覆盖掉 “workouts” 并且串联过滤器。

在浏览器中可以登陆 3000/api/v1/workouts?mode=amrap,会接受到所有包含 “AMRAP” 的训练:

Bildschirmfoto-2022-04-30-um-15.48.57

如果不填写查询参数的话,就会重新获得所有训练。你可以尝试添加 “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 或者 express 的中间件 apicache

我准备使用 apicache,但如果你想使用 Redis,我强烈推荐你阅读他们的文档

我们思考一下在 API 中使用缓存的场景。我认为使用缓存来返回所有训练会更加有效。

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

npm i apicache

我们在训练的路由中引用这个插件并配置好:

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

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

// *** 添加 ***
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,并在路由中当作中间件来使用。仅需在实际的路径和训练控制器之间放置这个参数。

你可以在中间件内部定义你需要保存缓存多久。在这篇教程中我选择 2 分钟。保存时间一般取决于你存储的数据多久更新一次。

让我们测试一下!

在 Postman 或者另外的 HTTP 客户端中,定义一个新的请求,获取所有的训练。之前我都是在浏览器中操作,但是这次我想给你更直观的感受,所以使用 Postman。

让我们第一次请求数据:

Bildschirmfoto-2022-04-26-um-15.36.46-1

你可以看到我们的 API 花了 22.93 毫秒来响应。一旦缓存被清空(2 分钟后),又回重新抓取数据保存到缓存,我们第一次获取数据的时候就将数据存储到了缓存。

在上述例子中,数据并不是有由缓存提供。而是通过“普通”方式来抓去数据库保存到缓存。

现在我们第二次请求数据,响应时间变短,因为我们直接从缓存中返回数据。

Bildschirmfoto-2022-04-26-um-15.36.59-1

比起第一次请求,我们快了三倍,这完全归功于缓存。

在我们的例子中,我们只缓存了一个路由,你可以在所有路由中应用:

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

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

app.use(bodyParser.json());
// *** 添加 ***
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 重要的一环。如果你创建出一个绝佳的 API,但是在服务器上运行的时候却十分脆弱,那这个 API 就变得无用且危险。

首先必须使用的是 SSL/TLS,因为这是当今互联网通讯的一个标准。特别是当 API 需要在客户端和服务器之间传输私人数据的时候。

如果你需要给验证客户提供数据,必须使用验证手段来保护数据。

在 Express 中,我们可以像在缓存中那样在路由中插入特定的中间件来检查请求的真实性再获取资源。

API 中的一些资源和交互是你可能不希望所有用户都可以请求的。这是就需要一个角色系统。在路由中添加一个检查逻辑来验证用户是否有权利来获取这些数据。

用户角色在我们的用例中也同样适用。比方说我们需要特定用户(教练)来使用创建、更新和删除训练和记录的功能。所有用户可以读取(同样可成为“普通”用户)。

这可以通过在路由中插入中间件来实现。如在我们的 /api/v1/workouts 的 POST 请求中插入。

在第一个中间件中我们检查用户是不是真实的,如果为真,就进入下一个中间件来检查用户角色,如果用户符合获取资源的角色,就移交到对应的控制器。

路由处理器如下:

// 在src/v1/routes/workoutRoutes.js
...

// 定制中间件
const authenticate = require("../../middlewares/authenticate");
const authorize = require("../../middlewares/authorize");

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

...

这个话题相关的最佳实践,我推荐阅读这篇文章

给 API 编写合适的文档

对于开发者来说编写文档确实不是一件让他们乐意干的活儿,但是是必须要做的事,特别是API的文档。

有人说过:

“API 得和文档一样优秀”

我认为这句话挺有道理,因为如果 API 的文档不好,这个 API 就不好使用。文档帮助开发者更方便地使用 API。

永远记住文档是 API 使用者和 API 交互的第一环节。用户能够更快读懂文档,就能够更快使用 API。

所以我们必须编写良好精确的文档。有一些比较好用的工具可以帮助我们实现。

和其他计算机科学领域一样,API 文档也有标准,查看 OpenAPI 细则

让我们来看看如何遵循这份规则来创建文档。 我们将使用 swagger-ui-expressswagger-jsdoc 工具包。你马上就会为这两个工具包能够做到的事感到惊奇。

首先我们设置好文档的基础结构。因为我们会有不同版本的 API,所以文档会有些许不同,这就是为什么我会创建 swagger 文件,来处理不同版本的文档。

# 安装必须的 NPM 包
npm i swagger-jsdoc swagger-ui-express 

# 创建新的文件来设置 swagger 文档
touch src/v1/swagger.js
// 在 src/v1/swagger.js 中
const swaggerJSDoc = require("swagger-jsdoc");
const swaggerUi = require("swagger-ui-express");

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

// 使用 JSON 格式的文档
const swaggerSpec = swaggerJSDoc(options);

// 设置文档的函数
const swaggerDocs = (app, port) => {
  // 处理文档路由
  app.use("/api/v1/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
  // 使得允许使用 JSON 格式文档
  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 服务器,确保文档也被启动。

// 在src/index.js
const express = require("express");
const bodyParser = require("body-parser");
const v1WorkoutRouter = require("./v1/routes/workoutRoutes");
// *** 添加 ***
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}`);
  /// *** 添加 ***
  V1SwaggerDocs(app, PORT);
});

现在你可以在你的控制台查看服务器是否在运行。

Bildschirmfoto-2022-04-28-um-20.23.51-1

当你登陆 localhost:3000/api/v1/docs,你会看到我们的文档已经准备好了:

Bildschirmfoto-2022-04-28-um-20.25.00-1

每次我都会感叹运作得如此顺畅。现在基本结构已经设置好,我们可以来实现文档的端点了,让我们开始吧!

当你查看 swagger.js 文件中的 options.apis,你会发现我们已经预留了处理训练路由和数据库中训练文件的路径。 这就是让魔法实现最重要的环节。

在 swagger 中有这些选项使得我们可以使用评论来引用 OpenAPI,并且使用类似 yaml 的语法来编写文档,这就是设置文档的所有必须条件了。

现在我们就可以开始来创建我们文档的第一个端点了,让我们开始吧!

// 在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 文档来添加端点的所有魔法了,你可以在他们的文档中查看所有细则。

当你重新加载文档页面,你会看到如下:

Bildschirmfoto-2022-04-29-um-07.21.51-1

如果你熟悉 OpenAPI 文档的话,这个画面对于你来说就不陌生。在这个页面中我们会看到所有端点,并且包含每一个端点的信息。

Bildschirmfoto-2022-04-29-um-07.41.46-1

但你仔细看会发现我们还没有定义正确的返回值,因为我们的 “data” 属性仅设定为一个空对象。

这时模式(schema)就发挥了作用。

// 在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"]
 */

...

在上面的示例中,我们创建了第一个模式,通常在你的模式或者模型文件(model file)中的定义的是数据模型。

这也很简单明了。我们定义了所有训练的属性,包括种类和例子。

再次浏览文档页面,你会看到另一个由模式主导的板块。

Bildschirmfoto-2022-04-29-um-07.29.49-1

我们可以在端点的响应中引用这个模式。

// 在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);

...

仔细看最底部的评论,在 “item” 内部,我们使用了 “$ref” 来创建引用,引用我们在训练文件中定义的模式。

现在我们就可以完整地展示训练的响应了。

Bildschirmfoto-2022-04-29-um-07.44.12-1

很不错!你可能会认为“编写这些评论很繁琐”。

这或许是真的,但是可以这样想,写在代码中的评论里可以帮助你作为一个 API 开发者更好地了解你的 API。当你想要了解某一个端点的时候,你不要阅读所有文档,你可以直接在代码中找到。

为端点写文档也可以帮助你更好的了解这些端点,“逼迫”自己去思考在实现的过程当中缺失了什么。

你看我确实忘记了一些东西,我忘记写可能出现的错误响应,查询参数也缺少了。

让我们调整一下:

// 在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);

...

当你查看评论最上方 “tag” 内部,会发现我添加了另一个键叫做 “parameters”,这里可以定义我们过滤所需的查询参数。

我们的文档也会展示出来:

Bildschirmfoto-2022-04-29-um-08.03.00-1

现阶段展示可能出现的错误,我们只用抛出 5XX 报错就行。所以在 “responses” 内部,我们可以定义另一个文档。

现在我们的文档页面如下:

Bildschirmfoto-2022-04-29-um-08.04.44-2

很棒!我们已经给一个端点创建了完整的文档,我强烈建议你为剩下的端点创建对应的文档,在这个过程中你会学到很多东西。

你或许也体会到了为 API 写文档并不总是一件头疼的事,使用我介绍给你的工具可以减轻你不少负担,搭建过程也十分简单明了。

这样你就可以把注意力集中在重要的事情上,编写文档内容。swagger 和 OpenAPI 的文档非常不错,在网络上你也可以找到其他的优秀例子。

那么因为太多“额外”工作而不写文档这个理由现在就不成立了。

总结

呼!这是一趟有趣的旅程!我非常享受写这篇文章也从中学习了很多。

这些最佳实践中有一些可能很重要。另一些可能不适用于你现在的情况。没关系,正如我一开始说的那样,对于开发者来说最重要的是能够根据情况挑选出最适合自己的方法。

我尽力把所有最佳实践融汇到这个 API 项目中,我从中获得非常多的乐趣。

我十分乐意接受各种反馈,任何你想要告诉我的事情(好的或坏的),别迟疑,请告诉我:

这是我的Instagram(你也可以关注我在软件工程师的成长道路上的见闻)。

下篇文章见!