文件分片上传??拿捏

产品:什么???上传一张不到三十兆的图片要将近一分钟,不知道的还以为系统挂了呢,能不能搞,能不能优化。

前端:这个得问后端

后端:上传接口都给你们前端了,你们就不能做个分片上传啊

工作中偶尔遇到这种扯淡的后端,连文件分片上传和文件普通上传的实现逻辑都没搞清楚,就开始甩锅给前端,这个时候,前端就应该要了解分片上传和文件普通上传的区别,就能让你有理有据的去怼后端

前端:通用的上传接口怎么处理文件分片和文件合并,你不会连分片需要合并都不知道吧?

后端:我们用的是阿里云OSS,合并什么?

前端:不会吧,难道你连阿里云OSS的分片上传方案都不知道吗!!!回去多看看书吧!!!

文件分片上传与合并

xml 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>文件分片上传</title>
    <style>
      .file-info {
        margin: 10px 0;
      }
    </style>
  </head>
  <body>
    <input type="file" id="fileInput" />
    <button id="uploadBtn">上传</button>
    <div id="fileInfo" class="file-info"></div>
​
    <script>
      const fileInput = document.getElementById("fileInput");
      const uploadBtn = document.getElementById("uploadBtn");
      const fileInfo = document.getElementById("fileInfo");
​
      let file = null;
      let fileHash = "";
      let chunkSize = 1 * 1024 * 1024; // 1MB
      let chunks = [];
      let uploadedChunks = 0;
​
      // 选择文件
      fileInput.addEventListener("change", (e) => {
        file = e.target.files[0];
        if (!file) return;
​
        fileInfo.innerHTML = `
                <p>文件名: ${file.name}</p>
                <p>文件大小: ${(file.size / 1024 / 1024).toFixed(2)} MB</p>
                <p>分片大小: ${(chunkSize / 1024 / 1024).toFixed(2)} MB</p>
                <p>总分片数: ${Math.ceil(file.size / chunkSize)}</p>
            `;
​
        // 生成文件唯一标识
        generateFileHash(file).then((hash) => {
          fileHash = hash;
          console.log("文件hash:", fileHash);
        });
      });
​
      // 上传按钮
      uploadBtn.addEventListener("click", async () => {
        if (!file) {
          alert("请先选择文件");
          return;
        }
​
        // 分割文件
        chunks = createChunks(file, chunkSize);
​
        // 上传所有分片
        await uploadChunks(chunks);
​
        // 合并请求
        await mergeRequest();
​
        alert("上传完成!");
      });
​
      // 生成文件hash
      async function generateFileHash(file) {
        return new Promise((resolve) => {
          const spark = new SparkMD5.ArrayBuffer();
          const reader = new FileReader();
          const size = file.size;
          const offset = 2 * 1024 * 1024;
​
          // 读取文件前2M,中间2M,最后2M的内容来计算hash
          const chunks = [
            file.slice(0, offset),
            file.slice(size / 2 - offset / 2, size / 2 + offset / 2),
            file.slice(size - offset, size),
          ];
​
          let currentChunk = 0;
​
          reader.onload = (e) => {
            spark.append(e.target.result);
            currentChunk++;
​
            if (currentChunk < chunks.length) {
              reader.readAsArrayBuffer(chunks[currentChunk]);
            } else {
              resolve(spark.end());
            }
          };
​
          reader.readAsArrayBuffer(chunks[currentChunk]);
        });
      }
​
      // 分割文件
      function createChunks(file, chunkSize) {
        const chunks = [];
        let start = 0;
​
        while (start < file.size) {
          chunks.push({
            index: chunks.length,
            file: file.slice(start, start + chunkSize),
          });
          start += chunkSize;
        }
​
        return chunks;
      }
​
      // 上传分片
      async function uploadChunks(existingChunks = []) {
        const uploadedChunkIndexes = existingChunks.map((c) => c.index);
​
        for (let i = 0; i < chunks.length; i++) {
          const formData = new FormData();
          formData.append("file", chunks[i].file);
          const searchParams = new URLSearchParams();
          searchParams.append("index", chunks[i].index);
          searchParams.append("fileHash", fileHash);
​
          try {
            await fetch(
              "http://localhost:3000/upload?" + searchParams.toString(),
              {
                method: "POST",
                body: formData,
              }
            );
          } catch (err) {
            console.error("上传失败:", err);
            break;
          }
        }
      }
​
      // 合并请求
      async function mergeRequest() {
        try {
          await fetch("http://localhost:3000/upload/merge", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              fileHash: fileHash,
              fileName: file.name,
              total: chunks.length,
            }),
          });
        } catch (err) {
          console.error("合并失败:", err);
        }
      }
    </script>
​
    <!-- 用于生成文件hash的库 -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
  </body>
</html>

