博客 / 詳情

返回

REST API 設計最佳實踐指南

過去幾年裏,我創建並使用過很多 API。在此過程中,我遇到過各種好的和壞的實踐,也在開發和調用 API 時碰到過不少棘手的問題,但也有很多順利的時刻。

網上有很多介紹最佳實踐的文章,但在我看來,其中不少都缺乏實用性。只懂理論、沒幾個實例固然有一定價值,但我總是會想:在更真實的場景中,這些理論該如何落地?

簡單的示例能幫助我們理解概念本身,避免過多複雜性干擾,但實際開發中事情往往沒那麼簡單。我相信你肯定懂這種感受 😁

這就是我決定寫這篇教程的原因。我把自己的所有經驗(好的、壞的都有)整合到這篇通俗易懂的文章裏,同時提供了可跟着操作的實戰案例。最終,我們會一步步落實最佳實踐,搭建出一個完整的 API。

開始前需要明確幾點: 所謂“最佳實踐”,並非必須嚴格遵守的法律或規則,而是經過時間檢驗、被證明有效的約定或建議。其中一些如今已成為標準,但這並不意味着你必須原封不動地照搬。

它們的核心目的是為你提供方向,幫助你從用户體驗(包括調用者和開發者)、安全性、性能三個維度優化 API。

但請記住:不同項目需要不同的解決方案。有些情況下,你可能無法或不應該遵循某條約定。因此,最終需要開發者自己或與團隊共同判斷。

好了,廢話不多説,我們開始吧!

目錄

  1. 示例項目介紹
  2. 前置要求
  3. 架構設計
  4. 基礎搭建
  5. REST API 最佳實踐

    1. 版本控制
    2. 資源命名使用複數形式
    3. 接收與返回數據採用 JSON 格式
    4. 用標準 HTTP 錯誤碼響應
    5. 端點名稱避免使用動詞
    6. 關聯資源分組(邏輯嵌套)
    7. 集成過濾、排序與分頁
    8. 用數據緩存提升性能
    9. 良好的安全實踐
    10. 完善 API 文檔
  6. 總結

1. 示例項目介紹

在這裏插入圖片描述

在將最佳實踐落地到示例項目前,先簡單介紹一下我們要做什麼:

我們將為一個 CrossFit 訓練應用搭建 REST API。如果你不瞭解 CrossFit,它是一種結合了高強度訓練與奧林匹克舉重、體操等多種運動元素的健身方式和競技運動。

在這個應用中,用户(健身房經營者)可以創建、查詢、更新和刪除 WOD(每日訓練計劃,Workout of the Day),制定訓練方案並統一管理;此外,還能為每個訓練計劃添加重要的訓練提示。

我們的任務就是為這個應用設計並實現 API。

2. 前置要求

要跟上本教程的節奏,你需要具備以下基礎:

  • JavaScript、Node.js、Express.js 的使用經驗
  • 後端架構的基礎認知
  • 瞭解 REST、API 的概念,理解客户端-服務器模型

當然,你不必是這些領域的專家,只要熟悉基本用法、有過實操經驗即可。

如果暫時不滿足這些要求,也不用跳過這篇教程——裏面仍有很多值得學習的內容,只是有基礎會更容易跟上步驟。

另外,雖然本 API 用 JavaScript 和 Express 編寫,但這些最佳實踐並不侷限於這兩種工具,同樣適用於其他編程語言或框架。

3. 架構設計

如前所述,我們將用 Express.js 搭建 API。為避免過度複雜,我們採用三層架構
在這裏插入圖片描述

  • 控制器層:處理所有 HTTP 相關邏輯,負責請求與響應的處理;上層通過 Express 的路由將請求分發到對應的控制器方法。
  • 服務層:包含所有業務邏輯,通過導出方法供控制器調用。
  • 數據訪問層:負責與數據庫交互,導出數據庫操作方法(如創建 WOD)供服務層調用。

本示例中,我們不會使用 MongoDB、PostgreSQL 等真實數據庫(以便聚焦最佳實踐本身),而是用一個本地 JSON 文件模擬數據庫。當然,這裏的邏輯也可以無縫遷移到真實數據庫中。

4. 基礎搭建

現在我們開始搭建 API 的基礎框架。不用搞得太複雜,重點是結構清晰。

首先創建項目文件夾、子目錄及必要文件,然後安裝依賴並測試是否能正常運行:

4.1 創建目錄結構

# 創建項目文件夾並進入
mkdir crossfit-wod-api && cd crossfit-wod-api

