跳轉到

6.3 後端實作:Functions API、D1 資料存取與 R2 檔案#

本節目標#

前端外殼好了,本節把 6.1 API 路由表上的六條路由全部實作完成。全部用 TypeScript(第 4 章的檔案式路由),重點放在三件後端的基本功:

  1. 參數驗證:不信任任何輸入,先驗後用
  2. D1 prepared statements:防 SQL 注入、效能好的標準寫法
  3. 錯誤處理與 CORS:status 正確、訊息有用途,前端才能接得住

型別與共用工具#

// functions/api/types.ts
/// <reference types="@cloudflare/workers-types" />

export interface Env {
  DB: D1Database;         // 6.1 綁定的 D1
  BUCKET: R2Bucket;       // 6.1 綁定的 R2
}

// 統一回應格式(6.1 規範的 { data } / { error })
export const ok = (data: unknown, cache = false) => new Response(
  JSON.stringify({ data }),
  { status: 200, headers: {
      'Content-Type': 'application/json; charset=utf-8',
      ...(cache ? {} : { 'Cache-Control': 'no-store' }),
  }},
);

export const err = (status: number, message: string) => new Response(
  JSON.stringify({ error: message }),
  { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } },
);

注意 Cache-Control: no-store:留言是即時資料,千萬不能被 CDN 快取;檔案下載則會個別設快取。

文章路由#

// functions/api/posts.ts
import { Env, ok, err } from './types';

export const onRequestGet: PagesFunction<Env> = async ({ env, request }) => {
  const limit = Math.min(parseInt(new URL(request.url).searchParams.get('limit') ?? '10', 10) || 10, 50);
  // prepared statement:「?」佔位符由 D1 綁定,使用者輸入永遠不進 SQL 字串
  const { results } = await env.DB.prepare(
    `SELECT slug, title, excerpt, created_at
     FROM posts ORDER BY created_at DESC LIMIT ?1`
  ).bind(limit).all();
  return ok(results, true); // 文章列表可快取 60 秒,降低 D1 讀取次數
};

D1 的 ?1(帶編號)佔位符可以同名重複綁定,比位置式 ? 好讀。.bind() 之後值就只能是「值」,不可能變成 SQL 語法——這就是第 3 章三道防線中最外層的注入防護,資料庫的 CHECK 是最內層。

// functions/api/posts/[slug].ts
import { Env, ok, err } from './types';

export const onRequestGet: PagesFunction<Env> = async ({ env, params }) => {
  const slug = String(params.slug);
  if (!/^[a-z0-9-]+$/.test(slug)) return err(400, 'slug 格式不正確');

  const post = await env.DB.prepare(
    `SELECT * FROM posts WHERE slug = ?1`
  ).bind(slug).first();
  return post ? ok(post, true) : err(404, '查無此文章');
};

slug 的白名單 regex 同時是路由安全(阻斷 ../ 之類亂數輸入)與快取鍵的保證。

留言路由(GET + POST)#

留言是全站唯一會被匿名寫入的通道,驗證集中在這裡:

// functions/api/posts/[slug]/comments.ts
import { Env, ok, err } from './types';

const validate = (author: unknown, body: unknown): string | null => {
  if (typeof author !== 'string' || author.trim().length < 1 || author.length > 30)
    return '暱稱需為 1~30 字';
  if (typeof body !== 'string' || body.trim().length < 1 || body.length > 500)
    return '留言需為 1~500 字';
  return null; // null = 驗證通過
};

export const onRequestGet: PagesFunction<Env> = async ({ env, params }) => {
  const slug = String(params.slug);
  const post = await env.DB.prepare(`SELECT id FROM posts WHERE slug = ?1`)
    .bind(slug).first<{ id: number }>();
  if (!post) return err(404, '查無此文章');

  const { results } = await env.DB.prepare(
    `SELECT author, body, created_at FROM comments
     WHERE post_id = ?1 ORDER BY created_at DESC LIMIT 100`
  ).bind(post.id).all();
  return ok(results);
};

