ExpressJS (TypeScript) – API 版本管理
主題:抽象化共用邏輯
簡介
在現代的 Web 服務中,API 版號管理是不可或缺的需求。隨著功能持續演進,我們往往需要同時支援 v1、v2、甚至 beta 版的介面,才能讓舊有客戶端平順升級。若每個版本的路由、驗證、錯誤處理都各自寫一次,程式碼很快就會變得 冗長且難以維護。
抽象化共用邏輯的目的,就是把 版本無關 的部份抽離出來,形成可重複使用的模組或函式。這樣不僅能減少重複程式碼,還能確保 行為一致性、錯誤處理統一,同時讓新版本的開發成本大幅下降。
本文將以 Express + TypeScript 為基礎,說明如何設計抽象層、實作範例,並分享常見陷阱與最佳實踐,幫助你在實務專案中快速落地 API 版號管理的抽象化策略。
核心概念
1️⃣ 版本路由的抽象化
最直接的抽象方式是建立一個 VersionedRouter 工具,讓每個版本只需要提供「該版本的路由定義」即可,路由前綴、錯誤捕捉等共用邏輯由工具負責。
// src/router/versionedRouter.ts
import { Router, Request, Response, NextFunction } from 'express';
/**
* 建立一個帶有版本前綴的 Router,並自動掛載共用 middleware
* @param version 版本字串,例如 'v1'、'v2'
* @param defineRoutes 讓使用者在此回呼中定義版本專屬路由
*/
export function createVersionedRouter(
version: string,
defineRoutes: (router: Router) => void,
): Router {
const router = Router();
// 1️⃣ 共用 middleware:記錄請求資訊
router.use((req: Request, _res: Response, next: NextFunction) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path} (API ${version})`);
next();
});
// 2️⃣ 讓呼叫端注入該版本的路由
defineRoutes(router);
// 3️⃣ 統一錯誤回傳格式
router.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
console.error(`Error in API ${version}:`, err);
res.status(err.status || 500).json({
success: false,
version,
message: err.message || 'Internal Server Error',
});
});
return router;
}
使用方式:
// src/routes/v1/user.ts
import { Router, Request, Response } from 'express';
import { createVersionedRouter } from '../../router/versionedRouter';
export const userV1Router = createVersionedRouter('v1', (router: Router) => {
router.get('/users', async (req: Request, res: Response) => {
// 這裡放 v1 版的商業邏輯
res.json({ success: true, data: [] });
});
});
在 app.ts 中僅需一次掛載:
import express from 'express';
import { userV1Router } from './routes/v1/user';
import { userV2Router } from './routes/v2/user';
const app = express();
app.use('/api/v1', userV1Router); // 前綴自動加在路由內部
app.use('/api/v2', userV2Router);
export default app;
重點:所有版本共用的日誌、錯誤回傳、預先驗證(如 JWT)皆可在
createVersionedRouter中一次設定,讓每個版本的路由檔案保持乾淨、只專注於「業務」本身。
2️⃣ 共用 Middleware 的抽象
在多個 API 版本中,認證、參數驗證、速率限制等需求往往相同。將它們寫成 可組合的 Middleware,再以 工廠函式 產生對應的實例,可避免在每個路由檔案重複 app.use(auth)。
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface AuthOptions {
/** 允許的權限角色 */
roles?: string[];
}
/**
* JWT 認證 Middleware 工廠
* @param opts 可自訂角色限制
*/
export function authMiddleware(opts: AuthOptions = {}) {
return (req: Request, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ success: false, message: 'Missing token' });
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET as string) as any;
// 若設定了角色限制,檢查是否符合
if (opts.roles && !opts.roles.includes(payload.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
// 把使用者資訊掛到 req 上,供後續使用
(req as any).user = payload;
next();
} catch (e) {
return res.status(401).json({ success: false, message: 'Invalid token' });
}
};
}
在版本路由中使用:
// src/routes/v2/user.ts
import { Router, Request, Response } from 'express';
import { createVersionedRouter } from '../../router/versionedRouter';
import { authMiddleware } from '../../middleware/auth';
export const userV2Router = createVersionedRouter('v2', (router: Router) => {
// 只允許 admin 角色存取
router.use(authMiddleware({ roles: ['admin'] }));
router.get('/users', async (req: Request, res: Response) => {
// 取得已驗證的使用者資訊
const user = (req as any).user;
res.json({ success: true, data: [], accessedBy: user.username });
});
});
技巧:把
router.use(authMiddleware(...))放在createVersionedRouter的 defineRoutes 回呼裡,能讓每個版本自行決定是否套用認證或是套用不同的權限組合。
3️⃣ 統一回應格式(Response Wrapper)
多個版本的 API 若回傳結構不一致,前端必須寫大量條件判斷。抽象化回應格式 能讓所有路由只關注「資料」本身。
// src/utils/response.ts
export interface ApiResponse<T = any> {
success: boolean;
version?: string;
data?: T;
message?: string;
}
/**
* 包裝成功回傳
* @param data 任意型別的資料
* @param version API 版號(可選)
*/
export function ok<T>(data: T, version?: string): ApiResponse<T> {
return { success: true, version, data };
}
/**
* 包裝失敗回傳
* @param message 錯誤訊息
* @param status HTTP 狀態碼(可選,用於 middleware)
*/
export function fail(message: string, version?: string): ApiResponse<null> {
return { success: false, version, message };
}
在路由中使用:
// src/routes/v1/product.ts
import { Router, Request, Response } from 'express';
import { createVersionedRouter } from '../../router/versionedRouter';
import { ok, fail } from '../../utils/response';
export const productV1Router = createVersionedRouter('v1', (router: Router) => {
router.get('/products', async (_req: Request, res: Response) => {
try {
const products = [{ id: 1, name: 'Apple' }];
res.json(ok(products, 'v1'));
} catch (e) {
res.json(fail('Failed to fetch products', 'v1'));
}
});
});
好處:前端只要判斷
success與data,不需要在每個版本寫不同的解構程式碼。
4️⃣ 錯誤處理的抽象(Error Builder)
在大型系統中,我們常會拋出自訂錯誤類別,並在統一的錯誤中介層轉換成 API 回傳格式。以下示範一個簡易的 HttpError 基底類別與 errorHandler 中介層。
// src/errors/httpError.ts
export class HttpError extends Error {
status: number;
constructor(message: string, status = 500) {
super(message);
this.status = status;
Object.setPrototypeOf(this, new.target.prototype);
}
}
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { HttpError } from '../errors/httpError';
import { fail } from '../utils/response';
export function errorHandler(
err: any,
_req: Request,
res: Response,
_next: NextFunction,
) {
if (err instanceof HttpError) {
res.status(err.status).json(fail(err.message, (err as any).version));
} else {
console.error('Unexpected error:', err);
res.status(500).json(fail('Internal Server Error'));
}
}
在 createVersionedRouter 中掛上:
router.use(errorHandler); // 取代先前的 router.use((err, ...) => {...})
使用範例:
// src/routes/v2/order.ts
import { Router, Request, Response, NextFunction } from 'express';
import { createVersionedRouter } from '../../router/versionedRouter';
import { HttpError } from '../../errors/httpError';
import { ok } from '../../utils/response';
export const orderV2Router = createVersionedRouter('v2', (router: Router) => {
router.get('/orders/:id', async (req: Request, res: Response, next: NextFunction) => {
const { id } = req.params;
if (isNaN(Number(id))) {
// 拋出自訂錯誤,會被統一 errorHandler 捕獲
return next(new HttpError('Order ID must be numeric', 400));
}
// 假設查詢成功
const order = { id, item: 'Coffee' };
res.json(ok(order, 'v2'));
});
});
5️⃣ 服務層(Service)與資料傳輸物件(DTO)的抽象
API 版本往往在 資料結構 上會有差異,但底層的業務邏輯(例如「計算折扣」)可以共享。把業務寫在 Service,把不同版本的輸入/輸出寫在 DTO,即可做到 版本升級不破壞底層。
// src/services/discount.service.ts
export class DiscountService {
/**
* 計算折扣金額
* @param price 原價
* @param discountRate 折扣比例 (0~1)
*/
static calculate(price: number, discountRate: number): number {
return Math.round(price * (1 - discountRate));
}
}
V1 DTO(只回傳折扣後金額):
// src/dto/v1/discount.dto.ts
export interface DiscountV1Response {
finalPrice: number;
}
V2 DTO(額外回傳折扣前金額與折扣比例):
// src/dto/v2/discount.dto.ts
export interface DiscountV2Response {
originalPrice: number;
discountRate: number;
finalPrice: number;
}
路由實作:
// src/routes/v1/discount.ts
import { Router, Request, Response } from 'express';
import { createVersionedRouter } from '../../router/versionedRouter';
import { DiscountService } from '../../services/discount.service';
import { DiscountV1Response } from '../../dto/v1/discount.dto';
import { ok } from '../../utils/response';
export const discountV1Router = createVersionedRouter('v1', (router: Router) => {
router.get('/discount', (req: Request, res: Response) => {
const price = Number(req.query.price) || 0;
const rate = Number(req.query.rate) || 0;
const finalPrice = DiscountService.calculate(price, rate);
const payload: DiscountV1Response = { finalPrice };
res.json(ok(payload, 'v1'));
});
});
// src/routes/v2/discount.ts
import { Router, Request, Response } from 'express';
import { createVersionedRouter } from '../../router/versionedRouter';
import { DiscountService } from '../../services/discount.service';
import { DiscountV2Response } from '../../dto/v2/discount.dto';
import { ok } from '../../utils/response';
export const discountV2Router = createVersionedRouter('v2', (router: Router) => {
router.get('/discount', (req: Request, res: Response) => {
const price = Number(req.query.price) || 0;
const rate = Number(req.query.rate) || 0;
const finalPrice = DiscountService.calculate(price, rate);
const payload: DiscountV2Response = {
originalPrice: price,
discountRate: rate,
finalPrice,
};
res.json(ok(payload, 'v2'));
});
});
關鍵:Service 完全不關心 API 版號;DTO 只負責不同版號的資料形態。這樣即使將來要推出 v3,只需要新增對應的 DTO,Service 直接重用即可。
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 解決方案 |
|---|---|---|
| 路由硬編碼版號 | 每個檔案自行寫 app.use('/api/v1/...'),導致版號變更時要大量搜尋替換。 |
使用 VersionedRouter,版號只在 app.ts 一處設定。 |
| 共用 middleware 重複注入 | 在每個路由檔案都 router.use(auth),造成同一請求被多次驗證。 |
把共用 middleware 放在 createVersionedRouter,或在 app.ts 全局掛載。 |
| 錯誤資訊外洩 | 直接把 err.stack 回傳給前端,危害安全。 |
統一 errorHandler,只回傳 message,內部記錄 stack。 |
| DTO 與 Business Logic 混雜 | 把資料轉換寫在 Service 裡,導致不同版本的回傳結構互相干擾。 | 分層:Controller → Service → DTO,保持單一職責。 |
| 版本升級忘記更新測試 | 新增 v2 路由卻未補足測試,導致回傳格式錯誤。 | 為每個 VersionedRouter 建立對應的 integration test,使用 Jest + supertest。 |
最佳實踐清單
- 一次抽象,處處使用:任何「版號不變」的邏輯(日誌、認證、錯誤處理)都應抽成函式或中介層。
- 遵守 SOLID 中的 SRP(單一職責原則):Controller 只負責「接收請求、回傳結果」,Service 才是「業務運算」的地方。
- 版本資訊寫入回應:讓前端可以快速辨識資料來源(
version欄位)。 - 型別安全:使用 TypeScript 的 interface / type 定義每個版本的 DTO,編譯時即能捕捉不一致。
- 自動化測試:每新增一個版本,都要確保 單元測試(Service)+ 端點測試(Router)完整覆蓋。
實際應用場景
| 場景 | 需求 | 抽象化帶來的好處 |
|---|---|---|
| 電商平台 | 2023 年推出 v1 商品 API,2024 年需要新增「商品折扣」欄位且保持舊版相容。 | 只需新增 v2 DTO 與路由,原有 v1 不受影響;折扣計算寫在 Service。 |
| 行動應用 | 手機端升級到新版本時,仍需支援舊版 API 以避免斷層。 | 透過 createVersionedRouter 同時提供 /api/v1 與 /api/v2,共用認證、錯誤處理。 |
| 多租戶 SaaS | 不同客戶可能在不同時間升級到新 API,必須同時維護多個版本。 | 把租戶驗證抽成 middleware,根據客戶設定自動切換使用的 version router。 |
| 微服務間的內部呼叫 | 服務 A 呼叫服務 B 的多個版本 API,需保持呼叫方式一致。 | 使用 Axios 包裝的 apiClient,在 client 中注入 version,自動拼接正確路徑。 |
總結
在 Express + TypeScript 專案中,抽象化共用邏輯 是管理 API 版本的關鍵策略。透過:
createVersionedRouter讓版號、日誌、錯誤處理一次設定;- 可組合的 authMiddleware、errorHandler 等共用中介層;
- Response Wrapper 統一回傳結構;
- Service + DTO 的分層設計;
我們不僅能減少重複程式碼,還能確保 行為一致性、型別安全,以及 易於測試。只要遵守上述最佳實踐,任何新版本的 API 都能在幾分鐘內上線,而不必擔心舊版功能被意外破壞。
最後提醒:抽象化不是為了「寫得更炫」而犧牲可讀性,應該在 需求穩定、共用邏輯明確 時才開始拆分。適度的抽象,才能在專案成長的每一步,為開發團隊帶來真正的效率提升。祝你在 ExpressJS + TypeScript 的 API 版號管理之路上,寫出乾淨、可維護的程式碼!