ExpressJS (TypeScript) – API 版本管理
主題:維護多版本 API 的策略
簡介
在現代 Web 開發中,API 不只是前後端溝通的橋樑,更是產品持續演進的核心。隨著功能需求變更、資料結構重構或安全規範升級,舊有的 API 可能必須保留一段時間,以避免影響已上線的客戶端應用。若沒有妥善的版本管理策略,開發團隊很容易陷入「改了某個欄位,所有使用者都當機」的慘況。
本單元將說明在 ExpressJS + TypeScript 專案中,如何設計、實作與維護多版本 API。文章以淺顯易懂的語言切入,提供完整範例、常見陷阱與最佳實踐,協助初學者快速上手,同時也給中級開發者一套可擴充、可維護的方案。
核心概念
1️⃣ 版本號的定位方式
API 版本號可以放在 URL 路徑、請求 Header,或 Query String。在 Express 中最常見且最直觀的方式是將版本號寫入路徑,例如:
/api/v1/users
/api/v2/users
- 優點:路由一目了然,瀏覽器、測試工具皆可直接看到版本。
- 缺點:若要支援大量版本,路徑會變長,且需要在路由層面重複定義。
實務建議:對外公開的 API 建議使用路徑版本,內部微服務間可考慮 Header 或 Query 方式,以減少 URL 複雜度。
2️⃣ 基於路由分離的模組化設計
將不同版本的路由、控制器、DTO(Data Transfer Object)分別放在獨立目錄,讓 版本升級不會牽連其他版本的程式碼。以下是一個典型的目錄結構:
src/
├─ api/
│ ├─ v1/
│ │ ├─ routes/
│ │ │ └─ user.routes.ts
│ │ ├─ controllers/
│ │ │ └─ user.controller.ts
│ │ └─ dtos/
│ │ └─ user.dto.ts
│ └─ v2/
│ ├─ routes/
│ │ └─ user.routes.ts
│ ├─ controllers/
│ │ └─ user.controller.ts
│ └─ dtos/
│ └─ user.dto.ts
└─ server.ts
這樣的結構讓每個版本都是 自包含(self‑contained) 的模組,未來若要棄用 v1,只需要在 server.ts 中移除對應的路由掛載。
3️⃣ 中介層(Middleware)統一處理跨版本需求
雖然每個版本的路由與控制器是獨立的,但仍有一些 共通功能 必須在所有版本上執行,例如:
- 請求驗證(JWT、API Key)
- 請求日誌(logging)
- 錯誤格式化(統一回傳格式)
可以在根路由層級掛載這些中介層,確保所有版本都受益。例如:
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!);
// @ts-ignore: payload is attached for downstream use
req.user = payload;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid token' });
}
};
在 server.ts 中:
import express from 'express';
import { authenticate } from './middleware/auth';
import v1Routes from './api/v1/routes';
import v2Routes from './api/v2/routes';
const app = express();
app.use(express.json());
app.use(authenticate); // **所有版本共用的驗證中介層**
app.use('/api/v1', v1Routes); // v1 路由
app.use('/api/v2', v2Routes); // v2 路由
export default app;
4️⃣ DTO 與驗證規則的版本化
使用 class‑validator 搭配 class‑transformer 可以在 TypeScript 中為每個版本定義不同的 DTO,確保資料結構的變動不會影響舊版 API。
// src/api/v1/dtos/user.dto.ts
import { IsString, IsEmail } from 'class-validator';
export class CreateUserV1Dto {
@IsString()
name!: string;
@IsEmail()
email!: string;
}
// src/api/v2/dtos/user.dto.ts
import { IsString, IsEmail, IsOptional, IsArray } from 'class-validator';
export class CreateUserV2Dto {
@IsString()
name!: string;
@IsEmail()
email!: string;
/** v2 新增的欄位:使用者的角色列表 */
@IsArray()
@IsOptional()
roles?: string[];
}
在控制器中使用:
// src/api/v2/controllers/user.controller.ts
import { Request, Response } from 'express';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateUserV2Dto } from '../dtos/user.dto';
export const createUser = async (req: Request, res: Response) => {
const dto = plainToInstance(CreateUserV2Dto, req.body);
const errors = await validate(dto);
if (errors.length > 0) {
return res.status(400).json({ errors });
}
// TODO: 實作建立使用者的商業邏輯
res.status(201).json({ message: 'User created (v2)', data: dto });
};
5️⃣ 版本路由的自動載入(Optional)
當 API 版本數量增多時,手動在 server.ts 中掛載每個版本會變得繁瑣。可以利用 Node 的 fs 模組 動態載入:
// src/server.ts
import express from 'express';
import fs from 'fs';
import path from 'path';
import { authenticate } from './middleware/auth';
const app = express();
app.use(express.json());
app.use(authenticate);
const apiDir = path.join(__dirname, 'api');
fs.readdirSync(apiDir).forEach((version) => {
const versionPath = path.join(apiDir, version);
if (fs.statSync(versionPath).isDirectory()) {
const routes = require(path.join(versionPath, 'routes')).default;
app.use(`/api/${version}`, routes);
console.log(`Mounted API version: ${version}`);
}
});
export default app;
注意:自動載入適合在 開發環境 或 小型專案 使用;正式上線時仍建議明確列出每個版本,以避免意外載入未測試的路由。
程式碼範例(實用範例 3‑5 個)
範例 1:最簡單的路徑版本路由
// src/api/v1/routes/user.routes.ts
import { Router } from 'express';
import { getAllUsers, createUser } from '../controllers/user.controller';
const router = Router();
router.get('/users', getAllUsers);
router.post('/users', createUser);
export default router;
// src/api/v2/routes/user.routes.ts
import { Router } from 'express';
import { getAllUsersV2, createUserV2 } from '../controllers/user.controller';
const router = Router();
router.get('/users', getAllUsersV2); // v2 版支援分頁參數
router.post('/users', createUserV2);
export default router;
重點:路由檔案只負責 定義 URL,實際業務邏輯交給對應的控制器。
範例 2:控制器內部的版本差異(分頁 vs. 不分頁)
// src/api/v1/controllers/user.controller.ts
import { Request, Response } from 'express';
export const getAllUsers = async (_req: Request, res: Response) => {
// 假設從資料庫一次抓全部
const users = await UserModel.findAll();
res.json({ data: users });
};
// src/api/v2/controllers/user.controller.ts
import { Request, Response } from 'express';
export const getAllUsersV2 = async (req: Request, res: Response) => {
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 20;
const offset = (page - 1) * limit;
const [users, total] = await Promise.all([
UserModel.findAll({ offset, limit }),
UserModel.count(),
]);
res.json({
data: users,
meta: { page, limit, total },
});
};
技巧:在 v2 中加入分頁參數,保持向後相容,舊版客戶端仍可呼叫
/api/v1/users取得完整清單。
範例 3:使用 Header 方式的版本切換(適合內部 API)
// src/middleware/apiVersion.ts
import { Request, Response, NextFunction } from 'express';
export const apiVersion = (req: Request, res: Response, next: NextFunction) => {
const version = req.headers['x-api-version']?.toString() || '1';
// 把版本資訊掛到 req 物件,供之後的路由使用
// @ts-ignore
req.apiVersion = version;
next();
};
// src/api/router.ts
import { Router } from 'express';
import v1Routes from './v1/routes';
import v2Routes from './v2/routes';
import { apiVersion } from '../middleware/apiVersion';
const router = Router();
router.use(apiVersion);
// 根據 Header 動態分派
router.use((req, res, next) => {
// @ts-ignore
const version = req.apiVersion;
if (version === '2') return v2Routes(req, res, next);
return v1Routes(req, res, next);
});
export default router;
適用情境:當前端或第三方服務需要在同一 URL 上切換版本,且不想改動路徑結構時,可透過自訂 Header (
X-API-Version) 來完成。
範例 4:錯誤回傳的版本化(保持向後相容)
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
export const errorHandler = (
err: any,
_req: Request,
res: Response,
_next: NextFunction,
) => {
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
// 依照請求的 API 版本回傳不同格式
// @ts-ignore
const version = _req.apiVersion || '1';
if (version === '2') {
return res.status(status).json({
error: {
code: status,
message,
timestamp: new Date().toISOString(),
},
});
}
// v1 版的簡易格式
res.status(status).json({ error: message });
};
在 server.ts 中最後掛載:
app.use(errorHandler);
說明:v2 引入了更結構化的錯誤物件,舊版仍保留簡潔的字串訊息,避免破壞既有客戶端。
範例 5:自動載入所有版本(開發便利)
// src/api/index.ts
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
const router = Router();
const apiRoot = __dirname;
fs.readdirSync(apiRoot).forEach((folder) => {
const versionPath = path.join(apiRoot, folder);
if (fs.statSync(versionPath).isDirectory() && folder.startsWith('v')) {
const versionRouter = require(path.join(versionPath, 'routes')).default;
router.use(`/${folder}`, versionRouter);
console.log(`🟢 Loaded API ${folder}`);
}
});
export default router;
提示:此方式僅建議在測試或原型階段使用,正式環境仍建議明確列出每個版本,以減少不必要的載入成本與安全風險。
常見陷阱與最佳實踐
| 陷阱 | 可能造成的問題 | 最佳實踐 |
|---|---|---|
把版本號寫在 query string (?v=1) |
版號容易被忽略,快取(CDN)失效 | 使用路徑或 Header,確保版本資訊明確且不易遺漏 |
| 所有版本共用同一個 controller | 變更會同時影響舊版,導致向後相容性失敗 | 分離 controller,每個版本保持獨立 |
| DTO 直接引用舊版模型 | 新增欄位會自動流入舊版回傳,破壞合約 | 為每個版本建立獨立 DTO,使用 class-transformer 控制序列化 |
| 忘記在新版路由掛載共用中介層 | 安全檢查、日誌等功能在新版本失效 | 在根層級掛載,或使用 router.use() 為每個版本加上相同中介層 |
| 版本過多導致路由檔案雜亂 | 難以維護、測試覆蓋率下降 | 定期檢視使用率,將 低使用率的版本 標記為 deprecated,最後移除 |
| 錯誤回傳格式不一致 | 前端必須寫多套錯誤處理程式 | 在 errorHandler 中根據版本統一回傳結構,或在升級時提供 遷移指南 |
最佳實踐總結:
- 路徑版本是最直觀、最易於文件化的方式。
- 每個版本獨立模組化,包括路由、controller、DTO、service。
- 共用中介層放在根路由,確保安全與日誌一致。
- 使用 class-validator + class-transformer 讓資料驗證與轉換保持可讀性。
- 提供清晰的棄用流程:在文件、回應 header 中加入
Deprecation或Sunset訊息,給客戶端足夠時間遷移。
實際應用場景
| 場景 | 為何需要多版本 API | 推薦的實作方式 |
|---|---|---|
| 行動 App 需要長期支援舊版 | 手機作業系統升級緩慢,舊版 App 仍在使用舊 API | 使用 路徑版本,同時在舊版路由加入 Deprecation 標頭,提醒開發者升級 |
| 第三方合作夥伴的 SDK 需要穩定介面 | 合作夥伴的部署週期較長,無法快速跟隨 API 變更 | Header 版號 + 版本化 DTO,讓合作夥伴自行決定升級時機 |
| 微服務間的內部呼叫 | 內部服務頻繁迭代,但不想影響外部使用者 | Query String 或 Header 方式,僅在內部服務間使用,降低 URL 雜訊 |
| 逐步淘汰舊功能 | 某些功能已不再維護,需要逐步下線 | 在舊版路由回傳 410 Gone 並在回應中提供 遷移文件 URL |
| A/B 測試新功能 | 想要讓部分使用者先體驗新 API | 自訂 Header (X-Feature-Flag) 搭配版本路由,僅對特定使用者開放新功能 |
總結
多版本 API 的管理不僅是 技術挑戰,更是一項 產品策略。透過本文所介紹的 路徑版本、模組化設計、DTO 版本化、共用中介層 以及 錯誤回傳的統一化,開發者可以在 ExpressJS + TypeScript 的環境中,快速建立可擴充、可維護且向後相容的 API。
關鍵要點
- 版本號要明確(路徑或 Header),避免混淆。
- 每個版本保持獨立,包括路由、控制器與資料結構。
- 共用功能放在根中介層,確保安全與日誌一致。
- 提供棄用與遷移資訊,讓使用者有時間升級。
只要遵循上述原則,即使未來 API 數量激增,也能夠以乾淨、可測試的方式持續迭代,為產品的長期成功奠定堅實基礎。祝開發順利,API 版本管理玩得開心! 🎉