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

透過 型別分離,即使在同一個目錄下有 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,造成版本過多難以維護。 盡量 向後相容(例如新增欄位、使用可選屬性),僅在破壞性變更時才升級大版本。

進階最佳實踐

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

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

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

  4. 漸進式淘汰
    設定 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 版本管理,讓服務在未來的迭代中更具彈性與可預測性。祝開發順利! 🚀