# 創建src文件夾並進入
mkdir src && cd src

# 創建子文件夾
mkdir controllers && mkdir services && mkdir database && mkdir routes

# 創建入口文件index.js
touch index.js

# 返回項目根目錄
cd ..

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

4.2 安裝依賴

# 開發依賴(熱重載)
npm i -D nodemon 

# 核心依賴(Express框架)
npm i express

4.3 配置 Express

打開src/index.js,寫入以下代碼:

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

// 測試接口
app.get("/", (req, res) => { 
    res.send("<h2>運行正常!</h2>"); 
}); 

// 啓動服務
app.listen(PORT, () => { 
    console.log(`API正在監聽 ${PORT} 端口`); 
});

4.4 配置開發腳本

package.json中添加dev腳本(實現代碼修改後自動重啓服務):

{
  "name": "crossfit-wod-api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon src/index.js"  // 新增這行
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "nodemon": "^2.0.15"
  },
  "dependencies": {
    "express": "^4.17.3"
  }
}

4.5 測試基礎搭建

啓動開發服務器:

npm run dev

終端會顯示“API 正在監聽 3000 端口”,此時在瀏覽器中訪問localhost:3000,若看到“運行正常!”則説明基礎搭建完成。

5. REST API 最佳實踐

有了 Express 的基礎框架後,我們就可以結合以下最佳實踐來擴展 API 了。

先從最基礎的 CRUD 端點開始,再逐步集成各項最佳實踐。

5.1 版本控制(Versioning)

在編寫任何 API 特定代碼前,必須先考慮版本控制。和其他應用一樣,API 也會不斷迭代、新增功能,因此版本控制至關重要。

版本控制的優勢:

  • 開發新版本時,舊版本仍可正常使用,不會因破壞性變更影響現有用户;
  • 無需強制用户立即升級到新版本,用户可在新版本穩定後自行遷移;
  • 新舊版本並行運行,互不干擾。

如何實現版本控制?

一個常用的最佳實踐是在 URL 中添加版本標識(如v1v2):

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

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

這是對外暴露的 URL 格式,供其他開發者調用。同時,項目結構也需要區分不同版本:

步驟 1:創建版本目錄

src下創建v1文件夾,用於存放版本 1 的代碼:

mkdir src/v1

將之前創建的routes文件夾移動到v1目錄下:

# 先查看當前目錄路徑並複製(例如/Users/xxx/crossfit-wod-api)
pwd 

# 移動routes文件夾到v1目錄(將{pwd}替換為複製的路徑)
mv {pwd}/src/routes {pwd}/src/v1

步驟 2:創建版本路由測試文件

src/v1/routes下創建index.js,編寫簡單的路由測試代碼:

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

// 測試路由
router.route("/").get((req, res) => {
  res.send(`<h2>來自 ${req.baseUrl} 的響應</h2>`); 
});

module.exports = router;

步驟 3:關聯根入口文件與版本路由

修改src/index.js,引入 v1 路由並配置訪問路徑:

const express = require("express"); 
// 引入v1路由
const v1Router = require("./v1/routes"); 
const app = express(); 
const PORT = process.env.PORT || 3000; 

// 移除舊的測試接口
// app.get("/", (req, res) => { 
//     res.send("<h2>運行正常!</h2>"); 
// }); 

// 配置v1路由的訪問路徑
app.use("/api/v1", v1Router);

app.listen(PORT, () => { 
    console.log(`API正在監聽 ${PORT} 端口`); 
});

步驟 4:測試版本路由

訪問localhost:3000/api/v1,若看到“來自 /api/v1 的響應”則説明版本路由配置成功。

注意事項:

目前我們只將routes放入v1目錄,controllersservices等仍在src根目錄——這對小型 API 來説沒問題,可以讓多個版本共享這些通用邏輯。

但如果 API 規模擴大,比如 v2 需要特定的控制器或服務(修改通用邏輯可能影響舊版本),則建議將controllersservices也按版本拆分到對應目錄,實現版本內邏輯的完全封裝。

5.2 資源命名使用複數形式(Name resources in plural)

接下來開始實現 API 的核心功能——為 WOD 設計 CRUD 端點。首先要解決的是資源命名問題。

為什麼用複數?

資源可以理解為“一個存放數據的集合”(比如“workouts”是所有訓練計劃的集合)。用複數命名能讓調用者一目瞭然地知道這是一個“集合”,而非單個資源,避免歧義。

步驟 1:創建 WOD 相關文件

創建控制器、服務和路由文件,分別對應三層架構:

