本文保留當時的瀏覽器遊戲與網站練習脈絡。目前的專案與工具已另外整理。
用 Cloudflare Workers + D1 打造 1 人可營運的遊戲後端
Hao0321 遊戲大廳 14 款遊戲都共用同一套後端 — 登入、每日簽到、排行榜、金幣、成就系統。整個後端是一個 250 行左右的 worker.js,跑在 Cloudflare Workers 上,資料存在 Cloudflare D1(邊緣 SQLite)。這篇把整個架構拆解,順便分享 1 人工作室在後端設計上可以偷懶(或不能偷懶)的地方。
選擇 Cloudflare Workers 的理由
一開始我評估過幾個選項:
| 方案 | 優點 | 為什麼不選 |
|---|---|---|
| Vercel Serverless + Postgres | Next.js 體驗流暢 | 冷啟動慢(200ms+)、DB 費用貴 |
| Fly.io + SQLite | 多區部署、便宜 | 要管 container,複雜度上升 |
| 自架 VPS | 完全控制 | 一個人沒時間管作業系統 |
| Firebase | 開箱即用 | vendor lock-in 嚴重、收費模型複雜 |
| Cloudflare Workers + D1 | 邊緣運算、零冷啟動、免費額度大 | (選了這個) |
對遊戲排行榜這種讀多寫少、對延遲敏感、流量可預測的應用,Cloudflare Workers 是甜蜜點。
Workers 的核心優勢:V8 Isolates
跟 AWS Lambda 不同,Cloudflare Workers 不是啟動 container,而是在 V8 engine 裡建立一個 isolated context。這個 context 大約 5ms 啟動,幾乎感受不到冷啟動。台灣使用者打 api.hao0321.com/play-counts 的端到端延遲大約 60ms,大部分時間其實花在 TLS 握手與 D1 查詢上,Worker 本身只佔 2-5ms。
Trade-off:每個 request 的 CPU 時間有上限(免費版 10ms / request,付費版 50ms)。這表示你不能在 Worker 裡跑 ML 推理或大量計算。但對 CRUD API 綽綽有餘。
整體架構
[ Game Client ] [ Game Hall ]
│ │
├─ POST /play (beacon) ├─ Google OAuth
├─ POST /score (JWT auth) ├─ GET /leaderboard
│ └─ GET /profile
▼ ▼
┌────────────────────────────────────────────────┐
│ Cloudflare Worker: api.hao0321.com │
│ • Route dispatch (path + method) │
│ • JWT verify / sign │
│ • Achievement detection │
│ • Input validation │
└────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────┐
│ Cloudflare D1 (SQLite on edge) │
│ • users, checkins, scores, best_scores │
│ • play_counts, achievements │
└────────────────────────────────────────────────┘
路由設計
Worker 裡不用 framework(Hono、itty-router 之類),直接手寫 if 判斷:
export default {
async fetch(req, env) {
if (req.method === 'OPTIONS') return new Response(null, { headers: CORS });
if (!env.JWT_SECRET) return err('JWT_SECRET not set', 500);
const url = new URL(req.url);
const path = url.pathname.replace(/^\/+|\/+$/g, '');
try {
if (path === 'auth/google') { /* ... */ }
if (path === 'checkin' && req.method === 'POST') { /* ... */ }
if (path === 'score' && req.method === 'POST') { /* ... */ }
if (path.startsWith('leaderboard/')) { /* ... */ }
// ...
return err('Not found', 404);
} catch (e) {
return err('Server error: ' + e.message, 500);
}
},
};
看起來很土,但 9 個端點手寫 if 完全不會亂。framework 的 routing DSL 要花時間學、要維護版本、要擔心 middleware 執行順序,對這個規模不值得。
JWT:為什麼不自己管密碼
遊戲大廳 100% 靠 Google Sign-In,Worker 端的工作是:
- 接收前端傳來的 Google ID token(由 Google Identity Services SDK 產生)
- 呼叫
https://oauth2.googleapis.com/tokeninfo?id_token=...向 Google 驗證 - 拿到 Google user info 後,在 D1
users表找 / 建使用者 - 用 Worker 自己的
JWT_SECRET簽一個 30 天期限的 session JWT 回給前端
JWT 簽章用 Web Crypto API 的 HMAC-SHA256:
async function signToken(payload, secret) {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = btoa(JSON.stringify({
...payload,
exp: Date.now() + 30 * 24 * 3600 * 1000,
}));
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign(
'HMAC', key,
new TextEncoder().encode(header + '.' + body)
);
return header + '.' + body + '.' + btoa(String.fromCharCode(...new Uint8Array(sig)));
}
Verify 的邏輯是對稱的。不用 jsonwebtoken 套件是因為 Workers runtime 不支援 Node built-ins,而且 Web Crypto API 本身就夠用,多一個套件只是徒增部署大小。
JWT_SECRET 用 wrangler secret put JWT_SECRET 存在 Cloudflare 的加密環境變數,不要寫在 wrangler.toml 的 [vars] 區塊(那會被 commit 進 git)。我們踩過一次,rotate 後才逃過一劫。
D1 Schema 設計
D1 是 Cloudflare 的 SQLite-on-edge 產品。Schema 6 張表:
-- 使用者(Google OAuth)
CREATE TABLE users (
id TEXT PRIMARY KEY, -- 自產 short ID (u_xxxxxxxxxxxx)
google_id TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
avatar TEXT,
title TEXT DEFAULT 'Newbie',
coins INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
last_login TEXT DEFAULT (datetime('now'))
);
-- 每日簽到
CREATE TABLE checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
date TEXT NOT NULL, -- YYYY-MM-DD
streak INTEGER DEFAULT 1,
reward INTEGER DEFAULT 10,
UNIQUE(user_id, date)
);
-- 每次分數(歷史完整)
CREATE TABLE scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
game_id TEXT NOT NULL,
score INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
-- 每人每遊戲的最佳分(排行榜用)
CREATE TABLE best_scores (
user_id TEXT NOT NULL,
game_id TEXT NOT NULL,
score INTEGER NOT NULL,
updated_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (user_id, game_id)
);
-- 每款遊戲的遊玩次數(公開統計)
CREATE TABLE play_counts (
game_id TEXT PRIMARY KEY,
count INTEGER DEFAULT 0
);
-- 成就解鎖
CREATE TABLE achievements (
user_id TEXT NOT NULL,
achievement_id TEXT NOT NULL,
unlocked_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (user_id, achievement_id)
);
為什麼同時有 scores 和 best_scores?
每次 POST /score 都會 insert 一筆到 scores(歷史紀錄),並檢查是否更新 best_scores(排行榜用)。這個雙表設計有幾個好處:
- 排行榜查詢快:直接
SELECT FROM best_scores ORDER BY score DESC,不用跨所有歷史紀錄 GROUP BY。 - 歷史資料完整:未來要做「過去 7 天趨勢圖」或「平均分 vs 最高分」分析時,不用回推。
- 寫入成本低:
scores的 insert 沒有 index 更新成本,best_scores的 update 只在新紀錄時發生。
關鍵端點解析
POST /checkin — 連續簽到邏輯
if (path === 'checkin' && req.method === 'POST') {
const u = await getUser(req, env);
if (!u) return err('Unauthorized', 401);
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
const existing = await env.DB.prepare(
'SELECT * FROM checkins WHERE user_id = ? AND date = ?'
).bind(u.uid, today).first();
if (existing) return json({ already: true, streak: existing.streak });
const prev = await env.DB.prepare(
'SELECT streak FROM checkins WHERE user_id = ? AND date = ?'
).bind(u.uid, yesterday).first();
const streak = prev ? prev.streak + 1 : 1;
const reward = Math.min(10 + (streak - 1) * 5, 50);
await env.DB.prepare(
'INSERT INTO checkins (user_id, date, streak, reward) VALUES (?, ?, ?, ?)'
).bind(u.uid, today, streak, reward).run();
await env.DB.prepare(
'UPDATE users SET coins = coins + ? WHERE id = ?'
).bind(reward, u.uid).run();
return json({ streak, reward, already: false });
}
連續簽到的 streak 計算靠 查昨天的紀錄 — 如果昨天有簽、streak += 1;沒有、從 1 開始。時區邊界問題先忽略(後端是 UTC,使用者可能會發現「跨日前一刻簽到 = 兩天算一天」,但影響極微)。
POST /score — 分數驗證
為了防止作弊(前端被改),伺服器端做最小限度的驗證:
const body = await readJson(req);
if (!body) return err('body required');
const { game, score } = body;
if (!game || score === undefined) return err('game and score required');
if (typeof score !== 'number' || !Number.isFinite(score)
|| score < 0 || score > 1e9) return err('invalid score');
這擋不住有心作弊的玩家(前端本來就會執行他們可以改的 JS),但擋住大部分意外錯誤(NaN、字串、負數)。真要完全防弊,得把遊戲邏輯搬到伺服器,那是另一個量級的工程。
Achievement Detection
成就系統跟 POST /score、POST /checkin、POST /auth/google 三個端點整合 — 每次這些動作後檢查對應成就是否達成:
async function tryUnlock(env, uid, aid) {
const res = await env.DB.prepare(
'INSERT OR IGNORE INTO achievements (user_id, achievement_id) VALUES (?, ?)'
).bind(uid, aid).run();
return res.meta && res.meta.changes > 0 ? aid : null;
}
async function checkCoinAchievements(env, uid, coins) {
const unlocked = [];
if (coins >= 50) { const r = await tryUnlock(env, uid, 'coins_50'); if (r) unlocked.push(r); }
if (coins >= 200) { const r = await tryUnlock(env, uid, 'coins_200'); if (r) unlocked.push(r); }
if (coins >= 1000) { const r = await tryUnlock(env, uid, 'coins_1000'); if (r) unlocked.push(r); }
return unlocked;
}
INSERT OR IGNORE 讓重複 insert 成為 no-op,不用先 SELECT 再判斷。res.meta.changes 告訴我們這次 insert 是否真的寫入(= 新解鎖)。回傳新解鎖的 achievement IDs 給前端,前端就能即時彈出「成就解鎖」toast。
POST /play — 匿名遊玩次數
這個端點不需要驗證,誰點遊戲卡片都會打。用 sendBeacon 送出避免影響跳轉速度:
// 前端(遊戲大廳 index.html)
card.addEventListener('click', () => {
navigator.sendBeacon(
API_BASE + '/play',
new Blob([JSON.stringify({ game: id })], { type: 'application/json' })
);
});
這個資料用來顯示「已被玩 XXX 次」,也是純公開的統計。無匿名驗證,接受被刷 — 假設有人刷某款遊戲到 10 萬次,也只是數字好看,無實質影響。
部署與 DNS 踩坑
Worker 本身用 wrangler deploy 部署,wrangler.toml 指定綁定路由:
name = "hao-games-api" main = "worker.js" compatibility_date = "2024-01-01" [vars] GOOGLE_CLIENT_ID = "1098993759422-xxxxxxxx.apps.googleusercontent.com" [[d1_databases]] binding = "DB" database_name = "hao-games-db" database_id = "5cf30197-xxxx-xxxx-xxxx-xxxxxxxxxxxx" [[routes]] pattern = "api.hao0321.com/*" zone_name = "hao0321.com"
重要踩坑:一開始我的 route 設成 api.game.hao0321.com/*(三層子網域)。結果 Cloudflare Universal SSL(免費方案)只涵蓋 *.hao0321.com(兩層通配),瀏覽器打開會回 ERR_SSL_VERSION_OR_CIPHER_MISMATCH。
花了半小時確認這是 cert 問題而不是 DNS 問題,最後遷到 api.hao0321.com 就好了。教訓:免費 Cloudflare 用戶自己用子網域要留在兩層以內。
錯誤處理與可觀測性
Workers 內建的 dashboard 顯示每個 deploy 的請求數、錯誤率、P50/P95/P99 延遲。我不另外架 Grafana 或 Sentry,因為目前流量規模完全沒必要。
Worker 內的 uncaught exception 會被包進一個 try { ... } catch (e) { return err(...) } 回 500,錯誤訊息會寫進 Cloudflare 的 logs(用 wrangler tail 可以即時 stream)。
之前踩過一個坑:POST /play 傳壞 JSON(非物件字串)時,req.json() throw SyntaxError,catch 把錯誤訊息包進回傳,結果 leak 了 stack trace 給攻擊者。後來加了 readJson() helper 優雅處理 parse error,回 400 而不是 500。
成本
目前每月 Workers 請求約 1500、D1 讀取 5000、D1 寫入 200。全部在 free tier 內。真正要開始付費的觸發點:
- Workers 請求 > 10 萬 / 天 → $5 USD 起跳
- D1 儲存 > 5GB → 每 GB $0.75 / 月
- D1 寫入 > 10 萬 / 月 → $1 USD / 百萬寫入
以工作室規模要用到付費,得每日 DAU 破千才會發生。
可以改進的地方
- 速率限制:目前沒有。如果未來要防止腳本刷分數,可以用 Cloudflare Rate Limiting Rules(邊緣層)或在 Worker 內用 D1 存 IP+timestamp 做 leaky bucket。
- 離峰時段的定期清理:
scores表會無限成長。之後加 cron trigger(Cloudflare Workers Cron)每月刪除半年前的舊紀錄。 - 快取 leaderboard:目前每次
GET /leaderboard都打 D1。高流量時可以用 Cloudflare KV 或 Cache API 快取 60 秒。 - 批次寫入:多個遊戲同時結算時,理論上可以合併
POST /score減少 D1 寫入。但目前沒有這個規模問題。
結語
Cloudflare Workers + D1 把「做一個 production 等級的後端」從「需要一個小型團隊」壓縮到「一個人一個週末」。這不是萬靈丹,但對工作室規模的應用是極佳的選擇。省下來的心力應該放在差異化的產品細節 — 遊戲好不好玩、設計有沒有記憶點、內容有沒有價值。
完整的 worker.js、schema.sql、wrangler.toml 都在 Hao0321 Studio 的 GitHub repo。歡迎看、歡迎提問、歡迎 fork。