sell整合實戰
upload_file

檔案上傳:前端 → Worker → R2

三層、一條流程:瀏覽器選檔案、Worker 存進 R2、D1 記住所有細節。

3層串接
$0R2 流出費
100 MB免費方案上傳上限
5 TiBR2 單一物件上限
insights

我們要串什麼?

我們會做一個很小的網頁,上面有一個檔案輸入框(file input)。使用者選好檔案後送到 Worker,Worker 把原始檔案內容存進 R2(物件儲存,object storage,就是專門存放整份檔案如圖片、PDF 的服務),把檔案細節寫進 D1(一種 SQL 資料庫),之後再用一個公開網址把檔案提供回去。

warehouse

把它想成寄物櫃

Worker 就像寄物櫃的櫃台。你把外套(檔案)交給它,它把外套放進後方倉庫(R2),並在登記簿(D1)寫下一個號碼牌。之後你出示號碼牌(也就是 key),它就能把外套拿回給你。

schema架構一眼看懂

POST 上傳

存入物件

寫入中繼資料

串流位元組

檔案網址

瀏覽器(檔案輸入框)

Worker

R2 儲存桶

D1 資料庫

account_tree

各層的職責

每一層只負責一件事。把「檔案內容」(放 R2)和「關於檔案的資料」(放 D1)分開,之後要查詢、列出清單、清理檔案時都會輕鬆很多。

web

前端網頁

顯示檔案選擇器,用 fetch 把選到的檔案以 multipart/form-data 格式 POST 出去。

bolt

Worker

接收上傳、把內容寫進 R2、在 D1 新增一筆資料,並在被請求時把檔案串流回去。

inventory_2

R2(檔案內容)

物件儲存,存放真正的檔案內容。把它讀出來給使用者完全不收流出(egress)費用。

database

D1(檔案資料)

一個 SQL 資料庫,記錄誰上傳了什麼、檔名、類型、大小與時間。

schema上傳路徑 vs 提供路徑

Cloudflare Worker

前端

上傳

存物件

寫入

檢視

讀取

查詢

檔案輸入框+fetch

POST /upload 處理器

GET /files/:key 處理器

R2 儲存桶

D1 中繼資料

schema

中繼資料表

R2 只會把檔案內容存在一個 key(key=物件的唯一名稱/路徑)底下。但要列出某位使用者的上傳清單、或顯示原始檔名,就得在 D1 裡為每個物件保留一小筆中繼資料。

schema使用者與檔案

上傳

USERS

int

id

PK

text

email

FILES

text

key

PK

int

user_id

FK

text

filename

text

content_type

int

size_bytes

text

uploaded_at

sqlschema.sql
CREATE TABLE IF NOT EXISTS files (
  key          TEXT PRIMARY KEY,   -- R2 object key, e.g. uploads/172...-photo.jpg
  filename     TEXT NOT NULL,      -- original name the user picked
  content_type TEXT,               -- MIME type, e.g. image/png
  size_bytes   INTEGER,            -- file size, for quotas and display
  uploaded_at  TEXT NOT NULL       -- ISO timestamp
);
info

為什麼用 key 當主鍵?

R2 的 key 本來就是唯一的,而且正是日後取回物件所需的名稱,因此用它當主鍵(primary key)最乾淨,能把 D1 的資料列和 R2 的物件一對一綁在一起。

swap_vert

請求流程

1)上傳檔案

schema上傳流程
D1R2Worker瀏覽器D1R2Worker瀏覽器選擇檔案POST /upload(multipart)env.BUCKET.put(key, body)已存好物件寫入檔案中繼資料已新增資料列200 JSON 回傳網址與 key

瀏覽器會把檔案打包成 multipart 內容(multipart/form-data=瀏覽器把一個或多個檔案連同表單欄位一起打包的標準格式)。Worker 讀取後用 put() 把內容寫進 R2,再把一筆資料寫進 D1,最後回傳一個網頁可以連結的網址。

2)把檔案提供回去

schema下載/提供流程
R2Worker瀏覽器R2Worker瀏覽器GET /files/:keyenv.BUCKET.get(key)物件內容與標頭200 串流檔案位元組

要看檔案時,網頁只要請求 /files/<key>。Worker 呼叫 get(),把儲存時記下的 Content-Type 複製到回應的標頭上,然後把位元組直接串流給瀏覽器——過程中不會把整份檔案一次塞進記憶體。

construction

動手一步步串起來

