跳至主要內容
Skip to content

路由設計與參數處理: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/comments

5.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/Posts

7.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`),這完全可以接受。


進階挑戰

  1. 設計一個電商 API 的路由結構(商品、購物車、訂單、評論)。
  2. 實作一個游標分頁(cursor pagination),比較與 offset 分頁的差異。
  3. 研究 HATEOAS 原則,思考如何在 API 回應中加入連結。

延伸閱讀與資源