大文件上传优化「 切片、秒传 、断点续传」

440 阅读4分钟

大文件上传痛点

  1. 1G的文件20M的带宽,上传时页面停留什么都不做要等7分钟甚至更久
  2. 文件过大,上传时间过长,中途断网或者浏览器崩溃,都会导致上传中断
  3. 同一个文件多次上传,浪费服务器资源

文件切片上传

前端

  • 使用Blob.prototype.slice方法对文件切片
  • 使用spark-md5库计算文件hash
  • 将各个分片文件上传到服务器,并携带hash
  • 当所有文件上传完后,需要发送合并请求,携带文件hash、后缀名

服务端

  • 根据接收到的hash创建文件夹,将分片文件存储到文件夹中
  • 收到合并请求后,读取各个分片文件。根据hash和后缀名,合并生成完整文件
  • 删除存储分片文件的文件夹及其内容

前端代码实现

  • 基础页面
<template>
    <input type="file" id="file" @click="tip = ''" />
    <input type="button" id="upload" value="上传" />
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
onMounted(() => {
  // 获取文件域
  const fileEle = document.querySelector("#file");
  const uploadButton = document.querySelector("#upload");
  uploadButton.addEventListener("click", async () => {
      let file = fileEle.files[0];
      // 文件上传
      uploadFile(file)
  })
});
</script>
  • 获取文件 Hash 值
// import SparkMD5 from 'spark-md5';

const getHash = (file) => {
    return new Promise((resolve) => {
        const fileReader = new FileReader();
        fileReader.readAsArrayBuffer(file);
        fileReader.onload = function (event) {
            const spark = new SparkMD5.ArrayBuffer();
            spark.append(event.target.result);
            const hash = spark.end();
            console.log('hash', hash);
            resolve(hash);
        }
    });
}
  • 文件切片
const createChunks = (file) => {
    // 使用单独常量保存预设切片大小 1MB
    const chunkSize = 1024 * 1024 * 1;
    // 接受一个文件对象,要把这个文件对象切片,返回一个切片数组
    const chunks = [];
    // 文件大小.slice(开始位置,结束位置)
    let start = 0;
    let index = 0;
    while (start < file.size) {
        let curChunk = file.slice(start, start + chunkSize);
        chunks.push({
            file: curChunk,
            fileHash: fileHash.value,
            chunkIndex: index,
        });
        index++;
        start += chunkSize;
    }
    return chunks;
}
// 文件上传
const uploadFile = async (file) => {
    // 设置文件名
    fileName.value = file.name;
    // 获取文件hash值
    fileHash.value = await getHash(file);
    // 获取切片
    let chunks = createChunks(file);
}
  • 单个文件上传与批量上传切片
// 单个文件上传
const uploadHandler = (chunk) => {
  return new Promise(async (resolve, reject) => {
      try {
          let fd = new FormData();
          fd.append('file', chunk.file);
          fd.append('fileHash', chunk.fileHash);
          fd.append('chunkIndex', chunk.chunkIndex);
          let result = await fetch('http://localhost:3000/upload', {
              method: 'POST',
              body: fd
          }).then(res => res.json());
          resolve(result)
      } catch (err) {
          reject(err)
      }
  })
}
// 批量上传切片
const uploadChunks = (chunks, maxRequest = 6) => {
    return new Promise((resolve, reject) => {
        if (chunks.length == 0) {
            resolve([]);
        }
        let requestSliceArr = []
        let start = 0;
        while (start < chunks.length) {
            requestSliceArr.push(chunks.slice(start, start + maxRequest))
            start += maxRequest;
        }
        let index = 0;
        let requestReaults = [];
        let requestErrReaults = [];

        const request = async () => {
            if (index > requestSliceArr.length - 1) {
                resolve(requestReaults)
                return;
            }
            let sliceChunks = requestSliceArr[index];
            Promise.all(
                sliceChunks.map(chunk => uploadHandler(chunk))
            ).then((res) => {
                requestReaults.push(...(Array.isArray(res) ? res : []))
                index++;
                request()
            }).catch((err) => {
                requestErrReaults.push(...(Array.isArray(err) ? err : []))
                reject(requestErrReaults)
            })
        }
        request()
    })
}

可优化:上述代码是在请求池中所有请求完成后才进行下一个请求池的操作,现在可以做一个改进,即请求池中每完成一个请求就新添加一个请求

const multiRequest = (requests, maxNum) => {
    requests = requests.map((f, idx) => ({ idx, f }))
    let count = requests.length
    const res = []
    return new Promise(resolve => {
        const next = (idx, f) => {
            f()
                .then(r => {
                    res[idx] = r
                    count--
                    console.log(
                        `成功 idx:${idx} count:${count} requests.length:${requests.length}`
                    )
                    if (requests.length) {
                        const { idx, f } = requests.shift()
                        next(idx, f)
                    }
                    if (!count) {
                        resolve(res)
                    }
                })
                .catch(e => {
                    // count--
                    // if (requests.length) {
                    //     const { idx, f } = requests.shift()
                    //     next(idx, f)
                    // }
                    // if (!count) {
                    //     resolve(res)
                    // }
                    // 失败后重新请求
                    next(idx, f);
                })
        }
        requests.splice(0, maxNum).forEach(({ idx, f }) => {
            next(idx, f)
        })
    })
};
const uploadChunks = async (chunks, maxRequest = 6) => {
    let requestArr = []
    chunks.forEach(item => {
        requestArr.push(() => uploadHandler(item))
    })
    await multiRequest(requestArr, maxRequest);
};
  • 合并分片请求
