檔案上傳的 HTTP 細節:Multipart 與斷點續傳
檔案上傳是 Web 應用的常見需求。本篇將深入解析 HTTP 層面的實現細節。
一、 Multipart 格式
1.1 什麼是 Multipart?
Multipart 允許在一個請求中傳送多個不同類型的資料:
http
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxk
------WebKitFormBoundary7MA4YWxk
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg
[二進位檔案內容]
------WebKitFormBoundary7MA4YWxk
Content-Disposition: form-data; name="description"
這是一張照片
------WebKitFormBoundary7MA4YWxk--1.2 格式結構
1.3 前端實作
javascript
// 使用 FormData
const formData = new FormData();
formData.append("file", fileInput.files[0]);
formData.append("description", "這是一張照片");
const response = await fetch("/upload", {
method: "POST",
body: formData,
// 注意:不要手動設定 Content-Type,瀏覽器會自動加上 boundary
});二、 後端處理
2.1 使用 Multer
javascript
const multer = require("multer");
// 記憶體儲存
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
fileFilter: (req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/gif"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Invalid file type"), false);
}
},
});
// 單一檔案
app.post("/upload", upload.single("file"), (req, res) => {
console.log(req.file);
// {
// fieldname: 'file',
// originalname: 'photo.jpg',
// encoding: '7bit',
// mimetype: 'image/jpeg',
// buffer: <Buffer ...>,
// size: 102400
// }
res.json({ success: true, filename: req.file.originalname });
});
// 多個檔案
app.post("/gallery", upload.array("photos", 10), (req, res) => {
console.log(req.files); // 陣列
res.json({ count: req.files.length });
});
// 多個欄位
app.post(
"/form",
upload.fields([
{ name: "avatar", maxCount: 1 },
{ name: "documents", maxCount: 5 },
]),
(req, res) => {
console.log(req.files.avatar);
console.log(req.files.documents);
}
);2.2 自訂儲存
javascript
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, "uploads/");
},
filename: (req, file, cb) => {
const uniqueSuffix =
Date.now() + "-" + Math.random().toString(36).substr(2, 9);
const ext = path.extname(file.originalname);
cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
},
});
const upload = multer({ storage });三、 大檔案處理
3.1 串流上傳
使用 Busboy 進行串流處理,避免記憶體問題:
javascript
const Busboy = require("busboy");
const fs = require("fs");
const path = require("path");
app.post("/upload-large", (req, res) => {
const busboy = Busboy({
headers: req.headers,
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
});
let uploadedFile = null;
busboy.on("file", (name, file, info) => {
const { filename, mimeType } = info;
const savePath = path.join("uploads", `${Date.now()}-${filename}`);
const writeStream = fs.createWriteStream(savePath);
file.pipe(writeStream);
file.on("limit", () => {
fs.unlinkSync(savePath);
res.status(400).json({ error: "File too large" });
});
writeStream.on("close", () => {
uploadedFile = savePath;
});
});
busboy.on("finish", () => {
if (uploadedFile) {
res.json({ success: true, path: uploadedFile });
}
});
req.pipe(busboy);
});3.2 上傳進度
javascript
// 前端
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
console.log(`Progress: ${percent.toFixed(1)}%`);
}
});
xhr.open("POST", "/upload");
xhr.send(formData);
// 使用 fetch(需要 ReadableStream)
async function uploadWithProgress(url, file, onProgress) {
const totalSize = file.size;
let uploaded = 0;
const stream = file.stream();
const reader = stream.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
uploaded += value.length;
onProgress((uploaded / totalSize) * 100);
}
const blob = new Blob(chunks);
return fetch(url, {
method: "POST",
body: blob,
});
}四、 Range 請求與斷點續傳
4.1 Range 標頭
http
# 請求部分內容
GET /large-file.zip HTTP/1.1
Range: bytes=0-1023
# 回應
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/102400
Content-Length: 10244.2 下載斷點續傳
javascript
app.get("/download/:filename", (req, res) => {
const filePath = path.join("files", req.params.filename);
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const range = req.headers.range;
if (range) {
// 解析 Range 標頭
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
const stream = fs.createReadStream(filePath, { start, end });
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
"Accept-Ranges": "bytes",
"Content-Length": chunkSize,
"Content-Type": "application/octet-stream",
});
stream.pipe(res);
} else {
// 完整檔案
res.writeHead(200, {
"Accept-Ranges": "bytes",
"Content-Length": fileSize,
"Content-Type": "application/octet-stream",
});
fs.createReadStream(filePath).pipe(res);
}
});4.3 上傳斷點續傳
前端:分塊上傳
javascript
async function uploadChunked(file, chunkSize = 1024 * 1024) {
const totalChunks = Math.ceil(file.size / chunkSize);
const uploadId = crypto.randomUUID();
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const response = await fetch("/upload/chunk", {
method: "POST",
headers: {
"X-Upload-Id": uploadId,
"X-Chunk-Index": i,
"X-Total-Chunks": totalChunks,
"X-File-Name": file.name,
"Content-Range": `bytes ${start}-${end - 1}/${file.size}`,
},
body: chunk,
});
if (!response.ok) {
throw new Error(`Chunk ${i} failed`);
}
console.log(`Uploaded chunk ${i + 1}/${totalChunks}`);
}
// 完成上傳
await fetch("/upload/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uploadId, fileName: file.name }),
});
}後端:處理分塊
javascript
const uploadChunks = new Map();
app.post("/upload/chunk", express.raw({ limit: "2mb" }), (req, res) => {
const uploadId = req.headers["x-upload-id"];
const chunkIndex = parseInt(req.headers["x-chunk-index"]);
const totalChunks = parseInt(req.headers["x-total-chunks"]);
// 儲存分塊
if (!uploadChunks.has(uploadId)) {
uploadChunks.set(uploadId, {
chunks: new Array(totalChunks),
fileName: req.headers["x-file-name"],
});
}
const upload = uploadChunks.get(uploadId);
upload.chunks[chunkIndex] = req.body;
res.json({ received: chunkIndex });
});
app.post("/upload/complete", express.json(), async (req, res) => {
const { uploadId, fileName } = req.body;
const upload = uploadChunks.get(uploadId);
if (!upload || upload.chunks.includes(undefined)) {
return res.status(400).json({ error: "Missing chunks" });
}
// 合併分塊
const combined = Buffer.concat(upload.chunks);
const filePath = path.join("uploads", fileName);
fs.writeFileSync(filePath, combined);
// 清理
uploadChunks.delete(uploadId);
res.json({ success: true, path: filePath });
});五、 安全考慮
5.1 檔案類型驗證
javascript
const fileType = require("file-type");
async function validateFileType(buffer, allowedTypes) {
const type = await fileType.fromBuffer(buffer);
if (!type || !allowedTypes.includes(type.mime)) {
throw new Error("Invalid file type");
}
return type;
}
// 使用
app.post("/upload", upload.single("file"), async (req, res) => {
try {
await validateFileType(req.file.buffer, [
"image/jpeg",
"image/png",
"image/gif",
]);
// 繼續處理...
} catch (err) {
res.status(400).json({ error: err.message });
}
});5.2 檔案名稱清理
javascript
const sanitize = require("sanitize-filename");
function safeFileName(originalName) {
// 移除危險字符
const sanitized = sanitize(originalName);
// 生成唯一名稱
const ext = path.extname(sanitized);
const base = path.basename(sanitized, ext);
return `${base}-${Date.now()}${ext}`;
}5.3 大小限制
javascript
// Nginx
client_max_body_size 100M;
// Express
app.use(express.json({ limit: '10mb' }))
app.use(express.raw({ limit: '100mb' }))
// Multer
const upload = multer({
limits: {
fileSize: 100 * 1024 * 1024, // 100MB
files: 10 // 最多 10 個檔案
}
})總結
| 概念 | 說明 |
|---|---|
| Multipart | 多部分表單格式 |
| boundary | 分隔符 |
| Range | 部分內容請求 |
| 206 | Partial Content 狀態碼 |
| 分塊上傳 | 大檔案拆分上傳 |
> **最佳實踐**:
- 大檔案使用串流處理
- 實作斷點續傳提升體驗
- 嚴格驗證檔案類型和大小
進階挑戰
- 實作一個完整的分塊上傳系統,支援斷點續傳和進度追蹤。
- 研究 tus(resumable upload protocol)協定。
- 實作上傳檔案的病毒掃描功能。