# 控制器(處理HTTP請求/響應)
touch src/controllers/workoutController.js 

# 服務(處理業務邏輯)
touch src/services/workoutService.js 

# 路由(分發請求)
touch src/v1/routes/workoutRoutes.js

步驟 2:編寫 WOD 路由(複數命名)

src/v1/routes/workoutRoutes.js中定義 CRUD 端點,注意 URL 使用複數/workouts

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

// 獲取所有訓練計劃
router.get("/", (req, res) => {
  res.send("獲取所有訓練計劃"); 
});

// 獲取單個訓練計劃(通過ID)
router.get("/:workoutId", (req, res) => {
  res.send("獲取單個訓練計劃"); 
});

// 創建訓練計劃
router.post("/", (req, res) => {
  res.send("創建訓練計劃"); 
});

// 更新訓練計劃
router.patch("/:workoutId", (req, res) => {
  res.send("更新訓練計劃"); 
});

// 刪除訓練計劃
router.delete("/:workoutId", (req, res) => {
  res.send("刪除訓練計劃"); 
});

module.exports = router;

刪除之前用於測試的src/v1/routes/index.js(已不再需要)。

步驟 3:關聯根入口文件與 WOD 路由

修改src/index.js,替換舊的 v1 路由,改用 WOD 路由:

const express = require("express"); 
// 移除舊的v1路由引入
// const v1Router = require("./v1/routes"); 
// 引入WOD路由
const v1WorkoutRouter = require("./v1/routes/workoutRoutes"); 
const app = express(); 
const PORT = process.env.PORT || 3000; 

// 移除舊的v1路由配置
// app.use("/api/v1", v1Router); 
// 配置WOD路由的訪問路徑(複數)
app.use("/api/v1/workouts", v1WorkoutRouter);

app.listen(PORT, () => { 
    console.log(`API正在監聽 ${PORT} 端口`); 
});

步驟 4:編寫控制器方法

src/controllers/workoutController.js中定義與路由對應的控制器方法:

// src/controllers/workoutController.js
// 獲取所有訓練計劃
const getAllWorkouts = (req, res) => {
  res.send("獲取所有訓練計劃"); 
}; 

// 獲取單個訓練計劃
const getOneWorkout = (req, res) => {
  res.send("獲取單個訓練計劃"); 
}; 

// 創建訓練計劃
const createNewWorkout = (req, res) => {
  res.send("創建訓練計劃"); 
}; 

// 更新訓練計劃
const updateOneWorkout = (req, res) => {
  res.send("更新訓練計劃"); 
}; 

// 刪除訓練計劃
const deleteOneWorkout = (req, res) => {
  res.send("刪除訓練計劃"); 
};

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

步驟 5:路由關聯控制器

修改src/v1/routes/workoutRoutes.js,將路由與控制器方法綁定:

// src/v1/routes/workoutRoutes.js
const express = require("express"); 
// 引入控制器
const workoutController = require("../../controllers/workoutController"); 
const router = express.Router();

// 綁定路由與控制器方法
router.get("/", workoutController.getAllWorkouts);
router.get("/:workoutId", workoutController.getOneWorkout);
router.post("/", workoutController.createNewWorkout);
router.patch("/:workoutId", workoutController.updateOneWorkout);
router.delete("/:workoutId", workoutController.deleteOneWorkout);

module.exports = router;

測試路由

訪問localhost:3000/api/v1/workouts/123,若看到“獲取單個訓練計劃”則説明路由配置成功。

5.3 接收與返回數據採用 JSON 格式

調用 API 時,請求和響應都需要傳遞數據。JSON(JavaScript 對象表示法) 是通用的標準化格式,不受編程語言限制(Java、Python 等都能處理 JSON),因此 API 應統一使用 JSON 接收和返回數據。

步驟 1:編寫服務層基礎代碼

服務層負責業務邏輯,先在src/services/workoutService.js中創建與控制器對應的方法:

// src/services/workoutService.js
const getAllWorkouts = () => {
  return; 
}; 

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

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

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

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

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

步驟 2:創建模擬數據庫(JSON 文件)

src/database下創建db.json(模擬數據庫)和Workout.js(數據訪問方法):

# 模擬數據庫
touch src/database/db.json 

# 數據訪問層方法
touch src/database/Workout.js

db.json中添加測試數據(3 個訓練計劃):

