PCA 雲端架構師之旅 06 — REST API 設計
服務邊界畫好後,還要決定它們怎麼對外提供資料。API 一旦有人串接,路徑、欄位和錯誤碼就不能隨意更動;設計時多花一點時間,後面改版會容易得多。
例如訂單 API 的欄位名稱一改,外部合作夥伴可能還在送舊格式。這時不是把舊欄位刪掉就好,還得考慮版本、過渡期和相容處理。
這篇會從重試、版本、錯誤回應和存取控制幾個實際問題切入。它是 PCA 雲端架構師之旅 的第六步;上一篇是 05 · Microservices 拆分。

圖解:客戶端沒收到回應時會重送 POST;兩次請求若帶同一把 idempotency key,伺服器可辨識它們是同一次操作,避免重複建立訂單。前提是查重與建立訂單有一致的保護,不能只在寫入前查一下 key。
網路逾時後,重送會發生什麼?
假設客戶端送出訂單後逾時了:請求可能根本沒到伺服器,也可能訂單已建立,只是回應沒送回來。客戶端無法只憑逾時判斷是哪一種,因此需要能安全重試的設計。
這就是冪等性(idempotency)要處理的問題:同一個操作重送,不應再建立第二筆訂單。重試機制與去重必須一起設計;光說「失敗就再送一次」不夠。
除了重試,API 上線後還常遇到三件事:
- 把動詞塞進 URL,寫成
/getOrder、/createOrder,這不符合 REST 把資源當名詞的精神。 - 沒有版本或相容策略,欄位一改就影響既有客戶端。
- 錯誤全部回 500,客戶端分不出該修正請求還是稍後重試。
流程圖暫時無法顯示。可先查看下方的流程與節點文字,或重新整理頁面後再試。
查看流程與節點文字
箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。
sequenceDiagram
participant C as Client
participant A as Order API
participant D as Order DB(含唯一 key 紀錄)
C->>A: POST /orders + Key K1
A->>D: 以 K1 原子建立或查詢訂單
alt 第一次請求
D-->>A: 建立 O1 並提交交易
A-->>C: 201 O1
else 逾時後重送
D-->>A: K1 已對應 O1
A-->>C: 回傳既有結果,不重做副作用
end 先把資源、版本和重試說清楚
Google 的 AIP(API Improvement Proposals)整理了不少 API 設計慣例。這裡先抓幾項會影響客戶端使用方式的原則:
| 原則 | 說明 |
|---|---|
| 資源導向 | URL 是名詞,不是動詞。POST /orders 不是 /createOrder |
| 方法語意 | GET 讀取、POST 建立或執行動作、PATCH 更新、DELETE 刪除;List 通常用 GET 讀取集合 |
| 版本化 | /v1/orders,breaking change 上 v2 |
| 冪等性 | PUT、DELETE 應有冪等語意;POST 依操作設計重試保護 |
| 一致的錯誤格式 | 用 RFC 9457(RFC 7807 的後繼版)或 Google Error Model |
表格超出欄寬時,可左右捲動;鍵盤可聚焦表格後用左右方向鍵查看。
這裡最容易誤會的是冪等性。它說的是同一個操作重複執行後,預期的伺服器狀態與執行一次相同;每次 HTTP 回應不一定逐字相同。
網路逾時時,客戶端可能會重送請求。若結帳 API 沒有處理重複操作,使用者按第二次就可能建立兩筆訂單。HTTP 方法的語意能幫忙判斷風險,但實作仍要遵守那些語意,不能以為用了 PUT 或 DELETE 就自動安全。
對會建立訂單或付款的 POST,可以讓客戶端帶 Idempotency-Key。伺服器用唯一鍵與交易,讓同一個 key 只對應一次建立操作,後續重送再讀取既有結果。單純「查不到 key 就繼續執行」不夠:兩個請求可能同時查不到,仍各做一次。付款若交給外部服務,還要把同一操作的去重需求帶到下游。(參考:Stripe 的冪等請求說明、RFC 9110 的 HTTP 方法語意)
📝 考場提點
讀到重試或重複投遞,先問「同一操作再跑一次會不會多做一筆?」GET、PUT、DELETE 有各自的 HTTP 語意,但仍要檢查實作;POST 也不是一律只能靠 idempotency key,可以依資源 ID、唯一約束或交易設計重試保護。

