跳到主要內容
← 設計日記 聲音來賓
EP.07 · 設計日記

技術宅爸爸的架構筆記

Side project 的第一定律

先說結論:Side project 最重要的特質不是快、不是潮、不是可擴展。是活得久。

我見過太多 side project 死在帳單上。有人用 AWS 架了一個很棒的服務,上線第一個月就收到 50 美金的帳單。不多,但對一個沒有收入的 side project 來說,足以讓你在第三個月按下 terraform destroy

也有人死在維護上。一年前用的框架今天已經大改版了,dependencies 全部紅字,光是跑起來就要花一整個週末。然後那個週末你選擇帶女兒去公園,然後就沒有然後了。

所以在選技術的時候,我的判斷標準不是「這個技術最適合這個需求」,而是:

「一年後的某個週六下午,我打開這個專案,能不能在五分鐘內開始改東西?」


為什麼是 Astro

Astro 的核心概念是「島嶼架構」— 頁面預設是靜態 HTML,只有需要互動的部分才載入 JavaScript。

對聲音來賓來說,這個架構剛好:

  • 大部分頁面是靜態的 — 首頁、年代選擇、歌手搜尋、部落格。這些頁面不需要即時資料,prerender 之後就是純 HTML,載入速度極快。
  • 互動集中在遊戲頁面 — 猜歌的核心循環(播放音訊 → 選答案 → 顯示結果)需要 JavaScript,但也只有這個部分需要。
  • API 路由內建src/pages/api/ 底下直接寫 TypeScript 就是 API endpoint。不需要另外架 Express、不需要寫 serverless function 的設定檔。

而且 Astro 的開發體驗非常好。.astro 檔案把 HTML、CSS、TypeScript 寫在同一個檔案裡,像是 Vue 的 SFC 但更直覺。你不需要學什麼 JSX、不需要搞懂 virtual DOM、不需要理解 hydration boundary。

寫起來就像在寫 HTML,只是更強大。

對一個需要在哄完小孩之後用零碎時間開發的爸爸來說,「打開就能寫」的框架比「功能最完整」的框架重要一百倍。


Cloudflare 全家餐

後端我選了 Cloudflare 全家桶:

  • Workers — 跑 Astro SSR,處理 API 請求
  • D1 — SQLite 資料庫,存玩家、挑戰、成績
  • KV — Key-Value 快取,存搜尋結果、音訊 URL、每日挑戰

為什麼全部押在 Cloudflare?

第一,免費額度夠用。 Workers 每天 10 萬次請求免費。D1 每天 500 萬次讀取免費。KV 每天 10 萬次讀取免費。對一個親子猜歌遊戲來說,這些額度大概可以撐到我女兒上大學。

第二,零冷啟動。 Workers 不像傳統 serverless 有冷啟動問題。你的第一個請求跟第一萬個請求一樣快。對遊戲來說這很重要 — 沒有人願意等三秒鐘才開始猜歌。

第三,一個設定檔搞定。 整個後端的設定就是一個 wrangler.jsonc

{
  "d1_databases": [{ "binding": "DB", ... }],
  "kv_namespaces": [{ "binding": "KV", ... }]
}

不需要 IAM role、不需要 VPC、不需要 security group。想加一個資料庫?加一行。想加一個 KV?再加一行。


D1:夠用就好的資料庫

D1 是 Cloudflare 的 serverless SQLite。

說「SQLite」可能會讓一些人皺眉頭 — 不是應該用 PostgreSQL 嗎?不是應該用 MongoDB 嗎?

對一個猜歌遊戲來說,SQLite 完全夠用。而且它有一個 PostgreSQL 做不到的優點:不需要連線管理。

傳統資料庫需要 connection pool、需要處理連線逾時、需要考慮 max connections。D1 不用。每次 Worker 跑起來就能直接 query,不需要任何初始化。

目前的 schema 就四張表:

  • players — 玩家暱稱和 ID
  • challenges — 年代猜歌挑戰
  • artist_challenges — 歌手挑戰
  • daily_results — 每日挑戰成績 + 連勝紀錄