{
  "workouts": [
    {
      "id": "61dbae02-c147-4e28-863c-db7bd402b2d6",
      "name": "Tommy V",
      "mode": "計時完成",
      "equipment": ["槓鈴", "繩梯"],
      "exercises": [
        "21次火箭推",
        "12次15英尺繩爬",
        "15次火箭推",
        "9次15英尺繩爬",
        "9次火箭推",
        "6次15英尺繩爬"
      ],
      "createdAt": "2022-04-20 14:21:56",
      "updatedAt": "2022-04-20 14:21:56",
      "trainerTips": [
        "21次火箭推可拆分完成",
        "9次和6次火箭推儘量不間斷完成",
        "標準重量:115磅/75磅"
      ]
    },
    {
      "id": "4a3d9aaa-608c-49a7-a004-66305ad4ab50",
      "name": "Dead Push-Ups",
      "mode": "10分鐘內儘可能多組",
      "equipment": ["槓鈴"],
      "exercises": [
        "15次硬拉",
        "15次釋放式俯卧撐"
      ],
      "createdAt": "2022-01-25 13:15:44",
      "updatedAt": "2022-03-10 08:21:56",
      "trainerTips": [
        "硬拉重量宜輕,速度宜快",
        "儘量不間斷完成一組",
        "標準重量:135磅/95磅"
      ]
    },
    {
      "id": "d8be2362-7b68-4ea4-a1f6-03f8bc4eede7",
      "name": "Heavy DT",
      "mode": "5輪計時完成",
      "equipment": ["槓鈴", "繩梯"],
      "exercises": [
        "12次硬拉",
        "9次懸掛式力量抓舉",
        "6次推挺"
      ],
      "createdAt": "2021-11-20 17:39:07",
      "updatedAt": "2021-11-20 17:39:07",
      "trainerTips": [
        "推挺儘量不間斷",
        "前3輪可能很痛苦,但堅持住",
        "標準重量:205磅/145磅"
      ]
    }
  ]
}

步驟 3:編寫數據訪問層方法(獲取所有訓練計劃)

src/database/Workout.js中實現從 JSON 文件讀取數據的方法:

// src/database/Workout.js
// 引入模擬數據庫
const DB = require("./db.json");

// 獲取所有訓練計劃
const getAllWorkouts = () => {
  return DB.workouts;
};

module.exports = { getAllWorkouts };

步驟 4:服務層調用數據訪問層

修改src/services/workoutService.js,調用數據訪問層方法獲取數據:

// src/services/workoutService.js
// 引入數據訪問層
const Workout = require("../database/Workout");

// 獲取所有訓練計劃
const getAllWorkouts = () => {
  const allWorkouts = Workout.getAllWorkouts();
  return allWorkouts;
}; 

// 其他方法暫不修改
const getOneWorkout = () => { return; }; 
const createNewWorkout = () => { return; }; 
const updateOneWorkout = () => { return; }; 
const deleteOneWorkout = () => { return; };

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

步驟 5:控制器返回 JSON 數據

修改src/controllers/workoutController.js,通過服務層獲取數據並以 JSON 格式返回:

// src/controllers/workoutController.js
// 引入服務層
const workoutService = require("../services/workoutService");

// 獲取所有訓練計劃(返回JSON)
const getAllWorkouts = (req, res) => {
  const allWorkouts = workoutService.getAllWorkouts();
  // 以JSON格式返回數據(包含狀態和數據)
  res.send({ status: "成功", data: allWorkouts });
}; 

// 其他方法暫不修改
const getOneWorkout = (req, res) => { res.send("獲取單個訓練計劃"); }; 
const createNewWorkout = (req, res) => { res.send("創建訓練計劃"); }; 
const updateOneWorkout = (req, res) => { res.send("更新訓練計劃"); }; 
const deleteOneWorkout = (req, res) => { res.send("刪除訓練計劃"); };

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

測試返回 JSON

訪問localhost:3000/api/v1/workouts,瀏覽器會顯示 JSON 格式的訓練計劃數據,説明返回 JSON 配置成功。

步驟 6:配置 API 接收 JSON 請求

創建或更新訓練計劃時,需要接收客户端發送的 JSON 數據。需安裝body-parser解析請求體:

npm i body-parser

修改src/index.js,配置解析 JSON 請求體:

const express = require("express"); 
// 引入body-parser
const bodyParser = require("body-parser");
const v1WorkoutRouter = require("./v1/routes/workoutRoutes"); 
const app = express(); 
const PORT = process.env.PORT || 3000; 

// 配置解析JSON請求體
app.use(bodyParser.json());

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

app.listen(PORT, () => { 
    console.log(`API正在監聽 ${PORT} 端口`); 
});

