測驗:API 設計 — 打造系統的溝通契約
共 5 題,點選答案後會立即顯示結果
1. 在 RESTful API 設計中,URL 應該代表什麼?
2. 為什麼 POST 方法被稱為「不冪等」?
3. 當 API 回傳 HTTP 狀態碼 401 時,代表什麼意思?
4. 關於 API 版本控制,以下哪種修改「不需要」升版?
5. 在分頁設計中,什麼情況下 cursor-based 分頁比 offset-based 更適合?
系統裡的每個元件都需要「對話」,而 API 就是它們之間的共同語言。
設計好的 API,就像簽一份清楚的契約 — 雙方都知道該給什麼、會拿到什麼。
這篇文章解決什麼問題?
如果你有以下困惑,這篇文章適合你:
- AI 幫你產生了一堆 API endpoint,但你不確定命名和結構對不對
- 前端呼叫後端 API 時,不知道該用 POST 還是 PUT,回傳格式也不一致
- 系統越做越大,舊的 API 改了會壞掉新功能,不知道怎麼管理版本
一句話說明
API 是系統元件之間的溝通契約,定義好「怎麼問、怎麼答」,系統才能穩定擴展。
API 在系統設計中的角色
在上一篇我們談了系統設計思維,知道系統是由多個元件組成的。那這些元件怎麼溝通?答案就是 API。
┌──────────┐ API ┌──────────┐
│ 前端 App │ ──────────→ │ 後端 API │
└──────────┘ └──────────┘
│ API
▼
┌──────────┐
│ 資料庫 │
└──────────┘
API 出現在三個關鍵位置:
| 場景 | 誰跟誰溝通 | 範例 |
|---|---|---|
| 前後端分離 | 瀏覽器 → 後端伺服器 | React 呼叫 FastAPI |
| 微服務通訊 | 服務 A → 服務 B | 訂單服務 → 庫存服務 |
| 第三方整合 | 你的系統 → 外部服務 | 你的 App → Stripe 付款 |
重點:不管哪個場景,核心概念都一樣 — 雙方約定好「請求格式」和「回應格式」,這就是契約。
RESTful API 設計原則
REST 是目前最主流的 API 設計風格。你在大多數 AI 生成的後端程式碼中都會看到它。
原則 1:資源導向的 URL
URL 代表「資源」(名詞),不是「動作」(動詞)。
# 好的設計 -- URL 是名詞
GET /users # 取得所有使用者
GET /users/123 # 取得 ID 為 123 的使用者
POST /users # 建立新使用者
PUT /users/123 # 更新 ID 為 123 的使用者
DELETE /users/123 # 刪除 ID 為 123 的使用者
# 不好的設計 -- URL 是動詞
GET /getUsers
POST /createUser
POST /deleteUser/123
Code language: PHP (php)翻譯:把 URL 想成「對誰做事」– /users/123 就是「ID 123 的使用者」,至於「做什麼事」由 HTTP 方法決定。
原則 2:HTTP 方法的語義
每個 HTTP 方法都有固定的含義:
| 方法 | 做什麼 | 安全性 | 冪等性 | 白話翻譯 |
|---|---|---|---|---|
| GET | 讀取資料 | 安全(不改資料) | 冪等(重複呼叫結果相同) | 「給我看看」 |
| POST | 建立新資料 | 不安全 | 不冪等(重複呼叫會建多筆) | 「幫我新增」 |
| PUT | 完整更新 | 不安全 | 冪等 | 「用這份覆蓋掉」 |
| PATCH | 部分更新 | 不安全 | 冪等 | 「只改這幾個欄位」 |
| DELETE | 刪除 | 不安全 | 冪等 | 「幫我刪掉」 |
什麼是冪等? 呼叫一次跟呼叫十次的結果一樣。DELETE /users/123 不管刪幾次,最後那筆都是被刪除的狀態。但 POST /users 呼叫十次就會建出十筆資料。
快速決策:PUT 還是 PATCH?
要更新資料,用 PUT 還是 PATCH?
Q1: 你要傳送完整的資源內容嗎?
├─ 是(全部欄位都傳)→ 用 PUT
└─ 否(只傳要改的欄位)→ 用 PATCH
實務上,大多數 AI 生成的程式碼都用 PATCH 做部分更新,這也是比較常見的做法。
原則 3:無狀態
每個請求都包含足夠的資訊,伺服器不需要「記住」之前的請求。
# 好的設計 -- 每個請求帶上身份驗證
GET /users/123
Headers: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
# 不好的設計 -- 依賴伺服器記住「你是誰」
# 第一步:先登入(伺服器記住 session)
POST /login
# 第二步:靠 session 取資料
GET /users/123 # 伺服器從 session 查你是誰
Code language: PHP (php)翻譯:就像去便利商店買東西,每次都要帶錢(token),不能說「我上次來過,老闆你應該記得」。
請求與回應設計
統一的 JSON 回應結構
好的 API 回應格式要一致。不管是取一筆還是取多筆,格式都可預期。
// 取得單筆資源:GET /users/123
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"created_at": "2024-01-15T08:30:00Z"
}
// 取得多筆資源:GET /users
{
"data": [
{"id": 123, "name": "Alice", "email": "alice@example.com"},
{"id": 124, "name": "Bob", "email": "bob@example.com"}
],
"pagination": {
"total": 50,
"page": 1,
"per_page": 20,
"total_pages": 3
}
}
Code language: JSON / JSON with Comments (json)翻譯:多筆資料放在 data 陣列裡,分頁資訊放在 pagination。前端拿到就知道怎麼處理。
分頁:Offset vs Cursor
當資料量大時,不能一次全部回傳,需要分頁。兩種主流做法:
Offset-based(頁碼式) — 適合大部分情況:
GET /users?page=2&per_page=20
翻譯:「給我第 2 頁,每頁 20 筆」。簡單直覺,但資料量很大時效能會變差。
Cursor-based(游標式) — 適合大量資料或即時動態:
GET /users?cursor=eyJpZCI6MTIzfQ&limit=20
翻譯:「從上次的位置繼續往下給我 20 筆」。效能好,但沒辦法跳頁。
| 情境 | 建議選擇 | 原因 |
|---|---|---|
| 後台管理頁面 | Offset | 使用者需要跳到特定頁 |
| 社群動態牆 | Cursor | 資料持續新增,offset 會重複 |
| 資料量 < 10 萬 | Offset | 簡單好實作 |
| 資料量 > 百萬 | Cursor | Offset 在大量資料時會變慢 |
過濾與排序
讓前端可以彈性查詢:
# 過濾:只要活躍的使用者
GET /users?status=active
# 排序:按建立時間倒序
GET /users?sort=-created_at
# 組合使用
GET /users?status=active&sort=-created_at&page=1&per_page=20
Code language: PHP (php)翻譯:sort=-created_at 的 - 代表降序(最新的在前面),沒有 - 就是升序。
錯誤處理模式
HTTP 狀態碼語義
狀態碼告訴你「這次請求的結果如何」:
2xx -- 成功
200 OK → 一般成功
201 Created → 成功建立新資源
204 No Content → 成功,但沒有要回傳的內容(常見於 DELETE)
4xx -- 用戶端錯誤(你的問題)
400 Bad Request → 請求格式不對
401 Unauthorized → 沒有登入(身份驗證失敗)
403 Forbidden → 有登入但沒有權限
404 Not Found → 資源不存在
409 Conflict → 資源衝突(例如 email 已被註冊)
422 Unprocessable → 格式對但內容不合理(例如年齡是負數)
5xx -- 伺服器錯誤(不是你的問題)
500 Internal Server Error → 伺服器壞了
503 Service Unavailable → 伺服器暫時忙不過來
Code language: JavaScript (javascript)快速判斷:看到 4xx 先檢查你的請求,看到 5xx 就是後端有問題。
統一錯誤格式
好的 API 不只回傳狀態碼,還會回傳結構化的錯誤訊息:
// 422 Unprocessable Entity
{
"error": {
"code": "VALIDATION_ERROR",
"message": "輸入資料驗證失敗",
"details": [
{
"field": "email",
"message": "email 格式不正確"
},
{
"field": "age",
"message": "年齡必須大於 0"
}
]
}
}
Code language: JSON / JSON with Comments (json)這個結構的好處:
code:程式可以用來判斷錯誤類型message:人可以讀的描述details:具體哪裡出錯,前端可以標示在對應的欄位上
API 版本控制
系統會成長,API 也需要改版。但舊的用戶端可能還在用舊版 API,怎麼辦?
兩種主流做法
URL Path Versioning(路徑版本) — 最常見:
GET /v1/users/123
GET /v2/users/123
Header Versioning(標頭版本):
GET /users/123
Headers: API-Version: 2
| 做法 | 優點 | 缺點 | 適合場景 |
|---|---|---|---|
| URL 路徑 | 一眼就知道版本,好測試 | URL 長了一節 | 公開 API、大部分情況 |
| Header | URL 乾淨 | 不容易看出版本 | 內部 API |
實務建議:用 URL 路徑版本就對了。簡單、直覺、大家都看得懂。
什麼時候要升版?
不需要升版(向後相容的修改):
- 新增欄位
- 新增 endpoint
- 新增可選的 query parameter
需要升版(破壞性修改):
- 刪除或重新命名欄位
- 改變欄位的資料型別
- 改變必要參數
- 改變回應結構
翻譯:「加東西」不用升版,「改東西」或「刪東西」才要。
認證與授權基礎
API 要保護起來,不是誰都能呼叫的。兩個核心概念:
- 認證(Authentication):「你是誰?」– 證明你的身份
- 授權(Authorization):「你能做什麼?」– 檢查你的權限
兩種常見方案
API Key — 簡單但功能有限:
GET /users
Headers: X-API-Key: sk_live_abc123def456
翻譯:就像一把鑰匙,有鑰匙就能進門。適合服務之間的通訊。
JWT Token — 目前最主流:
GET /users
Headers: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjN9.xxx
翻譯:Token 裡面藏了「你是誰」的資訊,伺服器解開就知道。適合前後端分離的使用者認證。
| 方案 | 適合場景 | 特點 |
|---|---|---|
| API Key | 後端服務之間、第三方 API | 簡單,不過期(除非主動撤銷) |
| JWT Token | 使用者登入、前後端分離 | 帶有使用者資訊,會過期 |
實作範例:用 FastAPI 打造待辦事項 API
來看一個完整的範例,把以上所有原則用在一個「待辦事項 API」上。
後端:FastAPI 實作
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
from datetime import datetime
app = FastAPI() # 建立 API 應用程式
# --- 資料模型(定義資料長什麼樣)---
class TodoCreate(BaseModel): # 建立待辦事項時要傳什麼
title: str # 必填:標題(文字)
description: str = "" # 選填:描述(預設空字串)
class TodoUpdate(BaseModel): # 更新時可以只傳要改的欄位
title: str | None = None
description: str | None = None
completed: bool | None = None
class Todo(BaseModel): # 完整的待辦事項結構
id: int
title: str
description: str
completed: bool
created_at: datetime
class ErrorDetail(BaseModel):
code: str
message: str
details: list = []
class ErrorResponse(BaseModel): # 統一的錯誤格式
error: ErrorDetail
# --- 模擬資料庫 ---
todos_db: dict[int, Todo] = {}
next_id = 1
# --- API Endpoints ---
# 取得所有待辦事項(支援過濾、分頁)
@app.get("/v1/todos")
def list_todos(
completed: bool | None = None, # 過濾:是否完成
sort: str = "-created_at", # 排序:預設最新在前
page: int = Query(default=1, ge=1), # 分頁:第幾頁(最小 1)
per_page: int = Query(default=20, le=100) # 每頁幾筆(最多 100)
):
items = list(todos_db.values())
# 過濾
if completed is not None:
items = [t for t in items if t.completed == completed]
# 排序
reverse = sort.startswith("-")
sort_field = sort.lstrip("-")
items.sort(key=lambda t: getattr(t, sort_field), reverse=reverse)
# 分頁
total = len(items)
start = (page - 1) * per_page
items = items[start:start + per_page]
return {
"data": items,
"pagination": {
"total": total,
"page": page,
"per_page": per_page,
"total_pages": (total + per_page - 1) // per_page
}
}
# 取得單筆待辦事項
@app.get("/v1/todos/{todo_id}")
def get_todo(todo_id: int):
if todo_id not in todos_db:
raise HTTPException(
status_code=404,
detail={
"error": {
"code": "NOT_FOUND",
"message": f"待辦事項 {todo_id} 不存在"
}
}
)
return todos_db[todo_id]
# 建立待辦事項
@app.post("/v1/todos", status_code=201) # 201 = Created
def create_todo(todo: TodoCreate):
global next_id
new_todo = Todo(
id=next_id,
title=todo.title,
description=todo.description,
completed=False,
created_at=datetime.now()
)
todos_db[next_id] = new_todo
next_id += 1
return new_todo
# 部分更新待辦事項
@app.patch("/v1/todos/{todo_id}")
def update_todo(todo_id: int, updates: TodoUpdate):
if todo_id not in todos_db:
raise HTTPException(
status_code=404,
detail={
"error": {
"code": "NOT_FOUND",
"message": f"待辦事項 {todo_id} 不存在"
}
}
)
existing = todos_db[todo_id]
update_data = updates.model_dump(exclude_unset=True) # 只取有傳的欄位
updated = existing.model_copy(update=update_data)
todos_db[todo_id] = updated
return updated
# 刪除待辦事項
@app.delete("/v1/todos/{todo_id}", status_code=204) # 204 = No Content
def delete_todo(todo_id: int):
if todo_id not in todos_db:
raise HTTPException(
status_code=404,
detail={
"error": {
"code": "NOT_FOUND",
"message": f"待辦事項 {todo_id} 不存在"
}
}
)
del todos_db[todo_id]
逐行翻譯重點
@app.get("/v1/todos") # 當有人用 GET 方法訪問 /v1/todos 時執行
def list_todos(
completed: bool | None = None, # 可選參數,FastAPI 自動從 ?completed=true 取值
page: int = Query(default=1, ge=1), # Query() 加上驗證:最小值 1
):
Code language: PHP (php)raise HTTPException(status_code=404, detail={...})
# ↑ 拋出 HTTP 錯誤,FastAPI 會自動回傳 404 狀態碼 + 錯誤內容
Code language: PHP (php)updates.model_dump(exclude_unset=True)
# ↑ 只取出「有傳的欄位」,沒傳的不算
# 例如只傳 {"completed": true},就只會更新 completed
Code language: PHP (php)existing.model_copy(update=update_data)
# ↑ 用新資料覆蓋舊資料的對應欄位,其他欄位保持不變
Code language: PHP (php)前端:React 消費 API
// api.js -- API 呼叫函式
const API_BASE = "/v1";
// 取得待辦事項列表(帶分頁和過濾)
async function listTodos({ page = 1, completed = null } = {}) {
const params = new URLSearchParams({ page, per_page: 20 });
if (completed !== null) params.set("completed", completed);
const res = await fetch(`${API_BASE}/todos?${params}`);
if (!res.ok) {
const error = await res.json(); // 解析錯誤回應
throw new Error(error.error.message);
}
return res.json(); // { data: [...], pagination: {...} }
}
// 建立待辦事項
async function createTodo(title, description = "") {
const res = await fetch(`${API_BASE}/todos`, {
method: "POST", // 建立用 POST
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, description }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error.message);
}
return res.json();
}
// 更新待辦事項(部分更新)
async function updateTodo(id, updates) {
const res = await fetch(`${API_BASE}/todos/${id}`, {
method: "PATCH", // 部分更新用 PATCH
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates), // 只傳要改的欄位
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error.message);
}
return res.json();
}
// 刪除待辦事項
async function deleteTodo(id) {
const res = await fetch(`${API_BASE}/todos/${id}`, {
method: "DELETE",
});
if (!res.ok && res.status !== 204) {
const error = await res.json();
throw new Error(error.error.message);
}
// DELETE 成功回傳 204,沒有 body
}
Code language: JavaScript (javascript)// TodoList.jsx -- React 元件使用 API
import { useState, useEffect } from "react";
function TodoList() {
const [todos, setTodos] = useState([]);
const [pagination, setPagination] = useState(null);
const [page, setPage] = useState(1);
// 載入待辦事項
useEffect(() => {
listTodos({ page }).then((result) => {
setTodos(result.data); // API 回傳的 data 陣列
setPagination(result.pagination); // API 回傳的分頁資訊
});
}, [page]);
// 切換完成狀態
async function toggleComplete(todo) {
const updated = await updateTodo(todo.id, {
completed: !todo.completed, // 只傳 completed 欄位
});
setTodos(todos.map((t) => (t.id === updated.id ? updated : t)));
}
return (
<div>
{todos.map((todo) => (
<div key={todo.id}>
<span>{todo.title}</span>
<button onClick={() => toggleComplete(todo)}>
{todo.completed ? "取消完成" : "完成"}
</button>
</div>
))}
{pagination && (
<div>
第 {pagination.page} / {pagination.total_pages} 頁
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>
上一頁
</button>
<button
disabled={page >= pagination.total_pages}
onClick={() => setPage(page + 1)}
>
下一頁
</button>
</div>
)}
</div>
);
}
Code language: JavaScript (javascript)這就是「契約」的體現:後端定義好 /v1/todos 回傳 { data, pagination } 的格式,前端就照這個格式解析。只要契約不變,前後端可以各自開發、各自部署。
常見坑與解法
坑 1:GET 請求帶 body
症狀:某些 HTTP 客戶端會忽略 GET 請求的 body,導致參數沒送到。
為什麼會發生:HTTP 規範允許 GET 帶 body,但不建議,很多工具不支援。
怎麼避免:GET 的參數一律放在 URL query string 裡。
# 正確
GET /users?status=active&page=2
# 不要這樣做
GET /users
Body: {"status": "active", "page": 2}
Code language: PHP (php)坑 2:不一致的錯誤格式
症狀:有些 endpoint 回傳 {"error": "..."} 字串,有些回傳 {"message": "..."},前端不知道怎麼統一處理。
為什麼會發生:沒有在專案開始時定義統一的錯誤格式。
怎麼避免:在專案一開始就定義好錯誤回應的結構,全部 endpoint 都遵守。
跟 AI 說:
「幫我建立一個 FastAPI 的統一錯誤處理機制,所有錯誤都回傳
{error: {code, message, details}}格式」
坑 3:忘記處理分頁導致效能爆炸
症狀:API 越來越慢,因為一次回傳了幾萬筆資料。
為什麼會發生:開發初期資料少,沒加分頁。上線後資料量暴增。
怎麼避免:從第一天就加分頁,設定 per_page 的預設值和上限。
# 一開始就限制好
per_page: int = Query(default=20, le=100) # 每頁最多 100 筆
Code language: PHP (php)Vibe Coder 檢查點
看到 AI 生成的 API 程式碼時,確認以下事項:
- [ ] URL 是名詞(資源)不是動詞(動作)嗎?
- [ ] HTTP 方法用對了嗎?(GET 讀取、POST 建立、PATCH 更新、DELETE 刪除)
- [ ] 有加分頁嗎?
per_page有設上限嗎? - [ ] 錯誤回應格式統一嗎?有用合適的 HTTP 狀態碼嗎?
- [ ] 有版本號嗎?(
/v1/前綴) - [ ] 需要認證的 endpoint 有加上驗證嗎?
AI 對話範例
入門對話:
「幫我寫一個待辦事項的 API」
進階對話(更好的做法):
「幫我用 FastAPI 設計一個待辦事項 REST API。要求:
1. RESTful 風格,URL 用 /v1/todos
2. 支援 CRUD(GET/POST/PATCH/DELETE)
3. 列表 API 要有分頁(offset-based,每頁預設 20 筆,上限 100)
4. 支援 ?completed=true 過濾和 ?sort=-created_at 排序
5. 統一錯誤格式 {error: {code, message, details}}
6. 用 Pydantic model 定義請求和回應結構」
提供越多上下文,AI 產出的 API 設計就越符合最佳實踐。
小結
記住這個判斷框架:
- URL 設計:名詞(資源),不是動詞(動作)
- HTTP 方法:GET 讀、POST 建、PATCH 改、DELETE 刪
- 回應格式:成功和錯誤都要有一致的結構
- 分頁:從第一天就加上,設好上限
- 版本控制:用
/v1/前綴,加東西不升版,改/刪才升版
最重要的原則:API 是契約,一旦發布就不能隨意更動。設計時多花十分鐘想清楚格式,後面少花十小時修 bug。
延伸:知道就好
這些進階主題遇到再深入:
- GraphQL:另一種 API 風格,讓前端自己決定要哪些欄位。適合資料關係複雜的場景
- gRPC:Google 的高效能 RPC 框架,適合微服務之間的通訊
- OpenAPI / Swagger:API 文件自動生成工具,FastAPI 內建支援(訪問
/docs就能看到) - Rate Limiting:限制 API 呼叫頻率,防止被濫用
- HATEOAS:REST 的進階概念,API 回應中包含相關資源的連結
進階測驗:API 設計 — 打造系統的溝通契約
共 5 題,包含情境題與錯誤診斷題。