第 3 週 · 資料庫與 D1
留言板改用 D1,以及第 3 週複習
把記憶體留言板的四個動作改成 D1 上的完整 CRUD。
正在打開這一關…
學習目標
- 用 D1 完成列表、新增、單筆讀取、刪除
- 重新啟動開發伺服器後,留言仍然在
- 錯誤時不把 SQL 細節回給客人
白板換成帳本。API 的路徑維持第 2 週的設計,儲存換成 env.DB。這一課結束時,你會有一個重啟後資料還在的留言板。登入與「只能刪自己的留言」是下一週的事,今天仍信任送來的作者名字。
用生活例子理解
路徑是櫃台,D1 是倉庫帳本。櫃台的規矩沒變:看全部、新增、刪除某一號。改變的是寫字的地方。
生活比喻:你把白板照片存進相簿。關燈再開,相簿還在。白板則是關燈就沒了。
刪除前先確認那一列在不在。first() 得到 null 就回 404,不要假裝刪除成功。
新增用 run(),再用 meta.last_row_id 把剛寫入的那列讀出來回給前端。如此回應裡的時間是資料庫寫下的 created_at,不是你在 JavaScript 另外編的。
動手做
大約 30 分鐘。完整檔案在 examples/03-guestbook-d1/。
- 套用本機遷移:
npx wrangler d1 migrations apply guestbook-db --local。 npm run dev,打開首頁,新增兩則留言。- 停掉再啟動,重新整理。兩則應該還在。
- 刪掉一則,清單變少。再刪同一個編號,應得到
404。 - 送出空白內容,應是
400,而且資料庫沒有多一列。可用這句確認:
npx wrangler d1 execute guestbook-db --local --command "SELECT COUNT(*) AS n FROM messages;"- 做第 3 週測驗。下一週會把作者從「自己填的名字」改成「登入的使用者」。
案例
function readMessage(data) {
if (!data || typeof data !== "object") return { error: "請傳送 JSON 物件" };
const author = typeof data.author === "string" ? data.author.trim() : "";
const body = typeof data.body === "string" ? data.body.trim() : "";
if (author.length < 1 || author.length > 20) return { error: "作者請介於 1 到 20 個字" };
if (body.length < 1 || body.length > 500) return { error: "留言請介於 1 到 500 個字" };
return { author, body };
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
try {
if (url.pathname === "/" || !url.pathname.startsWith("/api/")) {
return env.ASSETS.fetch(request);
}
return await handleApi(request, env, url);
} catch (error) {
console.error(JSON.stringify({
level: "error",
path: url.pathname,
message: error instanceof Error ? error.message : "unknown",
}));
return Response.json({ error: "伺服器暫時無法完成這件事" }, { status: 500 });
}
},
};
async function handleApi(request, env, url) {
const one = url.pathname.match(/^\/api\/messages\/(\d+)$/);
if (url.pathname === "/api/messages" && request.method === "GET") {
const { results } = await env.DB.prepare(
"SELECT id, author, body, created_at FROM messages ORDER BY id DESC",
).all();
return Response.json(results);
}
if (url.pathname === "/api/messages" && request.method === "POST") {
let data;
try {
data = await request.json();
} catch {
return Response.json({ error: "請傳送 JSON" }, { status: 400 });
}
const parsed = readMessage(data);
if (parsed.error) return Response.json({ error: parsed.error }, { status: 400 });
const inserted = await env.DB.prepare(
"INSERT INTO messages (author, body) VALUES (?, ?)",
).bind(parsed.author, parsed.body).run();
const row = await env.DB.prepare(
"SELECT id, author, body, created_at FROM messages WHERE id = ?",
).bind(inserted.meta.last_row_id).first();
return Response.json(row, { status: 201 });
}
if (one && request.method === "DELETE") {
const id = Number(one[1]);
const existing = await env.DB.prepare(
"SELECT id FROM messages WHERE id = ?",
).bind(id).first();
if (!existing) return Response.json({ error: "找不到留言" }, { status: 404 });
await env.DB.prepare("DELETE FROM messages WHERE id = ?").bind(id).run();
return Response.json({ deleted: id });
}
return Response.json({ error: "找不到這個路徑" }, { status: 404 });
}wrangler.jsonc 需要昨天的 D1 綁定,以及 assets 指向 public/、run_worker_first: true。首頁的 fetch("/api/messages") 與第 2 週相同,只是重新整理後資料還在。
常見錯誤
- 忘記
--local的遷移,開發時表不存在,Worker 回 500。先看終端機的錯誤,再對客人隱藏細節。 last_row_id沒有再查一次,前端只拿到編號,沒有created_at。- 把
error.message放進Response.json。SQL 錯誤可能含有表名。 - 仍把一份陣列留在模組外層,和 D1 各寫各的。選一個來源。這一課只留 D1。
自我檢查
- 重啟後資料還在,證明你換掉了哪一種儲存?
- 刪除不存在的編號,狀態碼是什麼?
- 為什麼 catch 裡只記路徑與訊息,不把請求本文寫進紀錄?
第 3 週的帳本已經會留字。下一週讓字屬於某個登入的人,並把網站佈署出去。