早期技術筆記 / 學習紀錄
本文保留當時的瀏覽器遊戲與網站練習脈絡。目前的專案與工具已另外整理。
Backend

用 Cloudflare Workers + D1 打造 1 人可營運的遊戲後端

約 12 分鐘閱讀 作者:

Hao0321 遊戲大廳 14 款遊戲都共用同一套後端 — 登入、每日簽到、排行榜、金幣、成就系統。整個後端是一個 250 行左右的 worker.js,跑在 Cloudflare Workers 上,資料存在 Cloudflare D1(邊緣 SQLite)。這篇把整個架構拆解,順便分享 1 人工作室在後端設計上可以偷懶(或不能偷懶)的地方。

選擇 Cloudflare Workers 的理由

一開始我評估過幾個選項:

方案優點為什麼不選
Vercel Serverless + PostgresNext.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 端的工作是:

  1. 接收前端傳來的 Google ID token(由 Google Identity Services SDK 產生)
  2. 呼叫 https://oauth2.googleapis.com/tokeninfo?id_token=... 向 Google 驗證
  3. 拿到 Google user info 後,在 D1 users 表找 / 建使用者
  4. 用 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。