iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Claude AI

奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲系列 第 22 篇

Day 22:前後端聯軍:API 串接與跨域戰爭,Vue 呼叫 Go 服務的實戰筆記

  • 分享至 

  • xImage
  •  

claude_22

系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code / curl + jq / Terraform(CloudFront 第二個 origin)
今日進度:本機 CORS 完整版、client.ts 的逾時重試與 409 處理、正式環境改走 CloudFront 同源、contract test 進 CI

前言

Day 21 的週記列了六個整合坑,四個是「兩邊各自正確、合起來不正確」的契約問題。今天要把兩邊真的變成一邊:本機把 CORS 做完整,client.ts 升級成能處理錯誤、逾時與重試的版本,接好戰鬥回報,最後決定正式環境的前端怎麼找到後端。

先講一件消失的事。舊架構這一天有齣經典戲碼:HTTPS 的 CloudFront 打 HTTP 的 ALB,瀏覽器判定 mixed content,連 preflight 都不送就擋掉。換成 API Gateway 之後這齣戲沒了——它只講 HTTPS,沒有 HTTP listener 可以設錯。題目只是換了一題:要決定的不再是「怎麼讓後端有 TLS」,而是「要不要讓瀏覽器知道後端在哪」。

結論先講:跨域戰爭最好的打法,是不要跨域。

一、CORS middleware:三個細節決定它能不能用

Day 21 坑 #1 的根因是 chi 對沒註冊的 OPTIONS 回 404,瀏覽器判定 preflight 失敗。完整版在 httpapi/middleware/cors.go,只允許一個來源:

w.Header().Add("Vary", "Origin") // 一律先加
if strings.EqualFold(r.Header.Get("Origin"), allowedOrigin) {
	w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
	// 另外三行:Allow-Methods / Allow-Headers / Max-Age
}
if r.Method == http.MethodOptions { // preflight 到此為止,不進 router
	w.WriteHeader(http.StatusNoContent); return
}

三個細節:Vary: Origin 不管命中與否都加,否則中間的快取可能把「給 A 的回應」餵給 B;比對用 EqualFold,回單一來源字面值、永遠不是 *;Max-Age 600 讓瀏覽器 10 分鐘內不再送 OPTIONS——一場從序章打到結局的遊玩,Network 面板的 OPTIONS 從 14 次降到 2 次。Authorization 現在就列進允許 header,Day 27 不用回來改。

router.go 的掛法也改了:WEB_ORIGIN 非空才 r.Use,預設是空字串,只有 make api 帶 http://localhost:5173。理由在第三節:正式環境同源,Terraform 不設這個變數,middleware 連掛都不掛——設空字串再去比對 Origin 是「看起來安全」,完全不執行才是真的安全。

手寫這 30 行而不掛 chi 的 cors 套件,是因為它只做我需要的事、每一行我都讀得懂:Claude Code 第一版掛了套件並把來源設成 *,理由是「開發方便」——方便是真的,但這一行會一路活到正式環境。

二、client.ts:錯誤、逾時、重試三件事分開想

Day 18 的 client.ts 只有「打 API、不 ok 就丟 ApiError」。今天補的在 frontend/src/api/client.ts:ApiError 多一個 isRetryable getter(status === 0 或 >= 500);request() 是一個 for 迴圈,用 AbortController 做 8 秒逾時,可重試且還有額度就等 300 * 2 ** attempt 毫秒再來。兩個決定值得說:

  • GET 預設重試 2 次、POST 0 次。 /battles 重送一次,後端會因為節點已經推進而回 409 INVALID_NODE——這不是錯誤,是「你已經回報過了」;重不重送要由知道語意的呼叫端決定,不是 client。4xx 一律不重試,那是我們自己的問題。
  • 逾時鏈整條要嚴格遞增:client 8 秒 < chi middleware.Timeout 10 秒 < Lambda timeout 15 秒 < API Gateway integration 20 秒。方向永遠是「外面的比裡面的有耐心」,否則會「後端已經放棄、前端還在轉圈」。

今天真正新增的是收到不是自己家的錯誤 body 時怎麼辦。Day 8 定的格式是 {error:{code,message}},舊架構裡 4xx/5xx 都由 Go 產生,格式必定正確。無伺服器不是這樣:API Gateway 節流回 {"message":"Too Many Requests"},WAF 擋下來回一頁 HTML,都沒經過我的程式。所以 parseError 解不出 error.code 時,改依狀態碼合成一個:STATUS_TO_CODE[status] ?? (status >= 500 ? 'INTERNAL' : 'BAD_REQUEST'),429 對到 RATE_LIMITED、409 對到 INVALID_NODE。

環境變數兩份:.env.development 是 VITE_API_BASE=http://localhost:8080,.env.production 留空,正式環境的值由 deploy-web.yml 用 vars.API_BASE 注入。Vite 在建置時把 VITE_* 寫死進 bundle,所以「換環境」永遠是重新 build。

接上戰鬥結束:BattleView.vue 的 finish() 在 battleEnded 後呼叫 story.reportBattle(),catch 到 409 就改呼叫新增的 story.refresh()。整個函式 12 行,重點只有一個:409 不是失敗,是「後端已經知道了」,唯一正確的動作是把後端狀態拉回來覆蓋前端。Claude Code 第一版把 POST 也預設重試兩次,理由是「不重試會讓玩家的戰果消失」——理由對,解法錯:要重新同步的是呼叫端,不是 client。

三、正式環境:同源怎麼換來的

本機 5173 → 8080 是乾淨的跨域,CORS 解決。上了 AWS,前端在 CloudFront 網域、後端在 https://<api-id>.execute-api.ap-east-2.amazonaws.com/prod,兩邊都是 HTTPS 也都合法——這次是選擇題,不是搶救題:

