API 版本控制策略:演進與相容
API 會隨著業務需求演進。良好的版本控制策略能讓 API 平滑升級,同時不破壞現有客戶端。
一、 為什麼需要版本控制?
1.1 破壞性變更
這些變更會破壞現有客戶端:
- 移除欄位或端點
- 更改欄位類型
- 更改必填欄位
- 更改認證方式
- 更改回應結構
1.2 版本控制的目標
二、 版本控制方式
2.1 URL 路徑
bash
# 最常見的方式
GET /v1/users
GET /v2/users
# 或使用日期
GET /2024-01-01/users優點:
- 簡單直觀
- 容易路由
- 可以快取
缺點:
- URL 變動
- 可能被認為「不 RESTful」
2.2 查詢參數
bash
GET /users?version=1
GET /users?version=2優點:
- URL 保持穩定
缺點:
- 容易被忽略
- 可能與其他參數混淆
2.3 請求標頭
bash
GET /users
Accept: application/vnd.myapi.v1+json
# 或自定義標頭
GET /users
X-API-Version: 1優點:
- URL 完全穩定
- 更「純粹」的 REST
缺點:
- 不易測試(需要設定標頭)
- 不能直接在瀏覽器測試
2.4 內容協商
bash
GET /users
Accept: application/vnd.myapi+json; version=22.5 比較表
| 方式 | URL 穩定 | 易用性 | 可快取 | 推薦度 |
|---|---|---|---|---|
| URL 路徑 | ❌ | ⭐⭐⭐ | ⭐⭐⭐ | 推薦 |
| 查詢參數 | ✅ | ⭐⭐ | ⭐⭐ | 中等 |
| 請求標頭 | ✅ | ⭐ | ⭐⭐ | 進階 |
三、 Express 實作
3.1 URL 路徑版本
javascript
// routes/v1/users.js
const router = require("express").Router();
router.get("/", (req, res) => {
res.json({
users: [{ id: 1, name: "John", email: "john@example.com" }],
});
});
module.exports = router;
// routes/v2/users.js
const router = require("express").Router();
router.get("/", (req, res) => {
res.json({
data: [
{
id: 1,
fullName: "John Doe", // 欄位名稱變更
contact: { email: "john@example.com" }, // 結構變更
},
],
pagination: { page: 1, total: 100 },
});
});
module.exports = router;
// app.js
app.use("/v1/users", require("./routes/v1/users"));
app.use("/v2/users", require("./routes/v2/users"));3.2 標頭版本
javascript
function versionMiddleware(req, res, next) {
const version = req.headers["x-api-version"] || "1";
req.apiVersion = parseInt(version);
next();
}
app.use(versionMiddleware);
app.get("/users", (req, res) => {
if (req.apiVersion >= 2) {
return res.json({ data: usersV2 });
}
res.json({ users: usersV1 });
});3.3 版本控制器
javascript
class VersionedController {
constructor() {
this.handlers = new Map();
}
version(v, handler) {
this.handlers.set(v, handler);
return this;
}
handle(req, res, next) {
const version = req.apiVersion || 1;
// 找到匹配或更低的版本
let handler = null;
for (let v = version; v >= 1; v--) {
if (this.handlers.has(v)) {
handler = this.handlers.get(v);
break;
}
}
if (handler) {
return handler(req, res, next);
}
res.status(400).json({ error: "Unsupported API version" });
}
}
// 使用
const getUsers = new VersionedController()
.version(1, (req, res) => res.json({ users: [] }))
.version(2, (req, res) => res.json({ data: [], pagination: {} }));
app.get("/users", (req, res, next) => getUsers.handle(req, res, next));四、 廢棄策略
4.1 標頭通知
javascript
function deprecationMiddleware(version, sunsetDate) {
return (req, res, next) => {
res.set("Deprecation", "true");
res.set("Sunset", sunsetDate);
res.set("Link", '</v2/users>; rel="successor-version"');
console.warn(`Deprecated API v${version} called: ${req.path}`);
next();
};
}
app.use("/v1", deprecationMiddleware(1, "Sat, 01 Jan 2025 00:00:00 GMT"));4.2 回應中提示
javascript
app.get("/v1/users", (req, res) => {
res.json({
_deprecated: true,
_deprecationMessage:
"This endpoint will be removed on 2025-01-01. Please migrate to /v2/users",
_successor: "/v2/users",
users: [],
});
});4.3 廢棄時程
五、 語義化版本
5.1 SemVer 原則
MAJOR.MINOR.PATCH
MAJOR: 破壞性變更
MINOR: 向後兼容的新功能
PATCH: 向後兼容的修復5.2 API 版本 vs 實作版本
javascript
// API 版本:v1, v2(主要版本)
// 實作版本:1.2.3(可以頻繁更新)
// 標頭中可以同時提供
res.set("X-API-Version", "2");
res.set("X-Implementation-Version", "2.15.3");六、 向後兼容技巧
6.1 添加欄位(安全)
javascript
// v1.0
{ "id": 1, "name": "John" }
// v1.1(向後兼容)
{ "id": 1, "name": "John", "email": "john@example.com" }6.2 欄位重命名
javascript
// 同時保留兩個欄位
{
"name": "John", // 舊欄位(廢棄)
"fullName": "John Doe" // 新欄位
}6.3 結構變更
javascript
// 使用轉換層
function transformToV1(v2Data) {
return {
users: v2Data.data.map((user) => ({
id: user.id,
name: user.fullName,
email: user.contact.email,
})),
};
}
function transformToV2(v1Data) {
return {
data: v1Data.users.map((user) => ({
id: user.id,
fullName: user.name,
contact: { email: user.email },
})),
pagination: { page: 1, total: v1Data.users.length },
};
}七、 文檔與通知
7.1 變更日誌
markdown
# API Changelog
## v2.0.0 (2025-01-01)
### Breaking Changes
- `GET /users` response structure changed
- `name` field renamed to `fullName`
- `email` moved to `contact.email`
### Migration Guide
1. Update response parsing
2. Use `fullName` instead of `name`
3. Access email via `contact.email`
## v1.5.0 (2024-12-01)
### Added
- `GET /users/:id/orders` endpoint
- `phone` field in user response
### Deprecated
- `GET /v1/users` (use v2)7.2 通知客戶端
javascript
// 電子郵件通知
// Slack/Discord webhook
// 儀表板公告
// API 內提示
app.use((req, res, next) => {
if (majorVersionAnnouncement) {
res.set("X-API-Announcement", "Version 3 coming soon! Check docs.");
}
next();
});總結
| 策略 | 適用場景 |
|---|---|
| URL 路徑 | 大多數 API |
| 請求標頭 | 企業級 API |
| 查詢參數 | 簡單 API |
| 無版本 | 內部 API |
> **最佳實踐**:
- 從第一天就有版本
- 保持至少一個舊版本
- 給予足夠的遷移時間
- 清晰的廢棄通知
進階挑戰
- 設計一個版本遷移工具,自動轉換請求/回應格式。
- 研究 Stripe API 的版本策略,分析其優缺點。
- 實作一個 API 版本監控系統,追蹤各版本使用量。