本文 AI 產出,尚未審核
ExpressJS (TypeScript) – RESTful API 設計
主題:Resource‑Based 設計
簡介
在現代 Web 應用程式中,RESTful API 已成為前後端溝通的事實標準。透過資源(Resource)導向的設計,我們可以讓 API 具備可預測性、可擴充性與一致性,進而降低前端開發與維護的成本。
對於使用 ExpressJS 搭配 TypeScript 的開發者而言,掌握資源導向的路由與資料模型,能讓程式碼更具型別安全、可讀性更高,同時也更容易符合企業級的開發規範。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你建構一套 Resource‑Based 的 RESTful API,適用於從小型專案到中大型系統的需求。
核心概念
1️⃣ 什麼是 Resource‑Based 設計?
- 資源(Resource)代表系統中可辨識的實體,例如
users、orders、products。 - 每個資源都有一個唯一的 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-validator、zod 或 class-validator,並在 TypeScript 中定義 DTO。 |
| 返回不一致的狀態碼 | 例如成功卻回 200 而非 201,或刪除成功卻回 200。 | 遵循 RFC 7231:GET → 200,POST → 201,PUT/PATCH → 200/204,DELETE → 204。 |
| 把業務邏輯寫在控制器 | 控制器變得龐大且難以測試。 | 將業務邏輯抽離至 Service 層,控制器只負責請求/回應。 |
實際應用場景
電商平台的商品管理
- 資源:
/api/products、/api/categories、/api/orders/:orderId/items - 使用 Resource‑Based 設計,可讓前端一次取得商品清單、單品細節或訂單項目,且不必為每個動作額外建立 API。
- 資源:
社交媒體的貼文與評論
- 資源:
/api/posts、/api/posts/:postId/comments - 透過子資源 (
comments) 表示「貼文下的評論」關係,並以POST /posts/:postId/comments新增評論,保持 URL 的語意清晰。
- 資源:
企業內部的員工目錄與部門
- 資源:
/api/employees、/api/departments/:deptId/employees - 可利用
GET /departments/:deptId/employees直接查詢某部門的員工,避免前端自行過濾大量資料。
- 資源:
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,讓每一條路由都說明「這是什麼」而非「怎麼做」吧!