sell整合實戰
sync_alt

前端 ↔ Worker ↔ D1:完整 CRUD 串接

做一個真正能跑的待辦事項 App:用一個 HTML 頁面,透過一個 Worker 對雲端 SQL 資料庫做新增、讀取、更新、刪除。

3層串接
4CRUD 動詞
1SQL 資料庫
0要管的伺服器
insights

我們要串什麼?

CRUD 是 Create、Read、Update、Delete(新增、讀取、更新、刪除)的縮寫,幾乎每個 App 對資料都在做這四件事。這篇我們要做一個小小的待辦事項 App:瀏覽器跟 Worker(跑在 Cloudflare 邊緣上的小程式)講話,Worker 再去讀寫 D1 資料庫(一個以 SQLite 為底的無伺服器 SQL 資料庫)。

三層、一條資料流。前端永遠不會直接碰資料庫——它一律透過 Worker,而唯一拿著資料庫綁定(binding)的也只有 Worker。先看一下全貌:

schema架構:瀏覽器 → Worker → D1

fetch /api/todos

prepare().bind()

資料列

JSON 回應

瀏覽器(HTML / JS)

Worker API

D1(SQLite)

restaurant

把它想成一間餐廳

瀏覽器是點餐的客人,Worker 是把點單送進廩房、再把菜端出來的服務生,而 D1 就是存放所有食材(你的資料)的廩房。客人不會自己走進廩房——一律跟服務生點。

account_tree

哪一層負責什麼?

web

前端(瀏覽器)

負責畫面並用 fetch() 呼叫 API。它只知道網址和 JSON 長什麼樣——完全不碰 SQL。

bolt

Worker(API)

接收 HTTP 請求、驗證輸入、依方法分流,透過 env.DB 綁定跑 SQL,再回傳 JSON。

database

D1(資料庫)

存放 todos 資料表,安全地執行真正的 SELECT / INSERT / UPDATE / DELETE 語句。

vpn_key

綁定(binding)

wrangler.toml 把 Worker 和 D1 以 env.DB 這個名字接起來——不用連線字串、也不用密碼。

四個 CRUD 動作都打到同一條 /api/todos 路徑;Worker 依 HTTP 方法(GET、POST、PUT、DELETE)決定要做什麼。一張圖看懂路由邏輯:

schemaWorker 依 HTTP 方法路由

GET

POST

PUT

DELETE

請求 /api/todos

HTTP 方法?

SELECT * FROM todos

INSERT INTO todos

UPDATE todos SET ...

DELETE FROM todos

Response.json(...)

schema

資料模型

我們的 App 只需要一張資料表:todos。每一列就是一件待辦事項。id 是主鍵(PK,用來唯一識別一列的號碼),title 是待辦內容,done 是 0 或 1(SQLite 沒有真正的布林型別,所以用整數代替),created_at 則是自動填上的時間戳記。

schematodos 資料表(ER 圖)

TODOS

integer

id

PK

自動遞增

text

title

待辦內容

integer

done

0 或 1

text

created_at

建立時間

轉成 SQL,這張表長這樣。把它存成 schema.sql——下面的動手步驟會把它套用到 D1。

sqlschema.sql
CREATE TABLE IF NOT EXISTS todos (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  title      TEXT    NOT NULL,
  done       INTEGER NOT NULL DEFAULT 0,
  created_at TEXT    NOT NULL DEFAULT (datetime('now'))
);
swap_vert

一次完整的往返

我們跟蹤一次 Create 從頭到尾:使用者打完待辦事項、送出表單。瀏覽器發出 POST /api/todos(帶著 JSON 資料),Worker 驗證後跑 INSERT,D1 透過 RETURNING * 回傳剛建好的那一列,Worker 再把那筆 JSON 送回來,畫面就能馬上顯示。

schema新增往返(POST)
"D1""Worker""瀏覽器""D1""Worker""瀏覽器"POST /api/todos(title)驗證 titleINSERT INTO todos RETURNING *新資料列201 Created + JSON畫出新待辦事項
bolt

為什麼用 RETURNING *?

沒有 RETURNING 的話,你得再跑一次 SELECT 才能拿到新的 id 跟 created_at。RETURNING * 讓你一個查詢就拿到整列資料——往返更少、程式更簡單。

construction

一步一步串起來

