大文件上传

前景提要

在工作中,经常会遇到上传文件的功能,但是当文件体积大时,如果使用把该文件直接在一个请求体中提交,会出现一些问题,以nginx为例:

  • 其默认允许1MB以内的文件
  • 超过1MB的文件,需要设置client_max_body_size放开体积限制

但是这样会存在一个问题,就是如果上传的文件体积很大,就会出现一些问题,最明显的问题是:

服务器的存储和网络带宽压力都会非常大

当服务器、产品、用户忍不了时,就需要对大文件上传进行优化。

1、大文件切片上传

逻辑梗概

  • 将大文件分割成多个文件块
  • 逐个上传文件块
  • 服务端将文件块顺序合并成完整文件

优势分析

  1. 减轻服务器压力:如果一次性上传大文件,服务器的存储和网络带宽压力都会非常大,而通过切片,可以将这些压力分散到多个小文件中,减轻服务器的压力。
  2. 断点续传、错误重试:因为大文件被肢解了,如果因为一些原因中断、错误了,已经上传的部分就不用再重新上传了,只需要把后续的传上就好了。

前端部分

1.1 切文件(前端)
1.2 判定切片是否完成上传完成(前端)
  • 客户端记录切片的上传状态,只需要上传未成功的切片
1.3 断点、错误续传(前端)
  • 客户端上传文件时,记录已上传的切片位置
  • 下次上传时,根据记录的位置,继续上传

后端部分

1.1 收切片、存切片
  • 将相关切片保存在目标文件夹
1.2 合并切片
  • 服务端根据切片的顺序,将切片合并成完整文件
1.3 文件是否存在校验
  • 服务端根据文件Hash值、文件名,校验该文件是否已经上传

代码实现

1、搭建基础项目
服务器(基于express)
 1const express = require('express')
 2const app = express()
 3app.listen(3000, () => {
 4    console.log('服务已运行:http://localhost:3000');
 5})
前端

基础页面

 1<!DOCTYPE html>
 2<html lang="en">
 3<head>
 4    <meta charset="UTF-8">
 5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 6    <title>Document</title>
 7    <style>
 8        input{
 9            display: block;
10            margin: 10px 0;
11        }
12    </style>
13</head>
14<body>
15    <input type="file" id="file">
16    <input type="button" id="upload" value="上传">
17    <input type="button" id="continue" value="继续上传">
18</body>
19</html>

引入资源

 1<script type="module" src="./spark-md5.js"></script>
 2<script type="module" src="./operate.js"></script>

operate.js

 1
 2const fileEle = document.querySelector("#file");
 3const uploadButton = document.querySelector("#upload");
 4const continueButton = document.querySelector("#continue");
 5uploadButton.addEventListener("click", async () => {
 6    console.log("点击了上传按钮")
 7})
 8continueButton.addEventListener('click', async () => {
 9    console.log("点击了继续上传按钮")
10})
3、静态资源托管(server)
 1app.use(express.static('static'))
4、上传接口
搭建上传接口(server)

使用body-parser中间价解析请求体

 1
 2const bodyParser = require('body-parser')
 3
 4
 5app.use(bodyParser.urlencoded({ extended: false })); 
 6
 7app.use(bodyParser.json()); 

上传接口

 1app.post('/upload', (req, res) => {
 2    res.send({
 3        msg: '上传成功',
 4        success: true
 5    })
 6})
测试接口(前端)
 1
 2const uploadHandler = async (file) => {
 3    fetch('http://localhost:3000/upload', {
 4        method: "POST",
 5        headers: {
 6            'Content-Type': 'application/json',
 7        },
 8        body: JSON.stringify({
 9            fileName: '大文件',
10        }),
11    })
12}
13uploadButton.addEventListener("click", async (e) => {
14    uploadHandler()
15})
5、文件上传接口存储文件(server)

使用multer中间件处理上传文件

设置uploadFiles文件夹为文件存储路径

 1const multer = require('multer')
 2const storage = multer.diskStorage({
 3    destination: function (req, file, cb) {
 4        cb(null, './uploadFiles');
 5    },
 6});
 7const upload = multer({
 8    storage
 9})
