過去幾年里,我創建并使用過很多 API。在此過程中,我遇到過各種好的和壞的實踐,也在開發和調用 API 時碰到過不少棘手的問題,但也有很多順利的時刻。
網上有很多介紹最佳實踐的文章,但在我看來,其中不少都缺乏實用性。只懂理論、沒幾個實例固然有一定價值,但我總是會想:在更真實的場景中,這些理論該如何落地?
簡單的示例能幫助我們理解概念本身,避免過多復雜性干擾,但實際開發中事情往往沒那么簡單。我相信你肯定懂這種感受 😁
這就是我決定寫這篇教程的原因。我把自己的所有經驗(好的、壞的都有)整合到這篇通俗易懂的文章里,同時提供了可跟著操作的實戰案例。最終,我們會一步步落實最佳實踐,搭建出一個完整的 API。
開始前需要明確幾點: 所謂“最佳實踐”,并非必須嚴格遵守的法律或規則,而是經過時間檢驗、被證明有效的約定或建議。其中一些如今已成為標準,但這并不意味著你必須原封不動地照搬。
它們的核心目的是為你提供方向,幫助你從用戶體驗(包括調用者和開發者)、安全性、性能三個維度優化 API。
但請記住:不同項目需要不同的解決方案。有些情況下,你可能無法或不應該遵循某條約定。因此,最終需要開發者自己或與團隊共同判斷。
好了,廢話不多說,我們開始吧!
目錄
- 示例項目介紹
- 前置要求
- 架構設計
- 基礎搭建
- REST API 最佳實踐
- 版本控制
- 資源命名使用復數形式
- 接收與返回數據采用 JSON 格式
- 用標準 HTTP 錯誤碼響應
- 端點名稱避免使用動詞
- 關聯資源分組(邏輯嵌套)
- 集成過濾、排序與分頁
- 用數據緩存提升性能
- 良好的安全實踐
- 完善 API 文檔
- 總結
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 中添加版本標識(如v1
、v2
):
// 版本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
目錄,controllers
、services
等仍在src
根目錄——這對小型 API 來說沒問題,可以讓多個版本共享這些通用邏輯。
但如果 API 規模擴大,比如 v2 需要特定的控制器或服務(修改通用邏輯可能影響舊版本),則建議將controllers
、services
也按版本拆分到對應目錄,實現版本內邏輯的完全封裝。
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 文件”的工具方法:
- 創建工具方法:在
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 };
- 更新服務層:安裝
uuid
生成唯一 ID,修改src/services/workoutService.js
:
npm i uuid
// src/services/workoutService.js
const { v4: uuid } = require("uuid"); // 生成唯一ID
const Workout = require("../database/Workout");// 獲取所有訓練計劃(不變)
const getAllWorkouts = () => {const allWorkouts = Workout.getAllWorkouts();return allWorkouts;
}; // 創建訓練計劃(添加ID、時間戳)
const createNewWorkout = (newWorkout) => {// 補充必要字段(ID、創建時間、更新時間)const workoutToAdd = {...newWorkout,id: uuid(), // 唯一IDcreatedAt: new Date().toLocaleString("zh-CN", { timeZone: "UTC" }),updatedAt: new Date().toLocaleString("zh-CN", { timeZone: "UTC" }),};const createdWorkout = Workout.createNewWorkout(workoutToAdd);return createdWorkout;
}; // 其他方法暫不修改
const getOneWorkout = () => { return; };
const updateOneWorkout = () => { return; };
const deleteOneWorkout = () => { return; };module.exports = {getAllWorkouts,getOneWorkout,createNewWorkout,updateOneWorkout,deleteOneWorkout,
};
- 更新控制器:修改
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:完善“創建訓練計劃”的錯誤處理
修改數據訪問層、服務層和控制器,添加錯誤拋出和捕獲:
- 數據訪問層(拋出錯誤):修改
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,
};
測試錯誤處理
- 重復創建同名訓練計劃:發送相同名稱的 POST 請求,返回 400 錯誤和“訓練計劃已存在”的信息;
- 缺失必填字段:請求體不包含
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-jsdoc
和swagger-ui-express
; - 在代碼中添加 JSDoc 風格的注釋(描述端點、參數、響應等);
- 配置 Swagger 路由,訪問
/api-docs
即可查看交互式文檔。
6. 總結
REST API 的最佳實踐并非一成不變的規則,而是基于“提升可用性、安全性和性能”的設計原則。本文通過一個 CrossFit 訓練應用的示例,落地了以下核心實踐:
- 版本控制:URL 添加版本標識,支持新舊版本并行;
- 資源命名:用復數形式命名資源,避免歧義;
- 數據格式:統一使用 JSON 接收和返回數據;
- 錯誤處理:用標準 HTTP 錯誤碼+清晰信息,便于調試;
- 端點設計:URL 指向資源,不包含動詞;
- 關聯資源:用嵌套路由分組關聯資源。
實際開發中,需根據項目規模和需求靈活調整(如小型 API 可簡化版本控制,大型 API 需嚴格區分版本內邏輯)。掌握這些實踐,能讓你的 API 更易于維護和使用。
擴展鏈接
數據同步功能