步驟 7:實現“創建訓練計劃”(接收並存儲 JSON)

要實現創建功能,需先添加“保存數據到 JSON 文件”的工具方法:

  1. 創建工具方法:在src/database下創建utils.js,實現寫入 JSON 文件的邏輯:
touch src/database/utils.js
// src/database/utils.js
const fs = require("fs");

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

module.exports = { saveToDatabase };

2.更新數據訪問層:修改src/database/Workout.js,添加創建訓練計劃的方法:

// src/database/Workout.js
const DB = require("./db.json");
// 引入保存工具
const { saveToDatabase } = require("./utils");

// 獲取所有訓練計劃
const getAllWorkouts = () => {
  return DB.workouts;
};

// 創建訓練計劃
const createNewWorkout = (newWorkout) => {
  // 檢查是否已存在同名訓練計劃
  const isAlreadyExists = DB.workouts.findIndex(w => w.name === newWorkout.name) > -1;
  if (isAlreadyExists) {
    return; // 已存在則返回空
  }
  // 新增訓練計劃並保存
  DB.workouts.push(newWorkout);
  saveToDatabase(DB);
  return newWorkout;
};

module.exports = { getAllWorkouts, createNewWorkout };
  1. 更新服務層:安裝uuid生成唯一 ID,修改src/services/workoutService.js
npm i uuid
// src/services/workoutService.js
const { v4: uuid } = require("uuid"); // 生成唯一ID
const Workout = require("../database/Workout");

// 獲取所有訓練計劃(不變)
const getAllWorkouts = () => {
  const allWorkouts = Workout.getAllWorkouts();
  return allWorkouts;
}; 

// 創建訓練計劃(添加ID、時間戳)
const createNewWorkout = (newWorkout) => {
  // 補充必要字段(ID、創建時間、更新時間)
  const workoutToAdd = {
    ...newWorkout,
    id: uuid(), // 唯一ID
    createdAt: new Date().toLocaleString("zh-CN", { timeZone: "UTC" }),
    updatedAt: new Date().toLocaleString("zh-CN", { timeZone: "UTC" }),
  };
  const createdWorkout = Workout.createNewWorkout(workoutToAdd);
  return createdWorkout;
}; 

// 其他方法暫不修改
const getOneWorkout = () => { return; }; 
const updateOneWorkout = () => { return; }; 
const deleteOneWorkout = () => { return; };

module.exports = {
  getAllWorkouts,
  getOneWorkout,
  createNewWorkout,
  updateOneWorkout,
  deleteOneWorkout, 
};
  1. 更新控制器:修改src/controllers/workoutController.js,接收 JSON 請求並驗證:
// src/controllers/workoutController.js
const workoutService = require("../services/workoutService");

// 獲取所有訓練計劃(不變)
const getAllWorkouts = (req, res) => {
  const allWorkouts = workoutService.getAllWorkouts();
  res.send({ status: "成功", data: allWorkouts });
}; 

// 其他方法暫不修改
const getOneWorkout = (req, res) => { res.send("獲取單個訓練計劃"); }; 

// 創建訓練計劃(接收JSON並驗證)
const createNewWorkout = (req, res) => {
  const { body } = req;
  // 驗證必填字段
  if (!body.name || !body.mode || !body.equipment || !body.exercises || !body.trainerTips) {
    res.status(400).send({ 
      status: "失敗", 
      data: { error: "請求體缺少以下必填字段:'name'、'mode'、'equipment'、'exercises'、'trainerTips'" } 
    });
    return;
  }
  // 調用服務層創建訓練計劃
  const createdWorkout = workoutService.createNewWorkout(body);
  // 返回201(創建成功)和新訓練計劃
  res.status(201).send({ status: "成功", data: createdWorkout });
}; 

const updateOneWorkout = (req, res) => { res.send("更新訓練計劃"); }; 
const deleteOneWorkout = (req, res) => { res.send("刪除訓練計劃"); };

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

測試接收 JSON

用 Postman 或 Apifox 發送POST請求到localhost:3000/api/v1/workouts,請求體為 JSON:

{
  "name": "核心爆發",
  "mode": "20分鐘內儘可能多組",
  "equipment": ["架子", "槓鈴", "腹肌墊"],
  "exercises": [
    "15次舉腿觸槓",
    "10次火箭推",
    "30次腹肌墊卷腹"
  ],
  "trainerTips": [
    "舉腿觸槓最多分兩組完成",
    "火箭推儘量不間斷",
    "卷腹時調整呼吸節奏"
  ]
}