10
11app.post('/upload', upload.single('file'), (req, res) => {
12    
13})

测试

 1
 2const uploadHandler = async (file) => {
 3    let fd = new FormData();
 4    fd.append('file', file);
 5    fetch('http://localhost:3000/upload', {
 6        method: "POST",
 7        body: fd
 8    })
 9}
10uploadButton.addEventListener("click", async () => {
11    let file = fileEle.files[0];
12    uploadHandler(file)
13})
6、文件切片

注意

假设切片大小为1M 保存切片顺序(为了合成大文件时正确性) 上传状态(为了断点续传、前端显示进度条)

 1
 2const chunkSize = 1024 * 1024 * 1; 
 3
 4const createChunks = (file) => {
 5    
 6    const chunks = [];
 7    
 8    let start = 0;
 9    let index = 0;
10    while (start < file.size) {
11        let curChunk = file.slice(start, start + chunkSize);
12        chunks.push({
13            file: curChunk,
14            uploaded: false,
15            chunkIndex: index,
16        });
17        index++;
18        start += chunkSize;
19    }
20    return chunks;
21}

测试文件切片函数

 1
 2let chunks = [];
 3uploadButton.addEventListener("click", async () => {
 4    let file = fileEle.files[0];
 5    chunks = createChunks(file);
 6    console.log(chunks);
 7})

注意:将来要把这些切片全部都上传到服务器,并且最后需要把这些切片合并成一个文件,且要做出文件秒传功能,需要保留当前文件的hash值和文件名,以辨别文件和合并文件。

在页面中引入spark-md5.js

 1<script type="module" src="./spark-md5.js"></script>

获取文件Hash值

 1const getHash = (file) => {
 2    return new Promise((resolve) => {
 3        const fileReader = new FileReader();
 4        fileReader.readAsArrayBuffer(file);
 5        fileReader.onload = function (e) {
 6            let fileMd5 = SparkMD5.ArrayBuffer.hash(e.target.result);
 7            resolve(fileMd5);
 8        }
 9    });
10}

把文件的hash值保存在切片信息中

 1
 2let fileHash = "";
 3
 4let fileName = "";
 5
 6const createChunks = (file) => {
 7    
 8    const chunks = [];
 9    
10    let start = 0;
11    let index = 0;
12    while (start < file.size) {
13        let curChunk = file.slice(start, start + chunkSize);
14        chunks.push({
15            file: curChunk,
16            uploaded: false,
17            fileHash: fileHash,
18            chunkIndex: index,
19        });
20        index++;
21        start += chunkSize;
22    }
23    return chunks;
24}
25
26const uploadFile = async(file) => {
27    
28    fileName = file.name;
29    
30    fileHash = await getHash(file);
31    chunks = createChunks(file);
32    console.log(chunks);
33}
7、上传逻辑修改

前端部分

单个文件上传函数修改:

插入文件名、文件Hash值、切片索引

上传成功之后修改状态标识(可用于断点续传、上传进度回显)

 1
 2const uploadHandler = (chunk) => {
 3    return new Promise(async (resolve, reject) => {
 4        try {
 5            let fd = new FormData();
 6            fd.append('file', chunk.file);
 7            fd.append('fileHash', chunk.fileHash);
 8            fd.append('chunkIndex', chunk.chunkIndex);
 9            let result = await fetch('http://localhost:3000/upload', {
10                method: 'POST',
11                body: fd
12            }).then(res => res.json());
13            chunk.uploaded = true;
14            resolve(result)
15        } catch (err) {
16            reject(err)
17        }
18    })
19}

批量上传切片

