ExpressJS (TypeScript) – Swagger / OpenAPI 文件化
主題:建立 API 檔案版本管理
簡介
在現代的 Web 開發中,API 版本管理 是不可或缺的基礎設施。隨著產品功能不斷演進,舊的介面仍需對外提供支援,而新功能則必須以全新版本上線。若沒有明確的版本機制,開發團隊很容易在升級時破壞既有客戶端,導致服務中斷與信任流失。
將版本資訊與 Swagger / OpenAPI 文件結合,不僅可以自動產生清晰的 API 文檔,還能讓前後端、測試與第三方開發者即時了解每個版本的差異與支援範圍。本文將以 ExpressJS + TypeScript 為例,說明如何在專案中落實 API 檔案版本管理,並搭配 swagger-ui-express 與 swagger-jsdoc 產出多版本的 OpenAPI 規格。
核心概念
1. 為什麼要在路由層面寫入版本號?
- 向後相容:舊版客戶端仍能呼叫
v1的介面,新的功能則放在v2、v3中。 - 清晰的升級路徑:開發者可以在文件中看到「此端點已在 v2 中棄用」的提示。
- 獨立部署:不同版本的路由可以分別掛載中間件、驗證或限流策略。
2. OpenAPI 中的 servers 與 paths 如何配合版本?
OpenAPI 規範允許在 servers 中定義 base URL,例如:
servers:
- url: https://api.example.com/v1
description: API 第 1 版
- url: https://api.example.com/v2
description: API 第 2 版
在 paths 中,不需要重複寫入版本號,因為 servers 已經決定了 base path。這樣的設計可以讓同一套 Swagger 定義同時支援多個版本,只要在 UI 中切換 servers 即可。
3. 建立版本化的目錄結構
src/
├─ api/
│ ├─ v1/
│ │ ├─ user.controller.ts
│ │ └─ user.routes.ts
│ ├─ v2/
│ │ ├─ user.controller.ts
│ │ └─ user.routes.ts
│ └─ swagger/
│ ├─ v1.swagger.ts
│ └─ v2.swagger.ts
├─ app.ts
└─ server.ts
好處:每個版本的控制器、路由與 Swagger 定義皆獨立,互不干擾,後續維護與測試更為方便。
4. 使用 swagger-jsdoc 產生多版本文件
swagger-jsdoc 會根據 JSDoc 註解產出 JSON。只要給予不同的 definition 與 apis 路徑,即可產出多套規格。
// src/swagger/v1.swagger.ts
import swaggerJSDoc from 'swagger-jsdoc';
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'My API - v1',
version: '1.0.0',
description: '第一版 API 介面',
},
servers: [{ url: '/api/v1', description: 'v1 server' }],
},
// 只掃描 v1 目錄下的 *.ts
apis: ['./src/api/v1/**/*.ts'],
};
export const swaggerSpecV1 = swaggerJSDoc(options);
// src/swagger/v2.swagger.ts
import swaggerJSDoc from 'swagger-jsdoc';
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'My API - v2',
version: '2.0.0',
description: '第二版 API,加入新功能與欄位',
},
servers: [{ url: '/api/v2', description: 'v2 server' }],
},
apis: ['./src/api/v2/**/*.ts'],
};
export const swaggerSpecV2 = swaggerJSDoc(options);
5. 在 Express 中掛載多版本路由與 Swagger UI
// src/app.ts
import express from 'express';
import { router as v1Router } from './api/v1/user.routes';
import { router as v2Router } from './api/v2/user.routes';
import swaggerUi from 'swagger-ui-express';
import { swaggerSpecV1 } from './swagger/v1.swagger';
import { swaggerSpecV2 } from './swagger/v2.swagger';
const app = express();
// 版本化路由
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// 版本化文件
app.use('/docs/v1', swaggerUi.serve, swaggerUi.setup(swaggerSpecV1));
app.use('/docs/v2', swaggerUi.serve, swaggerUi.setup(swaggerSpecV2));
export default app;
6. JSDoc 註解範例(v1)
// src/api/v1/user.controller.ts
import { Request, Response } from 'express';
/**
* @openapi
* /users:
* get:
* tags:
* - User
* summary: 取得所有使用者(v1)
* responses:
* 200:
* description: 成功回傳使用者陣列
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/UserV1'
*/
export const getAllUsers = async (req: Request, res: Response) => {
// 假資料
const users = [{ id: 1, name: 'Alice' }];
res.json(users);
};
7. JSDoc 註解範例(v2)— 新增欄位 email
// src/api/v2/user.controller.ts
import { Request, Response } from 'express';
/**
* @openapi
* /users:
* get:
* tags:
* - User
* summary: 取得所有使用者(v2),包含 email 欄位
* responses:
* 200:
* description: 成功回傳使用者陣列
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/UserV2'
*/
export const getAllUsersV2 = async (req: Request, res: Response) => {
const users = [{ id: 1, name: 'Alice', email: 'alice@example.com' }];
res.json(users);
};
8. 共用的 Swagger Components(可放在 components.ts)
// src/swagger/components.ts
export const components = {
schemas: {
UserV1: {
type: 'object',
properties: {
id: { type: 'integer', example: 1 },
name: { type: 'string', example: 'Alice' },
},
required: ['id', 'name'],
},
UserV2: {
allOf: [{ $ref: '#/components/schemas/UserV1' }],
type: 'object',
properties: {
email: { type: 'string', format: 'email', example: 'alice@example.com' },
},
required: ['email'],
},
},
};
在每個 swagger-*.ts 中透過 ...components 合併即可,避免重複定義。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 路由版本寫死在程式碼 | 直接在 app.use('/api/v1', ...),若未加統一前綴,容易遺漏 |
使用 環境變數 或 設定檔 (config.apiVersion) 統一管理 |
| Swagger 文檔與實際路由不一致 | 手動維護 servers 或 paths 時常出錯 |
讓 swagger-jsdoc 直接掃描路由檔案的 JSDoc,保持 一源真相 |
| 版本升級時遺忘刪除舊檔 | 舊版檔案堆積,增加維護成本 | 在 CI/CD 中加入 檢查腳本,提醒開發者是否仍需保留舊版 |
| 跨版本共用模型時衝突 | 兩個版本的 User 結構不同,導致 UI 顯示錯誤 |
使用 allOf 或 oneOf 方式在 components 中建立繼承關係 |
| 文件過於龐大 | 多版本同時載入會使 Swagger UI 變慢 | 分別 以 /docs/v1、/docs/v2 提供不同文件,僅載入需要的版本 |
最佳實踐:
- 版本前綴統一:所有路由、測試、文件均以
v{n}開頭。 - 自動化測試:在單元/整合測試中驗證每個版本的回傳結構。
- 語意化版本號:遵循 SemVer(MAJOR.MINOR.PATCH),僅在 MAJOR 變更時才升級路由前綴。
- 文件分層:核心模型放在共用
components,每個版本只寫差異部份。 - CI/CD 部署策略:新版上線前,先在測試環境佈署
v2,確保舊版 (v1) 不受影響,再逐步淘汰。
實際應用場景
1. SaaS 平台的功能迭代
一家提供訂閱制服務的 SaaS,原本的帳戶 API 為 v1,僅支援 GET /accounts。當加入「帳戶類型」與「帳單資訊」時,開發團隊在 v2 中新增 type、billingInfo 欄位,並同時保留 v1 讓尚未升級的客戶端繼續運作。透過上述目錄與 Swagger 設定,前端工程師只需要切換 /docs/v2 即可看到新欄位與說明。
2. 行動 App 與 Web 前端共用後端
行動端因為升級頻率較慢,需要維持舊版 API;而 Web 前端則可以立即使用最新功能。將路由分為 /api/v1(行動端)與 /api/v2(Web 前端),並在 CI 中針對兩個版本跑不同的端到端測試,確保 不會因為其中一個版本的變更而破壞另一端。
3. 第三方合作夥伴的 API 授權
當公司向合作夥伴提供 API 時,往往需要 契約(contract)穩定。使用版本化的 Swagger 文件,合作夥伴可以在合約上明確指定「本系統僅支援 v1」,而未來若要升級到 v2,雙方只需要簽署新版合約,避免突發的相容性問題。
總結
- API 版本管理 是保障服務穩定與持續演進的關鍵。
- 透過 Express + TypeScript 的目錄結構與 swagger-jsdoc、swagger-ui-express,我們可以快速產出 多版本的 OpenAPI 規格,讓文件與實作保持同步。
- 共用 Components、語意化版本號、以及 自動化測試,是避免版本衝突與維護負擔的最佳實踐。
- 在實務上,無論是 SaaS、行動 App,或是第三方夥伴授權,清晰的版本化文件 都能加速開發、減少溝通成本,並提升客戶對 API 的信任度。
從今天開始,把版本號寫進路由、把 Swagger 文件拆成多套,讓你的 Express API 既安全又易於演進!祝開發順利 🚀