export const onRequestPost: PagesFunction<Env> = async ({ env, params, request }) => {
  const slug = String(params.slug);
  if (request.headers.get('content-type') !== 'application/json')
    return err(415, '請以 application/json 送出');

  let author: unknown, body: unknown;
  try {
    ({ author, body } = await request.json());
  } catch { return err(400, 'JSON 格式錯誤'); }

  const problem = validate(author, body);
  if (problem) return err(400, problem);

  const post = await env.DB.prepare(`SELECT id FROM posts WHERE slug = ?1`)
    .bind(slug).first<{ id: number }>();
  if (!post) return err(404, '查無此文章');

  // batch():兩句寫入同一交易,不會出現「文章被刪但留言已寫入」的裂縫
  await env.DB.batch([
    env.DB.prepare(
      `INSERT INTO comments (post_id, author, body) VALUES (?1, ?2, ?3)`
    ).bind(post.id, (author as string).trim(), (body as string).trim()),
  ]);
  return ok({ message: '留言成功' });
};

層層驗證的意義:content-type 擋掉亂試的工具;型別與長度驗證擋掉腳本攻擊;CHECK 約束是就算前三層都失守也炸不掉的底線。

R2 檔案路由#

// functions/api/files.ts — 列表(R2 list 分頁上限 1000,示範場景夠用)
import { Env, ok } from './types';

export const onRequestGet: PagesFunction<Env> = async ({ env }) => {
  const listed = await env.BUCKET.list();
  return ok(listed.objects.map((o) => ({
    key: o.key, size: o.size, uploaded: o.uploaded.toISOString(),
  })), true);
};

// functions/api/files/[key].ts — 下載(串流)
import { Env, err } from './types';

export const onRequestGet: PagesFunction<Env> = async ({ env, params }) => {
  const key = String(params.key);
  const obj = await env.BUCKET.get(key);
  if (obj === null) return err(404, '查無此檔案');

  return new Response(obj.body, {   // obj.body 是 ReadableStream,直接串流不佔記憶體
    headers: {
      'Content-Type': obj.httpMetadata?.contentType ?? 'application/octet-stream',
      'Content-Disposition': `attachment; filename="${encodeURIComponent(key)}"`,
      'ETag': obj.httpEtag,
      'Cache-Control': 'public, max-age=3600',   // 檔案少變動,可快取
    },
  });
};

obj.body 直接塞進 Response 是 Workers 平台的紅利:R2 物件以串流穿越邊緣節點,180MB 的 ISO 也不會撐爆 128MB 的 Isolate 記憶體(第 4 章原理的實際應用)。

CORS:現在不用,但要會#

我們的前端與 API 同源(Same-origin),瀏覽器根本不會送 preflight —— 這是多數個人網站的正解。但若 API 想開放給其他人呼叫,加上白名單而非 * 一放了事:

// 放在共用工具,回應前附加
const ALLOW = new Set(['https://myfriend.dev']);
const corsFor = (origin: string | null) =>
  origin && ALLOW.has(origin)
    ? { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' }
    : {};

全域錯誤處理#

任何沒被接住的例外會回 500 裸頁,我在 functions/api/[[path]].ts 補一層防護網:

// functions/api/[[path]].ts:/api/* 的最後防線(catch-all)
export const onRequest: PagesFunction = async (ctx) => {
  const res = await ctx.next();
  return res.status === 404 && new URL(ctx.request.url).pathname.startsWith('/api')
    ? new Response(JSON.stringify({ error: '路由不存在' }),
        { status: 404, headers: { 'Content-Type': 'application/json; charset=utf-8' } })
    : res;
};

搭配 wrangler pages dev 測一轮:文章列表有資料、留言送出後重新整理仍在、檔案下載進度列正常——6.2 的前端瞬間活了過來。

小結與練習#

六條路由全部完成,後端的四門基本功都派上場:驗證(先驗後用)、prepared statements(值與語法分離)、串流(大檔案過節點不留宿)、status 語意(4xx 是使用者的錯、5xx 是我們的錯)。

練習

  1. 把 GET /api/posts 的快取從疼「沒快取」改成 60 秒(Cache-Control: public, s-maxage=60, stale-while-revalidate=120),觀察 dev 模式與 D1 metrics 的變化。
  2. 寫 DELETE /api/files/[key],用 env.BUCKET.delete(key);想清楚:這條路由上線前一定要加什麼?(答案:第 7 章的認證)
  3. 幫留言 POST 加上簡單速率限制:用 request.headers.get('cf-connecting-ip') 當 key 存進 KV,1 分鐘內超過 5 篇就回 429(KV 的 TTL 正好夠用)。