限制并发数量(减轻服务器压力)

 1
 2const uploadChunks = (chunks, maxRequest = 6) => {
 3    return new Promise((resolve, reject) => {
 4        if (chunks.length == 0) {
 5            resolve([]);
 6        }
 7        let requestSliceArr = []
 8        let start = 0;
 9        while (start < chunks.length) {
10            requestSliceArr.push(chunks.slice(start, start + maxRequest))
11            start += maxRequest;
12        }
13        let index = 0;
14        let requestReaults = [];
15        let requestErrReaults = [];
16
17        const request = async () => {
18            if (index > requestSliceArr.length - 1) {
19                resolve(requestReaults)
20                return;
21            }
22            let sliceChunks = requestSliceArr[index];
23            Promise.all(
24                sliceChunks.map(chunk => uploadHandler(chunk))
25            ).then((res) => {
26                requestReaults.push(...(Array.isArray(res) ? res : []))
27                index++;
28                request()
29            }).catch((err) => {
30                requestErrReaults.push(...(Array.isArray(err) ? err : []))
31                reject(requestErrReaults)
32            })
33        }
34        request()
35    })
36}

抽离上传操作

 1
 2const uploadFile = async (file) => {
 3    
 4    fileName = file.name;
 5    
 6    fileHash = await getHash(file);
 7    
 8    chunks = createChunks(file);
 9    try {
10        await uploadChunks(chunks)
11    } catch (err) {
12        return {
13            mag: "文件上传错误",
14            success: false
15        }
16    }
17}

后端部分

修改上传接口,增加功能

使用一个文件Hash值同名的文件夹保存所有切片

这里使用了node内置模块path处理路径

使用fs-extra第三方模块处理文件操作

 1const path = require('path')
 2const fse = require('fs-extra')
 3app.post('/upload', upload.single('file'), (req, res) => {
 4    const { fileHash, chunkIndex } = req.body;
 5    
 6    let tempFileDir = path.resolve('uploadFiles', fileHash);
 7    
 8    if (!fse.pathExistsSync(tempFileDir)) {
 9        fse.mkdirSync(tempFileDir)
10    }
11    
12    
13    
14    const tempChunkPath = path.resolve(tempFileDir, chunkIndex);
15    
16    let currentChunkPath = path.resolve(req.file.path);
17    if (!fse.existsSync(tempChunkPath)) {
18        fse.moveSync(currentChunkPath, tempChunkPath)
19    } else {
20        fse.removeSync(currentChunkPath)
21    }
22    res.send({
23        msg: '上传成功',
24        success: true
25    })
26})
8、合并文件

编写合并接口(server)

合并成的文件名为 文件哈希值.文件扩展名

所以需要传入文件Hash值、文件名

 1app.get('/merge', async (req, res) => {
 2    const { fileHash, fileName } = req.query;
 3    res.send({
 4        msg: `Hash:${fileHash},文件名:${fileName}`,
 5        success: true
 6    });
 7})

请求合并接口(前端)

封装合并请求函数

 1
 2const mergeRequest = (fileHash, fileName) => {
 3    return fetch(`http://localhost:3000/merge?fileHash=${fileHash}&fileName=${fileName}`, {
 4        method: "GET",
 5    }).then(res => res.json());
 6};

在切片上传完成后,调用合并接口

 1
 2const uploadFile = async (file) => {
 3    
 4    fileName = file.name;
 5    
 6    fileHash = await getHash(file);
 7    
 8    chunks = createChunks(file);
 9    try {
10        await uploadChunks(chunks)
11        await mergeRequest(fileHash, fileName)
12    } catch (err) {
13        return {
14            mag: "文件上传错误",
15            success: false
16        }
17    }
18}

合并接口逻辑

1、根据文件Hash值,找到所有切片

 1app.get('/merge', async (req, res) => {
 2    const { fileHash, fileName } = req.query;
 3    
 4    const filePath = path.resolve('uploadFiles', fileHash + path.extname(fileName));
 5    
 6    let tempFileDir = path.resolve('uploadFiles', fileHash);
 7    
 8    const chunkPaths = fse.readdirSync(tempFileDir);
 9    console.log('chunkPaths:', chunkPaths);
10    res.send({
11        msg: "合并成功",
12        success: true
13    });
14})

合并接口逻辑

