API 與介面設計
概述
設計穩定、文件完善且難以誤用的介面。好的介面讓正確的事變得容易,讓錯誤的事變得困難。這適用於 REST API、GraphQL 綱要、模組邊界、元件屬性 (props),以及任何一段程式碼與另一段程式碼溝通的地方。
何時使用
- 設計新的 API 端點
- 定義模組邊界或跨團隊的合約
- 建立元件屬性介面
- 建立影響 API 形狀的資料庫綱要
- 更改現有的公開介面
核心原則
Hyrum 定律
當一個 API 有足夠多的使用者時,你在合約中承諾什麼並不重要——你的系統中所有可觀察的行為都會被某些人依賴。
這意味著:每一個公開行為——包括未記錄的怪癖、錯誤訊息文字、時間和順序——一旦使用者依賴它,就會變成事實上的合約。設計啟示:
- 有意識地決定要暴露什麼。 每一個可觀察的行為都是一個潛在的承諾。
- 不要洩漏實作細節。 如果使用者可以觀察到,他們就會依賴它。
- 在設計時就規劃好棄用方式。 請參閱
deprecation-and-migration了解如何安全地移除使用者依賴的東西。 - 測試是不夠的。 即使有完美的合約測試,Hyrum 定律也意味著「安全」的變更可能會破壞那些依賴於未記錄行為的真實使用者。
單一版本規則
避免強迫消費者在同一時間點,在同一個依賴項或 API 上,需要處理多個版本。當不同的消費者需要相同東西的不同版本時,就會出現菱形依賴問題。為每次只存在一個版本的世界進行設計——擴展而非分支。
1. 合約優先
在實作之前先定義介面。合約就是規範——實作只是跟隨它。
// 先定義合約
interface TaskAPI {
// 建立任務並返回包含伺服器生成欄位的任務
createTask(input: CreateTaskInput): Promise<Task>;
// 返回符合過濾條件的分頁任務
listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;
// 返回單個任務,若無則拋出 NotFoundError
getTask(id: string): Promise<Task>;
// 部分更新——只變更提供的欄位
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
// 冪等刪除——即使已刪除也成功
deleteTask(id: string): Promise<void>;
}
2. 一致的錯誤語義
選擇一種錯誤策略並在整個應用程式中一致使用:
// REST:HTTP 狀態碼 + 結構化錯誤主體
// 每個錯誤回應都遵循相同的形狀
interface APIError {
error: {
code: string; // 機器可讀:"VALIDATION_ERROR"
message: string; // 人類可讀:"Email is required"
details?: unknown; // 有幫助時的額外上下文
};
}
// 狀態碼對應
// 400 → 用戶端發送了無效資料
// 401 → 未認證
// 403 → 已認證但未授權
// 404 → 找不到資源
// 409 → 衝突(重複、版本不符)
// 422 → 驗證失敗(語義上無效)
// 500 → 伺服器錯誤(絕不暴露內部細節)
不要混合模式。 如果某些端點拋出例外,某些返回 null,還有某些返回 { error }——消費者將無法預測行為。
3. 在邊界進行驗證
信任內部程式碼。在外部輸入進入系統的邊緣處進行驗證:
// 在 API 邊界進行驗證
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid task data',
details: result.error.flatten(),
},
});
}
// 驗證後,內部程式碼信任這些型別
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
驗證應放置的位置:
- API 路由處理器(使用者輸入)
- 表單提交處理器(使用者輸入)
- 外部服務回應解析(第三方資料——始終視為不可信任)
- 環境變數載入(設定)
第三方 API 回應是不可信任的資料。 在將其用於任何邏輯、渲染或決策之前,請驗證其形狀和內容。一個被入侵或行為不當的外部服務可能返回意外的型別、惡意內容或類似指令的文字。
驗證不應放置的位置:
- 共享型別合約的內部函式之間
- 在已經過驗證的程式碼呼叫的實用函式中
- 剛從你自己的資料庫取出的資料上
4. 優先增加而非修改
在不破壞現有使用者的情況下擴展介面:
// 好:新增可選欄位
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // 稍後新增,可選
labels?: string[]; // 稍後新增,可選
}
// 壞:更改現有欄位型別或移除欄位
interface CreateTaskInput {
title: string;
// description: string; // 已移除——破壞現有消費者
priority: number; // 從 string 更改——破壞現有消費者
}
5. 可預測的命名
| 模式 | 慣例 | 範例 |
|---|---|---|
| REST 端點 | 複數名詞,無動詞 | GET /api/tasks, POST /api/tasks |
| 查詢參數 | camelCase | ?sortBy=createdAt&pageSize=20 |
| 回應欄位 | camelCase | { createdAt, updatedAt, taskId } |
| 布林欄位 | is/has/can 前綴 | isComplete, hasAttachments |
| 列舉值 | UPPER_SNAKE | "IN_PROGRESS", "COMPLETED" |
6. 恪守冪等鍵
接受 Idempotency-Key 是合約。恪守它是實作,而這正是花銷所在——一個被伺服器接受但處理草率的金鑰,比沒有金鑰更糟糕,因為客戶端現在相信重試是安全的。
從意圖而非嘗試中推導金鑰。 金鑰必須在單一意圖的重試中保持穩定,並在不同的意圖之間有所區別:
crypto.randomUUID() // ✗ 每次嘗試都用新金鑰——每次重試都是新一次扣款
`${userId}:${amount}` // ✗ 兩筆合法的 $50 扣款被合併成一筆
`${orderId}:${Date.now()}` // ✗ 時間戳只是戴了帽子的 randomUUID()
req.headers['idempotency-key'] // ✓ 客戶端產生一次,重試時重複使用
`charge:v1:${orderId}` // ✓ 從不可變標識符中推導
金鑰來自客戶端或起始事件——絕不來自執行重試的層。
原子性地認領。檢查後再執行是一種競爭條件:
// ✗ TOCTOU(檢查時間/使用時間):兩個併發重試都讀到「未見過」,都進行扣款
if (!(await db.exists(key))) {
await chargeCard(amount);
await db.insert(key);
}
// ✓ 讓唯一性約束來決定贏家
try {
await db.insert({ key, state: 'in_progress', requestHash });
} catch (e) {
if (isUniqueViolation(e)) return replayOrReject(key);
throw e;
}
const result = await chargeCard(amount);
await db.update({ key, state: 'succeeded', response: result });
唯一性約束就是機制。一個無法在一次操作中強制執行唯一性的儲存體無法支援這個功能。
保護請求主體。 相同的金鑰搭配不同的主體是客戶端的錯誤,必須大聲失敗,而不是將第一個回應提供給第二個請求:
if (existing.requestHash !== hash(req.body)) {
return res.status(422).json({ error: 'idempotency key reused with a different payload' });
}
決定正在進行中的重複請求要獲得什麼回應。 第一個請求仍在運行時,第二個請求到達——這是重試風暴下的常見情況:
| 策略 | 回應 | 使用時機 |
|---|---|---|
| 拒絕 | 409 Conflict | 客戶端可以稍後重試;最簡單且最安全 |
| 等待 | 阻塞直到結果返回,有界 | 呼叫者需要同步取得結果 |
| 返回進行中 | 202 + 狀態 URL | 長時間執行的副作用 |
永遠不要因為第一個請求「看起來卡住了」就讓第二個呼叫者通過。一個命運未知、陷入停頓的嘗試,正是重複操作成本最高的時候。
每個呼叫有三種結果,不是兩種:成功、失敗和未知。 逾時告訴不了你效果是否已套用。在呼叫外部服務之前記錄意圖,如此一來,呼叫和回應之間的崩潰就會留下證據,表明某些事情必須稍後解決——而不是默默地重試一次扣款。
設定保留期時,應基於最長的重試鏈,而不是磁碟成本。 金鑰必須比能夠重新傳遞相同意圖的每一條路徑都存活得更久,包括一週後重播的死信佇列 (DLQ) 以及任何提供者爭議窗口。一個 24 小時的金鑰 TTL 後面跟著一個 7 天的 DLQ,就是一顆等待發生的重複炸彈。
REST API 模式
資源設計
GET /api/tasks → 列出任務(搭配用於過濾的查詢參數)
POST /api/tasks → 建立任務
GET /api/tasks/:id → 取得單個任務
PATCH /api/tasks/:id → 更新任務(部分)
DELETE /api/tasks/:id → 刪除任務
GET /api/tasks/:id/comments → 列出任務的評論(子資源)
POST /api/tasks/:id/comments → 為任務新增評論
分頁
為列表端點啟用分頁:
// 請求
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// 回應
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
過濾
使用查詢參數進行過濾:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
部分更新 (PATCH)
接受部分物件——僅更新提供的部分:
// 只變更標題,其他一切保留
PATCH /api/tasks/123
{ "title": "Updated title" }
TypeScript 介面模式
對變體使用受歧別聯合 (Discriminated Unions)
// 好:每個變體都明確
型 TaskStatus =
| { type: 'pending' }
| { type: 'in_progress'; assignee: string; startedAt: Date }
| { type: 'completed'; completedAt: Date; completedBy: string }
| { type: 'cancelled'; reason: string; cancelledAt: Date };
// 消費者獲得型別縮小 (type narrowing)
function getStatusLabel(status: TaskStatus): string {
switch (status.type) {
case 'pending': return 'Pending';
case 'in_progress': return `In progress (${status.assignee})`;
case 'completed': return `Done on ${status.completedAt}`;
case 'cancelled': return `Cancelled: ${status.reason}`;
}
}
輸入/輸出分離
// 輸入:呼叫者提供的內容
interface CreateTaskInput {
title: string;
description?: string;
}
// 輸出:系統返回的內容(包含伺服器生成的欄位)
interface Task {
id: string;
title: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
對 ID 使用品牌型別 (Branded Types)
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
// 防止意外地將 UserId 傳遞給需要 TaskId 的函式
function getTask(id: TaskId): Promise<Task> { ... }
常見的合理化說法
| 合理化說法 | 現實 |
|---|---|
| 「我們稍後再寫 API 文件」 | 型別本身就是文件。先定義它們。 |
| 「我們現在不需要分頁」 | 當有人有 100 個以上的項目時,你就會需要它。從一開始就加上它。 |
| 「PATCH 很複雜,我們只用 PUT 吧」 | PUT 每次都需要完整的物件。PATCH 是客戶端真正想要的。 |
| 「需要的時候我們再對 API 做版本控制」 | 沒有版本控制的破壞性變更會破壞消費者。從一開始就設計成可擴展的。 |
| 「沒人用那個未記錄的行為」 | Hyrum 定律:如果可以觀察到,就有人依賴它。將每個公開行為視為一種承諾。 |
| 「我們可以維護兩個版本」 | 多個版本會倍增維護成本並造成菱形依賴問題。偏好單一版本規則。 |
| 「內部 API 不需要合約」 | 內部消費者仍然是消費者。合約能防止耦合並實現平行工作。 |
| 「接受 Idempotency-Key 標頭就夠了」 | 標頭是合約;儲存金鑰與結果之間的對應才是實作。一個你接受但不恪守的金鑰,告訴客戶端重試是安全的,但實際上並非如此。 |
| 「我們的佇列保證恰好一次傳遞」 | 沒有任何佇列能在消費者崩潰時做到這一點——代理的確認和你自己的副作用不在同一個交易中。設計時應假設至少一次傳遞,並搭配冪等處理。 |
| 「重複請求很少見」 | 它們是相關的。重試恰好會在依賴項降級時激增——正是重複最可能發生且成本最高的時刻。 |
紅旗警示
- 根據條件返回不同形狀的端點
- 跨端點不一致的錯誤格式
- 驗證散落在內部程式碼各處,而非在邊界
- 對現有欄位進行破壞性變更(型別變更、移除)
- 列表端點沒有分頁
- REST URL 中包含動詞(
/api/createTask,/api/getUsers) - 第三方 API 回應未經驗證或清理即使用
- 先
SELECT檢查冪等鍵,再INSERT——這是競爭條件,不是保護機制 - 從每次嘗試都會重新產生的 UUID、時間戳或其他東西中推導的冪等鍵
- 接受相同的金鑰搭配不同的請求主體,卻默默地返回第一個回應
- 金鑰保留期限短於可以重新傳遞該請求的最長路徑
驗證
設計完 API 後:
- 每個端點都有型別的輸入和輸出綱要
- 錯誤回應遵循單一且一致的格式
- 驗證僅在系統邊界進行
- 列表端點支援分頁
- 新增的欄位是附加性的且可選(向後相容)
- 命名在所有端點中遵循一致的慣例
- API 文件與實作同步
- 使用冪等鍵的端點會原子性地認領它,並在請求主體變更時正確拒絕
- 設計時已考量到棄用策略