// 合并分片请求
const mergeRequest = (fileHash, fileName) => {
    return fetch(`http://localhost:3000/merge?fileHash=${fileHash}&fileName=${fileName}`, {
        method: "GET",
    }).then(res => {
        tip.value = res.msg;
    });
};
// 文件上传
const uploadFile = async (file) => {
    // 设置文件名
    fileName.value = file.name;
    // 获取文件hash值
    fileHash.value = await getHash(file);
    // 获取切片
    let chunks = createChunks(file);
    try {
        await uploadChunks(chunks);
        await mergeRequest(fileHash.value, fileName.value);
    } catch (err) {
        return {
            mag: "文件上传错误",
            success: false
        }
    }
}

服务端代码实现

  • 基础配置
const express = require('express');
const multer = require('multer');
const path = require('path');
const fse = require('fs-extra');
const cors = require('cors');

const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        cb(null, './uploadFiles');
    },
});
const upload = multer({
    storage
})
const app = express();
app.use(cors());
app.use(express.json())
  • 单个切片上传接口
app.post('/upload', upload.single('file'), (req, res) => {
    const { fileHash, chunkIndex } = req.body;
    // 上传文件临时目录文件夹
    let tempFileDir = path.resolve('uploadFiles', fileHash);
    // 如果当前文件的临时文件夹不存在,则创建该文件夹
    if (!fse.pathExistsSync(tempFileDir)) {
        fse.mkdirSync(tempFileDir)
    }
    // 如果无临时文件夹或不存在该切片,则将用户上传的切片移到临时文件夹里
    // 如果有临时文件夹并存在该切片,则删除用户上传的切片(因为用不到了)
    // 目标切片位置
    const tempChunkPath = path.resolve(tempFileDir, chunkIndex);
    // 当前切片位置(multer默认保存的位置)
    let currentChunkPath = path.resolve(req.file.path);
    if (!fse.existsSync(tempChunkPath)) {
        fse.moveSync(currentChunkPath, tempChunkPath)
    } else {
        fse.removeSync(currentChunkPath)
    }
    res.send({
        msg: '上传成功',
        success: true
    })
})
  • 请求合并切片接口
app.get('/merge', async (req, res) => {
    const { fileHash, fileName } = req.query;
    // 最终合并的文件路径
    const filePath = path.resolve('uploadFiles', fileHash + path.extname(fileName));
    // 临时文件夹路径
    let tempFileDir = path.resolve('uploadFiles', fileHash);

    // 读取临时文件夹,获取所有切片
    const chunkPaths = fse.readdirSync(tempFileDir);

    // 将切片追加到文件中
    let mergeTasks = [];
    for (let index = 0; index < chunkPaths.length; index++) {
        mergeTasks.push(new Promise((resolve) => {
            // 当前遍历的切片路径
            const chunkPath = path.resolve(tempFileDir, index + '');
            // 将当前遍历的切片切片追加到文件中
            fse.appendFileSync(filePath, fse.readFileSync(chunkPath));
            // 删除当前遍历的切片
            fse.unlinkSync(chunkPath);
            resolve();
        }))
    }
    await Promise.all(mergeTasks);
    // 等待所有切片追加到文件后,删除临时文件夹
    fse.removeSync(tempFileDir);
    res.send({
        msg: "合并成功",
        success: true
    });
})

文件秒传

前端

  • 上传文件之前,先计算hash,然后将hash和文件名发送到服务器
  • 服务器返回文件是否存在的状态
  • 如果存在,前端端提示文件上传成功,否则执行上传动作

服务端

  • 服务器根据hash和后缀名,在服务器中查找该文件
  • 返回该文件是否存在的状态

前端代码实现

// 校验文件、文件分片是否存在
const verify = (fileHash, fileName) => {
    return fetch(`http://localhost:3000/verify?fileHash=${fileHash}&fileName=${fileName}`, {
        method: "GET",
    }).then(res => res.json());
};
// 文件上传
const uploadFile = async (file) => {
    // 设置文件名
    fileName.value = file.name;
    // 获取文件hash值
    fileHash.value = await getHash(file);
    let { exitFile } = await verify(fileHash.value, fileName.value);
    if (exitFile) {
        tip.value = '文件已上传';
        return;
    }
    // 获取切片
    let chunks = createChunks(file);
    ...
    ...
}

服务端代码实现

  • 验证接口
// 文件秒传 验证文件是否已存在
app.get('/verify', (req, res) => {
    const { fileHash, fileName } = req.query;
    const filePath = path.resolve('uploadFiles', fileHash + path.extname(fileName));
    const exitFile = fse.pathExistsSync(filePath);
    res.send({
        exitFile
    })
})

文件断点续传

  • 和秒传的验证逻辑可以放在一起,修改验证接口,服务端返回已经上传切片的索引或序号标识,前端拿到后对切片数据进行比对,对未上传的切片继续进行上传

前端

  • 在上传文件之前,计算hash,将hash和文件名传给服务器
  • 服务器根据hash和后缀,查看是否已经上传
  • 如果没有完整文件,就查找有无分片数据
  • 如果有,则返回已上传的分片列表
  • 如果没有,客户端需要执行上传动作

存在的问题

上传大文件并在浏览器中进行SHA1或MD5哈希计算时可能会导致浏览器崩溃,原因通常是在处理大文件时所需的计算和内存资源超过了浏览器的能力

  • 通常的处理办法包括: 通过setTimeoutrequestAnimationFrame分时段计算、切割合适的块、使用Web Workers、使用Stream Processing优化、优化算法,内存管理、在服务端计算等。

  • 但最佳的处理办法,是在浏览器中使用webworker多线程计算hash,同时需要兼顾其兼容性、线程数量

其他

目前看到的比较详细的,企业项目级的方案:超详细的大文件分片上传⏫实战与优化⚡(前端部分)