本文 AI 產出,尚未審核

ExpressJS (TypeScript) – API 版本管理

主題:抽象化共用邏輯


簡介

在現代的 Web 服務中,API 版號管理是不可或缺的需求。隨著功能持續演進,我們往往需要同時支援 v1v2、甚至 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(...)) 放在 createVersionedRouterdefineRoutes 回呼裡,能讓每個版本自行決定是否套用認證或是套用不同的權限組合。


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

好處:前端只要判斷 successdata,不需要在每個版本寫不同的解構程式碼。


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。

最佳實踐清單

  1. 一次抽象,處處使用:任何「版號不變」的邏輯(日誌、認證、錯誤處理)都應抽成函式或中介層。
  2. 遵守 SOLID 中的 SRP(單一職責原則):Controller 只負責「接收請求、回傳結果」,Service 才是「業務運算」的地方。
  3. 版本資訊寫入回應:讓前端可以快速辨識資料來源(version 欄位)。
  4. 型別安全:使用 TypeScript 的 interface / type 定義每個版本的 DTO,編譯時即能捕捉不一致。
  5. 自動化測試:每新增一個版本,都要確保 單元測試(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 讓版號、日誌、錯誤處理一次設定;
  • 可組合的 authMiddlewareerrorHandler 等共用中介層;
  • Response Wrapper 統一回傳結構;
  • Service + DTO 的分層設計;

我們不僅能減少重複程式碼,還能確保 行為一致性型別安全,以及 易於測試。只要遵守上述最佳實踐,任何新版本的 API 都能在幾分鐘內上線,而不必擔心舊版功能被意外破壞。

最後提醒:抽象化不是為了「寫得更炫」而犧牲可讀性,應該在 需求穩定共用邏輯明確 時才開始拆分。適度的抽象,才能在專案成長的每一步,為開發團隊帶來真正的效率提升。祝你在 ExpressJS + TypeScript 的 API 版號管理之路上,寫出乾淨、可維護的程式碼!