若返回狀態 201 和包含 ID、時間戳的新訓練計劃,則説明 API 成功接收並存儲了 JSON 數據。再訪問localhost:3000/api/v1/workouts,可看到新增的訓練計劃。

5.4 用標準 HTTP 錯誤碼響應

實際開發中,API 難免出現錯誤(如參數缺失、資源不存在等)。使用標準 HTTP 錯誤碼並返回清晰的錯誤信息,能幫助調用者快速定位問題。

常見 HTTP 錯誤碼及場景:

  • 400:請求錯誤(如參數缺失、格式錯誤)
  • 404:資源不存在(如查詢的訓練計劃 ID 不存在)
  • 500:服務器內部錯誤(如數據庫操作失敗)

步驟 1:完善“創建訓練計劃”的錯誤處理

修改數據訪問層、服務層和控制器,添加錯誤拋出和捕獲:

  1. 數據訪問層(拋出錯誤):修改src/database/Workout.js
// src/database/Workout.js
const DB = require("./db.json");
const { saveToDatabase } = require("./utils");

// 獲取所有訓練計劃(添加錯誤捕獲)
const getAllWorkouts = () => {
  try {
    return DB.workouts;
  } catch (error) {
    throw { status: 500, message: "獲取訓練計劃失敗:" + error.message };
  }
};

// 創建訓練計劃(拋出錯誤)
const createNewWorkout = (newWorkout) => {
  try {
    const isAlreadyExists = DB.workouts.findIndex(w => w.name === newWorkout.name) > -1;
    if (isAlreadyExists) {
      throw { status: 400, message: `訓練計劃"${newWorkout.name}"已存在` };
    }
    DB.workouts.push(newWorkout);
    saveToDatabase(DB);
    return newWorkout;
  } catch (error) {
    throw { status: error.status || 500, message: error.message || "創建訓練計劃失敗" };
  }
};

module.exports = { getAllWorkouts, createNewWorkout };

2.服務層(捕獲並拋出錯誤):修改src/services/workoutService.js

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

// 獲取所有訓練計劃(錯誤處理)
const getAllWorkouts = () => {
  try {
    const allWorkouts = Workout.getAllWorkouts();
    return allWorkouts;
  } catch (error) {
    throw error;
  }
}; 

// 創建訓練計劃(錯誤處理)
const createNewWorkout = (newWorkout) => {
  try {
    const workoutToAdd = {
      ...newWorkout,
      id: uuid(),
      createdAt: new Date().toLocaleString("zh-CN", { timeZone: "UTC" }),
      updatedAt: new Date().toLocaleString("zh-CN", { timeZone: "UTC" }),
    };
    const createdWorkout = Workout.createNewWorkout(workoutToAdd);
    return createdWorkout;
  } catch (error) {
    throw error;
  }
}; 

// 其他方法暫不修改
const getOneWorkout = () => { return; }; 
const updateOneWorkout = () => { return; }; 
const deleteOneWorkout = () => { return; };

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

3.控制器(捕獲錯誤並返回錯誤碼):修改src/controllers/workoutController.js

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

// 獲取所有訓練計劃(錯誤處理)
const getAllWorkouts = (req, res) => {
  try {
    const allWorkouts = workoutService.getAllWorkouts();
    res.send({ status: "成功", data: allWorkouts });
  } catch (error) {
    res.status(error.status || 500).send({ 
      status: "失敗", 
      data: { error: error.message } 
    });
  }
}; 

// 其他方法暫不修改
const getOneWorkout = (req, res) => { res.send("獲取單個訓練計劃"); }; 

// 創建訓練計劃(錯誤處理)
const createNewWorkout = (req, res) => {
  const { body } = req;
  // 驗證必填字段(400錯誤)
  if (!body.name || !body.mode || !body.equipment || !body.exercises || !body.trainerTips) {
    res.status(400).send({ 
      status: "失敗", 
      data: { error: "請求體缺少以下必填字段:'name'、'mode'、'equipment'、'exercises'、'trainerTips'" } 
    });
    return;
  }
  try {
    const createdWorkout = workoutService.createNewWorkout(body);
    res.status(201).send({ status: "成功", data: createdWorkout });
  } catch (error) {
    res.status(error.status || 500).send({ 
      status: "失敗", 
      data: { error: error.message } 
    });
  }
}; 

const updateOneWorkout = (req, res) => { res.send("更新訓練計劃"); }; 
const deleteOneWorkout = (req, res) => { res.send("刪除訓練計劃"); };

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

