跳至主要內容
Skip to content

檔案上傳的 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: 1024

4.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部分內容請求
206Partial Content 狀態碼
分塊上傳大檔案拆分上傳

> **最佳實踐**:

  • 大檔案使用串流處理
  • 實作斷點續傳提升體驗
  • 嚴格驗證檔案類型和大小

進階挑戰

  1. 實作一個完整的分塊上傳系統,支援斷點續傳和進度追蹤。
  2. 研究 tus(resumable upload protocol)協定。
  3. 實作上傳檔案的病毒掃描功能。

延伸閱讀與資源