跳至主要內容
Skip to content

檔案上傳系統設計 (1) —— 底層原理與本地儲存

前言:為什麼檔案上傳是後端的「試金石」?

很多新手在學 Web 開發時,對於「表單提交」的理解往往停留在 JSON 格式:前端送一個物件 { "name": "user", "age": 18 },後端解析 JSON 存入資料庫。

但一旦遇到「檔案上傳」,這套邏輯就失效了。因為檔案(圖片、影片、PDF)本質上是 二進制數據 (Binary Data),而不是文字。

這一篇,我們不談花俏的 UI,我們要鑽進 HTTP 協議的下水道,看看檔案到底是怎麼從瀏覽器「流」進伺服器的硬碟裡。


一、 拆解 HTTP:為什麼是 multipart/form-data?

當你在 HTML 寫下 <form enctype="multipart/form-data"> 時,瀏覽器到底做了什麼?

普通的 JSON 請求,Body 是一整塊字串。但上傳檔案時,我們通常會混合「文字欄位(如 User ID)」和「檔案數據」。為了區分它們,HTTP 協議使用了一種特殊的格式:Multipart (多部分)

1. 邊界 (Boundary) 的概念

瀏覽器會隨機生成一個很長、幾乎不可能與檔案內容重複的字串,叫做 Boundary。它就像是「分隔線」,用來把不同的資料切開。

2. 看看真實的 HTTP Request 長什麼樣

假設我們上傳一張名為 me.png 的圖片,並附帶一個 name 欄位。HTTP 請求的 Raw Data 會長這樣:

http
POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="name"

John Doe
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="me.png"
Content-Type: image/png

(這裡是一堆你看不懂的亂碼,也就是圖片的二進制數據...)
------WebKitFormBoundary7MA4YWxkTrZu0gW--

TIP

注意最後一個 Boundary 後面多了兩個橫線 --,這代表 Body 的結束。


二、 實作核心:Stream (串流) vs Buffer (緩衝區)

這是後端處理檔案時最關鍵的概念。新手最常犯的錯誤就是想「先把檔案讀完,再處理」。

1. Buffer (緩衝區) - 一次性讀取

想像你要搬運一桶水,Buffer 的做法是把水全部裝進一個巨大的桶子,然後一次搬走。

  • 缺點: 如果檔案有 2GB,你的 Server 就需要 2GB 的記憶體來裝它。當併發(同時上傳)人數增加時,伺服器會立刻發生 OOM (Out Of Memory) 崩潰。

2. Stream (串流) - 分段處理

Stream 的做法是接一條水管。水從前端流進來,後端就立刻把它導向硬碟。

  • 優點: 無論檔案是 10MB 還是 10GB,記憶體佔用都非常低(通常只需幾 KB 的緩衝區)。這也是 Node.js 處理大檔案的首選方式。

實作範例 (以 Node.js + Multer 為例)

我們可以使用 multer 這個中間件來處理 multipart/form-data

javascript
const express = require("express");
const multer = require("multer");
const path = require("path");
const { v4: uuidv4 } = require("uuid");

const app = express();

// 設定儲存邏輯
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, "uploads/"); // 檔案存放在 uploads 資料夾
  },
  filename: function (req, file, cb) {
    // 這裡我們使用 UUID 重新命名,稍後會解釋原因
    const uniqueSuffix = uuidv4();
    const ext = path.extname(file.originalname);
    cb(null, uniqueSuffix + ext);
  },
});

const upload = multer({ storage: storage });

app.post("/upload", upload.single("avatar"), (req, res) => {
  // 檔案詳細資訊在 req.file
  res.send("檔案上傳成功!");
});

app.listen(3000);

三、 檔名處理的藝術:不要相信使用者!

這是新手最容易踩到的安全性地雷。

1. 為什麼不能用原始檔名 (file.originalname)?

  • 檔案覆蓋: 如果兩個使用者都上傳 avatar.png,後者會蓋掉前者。
  • 路徑遍歷攻擊 (Path Traversal): 惡意使用者可能將檔名設為 ../../etc/passwd,試圖寫入系統關鍵路徑。
  • 編碼問題: 中文或特殊字元在不同的系統、資料庫之間傳輸時常會產生亂碼。

2. 最佳實踐:隨機化檔名

建議的做法是:生成一個全域唯一識別碼 (UUID) 作為儲存檔名,並從原始檔名中僅提取 副檔名 (Extension)

javascript
// Good Practice
const storedName = uuidv4() + path.extname(file.originalname);

總結

今天我們學會了:

  1. 檔案上傳依賴 multipart/form-data 協議及其 Boundary 機制。
  2. 為了伺服器穩定性,應優先選用 Stream 而非 Buffer
  3. 永遠不要信任前端傳來的檔名,使用 UUID 重新命名是安全的基本功。

下一篇,我們將討論「當檔案變多之後怎麼辦」,學習如何設計資料庫與高效的目錄結構。


️ 進階挑戰

嘗試在你的開發環境建立一個簡單的 Node.js Server,並觀察當你使用不同的 Content-Type 傳送檔案時,後端解析會發生什麼事?


延伸閱讀與資源

返回專案首頁 | 下一章:資料庫設計與目錄結構