測試錯誤處理

  1. 重複創建同名訓練計劃:發送相同名稱的 POST 請求,返回 400 錯誤和“訓練計劃已存在”的信息;
  2. 缺失必填字段:請求體不包含name,返回 400 錯誤和“缺少必填字段”的信息。

5.5 端點名稱避免使用動詞

端點 URL 應指向資源,而非描述“動作”——因為 HTTP 方法(GET/POST/PATCH/DELETE)已經明確了動作含義,再在 URL 中加動詞會顯得冗餘且混亂。

錯誤示例(含動詞):

GET "/api/v1/getAllWorkouts"  // 冗餘:GET已表示“獲取”
POST "/api/v1/createWorkout"  // 冗餘:POST已表示“創建”
DELETE "/api/v1/deleteWorkout/123"  // 冗餘:DELETE已表示“刪除”

正確示例(無動詞,僅資源):

GET "/api/v1/workouts"  // 獲取所有訓練計劃(GET+複數資源)
POST "/api/v1/workouts"  // 創建訓練計劃(POST+複數資源)
DELETE "/api/v1/workouts/123"  // 刪除ID為123的訓練計劃(DELETE+資源+ID)

我們之前的實現已經遵循了這個最佳實踐,無需修改——核心原則是:HTTP 方法描述動作,URL 描述資源

5.6 關聯資源分組(邏輯嵌套)

當資源之間存在關聯關係時(如“訓練計劃”與“訓練記錄”),可通過 URL 嵌套實現邏輯分組,讓 API 結構更清晰。

例如,我們要為每個訓練計劃添加“會員記錄”(記錄會員完成該訓練的時間),可設計嵌套 URL:

// 獲取ID為123的訓練計劃的所有記錄
GET "/api/v1/workouts/123/records"

步驟 1:擴展模擬數據庫(添加會員數據)

修改src/database/db.json,添加members(會員)和records(記錄)字段:

{
  "workouts": [
    // 原有訓練計劃數據不變...
  ],
  "members": [
    {
      "id": "12a410bc-849f-4e7e-bfc8-4ef283ee4b19",
      "name": "Jason Miller",
      "gender": "男",
      "dateOfBirth": "1990-04-23",
      "email": "jason@mail.com",
      "password": "666349420ec497c1dc890c45179d44fb13220239325172af02d1fb6635922956"
    },
    {
      "id": "2b9130d4-47a7-4085-800e-0144f6a46059",
      "name": "Tiffany Brookston",
      "gender": "女",
      "dateOfBirth": "1996-06-09",
      "email": "tiffy@mail.com",
      "password": "8a1ea5669b749354110dcba3fac5546c16e6d0f73a37f35a84f6b0d7b3c22fcc"
    }
  ],
  "records": [
    {
      "id": "r1",
      "workoutId": "61dbae02-c147-4e28-863c-db7bd402b2d6",
      "memberId": "12a410bc-849f-4e7e-bfc8-4ef283ee4b19",
      "time": "12:30",
      "date": "2022-04-21"
    },
    {
      "id": "r2",
      "workoutId": "61dbae02-c147-4e28-863c-db7bd402b2d6",
      "memberId": "2b9130d4-47a7-4085-800e-0144f6a46059",
      "time": "14:15",
      "date": "2022-04-21"
    }
  ]
}

步驟 2:創建記錄相關文件

# 記錄控制器
touch src/controllers/recordController.js 

# 記錄服務
touch src/services/recordService.js 

# 記錄路由
touch src/v1/routes/recordRoutes.js

步驟 3:編寫記錄數據訪問層方法

src/database下創建Record.js

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

// 根據訓練計劃ID獲取記錄
const getRecordsByWorkoutId = (workoutId) => {
  return DB.records.filter(record => record.workoutId === workoutId);
};

module.exports = { getRecordsByWorkoutId };

步驟 4:編寫記錄服務層

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

// 根據訓練計劃ID獲取記錄
const getRecordsByWorkoutId = (workoutId) => {
  return Record.getRecordsByWorkoutId(workoutId);
};

module.exports = { getRecordsByWorkoutId };

步驟 5:編寫記錄控制器

// src/controllers/recordController.js
const recordService = require("../services/recordService");

// 根據訓練計劃ID獲取記錄
const getRecordsByWorkoutId = (req, res) => {
  const { workoutId } = req.params;
  if (!workoutId) {
    res.status(400).send({ status: "失敗", data: { error: "workoutId不能為空" } });
    return;
  }
  try {
    const records = recordService.getRecordsByWorkoutId(workoutId);
    res.send({ status: "成功", data: records });
  } catch (error) {
    res.status(500).send({ status: "失敗", data: { error: error.message } });
  }
};

