本文 AI 產出,尚未審核

ExpressJS (TypeScript) – RESTful API 設計

主題:Resource‑Based 設計


簡介

在現代 Web 應用程式中,RESTful API 已成為前後端溝通的事實標準。透過資源(Resource)導向的設計,我們可以讓 API 具備可預測性、可擴充性與一致性,進而降低前端開發與維護的成本。
對於使用 ExpressJS 搭配 TypeScript 的開發者而言,掌握資源導向的路由與資料模型,能讓程式碼更具型別安全、可讀性更高,同時也更容易符合企業級的開發規範。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你建構一套 Resource‑Based 的 RESTful API,適用於從小型專案到中大型系統的需求。


核心概念

1️⃣ 什麼是 Resource‑Based 設計?

  • 資源(Resource)代表系統中可辨識的實體,例如 usersordersproducts
  • 每個資源都有一個唯一的 URI(Uniform Resource Identifier),如 /api/users/:id
  • HTTP 方法(GET、POST、PUT、PATCH、DELETE)決定對資源的行為:
    • GET /users → 取得資源集合
    • GET /users/123 → 取得單一資源
    • POST /users → 建立新資源
    • PUT /users/123 → 完全取代資源
    • PATCH /users/123 → 部分更新資源
    • DELETE /users/123 → 刪除資源

重點:RESTful 的核心在於「以資源為中心」而非「以動作為中心」;路由的命名應該描述「什麼」而不是「怎麼做」。

2️⃣ 為什麼在 Express + TypeScript 中使用 Resource‑Based?

好處 具體說明
型別安全 TypeScript 可為每個資源的 DTO、參數與回傳值建立介面,編譯期即捕捉錯誤。
可讀性 統一的路由結構讓新加入的開發者能快速了解 API 的用途。
可測試性 每個資源的 CRUD 操作可以獨立寫單元測試與整合測試。
易於擴充 新增子資源(如 /users/:id/orders)只需在現有結構上延伸。

3️⃣ 基本目錄結構(建議)

src/
├─ controllers/          # 處理請求與回應的函式
│   └─ user.controller.ts
├─ routes/               # 資源路由定義
│   └─ user.routes.ts
├─ services/             # 業務邏輯
│   └─ user.service.ts
├─ models/               # TypeScript 介面 / DTO
│   └─ user.model.ts
├─ middlewares/          # 請求驗證、錯誤處理等
├─ app.ts                 # Express 初始化
└─ server.ts              # 啟動 HTTP server

程式碼範例

以下範例以 User 資源為例,示範從模型、服務層、控制器到路由的完整流程。所有檔案均使用 TypeScript,並以 express-validator 做基本參數驗證。