URL 先表達資源,HTTP 方法再表達動作。GET、PUT 與 DELETE 具冪等語意;POST
若可能因重送造成重複建立,就要另外設計去重。狀態碼也要讓客戶端能判斷該修正請求,還是依
Retry-After 或退避策略稍後重試。
三種 API 管理方式,怎麼比較?
API 設計完,前面通常要擺一層閘道。GCP 給你三個選項,差別很實際:
| 產品 | 適用 | 特色 |
|---|---|---|
| Cloud Endpoints | 基本 API 管理 | 整合 Cloud Run/GKE、OpenAPI 支援 |
| API Gateway | 全託管 serverless API | 輕量、適合起步 |
| Apigee | 企業級 API 管理 | 有 developer portal、分析、貨幣化 |
表格超出欄寬時,可左右捲動;鍵盤可聚焦表格後用左右方向鍵查看。
如果只需要替幾個服務加上基本驗證和流量管理,先釐清 API Gateway 或 Cloud Endpoints 是否已足夠。Apigee 提供更多 API 產品管理能力,也意味著不同的設定和費用;別只因功能最多就選它。
例如要經營 developer portal、替外部開發者提供產品方案和分析資料,才有理由進一步評估 Apigee。若是內部服務或單一對外 API,先列出鑑權、配額和營運要求,再選擇較合適的管理方式。
流程圖暫時無法顯示。可先查看下方的流程與節點文字,或重新整理頁面後再試。
查看流程與節點文字
箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。
flowchart TB
R[API 管理需求] --> P{需要 developer portal<br/>貨幣化或進階分析?}
P -- 是 --> A[Apigee]
P -- 否 --> S{主要是 serverless API<br/>且希望全託管?}
S -- 是 --> G[API Gateway]
S -- 否 --> E{需要 OpenAPI 與<br/>Cloud Run/GKE<br/>基本管理?}
E -- 是 --> C[Cloud Endpoints]
E -- 否 --> N[先確認是否真的<br/>需要閘道]
A --> V[再設定驗證、配額與監控]
G --> V
C --> V 每個 API,先問自己這五件事
設計 API 前,可以先問這五個問題:
- 這個動作的主體資源是什麼? 找到它,URL 就圍著它走,自然就不會冒出動詞。
- 重送會改變結果嗎? 若會,確認如何避免重複建立、扣款或通知。
- 失敗的時候,客戶端需要知道什麼? 重點不是訊息寫得多漂亮,是讓對方「知道該不該重試」。
- 誰能呼叫它? 匿名訪客、登入使用者、內部服務、外部合作夥伴,鑑權方式天差地遠。
- 需要怎樣的流量保護? 估計正常尖峰,並限制濫用或錯誤重試對後端的影響。
這五題不一定一次就能答完,但把未知條件記下來,比 API 上線後才發現客戶端無法安全重試容易處理。
流程圖暫時無法顯示。可先查看下方的流程與節點文字,或重新整理頁面後再試。
查看流程與節點文字
箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。
flowchart TB
U[使用者要完成的動作] --> R[找出主體資源與 URL]
R --> H[選 HTTP 方法與狀態碼]
H --> I{操作有不可重複的副作用?}
I -- 是 --> K[加入 idempotency key]
I -- 否 --> E[維持方法原生語意]
K --> A[定義呼叫者身分與權限]
E --> A
A --> Q[設定 quota/rate limit]
Q --> F[定義可判斷是否重試的<br/>錯誤格式] 走一遍範例 — 登雲書店
用登雲書店的訂單、購物車與書商匯入流程,看看不同 API 該怎麼定義資源、重試和錯誤。其他服務可以沿用判斷方式,但不必照搬同一組路徑。
order-service:訂單服務怎麼定 URL
POST /v1/orders 建立訂單
GET /v1/orders/{orderId} 查詢單一訂單
GET /v1/orders?buyer={id}&state=paid 列出訂單
POST /v1/orders/{orderId}:cancel 取消訂單(命名動作)
最後的 :cancel 表示取消訂單這個動作,不只是修改一個欄位。依 Google AIP 的 custom method 慣例,會改變資料的命名動作用 POST,路徑在資源名稱後接冒號與動詞;只有標準方法不足以表達意圖時才需要這樣設計。
建立訂單的請求長這樣,注意那個 Idempotency-Key:
POST /v1/orders HTTP/1.1
Authorization: Bearer <token>
Idempotency-Key: 0f9b4e2a-3c1d-4a22-bb9a-9a1b0f0f0f0f
Content-Type: application/json
{
"buyer_id": "usr_48211",
"items": [
{ "sku": "BK-9789571234567", "quantity": 1 },
{ "sku": "BK-9789867891230", "quantity": 2 }
],
"shipping_address_id": "addr_902"
}
成功就回 201,並用 Location header 告訴客戶端新訂單在哪:
201 Created
Location: /v1/orders/ord_20260415_0001
{
"order_id": "ord_20260415_0001",
"state": "pending_payment",
"total_amount": { "currency": "TWD", "value": 1380 }
}
若同一位呼叫者用相同 key 重送相同請求,API 應回傳前一次保存的結果,不再建立訂單。另一種情況才是衝突:同一個 key 被拿來送不同的訂單內容。這時可回 409,提醒客戶端不能把新操作當成舊操作重試:
{
"error": {
"code": 409,
"status": "ALREADY_EXISTS",
"message": "This idempotency key was used with different order details. Use a new key.",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "IDEMPOTENCY_KEY_REUSED",
"domain": "api.cloudon-books.example"
}
]
}
}
這個範例沿用 Google API 錯誤模型 的 JSON 結構:code 是 HTTP 狀態碼,status 是標準狀態,details 裡的 ErrorInfo.reason 則讓客戶端辨認具體原因。程式應依這些欄位處理錯誤,不要靠解析可能修改的 message 文字。
cart-service:先看重送的後果
GET /v1/carts/{cartId}
POST /v1/carts/{cartId}/items
PATCH /v1/carts/{cartId}/items/{itemId}
DELETE /v1/carts/{cartId}/items/{itemId}
POST /v1/carts/{cartId}:checkout → 呼叫 order-service
購物車操作也不能一概說「重送沒關係」。如果 POST 的意思是「加一件」,重送可能變成加兩件;若改用明確的品項 ID 和設定數量的操作,重試就比較容易控制。最後的 :checkout 風險更高:它會建立訂單,甚至觸發付款,必須讓同一次結帳在重送後仍只產生一筆業務結果。
先列出各操作重送一次會發生什麼,再決定用 idempotency key、固定資源 ID、唯一約束或其他方式保護;方法名稱本身不會替你完成這件事。
partner-ingest-service:批次處理不讓請求一直等
合作夥伴上傳書目資料後,檢查與匯入可能要跑上一段時間。API 可以先回傳工作 ID,讓客戶端查進度或等待通知;不必讓一次 HTTP 請求等到整批處理完:
POST /v1/ingestJobs
→ 202 Accepted
{ "job_id": "job_20260415_tienshia_01", "status": "queued" }
GET /v1/ingestJobs/{jobId}
→ 回傳 status: queued | running | succeeded | failed + 失敗列表 URL
收到請求先回 202 Accepted,意思是「我收下了,排隊處理中」,給一個 job_id;客戶端事後拿這個 id 去 poll 進度。
還有一個細節值得學起來:CSV 檔本身別走 API。讓客戶端拿 signed URL 直接把檔案丟進 Cloud Storage,幾百 MB 的大檔就不會卡在 API 閘道、也不會吃掉服務的記憶體和連線。閘道只負責收「請幫我處理這個檔」的指令,不負責搬運檔案本身。這個「控制流跟資料流分開」的設計,適合大檔上傳與長時間處理的情境。
📝 考場提點
大檔上傳和長時間處理不一定要綁在同一個同步請求裡。可以讓 API 建立工作並回傳 job ID,檔案則由客戶端透過 signed URL 上傳。是否要用這種方式,還得看檔案大小、處理時間、授權與失敗後的重試需求。