2、遍历获取所有切片路径数组,根据路径找到切片,合并成一个文件,删除原有文件夹

 1app.get('/merge', async (req, res) => {
 2    const { fileHash, fileName } = req.query;
 3    
 4    const filePath = path.resolve('uploadFiles', fileHash + path.extname(fileName));
 5    
 6    let tempFileDir = path.resolve('uploadFiles', fileHash);
 7
 8    
 9    const chunkPaths = fse.readdirSync(tempFileDir);
10
11    console.log('chunkPaths:', chunkPaths);
12
13    
14    let mergeTasks = [];
15    for (let index = 0; index < chunkPaths.length; index++) {
16        mergeTasks.push(new Promise((resolve) => {
17            
18            const chunkPath = path.resolve(tempFileDir, index + '');
19            
20            fse.appendFileSync(filePath, fse.readFileSync(chunkPath));
21            
22            fse.unlinkSync(chunkPath);
23            resolve();
24        }))
25    }
26    await Promise.all(mergeTasks);
27    
28    fse.removeSync(tempFileDir);
29    res.send({
30        msg: "合并成功",
31        success: true
32    });
33})
10、断点续传

封装continueUpload方法

continueUpload方法中,只上传 uploaded 为true的切片

修改后此功能对用户来说即是黑盒,用户只需要重复调用continueUpload方法即可

 1
 2const continueUpload = async (file) => {
 3    if(chunks.length == 0 || !fileHash || !fileName){
 4        return;
 5    }
 6    try {
 7        await uploadChunks(chunks.filter(chunk => !chunk.uploaded))
 8        await mergeRequest(fileHash, fileName)
 9    } catch (err) {
10        return {
11            mag: "文件上传错误",
12            success: false
13        }
14    }
15}

2、大文件秒传

逻辑梗概

  • 客户端上传文件时,先提交文件的哈希值,
  • 服务端根据哈希值查询文件是否已经上传,如果已上传,则直接返回已上传状态
  • 客户端收到已上传状态后,直接跳过上传过程

优势分析

  • 提高上传效率:秒传可以提高上传效率,因为文件已经在上传过程中被上传过了,直接返回已上传状态,省要再次上传,提高效率。

代码实现

校验接口,校验是否已经存在目标文件

逻辑:根据文件Hash值和文件名组成 “文件Hash.文件扩展名” ,以保证文件名唯一

 1app.get('/verify', (req, res) => {
 2    const { fileHash, fileName } = req.query;
 3    const filePath = path.resolve('uploadFiles', fileHash + path.extname(fileName));
 4    const exitFile = fse.pathExistsSync(filePath);
 5    res.send({
 6        exitFile
 7    })
 8})

校验函数

 1
 2const verify = (fileHash, fileName) => {
 3    return fetch(`http://localhost:3000/verify?fileHash=${fileHash}&fileName=${fileName}`, {
 4        method: "GET",
 5    }).then(res => res.json());
 6};
 7
 8
 9const uploadFile = async (file) => {
10    
11    fileName = file.name;
12    
13    fileHash = await getHash(file);
14    
15    let { exitFile } = await verify(fileHash, fileName);
16    if (exitFile) {
17        return {
18            mag: "文件已上传",
19            success: true
20        }
21    }
22    
23    chunks = createChunks(file);
24    try {
25        await uploadChunks(chunks.filter(chunk => !chunk.uploaded))
26        await mergeRequest(fileHash, fileName)
27    } catch (err) {
28        return {
29            mag: "文件上传错误",
30            success: false
31        }
32    }
33}

3、提取为公共方法

封装

编写 bigFileUpload.js 文件,暴露uploadFilecontinueUpload

 1
 2export default {
 3    uploadFile,
 4    continueUpload
 5}

使用

导入资源并调用

 1import bigUpload from './bigFileUpload.js'
 2uploadButton.addEventListener("click", async () => {
 3    let file = fileEle.files[0];
 4    bigUpload.uploadFile(file)
 5})
 6continueButton.addEventListener('click', async () => {
 7    bigUpload.continueUpload()
 8})

4、可优化

前端:

封装形式可优化,采用类的方式封装,以保证数据的独立性、可定制性

切片Hash的计算可以通过抽样切片的方式来进行

后端:

文件Hash校验可增加用户ip地址以保证文件唯一性

待合并项可定时删除

欢迎大家补充!

资源

express

body-parser

multer

Blob-slice

spark-md5

个人笔记记录 2021 ~ 2025