前端主要逻辑:前端获取一个hash做为单次上传的标识(正常通过服务端获取,以确保唯一性),对文件进行分片并序列化,然后进行所有分片文件的上传,同时传递对应hash以及分片的序列号,最后,所有分片上传成功后调用文件合并接口。

服务端使用node实现:

ini 复制代码
const express = require("express");
const multer = require("multer");
const fs = require("fs");
const path = require("path");
const cors = require("cors");
​
const app = express();
app.use(cors());
app.use(express.json());
​
// 上传目录
const UPLOAD_DIR = path.resolve(__dirname, "upload");
​
// 确保上传目录存在
if (!fs.existsSync(UPLOAD_DIR)) {
  fs.mkdirSync(UPLOAD_DIR);
}
​
// 修改multer配置
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const fileHash = req.query.fileHash;
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
    if (!fs.existsSync(chunkDir)) {
      fs.mkdirSync(chunkDir);
    }
    cb(null, chunkDir);
  },
  filename: (req, file, cb) => {
    let query = req.query;
    // 使用fileHash和index作为文件名  分片
    cb(null, `${query.fileHash}-${query.index}`);
  },
});
​
// 创建一个普通上传中间件来解析字段
const upload = multer({
  storage,
});
​
// 上传分片
app.post("/upload", upload.single("file"), (req, res) => {
  res.json({
    code: 200,
    success: true,
    message: "分片上传成功",
  });
});
​
// 合并分片
app.post("/upload/merge", async (req, res) => {
  const { fileHash, fileName, total } = req.body;
  const filePath = path.resolve(UPLOAD_DIR, `${fileHash}-${fileName}`);
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
​
  try {
    // 检查分片是否完整
    let chunks = fs.readdirSync(chunkDir);
    if (chunks.length !== total) {
      return res.status(400).json({
        success: false,
        message: "分片数量不匹配",
      });
    }
​
    // 按照索引排序
    chunks = chunks.sort((a, b) => {
      const aIndex = parseInt(a.split("-").pop());
      const bIndex = parseInt(b.split("-").pop());
      return aIndex - bIndex;
    });
​
    let writeStream = fs.createWriteStream(filePath);
​
    // 合并文件
    await Promise.all(
      chunks.map((chunk, index) => {
        const chunkPath = path.resolve(chunkDir, chunk);
        return new Promise((resolve) => {
          writeStream.write(fs.readFileSync(chunkPath), () => {
            resolve();
          });
        });
      })
    );
    writeStream.end();
​
    // 删除临时目录
    // fs.rmdirSync(chunkDir);
​
    res.json({
      code: 200,
      success: true,
      message: "合并成功",
    });
  } catch (err) {
    res.status(500).json({
      code: 500,
      success: false,
      message: "合并失败",
    });
  }
});
​
​
// 启动服务器
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`服务器已启动,访问 http://localhost:${PORT}`);
});
​

服务端主要逻辑:

  1. 上传分片接口:通过前端传递的参数和文件流,multer 插件将每一段分片存储在同一个hash目录下,并且每一段分片命名上通过 "-" 拼接序列号
  2. 合并文件接口:通过前端参数,对相应hash目录下的分片进行排序,创建一个文件流,并将分片按顺序写入到这个文件流中,最后返回文件的访问链接

简单分析上述逻辑

1、获取一个唯一hash,单次文件上传对应一个hash(通常后端接口获取)

2、通过slice 方法对File 对象进行分割(分片),设置一个size 大小,每段最多size大小

3、上传所有分片文件,并传递相应参数,如:hash、index(文件顺序)......

4、当所有分片上传成功,合并文件

实际场景

工作当中一般不会这样实现服务端,一般文件存储都会用流行的OSS 存储服务,并不会自己手动去实现这些功能,服务端只需要按要求调用OSS 服务提供的接口即可,即使是分片上传功能,OSS服务也是有提供对应的接口方案,但是,我们应该要了解分片是怎样进行存储,又是怎么合并成一个文件的。

最后

分片上传虽然能提升上传的速度,但也是受限于带宽的,分片处理方案只会更充分利用服务器的带宽,但如果服务器带宽就有限,那么即使是分片上传也提升不了太大上传速度。

相关推荐
AlienZHOU6 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
狗头大军之江苏分军8 小时前
《潮水漫过十七岁》开学了
后端
苏三说技术9 小时前
如何看待GPT-6在UP主众测中碾压夺冠?它是现在最强大模型吗?
后端
mldong9 小时前
一份 JSON,一条能跑的审批流:把报销流程送上工作流引擎
后端·架构
wno7049 小时前
Spring Boot WebFlux增删改查
java·spring boot·后端
Captaincc9 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
aramae10 小时前
模拟实现strlen()函数 (C语言)
c语言·开发语言·后端
计算机魔术师11 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen11 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒11 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端