路由設計與參數處理:RESTful 實踐
好的路由設計是 API 成功的一半。本篇將介紹如何設計清晰、一致、易於維護的路由結構。
一、 RESTful 基礎
1.1 核心原則
REST(Representational State Transfer)的核心理念:
| 原則 | 說明 |
|---|---|
| 資源導向 | URL 代表資源,不是動作 |
| HTTP 方法 | 用方法表示操作 |
| 無狀態 | 每個請求獨立 |
| 統一介面 | 一致的 URL 模式 |
1.2 資源 vs 動作
# ❌ 動作導向(RPC 風格)
POST /getUser
POST /createUser
POST /updateUser
POST /deleteUser
# ✅ 資源導向(RESTful)
GET /users # 取得列表
GET /users/123 # 取得單一
POST /users # 建立
PUT /users/123 # 完整更新
PATCH /users/123 # 部分更新
DELETE /users/123 # 刪除1.3 HTTP 方法對應
| 方法 | 操作 | SQL 對應 | 冪等 |
|---|---|---|---|
| GET | 讀取 | SELECT | ✅ |
| POST | 建立 | INSERT | ❌ |
| PUT | 完整更新 | UPDATE | ✅ |
| PATCH | 部分更新 | UPDATE | ❌ |
| DELETE | 刪除 | DELETE | ✅ |
二、 Express 路由基礎
2.1 基本路由
javascript
const express = require("express");
const app = express();
app.get("/users", (req, res) => {
res.json([{ id: 1, name: "John" }]);
});
app.post("/users", (req, res) => {
res.status(201).json({ id: 2, ...req.body });
});
app.get("/users/:id", (req, res) => {
res.json({ id: req.params.id });
});
app.put("/users/:id", (req, res) => {
res.json({ id: req.params.id, ...req.body });
});
app.delete("/users/:id", (req, res) => {
res.status(204).send();
});2.2 路由模組化
javascript
// routes/users.js
const router = require("express").Router();
router.get("/", listUsers);
router.get("/:id", getUser);
router.post("/", createUser);
router.put("/:id", updateUser);
router.delete("/:id", deleteUser);
module.exports = router;
// app.js
const usersRouter = require("./routes/users");
app.use("/api/users", usersRouter);三、 動態路由參數
3.1 路徑參數
javascript
// /users/123
app.get("/users/:id", (req, res) => {
console.log(req.params.id); // '123'
});
// /users/123/posts/456
app.get("/users/:userId/posts/:postId", (req, res) => {
console.log(req.params.userId); // '123'
console.log(req.params.postId); // '456'
});3.2 可選參數
javascript
// /users 或 /users/json
app.get("/users/:format?", (req, res) => {
const format = req.params.format || "json";
// ...
});3.3 正則限制
javascript
// 只接受數字 ID
app.get("/users/:id(\\d+)", (req, res) => {
console.log(req.params.id); // 只有數字
});
// 通配符
app.get("/files/*", (req, res) => {
console.log(req.params[0]); // 整個路徑
});3.4 參數預處理
javascript
router.param("id", async (req, res, next, id) => {
try {
const user = await User.findById(id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
req.user = user; // 掛載到 req
next();
} catch (err) {
next(err);
}
});
// 之後的路由可以直接使用 req.user
router.get("/:id", (req, res) => {
res.json(req.user);
});
router.delete("/:id", (req, res) => {
req.user.delete();
res.status(204).send();
});四、 查詢參數
4.1 基本用法
javascript
// GET /users?page=2&limit=10&sort=name
app.get("/users", (req, res) => {
const { page = 1, limit = 10, sort = "id" } = req.query;
console.log(page); // '2' (字串!)
console.log(limit); // '10'
console.log(sort); // 'name'
});4.2 類型轉換
javascript
app.get("/users", (req, res) => {
const page = parseInt(req.query.page, 10) || 1;
const limit = Math.min(parseInt(req.query.limit, 10) || 10, 100);
// 或使用套件
// const { page, limit } = req.query
// const parsed = qs.parse(req.query, { parseNumbers: true })
});4.3 過濾器模式
javascript
// GET /users?name=John&age_gt=18&status=active
app.get("/users", (req, res) => {
const filters = {};
if (req.query.name) {
filters.name = { $regex: req.query.name, $options: "i" };
}
if (req.query.age_gt) {
filters.age = { $gt: parseInt(req.query.age_gt) };
}
if (req.query.status) {
filters.status = req.query.status;
}
const users = User.find(filters);
res.json(users);
});五、 巢狀資源
5.1 設計模式
# 使用者的文章
GET /users/:userId/posts
# 文章的留言
GET /posts/:postId/comments
# 深層巢狀(避免超過 2 層)
GET /users/:userId/posts/:postId/comments5.2 實作
javascript
// routes/users.js
const postsRouter = require("./posts");
const router = express.Router();
// 巢狀掛載
router.use("/:userId/posts", postsRouter);
// routes/posts.js
const router = express.Router({ mergeParams: true }); // 取得父路由參數
router.get("/", async (req, res) => {
const posts = await Post.find({ author: req.params.userId });
res.json(posts);
});
router.get("/:postId", async (req, res) => {
const { userId, postId } = req.params;
// ...
});5.3 扁平 vs 巢狀
javascript
// 巢狀 - 強調關係
GET /users/123/posts
// 扁平 - 獨立存取
GET /posts?author=123
// 推薦:兩者都提供六、 參數驗證
6.1 手動驗證
javascript
app.get("/users/:id", (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id) || id <= 0) {
return res.status(400).json({ error: "Invalid ID" });
}
// ...
});6.2 使用 Joi
javascript
const Joi = require("joi");
const schemas = {
userId: Joi.object({
id: Joi.string().pattern(/^\d+$/).required(),
}),
createUser: Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(0).max(150),
}),
listUsers: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(10),
sort: Joi.string().valid("name", "createdAt", "-name", "-createdAt"),
}),
};
function validate(schema, source = "body") {
return (req, res, next) => {
const { error, value } = schema.validate(req[source]);
if (error) {
return res.status(400).json({
error: "Validation Error",
details: error.details.map((d) => d.message),
});
}
req[source] = value; // 使用驗證後的值
next();
};
}
// 使用
app.get("/users/:id", validate(schemas.userId, "params"), getUser);
app.get("/users", validate(schemas.listUsers, "query"), listUsers);
app.post("/users", validate(schemas.createUser, "body"), createUser);6.3 使用 express-validator
javascript
const { param, query, body, validationResult } = require("express-validator");
const userValidation = {
getUser: [param("id").isInt({ min: 1 }).withMessage("Invalid user ID")],
listUsers: [
query("page").optional().isInt({ min: 1 }),
query("limit").optional().isInt({ min: 1, max: 100 }),
],
createUser: [
body("name").trim().isLength({ min: 2, max: 50 }),
body("email").isEmail().normalizeEmail(),
],
};
function handleValidation(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
}
app.get("/users/:id", userValidation.getUser, handleValidation, getUser);七、 URL 設計最佳實踐
7.1 命名規範
# ✅ 使用複數名詞
/users
/posts
/comments
# ❌ 避免動詞
/getUsers
/createPost
/deleteComment
# ✅ 使用連字號
/user-profiles
/order-items
# ❌ 避免底線
/user_profiles
/order_items
# ✅ 小寫
/users/123/posts
# ❌ 避免大寫
/Users/123/Posts7.2 版本控制
javascript
// URL 版本
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
// 或使用標頭
app.use("/api", (req, res, next) => {
const version = req.headers["api-version"] || "v1";
req.apiVersion = version;
next();
});7.3 過濾、排序、分頁
# 過濾
GET /users?status=active&role=admin
# 排序(- 表示降序)
GET /users?sort=name
GET /users?sort=-createdAt
# 分頁
GET /users?page=2&limit=20
GET /users?offset=20&limit=20
GET /users?cursor=abc123
# 欄位選擇
GET /users?fields=id,name,email八、 錯誤處理
8.1 一致的錯誤格式
javascript
class ApiError extends Error {
constructor(statusCode, message, details = null) {
super(message);
this.statusCode = statusCode;
this.details = details;
}
}
// 使用
throw new ApiError(404, "User not found");
throw new ApiError(400, "Validation error", errors);
// 錯誤處理中介軟體
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: {
message: err.message,
details: err.details,
...(process.env.NODE_ENV === "development" && { stack: err.stack }),
},
});
});8.2 404 處理
javascript
// 放在所有路由之後
app.use((req, res) => {
res.status(404).json({
error: {
message: "Endpoint not found",
path: req.path,
},
});
});總結
| 原則 | 說明 |
|---|---|
| 資源導向 | URL 是名詞,方法是動詞 |
| 一致命名 | 複數、小寫、連字號 |
| 合理巢狀 | 不超過 2 層 |
| 參數驗證 | 永遠不信任輸入 |
| 錯誤處理 | 統一格式 |
> **RESTful 不是教條**:實際開發中,有時需要非 CRUD 操作(如 `/users/123/activate`),這完全可以接受。
進階挑戰
- 設計一個電商 API 的路由結構(商品、購物車、訂單、評論)。
- 實作一個游標分頁(cursor pagination),比較與 offset 分頁的差異。
- 研究 HATEOAS 原則,思考如何在 API 回應中加入連結。