跟著這七個步驟走,你就會有一個部署上線的全端待辦事項 App。你只需要裝好 Node.js;其他全都透過 npx wrangler(Cloudflare 的命令列工具)來跑。

  1. 建立 D1 資料庫

    Wrangler 會印出一組 database_id——複製起來,下一步要貼進 wrangler.toml。

    bash
    npx wrangler d1 create todo-db
  2. 把 D1 綁定到 Worker

    [[d1_databases]] 這段讓資料庫以 env.DB 的名字出現在 Worker 裡。把步驟 1 的 id 貼進來。

    toml
    name = "todo-api"
    main = "src/index.js"
    compatibility_date = "2024-09-23"
    
    [[d1_databases]]
    binding = "DB"
    database_name = "todo-db"
    database_id = "<paste-your-id-here>"
  3. 套用資料表結構

    把 schema.sql 跑在雲端真正的資料庫上。想先在本機測試的話,把 --remote 換成 --local。

    bash
    npx wrangler d1 execute todo-db --remote --file=./schema.sql
    
    # quick check it worked
    npx wrangler d1 execute todo-db --remote --command "SELECT name FROM sqlite_master WHERE type='table';"
  4. 寫 Worker(src/index.js)

    一個 fetch 處理函式用 prepare().bind() 加上 .all() / .first() / .run(),把四個 CRUD 動詞全都路由到 D1。CORS 標頭則讓另一個來源(origin)的瀏覽器能呼叫這個 API。

    js
    export default {
      async fetch(request, env) {
        const url = new URL(request.url);
        const id = url.pathname.match(/^\/api\/todos\/(\d+)$/);
        const cors = {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
          "Access-Control-Allow-Headers": "Content-Type"
        };
    
        if (request.method === "OPTIONS") return new Response(null, { headers: cors });
    
        try {
          // READ: list every todo
          if (url.pathname === "/api/todos" && request.method === "GET") {
            const { results } = await env.DB
              .prepare("SELECT * FROM todos ORDER BY created_at DESC")
              .all();
            return json(results, 200, cors);
          }
    
          // CREATE: insert one todo
          if (url.pathname === "/api/todos" && request.method === "POST") {
            const { title } = await request.json();
            if (!title) return json({ error: "title required" }, 400, cors);
            const row = await env.DB
              .prepare("INSERT INTO todos (title) VALUES (?) RETURNING *")
              .bind(title)
              .first();
            return json(row, 201, cors);
          }
    
          // UPDATE: toggle done or edit title
          if (id && request.method === "PUT") {
            const { title, done } = await request.json();
            const row = await env.DB
              .prepare("UPDATE todos SET title = COALESCE(?, title), done = COALESCE(?, done) WHERE id = ? RETURNING *")
              .bind(title ?? null, done ?? null, Number(id[1]))
              .first();
            return row ? json(row, 200, cors) : json({ error: "not found" }, 404, cors);
          }
    
          // DELETE: remove one todo
          if (id && request.method === "DELETE") {
            const info = await env.DB
              .prepare("DELETE FROM todos WHERE id = ?")
              .bind(Number(id[1]))
              .run();
            return json({ deleted: info.meta.changes }, 200, cors);
          }
    
          return json({ error: "not found" }, 404, cors);
        } catch (err) {
          return json({ error: err.message }, 500, cors);
        }
      }
    };
    
    function json(data, status, cors) {
      return new Response(JSON.stringify(data), {
        status,
        headers: { "Content-Type": "application/json", ...cors }
      });
    }
  5. 前端:四個 fetch 呼叫

    這幾個小函式跟 CRUD 一對一:GET 讀、POST 新增、PUT 更新、DELETE 刪除。把 API 指向你部署好的 Worker 網址。

    js
    const API = "https://todo-api.<your-subdomain>.workers.dev/api/todos";
    
    // READ — GET all todos
    export async function listTodos() {
      const res = await fetch(API);
      return res.json();
    }
    
    // CREATE — POST a new todo
    export async function addTodo(title) {
      const res = await fetch(API, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title })
      });
      return res.json();
    }
    
    // UPDATE — PUT to toggle done (or edit title)
    export async function toggleTodo(id, done) {
      const res = await fetch(`${API}/${id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ done })
      });
      return res.json();
    }
    
    // DELETE — remove a todo
    export async function removeTodo(id) {
      const res = await fetch(`${API}/${id}`, { method: "DELETE" });
      return res.json();
    }
  6. 接進網頁(index.html)

    一個完整、能跑的頁面:表單負責 POST 新增、清單負責 GET 讀取、勾選框負責 PUT 切換完成、按鈕負責 DELETE。用任何靜態伺服器打開即可。

    html
    <!DOCTYPE html>
    <html lang="zh-Hant">
    <head>
      <meta charset="UTF-8" />
      <title>Todo</title>
    </head>
    <body>
      <h1>My Todos</h1>
      <form id="new">
        <input id="title" placeholder="What needs doing?" required />
        <button type="submit">Add</button>
      </form>
      <ul id="list"></ul>
    
      <script type="module">
        const API = "https://todo-api.<your-subdomain>.workers.dev/api/todos";
    
        async function load() {
          const todos = await (await fetch(API)).json();
          const ul = document.getElementById("list");
          ul.innerHTML = "";
          for (const t of todos) {
            const li = document.createElement("li");
            const box = document.createElement("input");
            box.type = "checkbox";
            box.checked = !!t.done;
            box.onchange = () => update(t.id, box.checked ? 1 : 0);
            const span = document.createElement("span");
            span.textContent = " " + t.title + " ";
            const del = document.createElement("button");
            del.textContent = "x";
            del.onclick = () => remove(t.id);
            li.append(box, span, del);
            ul.appendChild(li);
          }
        }
    
        async function update(id, done) {
          await fetch(`${API}/${id}`, {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ done })
          });
          load();
        }
    
        async function remove(id) {
          await fetch(`${API}/${id}`, { method: "DELETE" });
          load();
        }
    
        document.getElementById("new").addEventListener("submit", async (e) => {
          e.preventDefault();
          const title = document.getElementById("title").value.trim();
          if (!title) return;
          await fetch(API, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ title })
          });
          e.target.reset();
          load();
        });
    
        load();
      </script>
    </body>
    </html>
  7. 本機跑跑看,再部署

    wrangler dev 在本機跑 Worker;wrangler deploy 把它發布到全世界,並印出你上線的 workers.dev 網址。

    bash
    # develop with live reload
    npx wrangler dev
    
    # ship it
    npx wrangler deploy
school

重點概念

shield

預備語句

prepare("... ?").bind(value) 把使用者輸入和 SQL 文字分開,擋掉 SQL 注入攻擊。千萬不要用字串拼接來組 SQL。

data_object

.all()、.first()、.run()

all() 回傳多列,first() 回傳一列(或 null),run() 用在只需要知道改了幾列的寫入操作。

swap_horiz

HTTP 方法就是意圖

GET 讀、POST 新增、PUT/PATCH 更新、DELETE 刪除。同一網址、不同動詞——這就是 REST 的精髓。

public

CORS(跨來源存取)

除非伺服器回以 Access-Control-* 標頭,否則瀏覽器會擋掉跨來源呼叫。這就是為什麼 Worker 每個回應都要加上它們。

cloud_sync

本機與遠端 D1

--local 打的是你磁碟上的 SQLite 檔、測試很快;--remote 打的是雲端真資料庫。兩邊是分開的,記得都要填資料。

playlist_add_check

RETURNING *

SQLite 的功能,讓 INSERT/UPDATE 在同一查詢裡就把受影響的那列送回來——不用再 SELECT 一次。

tips_and_updates

陷阱與計費

warning

不要把 D1 直接露給瀏覽器

env.DB 綁定只存在於 Worker 內部。千萬不要想從前端 JavaScript 查 D1——一律走 Worker,讓驗證和權限判斷都留在伺服器端。

payments

依列數計費,不看時間

D1 算的是讀取列數、寫入列數,再加上儲存量。免費額度每天給 500 萬列讀取、10 萬列寫入、加上 5 GB 儲存——做個待辦 App 綽綽有餘。

  • 在 Worker 裡先驗證輸入(例如拒絕空白的 title),再去碰資料庫。
  • 在常用來篩選或排序的欄位上建索引(index),可以少讀很多列、同時省錢。
  • 狀態碼要用得有意義:201 代表建立成功、404 找不到、400 輸入有誤。
  • 記得 --local 與 --remote 的 D1 是兩個不同的資料庫;你在測哪個就要填哪個。
  • 把前端放到 Cloudflare Pages,讓 HTML 和 API 共用同一個網域(連 CORS 都可以省)。