6.3 後端實作:Functions API、D1 資料存取與 R2 檔案#
本節目標#
前端外殼好了,本節把 6.1 API 路由表上的六條路由全部實作完成。全部用 TypeScript(第 4 章的檔案式路由),重點放在三件後端的基本功:
- 參數驗證:不信任任何輸入,先驗後用
- D1 prepared statements:防 SQL 注入、效能好的標準寫法
- 錯誤處理與 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 是我們的錯)。
練習
- 把
GET /api/posts的快取從疼「沒快取」改成 60 秒(Cache-Control: public, s-maxage=60, stale-while-revalidate=120),觀察 dev 模式與 D1 metrics 的變化。 - 寫
DELETE /api/files/[key],用env.BUCKET.delete(key);想清楚:這條路由上線前一定要加什麼?(答案:第 7 章的認證) - 幫留言 POST 加上簡單速率限制:用
request.headers.get('cf-connecting-ip')當 key 存進 KV,1 分鐘內超過 5 篇就回 429(KV 的 TTL 正好夠用)。