檔案上傳系統設計 (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 會長這樣:
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。
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)。
// Good Practice
const storedName = uuidv4() + path.extname(file.originalname);總結
今天我們學會了:
- 檔案上傳依賴
multipart/form-data協議及其Boundary機制。 - 為了伺服器穩定性,應優先選用 Stream 而非 Buffer。
- 永遠不要信任前端傳來的檔名,使用 UUID 重新命名是安全的基本功。
下一篇,我們將討論「當檔案變多之後怎麼辦」,學習如何設計資料庫與高效的目錄結構。
️ 進階挑戰
嘗試在你的開發環境建立一個簡單的 Node.js Server,並觀察當你使用不同的 Content-Type 傳送檔案時,後端解析會發生什麼事?
延伸閱讀與資源
- MDN Web Docs: multipart/form-data
- Node.js Multer: Documentation