claude_22_diagram_01

我選 C:modules/cdn/main.tf 多一個 apigw-api origin 與一個 behavior。理由不再是「幫後端做 TLS」,而是同源與收斂攻擊面:瀏覽器只認識一個網域,Day 27 的 WAF 掛上去就同時保護前後端,不會出現「前端有 WAF、API 裸奔」。REST API 的 invoke URL 結尾是 /prod,domain_name 又只吃不帶路徑的主機名,stage 因此靠 origin 的 origin_path 補回去。

ordered_cache_behavior {
  path_pattern             = "/api/*"
  target_origin_id         = "apigw-api"
  compress                 = false
  cache_policy_id          = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # CachingDisabled
  origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" # AllViewerExceptHostHeader
}

cache_policy_id 原本是自建的:TTL 全 0、cache key 全空、gzip 與 brotli 都開。語意正確,validate 過、plan 也過,apply 才知道它建不出來——The parameter EnableAcceptEncodingGzip is invalid for policy with caching disabled。TTL 全 0 在 CloudFront 眼裡就是 caching disabled,而 caching disabled 不准帶 gzip 開關。改用託管的 CachingDisabled,壓縮往上游搬一站,交給 REST API 自己的 minimum_compression_size = 1024——這比原本更好:CloudFront 只省最後一哩,API Gateway 連「到 CloudFront」那一段也一起省。compress = false 是誠實不是退步:CachingDisabled 的 cache key 裡沒有 Accept-Encoding,CloudFront 本來就不會壓。

origin_request_policy_id 也因此今天就得寫,不能留到 Day 27:Accept-Encoding 要真的傳到 origin,API Gateway 才壓得成。而且只能是 AllViewerExceptHostHeader 不是 AllViewer——API Gateway 是靠 Host 決定路由到哪一個 API 的,把瀏覽器的 Host 轉過去,它直接回 403。這條上線後驗過了:同一個 /api/v1/saves,帶 token 回 200、不帶回 401,Authorization 確實有走完 CloudFront 這一跳。

claude_22_diagram_02

代價是多一個 hop。從台灣打 /api/v1/healthz:直打 execute-api 平均 42 ms,經 CloudFront 49 ms,多 7 ms(各 50 次取平均)。

但多出來的不只是那 7 ms。第一發可能是冷啟動:那一發量到 1.6 秒(另計,不進平均),Lambda 得先拉容器映像。「一直有一個行程醒著」的假設不成立了;Day 24 會給它一條基準線。

換來的是正式環境 VITE_API_BASE 與 vars.API_BASE 都空著,CORS middleware 從此只服務本機。Claude Code 這一段的角色是把選項攤開:我給三個候選與「我沒有網域」這個限制,它列出各自的前置條件與長期成本,但沒有替我選。

四、把契約變成可執行的東西

Day 21 說契約寫在 docs/api.md 沒用,兩個 session 還是各解釋各的。scripts/contract-test.sh(make smoke)今天補成真正的 contract test:不設 SAVES_TABLE,倉儲自動 fallback 到記憶體版,起一個不需要 AWS、也不需要 Docker 的 API。

「起一個 API」這件事踩了一腳。第一版寫 go run ./cmd/api & 再 kill "$API_PID",第二次跑就沉默地卡住:go run 會另外 fork 一支編譯好的二進位,殺掉 go run 殺不到那支子行程,API 變孤兒繼續佔著 18080。現在先 go build -o "$API_BIN",再用 exec "$API_BIN" 起子 shell——$API_PID 就是 server 本身,kill 才殺得到。

九個斷言,每個是一個 curl 接一個 jq -e,代表作是重送戰報要拿到 409 INVALID_NODE。跑 3.4 秒,掛在 ci.yml 的 api job 最後一步。

為什麼不用 httptest?因為契約要驗的是「跨過 HTTP 邊界之後」的樣子——欄位大小寫、null 與空陣列的差別、狀態碼,只有真的序列化成 bytes、再用一個不認識 Go struct 的工具(jq)讀回來才會現形。

小結

今天沒有新功能,全是「讓兩邊成為一邊」的工作。最值得記的是做法 C:它不是 CORS 的解法,是讓 CORS 不存在的解法——但只在「我沒有網域」的前提下成立。次值得記的是那個自建的 cache policy:validate 過、plan 也過,apply 才告訴我那是非法組合。「設定檔通過檢查」跟「這個資源建得出來」是兩件事。

今日產出

  • [x] backend/internal/adapter/httpapi/:middleware/cors.go、router.go 改成 WebOrigin 非空才掛
  • [x] frontend/src/:api/client.ts(逾時、重試、isRetryable、合成 code)、stores/story.ts 的 refresh()、views/BattleView.vue 的 finish()
  • [x] infra/terraform/modules/cdn:apigw-api origin(origin_path = "/prod")、/api/* behavior 用託管 CachingDisabled + AllViewerExceptHostHeader
  • [x] scripts/contract-test.sh 九個斷言進 ci.yml;.env.* 兩份與 vars.API_BASE

明日預告

Day 23:敵軍波次平衡調校:用 Claude 協助遊戲數值設計與難度曲線分析——Day 11 的模擬器加上 -csv,讓 Cowork 把六關的難度曲線畫出來。


上一篇
Day 21:【週記】Day 15-20 回顧:前後端整合的第一場真實戰役(踩坑實錄)
下一篇
Day 23:敵軍波次平衡調校:用 Claude 協助遊戲數值設計與難度曲線分析
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言