1️⃣ 建立資料模型(src/models/user.model.ts

// src/models/user.model.ts
export interface User {
  /** 使用者唯一識別碼 */
  id: string;
  /** 使用者名稱 */
  name: string;
  /** 電子郵件,必須符合 email 格式 */
  email: string;
  /** 建立時間(ISO 8601) */
  createdAt: string;
}

/** 建立新使用者時的請求 DTO */
export interface CreateUserDTO {
  name: string;
  email: string;
}

/** 部分更新使用者時的請求 DTO */
export interface UpdateUserDTO {
  name?: string;
  email?: string;
}

2️⃣ 服務層實作(src/services/user.service.ts

// src/services/user.service.ts
import { User, CreateUserDTO, UpdateUserDTO } from '../models/user.model';
import { v4 as uuidv4 } from 'uuid';

class UserService {
  /** 暫存資料(實務上會換成 DB) */
  private users: Map<string, User> = new Map();

  /** 取得所有使用者 */
  getAll(): User[] {
    return Array.from(this.users.values());
  }

  /** 依 ID 取得單一使用者 */
  getById(id: string): User | undefined {
    return this.users.get(id);
  }

  /** 建立新使用者 */
  create(payload: CreateUserDTO): User {
    const id = uuidv4();
    const newUser: User = {
      id,
      name: payload.name,
      email: payload.email,
      createdAt: new Date().toISOString(),
    };
    this.users.set(id, newUser);
    return newUser;
  }

  /** 完全取代使用者(PUT) */
  replace(id: string, payload: CreateUserDTO): User | undefined {
    if (!this.users.has(id)) return undefined;
    const updated: User = {
      id,
      name: payload.name,
      email: payload.email,
      createdAt: this.users.get(id)!.createdAt,
    };
    this.users.set(id, updated);
    return updated;
  }

  /** 部分更新使用者(PATCH) */
  update(id: string, payload: UpdateUserDTO): User | undefined {
    const existing = this.users.get(id);
    if (!existing) return undefined;
    const updated: User = {
      ...existing,
      ...payload,
    };
    this.users.set(id, updated);
    return updated;
  }

  /** 刪除使用者 */
  delete(id: string): boolean {
    return this.users.delete(id);
  }
}

export const userService = new UserService();

3️⃣ 控制器(src/controllers/user.controller.ts

// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import {
  CreateUserDTO,
  UpdateUserDTO,
} from '../models/user.model';
import { userService } from '../services/user.service';

/** 取得使用者集合 */
export const getUsers = (req: Request, res: Response) => {
  const users = userService.getAll();
  res.json(users);
};

/** 取得單一使用者 */
export const getUser = (req: Request, res: Response, next: NextFunction) => {
  const user = userService.getById(req.params.id);
  if (!user) return res.status(404).json({ message: 'User not found' });
  res.json(user);
};

/** 建立使用者 */
export const createUser = (
  req: Request<{}, {}, CreateUserDTO>,
  res: Response,
) => {
  const newUser = userService.create(req.body);
  res.status(201).json(newUser);
};

/** 完全取代使用者 */
export const replaceUser = (
  req: Request<{ id: string }, {}, CreateUserDTO>,
  res: Response,
) => {
  const updated = userService.replace(req.params.id, req.body);
  if (!updated) return res.status(404).json({ message: 'User not found' });
  res.json(updated);
};

/** 部分更新使用者 */
export const updateUser = (
  req: Request<{ id: string }, {}, UpdateUserDTO>,
  res: Response,
) => {
  const updated = userService.update(req.params.id, req.body);
  if (!updated) return res.status(404).json({ message: 'User not found' });
  res.json(updated);
};

/** 刪除使用者 */
export const deleteUser = (req: Request, res: Response) => {
  const success = userService.delete(req.params.id);
  if (!success) return res.status(404).json({ message: 'User not found' });
  res.status(204).send();
};

4️⃣ 路由定義(src/routes/user.routes.ts

// src/routes/user.routes.ts
import { Router } from 'express';
import {
  getUsers,
  getUser,
  createUser,
  replaceUser,
  updateUser,
  deleteUser,
} from '../controllers/user.controller';
import { body, param } from 'express-validator';
import { validate } from '../middlewares/validation.middleware';

const router = Router();

/* GET /api/users */
router.get('/', getUsers);

/* GET /api/users/:id */
router.get(
  '/:id',
  param('id').isUUID(),
  validate,
  getUser,
);

/* POST /api/users */
router.post(
  '/',
  body('name').isString().notEmpty(),
  body('email').isEmail(),
  validate,
  createUser,
);

/* PUT /api/users/:id */
router.put(
  '/:id',
  param('id').isUUID(),
  body('name').isString().notEmpty(),
  body('email').isEmail(),
  validate,
  replaceUser,
);

/* PATCH /api/users/:id */
router.patch(
  '/:id',
  param('id').isUUID(),
  body('name').optional().isString(),
  body('email').optional().isEmail(),
  validate,
  updateUser,
);

/* DELETE /api/users/:id */
router.delete(
  '/:id',
  param('id').isUUID(),
  validate,
  deleteUser,
);

export default router;

5️⃣ 全域錯誤與驗證中介層(src/middlewares/validation.middleware.ts

// src/middlewares/validation.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { validationResult } from 'express-validator';

/** 驗證失敗時回傳 400 並列出錯誤訊息 */
export const validate = (req: Request, res: Response, next: NextFunction) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({
      message: 'Invalid request parameters',
      errors: errors.array(),
    });
  }
  next();
};

6️⃣ 組合 Express 應用(src/app.ts

// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import userRoutes from './routes/user.routes';

const app = express();

app.use(cors());
app.use(helmet());
app.use(express.json());

/* 前綴路徑統一使用 /api */
app.use('/api/users', userRoutes);

// 404 處理
app.use((_req, res) => {
  res.status(404).json({ message: 'Resource not found' });
});

export default app;

常見陷阱與最佳實踐

陷阱 解釋 最佳實踐
把動作寫進 URL /getUser/createUser 破壞資源導向的概念。 使用 HTTP 動詞(GET、POST…)搭配資源名稱。
過度嵌套路由 /api/users/123/orders/456/items/789 深度過深,維護成本高。 只保留必要層級,如 /api/users/:userId/orders,子資源可在 controller 中做關聯驗證。
忽略驗證 直接使用 req.body 可能導致資料不一致或安全漏洞。 使用 express-validatorzodclass-validator,並在 TypeScript 中定義 DTO。
返回不一致的狀態碼 例如成功卻回 200 而非 201,或刪除成功卻回 200。 遵循 RFC 7231GET → 200,POST → 201,PUT/PATCH → 200/204,DELETE → 204。
把業務邏輯寫在控制器 控制器變得龐大且難以測試。 將業務邏輯抽離至 Service 層,控制器只負責請求/回應。

實際應用場景

  1. 電商平台的商品管理

    • 資源:/api/products/api/categories/api/orders/:orderId/items
    • 使用 Resource‑Based 設計,可讓前端一次取得商品清單、單品細節或訂單項目,且不必為每個動作額外建立 API。
  2. 社交媒體的貼文與評論

    • 資源:/api/posts/api/posts/:postId/comments
    • 透過子資源 (comments) 表示「貼文下的評論」關係,並以 POST /posts/:postId/comments 新增評論,保持 URL 的語意清晰。
  3. 企業內部的員工目錄與部門

    • 資源:/api/employees/api/departments/:deptId/employees
    • 可利用 GET /departments/:deptId/employees 直接查詢某部門的員工,避免前端自行過濾大量資料。
  4. IoT 裝置管理平台

    • 資源:/api/devices/api/devices/:id/status/api/devices/:id/commands
    • 透過 PATCH /devices/:id/status 更新裝置狀態,POST /devices/:id/commands 發送指令,保持 API 與裝置操作的一致性。

總結

  • Resource‑Based 設計 是構建可預測、可維護的 RESTful API 的核心概念。
  • ExpressJS + TypeScript 的環境下,我們可以透過 介面、DTO、Service 層 以及 路由分層,把資源與操作清晰分離,同時享受型別安全的好處。
  • 遵守 HTTP 方法語意、統一路由結構、加入驗證與錯誤處理,能有效避免常見陷阱,提升 API 的可靠度。
  • 最後,將資源導向的思維延伸至實際業務(電商、社交、企業系統、IoT 等),即可在不同領域快速建立 一致且易於擴充 的後端服務。

從今天起,先從資源的角度思考你的 API,讓每一條路由都說明「這是什麼」而非「怎麼做」吧!