本文 AI 產出,尚未審核

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 中根據版本統一回傳結構,或在升級時提供 遷移指南

最佳實踐總結

  1. 路徑版本是最直觀、最易於文件化的方式。
  2. 每個版本獨立模組化,包括路由、controller、DTO、service。
  3. 共用中介層放在根路由,確保安全與日誌一致。
  4. 使用 class-validator + class-transformer 讓資料驗證與轉換保持可讀性。
  5. 提供清晰的棄用流程:在文件、回應 header 中加入 DeprecationSunset 訊息,給客戶端足夠時間遷移。

實際應用場景

場景 為何需要多版本 API 推薦的實作方式
行動 App 需要長期支援舊版 手機作業系統升級緩慢,舊版 App 仍在使用舊 API 使用 路徑版本,同時在舊版路由加入 Deprecation 標頭,提醒開發者升級
第三方合作夥伴的 SDK 需要穩定介面 合作夥伴的部署週期較長,無法快速跟隨 API 變更 Header 版號 + 版本化 DTO,讓合作夥伴自行決定升級時機
微服務間的內部呼叫 內部服務頻繁迭代,但不想影響外部使用者 Query StringHeader 方式,僅在內部服務間使用,降低 URL 雜訊
逐步淘汰舊功能 某些功能已不再維護,需要逐步下線 在舊版路由回傳 410 Gone 並在回應中提供 遷移文件 URL
A/B 測試新功能 想要讓部分使用者先體驗新 API 自訂 Header (X-Feature-Flag) 搭配版本路由,僅對特定使用者開放新功能

總結

多版本 API 的管理不僅是 技術挑戰,更是一項 產品策略。透過本文所介紹的 路徑版本、模組化設計、DTO 版本化、共用中介層 以及 錯誤回傳的統一化,開發者可以在 ExpressJS + TypeScript 的環境中,快速建立可擴充、可維護且向後相容的 API。

關鍵要點

  • 版本號要明確(路徑或 Header),避免混淆。
  • 每個版本保持獨立,包括路由、控制器與資料結構。
  • 共用功能放在根中介層,確保安全與日誌一致。
  • 提供棄用與遷移資訊,讓使用者有時間升級。

只要遵循上述原則,即使未來 API 數量激增,也能夠以乾淨、可測試的方式持續迭代,為產品的長期成功奠定堅實基礎。祝開發順利,API 版本管理玩得開心! 🎉