控制流只負責建立工作並回傳 202 Accepted 與 job_id;資料流讓大檔透過
signed URL 直傳 Cloud
Storage,再由事件或佇列觸發背景處理。客戶端另外查狀態,不把長工作綁在同一條 HTTP 連線上。
把共用的設計決策一次定清楚
每個服務若各訂一套版本和錯誤格式,串接者會很難維護。下面是假設登雲書店與合作夥伴協調後,訂出的一組 API 契約範例:
| 面向 | 決策 |
|---|---|
| 版本 | URL 路徑 /v1/、breaking change 時推 /v2/,保留 v1 12 個月 |
| 鑑權 | 外部 API 走 OIDC + API key;內部服務間以各服務自己的身分驗證(GKE 上以 Workload Identity Federation for GKE 取得身分) |
| 速率限制 | Apigee quota policy:消費者 100 req/min、合作夥伴 20 req/min |
| 錯誤處理 | 先分辨可恢復錯誤;429 遵守 Retry-After,可安全重試時才對 5xx 退避重送 |
| 分頁 | pageSize + pageToken(Google 風格,避免 offset 深分頁問題) |
表格超出欄寬時,可左右捲動;鍵盤可聚焦表格後用左右方向鍵查看。
這裡有兩項值得另外說明。登雲書店的內部服務若跑在 GKE,可用 Workload Identity Federation for GKE 取得身分,避免把長期金鑰放進服務。分頁則選 pageToken,讓服務端能以穩定的排序鍵延續查詢;它比深度 offset 更容易控制效能,但仍要設計好排序與資料更新時的行為,不能保證永遠不會漏掉或重複。
流程圖暫時無法顯示。可先查看下方的流程與節點文字,或重新整理頁面後再試。
查看流程與節點文字
箭頭表示流程方向;方括號與引號內是節點名稱,可搭配下方圖解閱讀。
flowchart TB
E[收到 HTTP 回應] --> S{狀態碼類型}
S -- 2xx --> O[成功,不重試]
S -- 4xx --> C{是 429?}
C -- 否 --> F[修正請求或權限<br/>不要盲目重試]
C -- 是 --> R[遵守 Retry-After]
S -- 5xx --> B[指數退避+隨機抖動]
R --> I{操作具冪等保護?}
B --> I
I -- 否 --> X[停止自動重試<br/>避免重複副作用]
I -- 是 --> T[在重試上限內再送] 上線後才痛的那幾個雷
下面這些都不是設計階段會自爆的問題,而是上線後、流量上來、客戶接多了才慢慢咬人的——也是設計 API 時值得先想清楚的點:
- HTTP 回 200,body 裡卻寫
{"error": ...}。 業務錯誤跟 HTTP 狀態碼是兩回事,但別把它們攪在一起。前面的 CDN、Load Balancer、監控系統全看狀態碼決定要不要重試、要不要報警,你回 200 它們就以為一切正常,錯誤被吞得無聲無息。 - 整支 API 只有一種籠統的錯誤。 客戶端沒辦法用程式判斷該怎麼處理,只能人工看 log。該給的是明確的錯誤碼加上結構化的
details,讓對方能寫 if-else 接住。 - query string 塞太多狀態。
/orders?state=paid&buyer=X&from=...&to=...&...一旦條件多到可能超出 URL 長度限制,就該認真考慮改成POST /orders:search,把查詢條件放進 body,URL 也乾淨。 - 破壞性變更卻不升版。 改欄位名、搬欄位位置、改型別,這些對接你 API 的人來說全是地雷。breaking change 就老老實實上
/v2/,舊版留一段過渡期,別在 v1 上面動刀。
延伸閱讀
- Google AIP — API Improvement Proposals — Google 內部的 API 設計標準,完整且實用。
- Google Cloud APIs Design Guide — GCP 官方 API 設計指南(2014 年起 Google 內部使用,多數主題目前導向對應的 AIP)。
- RFC 9457(取代 RFC 7807)— Problem Details for HTTP APIs — 標準化的錯誤回應格式(RFC 9457 於 2023 年發佈,正式取代 RFC 7807)。
- Stripe API Idempotency — Stripe 對冪等請求(Idempotency-Key)的做法,可當作設計參考。
下一步:API 的對外契約定好了,接著就要決定每個資源該落在哪種儲存——這正是下一篇 儲存特性分析 的主題。
🎯 換你練習
理論讀完,換自己來。到 架構師設計工作坊 · 步驟 6 填入你的 case study,邊寫邊內化。
讀完後,留一個學習紀錄
試著用自己的話說出一個重點,以及你會如何運用。實作文章請先核對結果與清理資源,再標記為讀完。
文章進度以你確認讀完的標記為準,可隨時取消。舊版造訪紀錄保留,但不計入讀完;這份自評不代表已掌握內容。