PCA 雲端架構師之旅 06 — REST API 設計
服務拆好了,接下來要定義它們對外講話的方式。API 就是服務之間的那紙合約——一旦發佈出去、有人開始接,你就被綁死了。當下隨手定的命名、隨手回的錯誤碼,往後 3 年都得拖著走。
我看過太多團隊在這裡吃虧。系統架構畫得漂漂亮亮,結果 API 是工程師趕著上線時邊寫邊定的,半年後想改一個欄位名稱,發現外部串接的合作夥伴一改就炸,只好硬著頭皮維護兩套邏輯。改 API 的痛,永遠來得比你想像中早。
PCA 不會考你 REST 跟 GraphQL 哪個比較好這種哲學題,它考的是上線後真的會咬人的東西:版本怎麼升、冪等怎麼保、錯誤怎麼回、誰能呼叫、流量怎麼擋。這是 PCA 雲端架構師之旅 的第六步,上一篇是 05 · Microservices 拆分。

圖解:客戶端沒收到回應時會重送 POST;兩次請求若帶同一把 idempotency key,伺服器只建立第一筆訂單,第二次直接回傳既有結果。沒有這把鑰匙,一次網路逾時就可能變成兩筆訂單、兩次扣款。
題目其實在偷偷考你冪等
PCA 的 case study 很愛在敘述裡埋一句「使用者 call API 失敗時應該重試」「同一筆訂單不能重複建立」,然後問你該怎麼設計 API、配哪個 GCP 服務(通常選項裡會有 API Gateway、Cloud Endpoints、Apigee)。
這種題目答錯,幾乎都是栽在同一個地方:沒搞懂冪等性(idempotency)跟重試是綁在一起的。題目講「失敗要重試」,潛台詞就是「那你重試會不會重複下單?」——它在考的是冪等,不是重試本身。看懂這層,這類題目你會答得很穩。
另外有三個錯誤是考生反覆在犯的,先擺出來,後面我們一個個拆:
- 把動詞塞進 URL,寫成
/getOrder、/createOrder,這不符合 REST 把資源當名詞的精神。 - 完全沒有版本策略,API 一出去就等於凍結,哪天要改只能整批換掉。
- 錯誤全部回 500,客戶端根本分不出是自己傳錯還是伺服器掛了,分不出來就只好一直重試,重試風暴就是這樣養出來的。
Google 的 API 設計就那幾條規矩
GCP 自己的 API 都照一套叫 AIP(API Improvement Proposals)的規範在走。你不用把每條 AIP 背起來,但底下這幾條原則是 PCA 等級該有的肌肉記憶:
| 原則 | 說明 |
|---|---|
| 資源導向 | URL 是名詞,不是動詞。POST /orders 不是 /createOrder |
| 標準動詞 | GET / POST / PATCH / DELETE / LIST 各有語意 |
| 版本化 | /v1/orders,breaking change 上 v2 |
| 冪等性 | PUT 與 DELETE 天生冪等;POST 要靠 idempotency key |
| 一致的錯誤格式 | 用 RFC 9457(RFC 7807 的後繼版)或 Google Error Model |
這張表裡最值得單獨講的是冪等性。所謂冪等,就是同一個請求送一次跟送十次,結果都一樣。
為什麼非在意它不可?因為網路一定會斷、客戶端一定會重試,這是你管不到的事實。沒做冪等保護,使用者點一次「結帳」轉圈圈以為失敗了又點一次,你後台就生出兩筆訂單、扣兩次款。這不是假設,是金流服務每天都在防的事。
做法其實不複雜:客戶端在請求帶一個 Idempotency-Key header(通常是 UUID),伺服器收到後先查這個 key 有沒有處理過——可以記在 Cloud Memorystore,也可以直接落資料庫——處理過就把上次的結果原封不動回給它,別再執行一次。Stripe 那套就是這麼幹的,業界很多人直接抄它的設計。
📝 考場提點
看到題目寫「客戶端會重試」「請求可能重複送達」「at-least-once 投遞」,腦中要立刻亮一盞燈:這題在考冪等。GET / PUT / DELETE 天生冪等,POST 不是——所以建立資源(下單、付款、發送)這類 POST 才需要 idempotency key。考場上別把「重試」跟「冪等」當兩件事看,它們在題目裡幾乎是同義詞。
三個閘道,別每題都選 Apigee
API 設計完,前面通常要擺一層閘道。GCP 給你三個選項,差別很實際:
| 產品 | 適用 | 特色 |
|---|---|---|
| Cloud Endpoints | 基本 API 管理 | 整合 Cloud Run/GKE、OpenAPI 支援 |
| API Gateway | 全託管 serverless API | 設定最簡單 |
| Apigee | 企業級 API 管理 | 有 developer portal、分析、貨幣化 |
過來人的提醒:很多人一看到「API 管理」就反射選 Apigee,因為它最豪華。但 Apigee 是企業級重武器,貴、設定也重,案子裡如果只是要把幾個 Cloud Run 服務包一層、加個 API key 驗證,用它根本是高射砲打蚊子。
判斷的關鍵字其實藏在題目裡:題目提到 developer portal、把 API 當產品賣(貨幣化)、要給外部開發者完整的分析報表——這些字眼出現,才輪到 Apigee。如果只是內部服務之間或單純對外開個 serverless API,API Gateway 或 Cloud Endpoints 就夠了,還省錢。
每個 API,先問自己這五件事
設計一支 API 之前,這幾個問題我每次都會過一遍,考場上也是用它快速收斂答案:
- 這個動作的主體資源是什麼? 找到它,URL 就圍著它走,自然就不會冒出動詞。
- 這個動作冪等嗎? 不冪等,就一定要配保護機制,沒有商量。
- 失敗的時候,客戶端需要知道什麼? 重點不是訊息寫得多漂亮,是讓對方「知道該不該重試」。
- 誰能呼叫它? 匿名訪客、登入使用者、內部服務、外部合作夥伴,鑑權方式天差地遠。
- 要不要限流? 不設速率限制,等著被外部 DDoS 或內部某個寫壞的迴圈打爆。
這五題不是考試專用。真要設計上線系統,少問哪一題都會在事後付代價——通常是凌晨三點被 on-call 叫醒的那種代價。
走一遍範例 — 登雲書店
老規矩,拿登雲書店來實際走一遍。先挑三個最關鍵的服務示範:order-service、cart-service、partner-ingest-service,其他幾個套同樣的邏輯就好。
order-service:訂單服務怎麼定 URL
POST /v1/orders 建立訂單
GET /v1/orders/{orderId} 查詢單一訂單
GET /v1/orders?buyer={id}&state=paid 列出訂單
PATCH /v1/orders/{orderId}:cancel 取消訂單(命名動作)
注意最後那個 :cancel。取消訂單不是建一個新資源、也不是單純改個欄位,它是一個「動作」。AIP 風格遇到這種真的塞不進標準 CRUD 的操作,會用冒號加動詞的「命名動作(custom method)」來表達,而不是退回去寫 /cancelOrder。
建立訂單的請求長這樣,注意那個 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 }
}
如果同一個 idempotency key 又送一次呢?這時候就靠前面講的機制接住它——回一個結構化的衝突錯誤,順便把上次那筆訂單的 ID 附上去,客戶端拿到就知道「喔,這筆其實已經成立了」:
{
"error": {
"code": 409,
"status": "ALREADY_EXISTS",
"message": "Order with this idempotency key already exists",
"details": [
{ "@type": "type.googleapis.com/OrderConflict", "existingOrderId": "ord_20260415_0001" }
]
}
}
這就是 Google Error Model 的長相:一個固定的 error 物件,裡面有數字碼、字串狀態、人看的訊息,外加一個 details 陣列裝結構化資訊。重點在它穩定——客戶端可以靠 status 寫程式判斷,不用去 parse 那串 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
這裡有個容易被忽略的拿捏:購物車增減品項,重送了其實沒什麼大不了,多加一件刪掉就是,所以那幾個操作不帶 idempotency key 也還好。但最後那個 :checkout 不一樣——它會去呼叫 order-service 真的下單,網路一抖重送一次就是兩筆訂單、兩次扣款。所以 checkout 這道關卡,冪等保護一定要做。
冪等不是「全部都加比較安全」,而是「會產生副作用、會花錢、會通知別人的那些操作」一定要加。分得清楚,設計才乾淨。
partner-ingest-service:大批次就別走同步
合作夥伴上傳書目資料,動輒幾十萬筆、又不需要即時回應。這種就不該讓客戶端傻等著 API 回傳,而是走非同步:
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 閘道、也不會吃掉服務的記憶體和連線。閘道只負責收「請幫我處理這個檔」的指令,不負責搬運檔案本身。這個「控制流跟資料流分開」的設計,在 case study 裡是很討喜的答案。
📝 考場提點
題目只要出現「大檔上傳」「批次匯入」「報表匯出」「處理要好幾分鐘」這類字眼,標準答案的方向是兩個:第一,改成非同步(先回 202 + job id,再讓對方 poll 狀態),別讓請求同步卡住;第二,大檔走 Cloud Storage signed URL 直傳,別硬塞進 API。這兩招幾乎是 GCP 題目的固定套路,看到關鍵字直接往這邊靠。
把共用的設計決策一次定清楚
每個服務各寫各的版本、各定各的鑑權,遲早一團亂。這幾項該在團隊層級統一拍板:
| 面向 | 決策 |
|---|---|
| 版本 | URL 路徑 /v1/、breaking change 時推 /v2/,保留 v1 12 個月 |
| 鑑權 | 外部 API 走 OIDC + API key;內部服務間走 Workload Identity Federation for GKE |
| 速率限制 | Apigee quota policy:消費者 100 req/min、合作夥伴 20 req/min |
| 錯誤處理 | 4xx 不重試、5xx 指數退避重試(最多 5 次)、429 遵守 Retry-After |
| 分頁 | pageSize + pageToken(Google 風格,避免 offset 深分頁問題) |
這張表裡有兩個地方常被問到,順手說一下。內部服務之間的鑑權走 Workload Identity Federation for GKE(舊稱 Workload Identity),不要在服務裡塞長期金鑰——這是 GCP 一貫的主張,case study 看到「服務間呼叫如何驗證」幾乎都是往這個方向答。分頁用 pageToken 而不是 offset,是因為資料量一大、offset 翻到很後面會越查越慢,還可能因為中間有人插入新資料而漏掉或重複;token-based 分頁就沒這問題。
上線後才痛的那幾個雷
下面這些都不是設計階段會自爆的問題,而是上線後、流量上來、客戶接多了才慢慢咬人的——也正是 PCA 愛考的點:
- HTTP 回 200,body 裡卻寫
{"error": ...}。 業務錯誤跟 HTTP 狀態碼是兩回事,但別把它們攪在一起。前面的 CDN、Load Balancer、監控系統全看狀態碼決定要不要重試、要不要報警,你回 200 它們就以為一切正常,錯誤被吞得無聲無息。 - 整支 API 只有一種籠統的錯誤。 客戶端沒辦法用程式判斷該怎麼處理,只能人工看 log。該給的是明確的錯誤碼加上結構化的
details,讓對方能寫 if-else 接住。 - query string 塞太多狀態。
/orders?state=paid&buyer=X&from=...&to=...&...一旦條件超過五六個,就該認真考慮改成POST /orders:search,把查詢條件放進 body,URL 也乾淨。 - 破壞性變更卻不升版。 改欄位名、搬欄位位置、改型別,這些對接你 API 的人來說全是地雷。breaking change 就老老實實上
/v2/,舊版留一段過渡期,別在 v1 上面動刀。
延伸閱讀
- Google AIP — API Improvement Proposals — Google 內部的 API 設計標準,完整且實用。
- Google Cloud APIs Design Guide — GCP 官方 API 設計指南,AIP 的摘要版。
- RFC 9457(取代 RFC 7807)— Problem Details for HTTP APIs — 標準化的錯誤回應格式(RFC 9457 於 2023 年發佈,正式取代 RFC 7807)。
- Stripe API Idempotency — 業界公認冪等設計的最佳範本。
下一步:API 的對外契約定好了,接著就要決定每個資源該落在哪種儲存——這正是下一篇 儲存特性分析 的主題。
🎯 換你練習
理論讀完,換自己來。到 架構師設計工作坊 · 步驟 6 填入你的 case study,邊寫邊內化。