ExpressJS (TypeScript) – API 版本管理:設計 /v1、/v2 結構
簡介
在 Web 服務的生命週期中,API 版本管理 是不可或缺的議題。隨著功能持續迭代、需求改變或資料結構調整,舊有的介面若直接改寫會導致已經整合的前端或第三方系統失效,進而影響業務運作。透過明確的版本號 (/v1、/v2 …) 來隔離不同階段的 API,不僅能保證向後相容,也讓開發團隊在佈署新功能時更具彈性。
在 ExpressJS 搭配 TypeScript 的環境裡,我們可以利用路由模組化、型別定義與中介層(middleware)來建立乾淨、可維護的版本化結構。本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶領讀者完成一套完整的 /v1、/v2 API 設計。
核心概念
1. 為什麼要使用路由前綴(Prefix)?
- 清晰的路徑:
/api/v1/users、/api/v2/users一目了然是哪個版本的介面。 - 分離實作:不同版本的控制器與服務可以放在不同目錄,避免相互干擾。
- 逐步淘汰:舊版路由可以在未來的升級計畫中逐步下線,而不必一次搬遷全部程式碼。
2. 目錄結構建議
src/
├─ api/
│ ├─ v1/
│ │ ├─ controllers/
│ │ │ └─ user.controller.ts
│ │ ├─ routes/
│ │ │ └─ user.route.ts
│ │ └─ types/
│ │ └─ user.dto.ts
│ └─ v2/
│ ├─ controllers/
│ │ └─ user.controller.ts
│ ├─ routes/
│ │ └─ user.route.ts
│ └─ types/
│ └─ user.dto.ts
├─ middlewares/
│ └─ versionHandler.ts
└─ app.ts
- controllers:實作業務邏輯,回傳符合該版本 DTO(Data Transfer Object)。
- routes:只負責路由定義與參數驗證,保持單一職責。
- types:存放 TypeScript 型別,讓編譯器在開發階段即捕捉錯誤。
3. 使用 Express Router 進行版本化
// src/api/v1/routes/user.route.ts
import { Router } from 'express';
import { getAllUsersV1, createUserV1 } from '../controllers/user.controller';
const router = Router();
/**
* GET /api/v1/users
* 取得全部使用者(v1 版)
*/
router.get('/', getAllUsersV1);
/**
* POST /api/v1/users
* 建立新使用者(v1 版)
*/
router.post('/', createUserV1);
export default router;
// src/api/v2/routes/user.route.ts
import { Router } from 'express';
import { getAllUsersV2, createUserV2 } from '../controllers/user.controller';
const router = Router();
/**
* GET /api/v2/users
* 取得全部使用者(v2 版)- 支援分頁與搜尋
*/
router.get('/', getAllUsersV2);
/**
* POST /api/v2/users
* 建立新使用者(v2 版)- 使用者資料更嚴格驗證
*/
router.post('/', createUserV2);
export default router;
4. 在 app.ts 中掛載版本路由
// src/app.ts
import express from 'express';
import userV1Router from './api/v1/routes/user.route';
import userV2Router from './api/v2/routes/user.route';
import versionHandler from './middlewares/versionHandler';
const app = express();
app.use(express.json());
// 讓所有 API 路徑都先經過 versionHandler,可在此統一記錄或檢查版本
app.use('/api/v1', versionHandler('v1'), userV1Router);
app.use('/api/v2', versionHandler('v2'), userV2Router);
// 全域錯誤處理
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).json({ message: 'Internal Server Error' });
});
export default app;
5. 中介層(Middleware)範例 – 版本資訊注入
// src/middlewares/versionHandler.ts
import { Request, Response, NextFunction } from 'express';
/**
* 依據路徑前綴注入版本資訊至 req 物件
* 方便後續 controller 直接取得目前執行的 API 版本
*/
export default function versionHandler(version: string) {
return (req: Request, _res: Response, next: NextFunction) => {
// 在 TypeScript 中自行擴充 Request 型別
(req as any).apiVersion = version;
console.log(`[Version] ${version} - ${req.method} ${req.originalUrl}`);
next();
};
}
小技巧:若要在 TypeScript 中安全使用
req.apiVersion,可以在src/types/express.d.ts裡擴充Express.Request介面。
// src/types/express.d.ts
declare namespace Express {
export interface Request {
apiVersion?: string;
}
}
6. 控制器實作 – 依版本回傳不同 DTO
// src/api/v1/controllers/user.controller.ts
import { Request, Response } from 'express';
import { UserV1DTO } from '../types/user.dto';
// 假資料
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
export function getAllUsersV1(_req: Request, res: Response) {
// V1 只回傳 id 與 name
const result: UserV1DTO[] = users.map(u => ({ id: u.id, name: u.name }));
res.json(result);
}
export function createUserV1(req: Request, res: Response) {
const { name } = req.body as Partial<UserV1DTO>;
if (!name) {
return res.status(400).json({ message: 'Name is required' });
}
const newUser = { id: Date.now(), name };
users.push(newUser);
res.status(201).json(newUser);
}
// src/api/v2/controllers/user.controller.ts
import { Request, Response } from 'express';
import { UserV2DTO } from '../types/user.dto';
// 假資料(加入 email 欄位)
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
export function getAllUsersV2(req: Request, res: Response) {
// 支援分頁參數
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 10;
const start = (page - 1) * limit;
const data = users.slice(start, start + limit).map(u => ({
id: u.id,
name: u.name,
email: u.email,
})) as UserV2DTO[];
res.json({
page,
limit,
total: users.length,
data,
});
}
export function createUserV2(req: Request, res: Response) {
const { name, email } = req.body as Partial<UserV2DTO>;
if (!name || !email) {
return res.status(400).json({ message: 'Name and email are required' });
}
// 簡易 email 格式檢查
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ message: 'Invalid email format' });
}
const newUser = { id: Date.now(), name, email };
users.push(newUser);
res.status(201).json(newUser);
}
7. DTO(Data Transfer Object)範例
// src/api/v1/types/user.dto.ts
export interface UserV1DTO {
id: number;
name: string;
}
// src/api/v2/types/user.dto.ts
export interface UserV2DTO {
id: number;
name: string;
email: string;
}
透過 型別分離,即使在同一個目錄下有 UserV1DTO 與 UserV2DTO,編譯器也能保證每個版本只能使用對應的結構,降低資料不一致的風險。
常見陷阱與最佳實踐
| 陷阱 | 為何會發生 | 解決方案 / 最佳實踐 |
|---|---|---|
| 路由重複定義 | 把所有版本的路由寫在同一個 router,容易產生衝突或忘記加版本前綴。 |
為每個版本建立獨立的 Router,並在 app.ts 中分別掛載。 |
| 型別混用 | 直接引用 any 或共用 DTO,導致 v1 呼叫到 v2 的欄位。 |
使用 namespace 或 文件夾分層,讓 TypeScript 能分辨 UserV1DTO 與 UserV2DTO。 |
| 忘記升級測試 | 只測試 v1,部署 v2 後未檢查相容性。 | 為每個版本寫 獨立的測試案例(如 Jest),並在 CI 中同時執行。 |
| 硬編碼版本字串 | 在多處硬寫 '/v1'、'/v2',未來改版時需要大量修改。 |
把版本字串抽成 常數(例如 enum ApiVersion { V1 = 'v1', V2 = 'v2' }),統一管理。 |
| 過度版本堆疊 | 每次小改動就新增 v3、v4,造成版本過多難以維護。 |
盡量 向後相容(例如新增欄位、使用可選屬性),僅在破壞性變更時才升級大版本。 |
進階最佳實踐
使用 OpenAPI (Swagger) 產生文件
為每個版本分別建立swagger.json,讓前端與第三方開發者清楚知道差異。統一錯誤格式
建議所有版本回傳的錯誤結構保持一致,例如{ code: number, message: string, details?: any },即使錯誤訊息內容會因版本不同而調整。自動化版本檢測
在versionHandler中可加入 Header 檢查(如Accept: application/vnd.myapi.v2+json),讓客戶端明確告知想使用的版本。漸進式淘汰
設定 Deprecation Header(X-API-Deprecated: true; version=v1),並在文件中標示何時停止支援。
實際應用場景
| 場景 | 為何需要版本化 | 可能的實作方式 |
|---|---|---|
| 行動 App 需要穩定的 API | 手機端更新頻率較慢,若後端立即變更介面會導致舊版 App 崩潰。 | 保留 v1 作為長期支援版,僅在 v2 中新增功能,逐步引導使用者升級 App。 |
| 第三方合作夥伴 | 合作夥伴的系統可能在不同時間點接入,不能保證同時升級。 | 為合作夥伴提供 專屬版本(如 v2-partner),或使用 Feature Toggle 依客戶決定功能開關。 |
| 微服務間的內部 API | 內部服務的資料模型演變頻繁,需要避免相依服務因升級失效。 | 采用 Semantic Versioning,在服務註冊中心(如 Consul)中標示版本,呼叫方根據需求選擇。 |
| A/B 測試新功能 | 想同時測試舊版與新版的行為差異。 | 在 v2 中加入新欄位或新端點,前端根據使用者分群決定呼叫 v1 或 v2。 |
總結
- API 版本化 能讓系統在持續演進的同時保持向後相容,降低升級風險。
- 在 ExpressJS + TypeScript 中,透過 Router 前綴、獨立目錄結構、型別分離,可建立乾淨且可測試的
/v1、/v2架構。 - 實作時要特別注意 路由重複、型別混用、測試缺失 等常見陷阱,並遵循 最佳實踐(如使用常數、OpenAPI、漸進式淘汰)。
- 真實案例如行動 App、第三方合作、微服務與 A/B 測試,都能從版本化中受益,提升開發與維運效率。
透過本文的概念與範例,你現在應該能在自己的 ExpressJS 專案裡,快速且安全地加入 API 版本管理,讓服務在未來的迭代中更具彈性與可預測性。祝開發順利! 🚀