本文 AI 產出,尚未審核

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;
}

透過 型別分離,即使在同一個目錄下有 UserV1DTOUserV2DTO,編譯器也能保證每個版本只能使用對應的結構,降低資料不一致的風險。


常見陷阱與最佳實踐

陷阱 為何會發生 解決方案 / 最佳實踐
路由重複定義 把所有版本的路由寫在同一個 router,容易產生衝突或忘記加版本前綴。 為每個版本建立獨立的 Router,並在 app.ts 中分別掛載。
型別混用 直接引用 any 或共用 DTO,導致 v1 呼叫到 v2 的欄位。 使用 namespace文件夾分層,讓 TypeScript 能分辨 UserV1DTOUserV2DTO
忘記升級測試 只測試 v1,部署 v2 後未檢查相容性。 為每個版本寫 獨立的測試案例(如 Jest),並在 CI 中同時執行。
硬編碼版本字串 在多處硬寫 '/v1''/v2',未來改版時需要大量修改。 把版本字串抽成 常數(例如 enum ApiVersion { V1 = 'v1', V2 = 'v2' }),統一管理。
過度版本堆疊 每次小改動就新增 v3v4,造成版本過多難以維護。 盡量 向後相容(例如新增欄位、使用可選屬性),僅在破壞性變更時才升級大版本。

進階最佳實踐

  1. 使用 OpenAPI (Swagger) 產生文件
    為每個版本分別建立 swagger.json,讓前端與第三方開發者清楚知道差異。

  2. 統一錯誤格式
    建議所有版本回傳的錯誤結構保持一致,例如 { code: number, message: string, details?: any },即使錯誤訊息內容會因版本不同而調整。

  3. 自動化版本檢測
    versionHandler 中可加入 Header 檢查(如 Accept: application/vnd.myapi.v2+json),讓客戶端明確告知想使用的版本。

  4. 漸進式淘汰
    設定 Deprecation HeaderX-API-Deprecated: true; version=v1),並在文件中標示何時停止支援。


實際應用場景

場景 為何需要版本化 可能的實作方式
行動 App 需要穩定的 API 手機端更新頻率較慢,若後端立即變更介面會導致舊版 App 崩潰。 保留 v1 作為長期支援版,僅在 v2 中新增功能,逐步引導使用者升級 App。
第三方合作夥伴 合作夥伴的系統可能在不同時間點接入,不能保證同時升級。 為合作夥伴提供 專屬版本(如 v2-partner),或使用 Feature Toggle 依客戶決定功能開關。
微服務間的內部 API 內部服務的資料模型演變頻繁,需要避免相依服務因升級失效。 采用 Semantic Versioning,在服務註冊中心(如 Consul)中標示版本,呼叫方根據需求選擇。
A/B 測試新功能 想同時測試舊版與新版的行為差異。 v2 中加入新欄位或新端點,前端根據使用者分群決定呼叫 v1v2

總結

  • API 版本化 能讓系統在持續演進的同時保持向後相容,降低升級風險。
  • ExpressJS + TypeScript 中,透過 Router 前綴獨立目錄結構型別分離,可建立乾淨且可測試的 /v1/v2 架構。
  • 實作時要特別注意 路由重複、型別混用、測試缺失 等常見陷阱,並遵循 最佳實踐(如使用常數、OpenAPI、漸進式淘汰)。
  • 真實案例如行動 App、第三方合作、微服務與 A/B 測試,都能從版本化中受益,提升開發與維運效率。

透過本文的概念與範例,你現在應該能在自己的 ExpressJS 專案裡,快速且安全地加入 API 版本管理,讓服務在未來的迭代中更具彈性與可預測性。祝開發順利! 🚀