Migration 就兩三個 .sql 檔案:

npm run db:migrate        # 本地
npm run db:migrate:remote # 正式環境

沒有 ORM。直接寫 SQL。

const challenge = await env.DB.prepare(
  "SELECT * FROM challenges WHERE id = ?"
).bind(id).first();

有人會說「你應該用 Drizzle / Prisma」。也許吧。但對這個規模的專案,raw SQL 已經夠清楚了。加 ORM 反而多一層東西要學、要維護、要升級。


KV:快取的三層策略

KV 在這個專案裡扮演快取層的角色。我把需要快取的東西分成三類:

短期快取(1 小時): 搜尋結果、歌手資料。iTunes API 的搜尋結果不會頻繁變動,但也不是永遠不變。快取一小時平衡了速度和新鮮度。

中期快取(1 天): 每日挑戰的歌曲、歌手的完整曲目列表。這些東西一天之內不需要更新。

長期快取(7 天): 音訊 URL。iTunes 的 preview URL 很少改變。快取七天,大幅減少對 iTunes API 的請求次數。

為什麼要這樣分?因為 iTunes API 雖然免費,但它有 rate limit。如果每次有人播放音訊都去 iTunes 問一次 URL,流量一上來就會被擋。用 KV 把 URL 快取起來,同一首歌的音訊只需要查一次 iTunes。


音訊代理:唯一的 hack

整個專案裡最「hack」的部分,是音訊代理 endpoint:

GET /api/audio/[trackId]

為什麼需要這個?因為 iTunes 的音訊 URL 不支援 CORS。

瀏覽器有一個安全機制叫 CORS(跨來源資源共享)。簡單說就是:如果你的網站是 sound-guest.com,你不能直接用 JavaScript 播放 itunes.apple.com 上的音訊,因為這是「跨來源」的請求。

解決方案是讓我的 server 去幫忙抓音訊,然後轉交給瀏覽器:

瀏覽器 → /api/audio/123 → 我的 Worker → iTunes → m4a 音訊 → 瀏覽器

Worker 發請求給 iTunes 不受 CORS 限制(CORS 只限制瀏覽器),拿到音訊之後加上正確的 CORS header 回傳給前端。

整個流程不到 50 行 code,但解決了最關鍵的問題 — 讓音樂能在網頁上播放。


月成本:$0

我算了一下這個架構的月成本:

項目費用
Cloudflare Workers$0(免費額度內)
Cloudflare D1$0(免費額度內)
Cloudflare KV$0(免費額度內)
iTunes API$0(完全免費)
域名~$10/年
每月總計$0

零。

這意味著就算我有一年沒碰這個 project,它也不會因為帳單問題而掛掉。它就靜靜地在那裡,等我和女兒什麼時候想玩就能玩。

Side project 的第一定律:活得久。零成本讓這件事成為可能。


本地開發:三行指令

最後聊一下開發體驗。

如果你 clone 了這個 repo,要怎麼在本地跑起來?

npm install
npm run db:migrate
npm run dev

三行。沒有 .env。沒有 API key。沒有「請先到 XXX 註冊一個開發者帳號然後申請 OAuth credentials」。

Cloudflare 的 platformProxy 讓你在本地開發的時候可以直接存取 D1 和 KV 的模擬版本。你寫的 code 跟正式環境一模一樣,只是資料存在本地。

這對 side project 來說至關重要。因為你可能兩個月沒碰了,終於有一個週六晚上女兒睡了你想改個東西。如果這時候你得先花半小時搞環境設定,你的動力大概在第十分鐘就蒸發了。

三行指令。五分鐘後開始改 code。這就是我要的開發體驗。


下集預告

架構講完了,但遊戲不是做給自己玩的。最有趣的事情發生在女兒把挑戰連結傳到班級群組之後。

EP.08 — 挑戰朋友:從自嗨到分享,下週見。


聲音來賓是開源專案。全部程式碼都在 GitHub 上。如果你是工程師爸媽,歡迎 fork 一份改成你家的版本。