module.exports = { getRecordsByWorkoutId };

步驟 6:編寫嵌套路由

// src/v1/routes/recordRoutes.js
const express = require("express");
const recordController = require("../../controllers/recordController");
const router = express.Router({ mergeParams: true }); // 允許訪問父路由參數

// 嵌套路由:/api/v1/workouts/:workoutId/records
router.get("/", recordController.getRecordsByWorkoutId);

module.exports = router;

步驟 7:關聯訓練計劃路由與記錄路由

修改src/v1/routes/workoutRoutes.js,引入記錄路由並配置嵌套:

// src/v1/routes/workoutRoutes.js
const express = require("express"); 
const workoutController = require("../../controllers/workoutController"); 
// 引入記錄路由
const recordRouter = require("./recordRoutes");
const router = express.Router();

// 嵌套路由:將/records掛載到/workouts/:workoutId下
router.use("/:workoutId/records", recordRouter);

// 原有CRUD路由不變
router.get("/", workoutController.getAllWorkouts);
router.get("/:workoutId", workoutController.getOneWorkout);
router.post("/", workoutController.createNewWorkout);
router.patch("/:workoutId", workoutController.updateOneWorkout);
router.delete("/:workoutId", workoutController.deleteOneWorkout);

module.exports = router;

測試嵌套路由

訪問localhost:3000/api/v1/workouts/61dbae02-c147-4e28-863c-db7bd402b2d6/records,可獲取該訓練計劃的所有會員記錄,説明嵌套路由配置成功。

5.7 其他最佳實踐(簡要説明)

由於篇幅限制,以下最佳實踐簡要介紹核心思路,可參考上述方法自行實現:

1. 集成過濾、排序與分頁

當資源數量龐大時,需支持過濾、排序和分頁,減輕服務器壓力:

  • 過濾GET /api/v1/workouts?mode=計時完成(篩選“計時完成”的訓練計劃);
  • 排序GET /api/v1/workouts?sort=createdAt&order=desc(按創建時間倒序);
  • 分頁GET /api/v1/workouts?page=1&limit=10(第 1 頁,每頁 10 條)。

實現思路:在服務層解析req.query中的參數,對數據進行過濾、排序或切片處理。

2. 用數據緩存提升性能

對頻繁訪問且更新不頻繁的數據(如熱門訓練計劃),可使用 Redis 緩存,減少數據庫查詢次數:

  • 首次請求:從數據庫獲取數據,存入 Redis;
  • 後續請求:直接從 Redis 獲取數據,若數據過期則重新從數據庫加載。

3. 良好的安全實踐

  • 身份驗證:用 JWT 或 OAuth2.0 驗證用户身份(如僅登錄用户可創建訓練計劃);
  • 權限控制:區分管理員和普通用户權限(如僅管理員可刪除訓練計劃);
  • 輸入驗證:用express-validator驗證請求參數,防止 SQL 注入或 XSS 攻擊;
  • HTTPS:生產環境強制使用 HTTPS,加密傳輸數據;
  • 限流:用express-rate-limit限制接口調用頻率,防止惡意請求。

4. 完善 API 文檔

API 文檔是調用者的使用指南,推薦用 Swagger/OpenAPI 自動生成文檔:

  • 安裝swagger-jsdocswagger-ui-express
  • 在代碼中添加 JSDoc 風格的註釋(描述端點、參數、響應等);
  • 配置 Swagger 路由,訪問/api-docs即可查看交互式文檔。

6. 總結

REST API 的最佳實踐並非一成不變的規則,而是基於“提升可用性、安全性和性能”的設計原則。本文通過一個 CrossFit 訓練應用的示例,落地了以下核心實踐:

  1. 版本控制:URL 添加版本標識,支持新舊版本並行;
  2. 資源命名:用複數形式命名資源,避免歧義;
  3. 數據格式:統一使用 JSON 接收和返回數據;
  4. 錯誤處理:用標準 HTTP 錯誤碼+清晰信息,便於調試;
  5. 端點設計:URL 指向資源,不包含動詞;
  6. 關聯資源:用嵌套路由分組關聯資源。

實際開發中,需根據項目規模和需求靈活調整(如小型 API 可簡化版本控制,大型 API 需嚴格區分版本內邏輯)。掌握這些實踐,能讓你的 API 更易於維護和使用。

擴展鏈接

數據同步功能

user avatar u_16213429 頭像
1 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.