Wrangler 是 Cloudflare 的命令列工具(CLI=你在終端機輸入指令操作的程式)。開始前請先執行過 npm install -g wrangler 與 wrangler login。

  1. 建立 R2 儲存桶與 D1 資料庫

    兩個都建立。把 wrangler 為 D1 資料庫印出的 database_id 複製下來,下一步要貼上。

    bash
    # Bucket that will hold the file bytes
    wrangler r2 bucket create user-uploads
    
    # Database that will hold the metadata (copy the printed database_id)
    wrangler d1 create uploads-meta
  2. 在 wrangler.toml 加上兩個綁定

    「綁定(binding)」會讓你的程式碼多一個變數來操作某個資源。這裡 [[r2_buckets]] 會給你 env.BUCKET,[[d1_databases]] 會給你 env.DB。

    toml
    name = "file-uploader"
    main = "src/index.js"
    compatibility_date = "2025-09-23"
    
    # R2 binding -> reachable as env.BUCKET in your Worker
    [[r2_buckets]]
    binding = "BUCKET"
    bucket_name = "user-uploads"
    
    # D1 binding -> reachable as env.DB in your Worker
    [[d1_databases]]
    binding = "DB"
    database_name = "uploads-meta"
    database_id = "paste-the-id-from-wrangler-d1-create"
  3. 建立中繼資料表

    對真正(remote)的 D1 資料庫執行 CREATE TABLE。你也可以把它存成 schema.sql,改用 --file=./schema.sql 來執行。

    bash
    wrangler d1 execute uploads-meta --remote --command "CREATE TABLE IF NOT EXISTS files (key TEXT PRIMARY KEY, filename TEXT NOT NULL, content_type TEXT, size_bytes INTEGER, uploaded_at TEXT NOT NULL);"
  4. 前端:檔案輸入框 + fetch

    FormData 會請瀏覽器幫我們組好 multipart/form-data 內容,所以我們完全不用自己手動拼裝上傳資料。

    html
    <!-- index.html (front-end) -->
    <input type="file" id="file" accept="image/*,.pdf" />
    <button id="send">Upload</button>
    <p id="status"></p>
    
    <script>
      const fileInput = document.getElementById("file");
      const statusEl = document.getElementById("status");
    
      document.getElementById("send").addEventListener("click", async () => {
        const file = fileInput.files[0];
        if (!file) {
          statusEl.textContent = "Pick a file first.";
          return;
        }
    
        // FormData makes the browser send a multipart/form-data body for us
        const form = new FormData();
        form.append("file", file);
    
        statusEl.textContent = "Uploading...";
        const res = await fetch("/upload", { method: "POST", body: form });
        const data = await res.json();
    
        statusEl.innerHTML = `Done! <a href="${data.url}">${data.filename}</a>`;
      });
    </script>
  5. Worker:存進 R2 + 記到 D1 + 提供下載

    同一個處理器負責兩件事:POST /upload 寫進 R2 和 D1;GET /files/<key> 從 R2 讀出來並串流回去。

    js
    export default {
      async fetch(request, env) {
        const url = new URL(request.url);
    
        // 1) UPLOAD: POST /upload  (multipart/form-data)
        if (request.method === "POST" && url.pathname === "/upload") {
          const form = await request.formData();
          const file = form.get("file");
          if (!(file instanceof File)) {
            return Response.json({ error: "No file field" }, { status: 400 });
          }
    
          // Build a unique key so two uploads never collide
          const key = `uploads/${Date.now()}-${crypto.randomUUID()}-${file.name}`;
    
          // Store the raw bytes in R2 (reading them back out is free)
          await env.BUCKET.put(key, file.stream(), {
            httpMetadata: { contentType: file.type },
          });
    
          // Remember the details in D1 (bound params = no SQL injection)
          await env.DB.prepare(
            "INSERT INTO files (key, filename, content_type, size_bytes, uploaded_at) VALUES (?, ?, ?, ?, ?)"
          )
            .bind(key, file.name, file.type, file.size, new Date().toISOString())
            .run();
    
          return Response.json({
            key,
            filename: file.name,
            url: `${url.origin}/files/${encodeURIComponent(key)}`,
          });
        }
    
        // 2) SERVE: GET /files/<key>
        if (request.method === "GET" && url.pathname.startsWith("/files/")) {
          const key = decodeURIComponent(url.pathname.slice("/files/".length));
          const object = await env.BUCKET.get(key);
          if (object === null) {
            return new Response("Not found", { status: 404 });
          }
    
          // Copy the stored content-type etc. onto the response, then stream it
          const headers = new Headers();
          object.writeHttpMetadata(headers);
          headers.set("etag", object.httpEtag);
          return new Response(object.body, { headers });
        }
    
        return new Response("Not found", { status: 404 });
      },
    };
  6. 部署上線

    把 Worker 發布到 Cloudflare 的全球網路。你的上傳頁面與 /files/ 端點會在全球同時上線。

    bash
    wrangler deploy
school

重點概念

inventory_2

物件儲存 (Object storage)

專門存放「整份檔案(物件)」的服務,而不是資料列或磁碟區塊。最適合圖片、PDF、影片。

key

鍵 (Key)

物件的唯一名稱/路徑,例如 uploads/2026/photo.jpg。之後就是用這個 key 把物件讀回來。

folder

儲存桶 (Bucket)

裝著很多物件、有名字的容器,可以想成你 App 檔案的最上層資料夾。

upload_file

multipart/form-data

瀏覽器傳送檔案時使用的標準請求格式。FormData 會自動幫你組好它。

description

Content-Type(MIME 類型)

像 image/png 這樣的標籤,告訴瀏覽器該如何顯示檔案。上傳時記下、提供時設回去。

link

預簽網址 (Presigned URL)

一個有時效、帶簽章的連結,讓瀏覽器直接對 R2 上傳或下載,遇到大檔案時就能繞過 Worker。

tips_and_updates

小提示、陷阱與計費

warning

大檔案?改用預簽網址

讓檔案經過 Worker 上傳最簡單,但單一請求內容有大小上限(免費方案約 100 MB)。遇到大檔案時,請改由 Worker 產生一個「預簽網址(presigned URL)」,讓瀏覽器以分段(multipart)方式直接傳給 R2,之後再回頭把中繼資料存起來。

savings

零流出費是最大賣點

不論使用者從 R2 下載一個檔案幾次,把它傳給網路上的使用者一律 0 元流出費——不像多數雲端,一個熱門檔案就可能讓帳單暴增。

  • key 一定要唯一(時間戳 + 隨機 id),新的上傳才不會覆蓋舊檔。
  • 在 Worker 端、put() 之前就驗證檔案大小與類型——絕不能只信任瀏覽器。
  • 務必記下並重新套用 Content-Type,否則瀏覽器可能會下載檔案而不是直接顯示。
  • 若網頁與 Worker 不在同一個來源(origin),記得在 /upload 加上 CORS 標頭。
  • 別把使用者的原始檔名直接塞進 key,請先編碼或去掉特殊字元。