大文件上传
前景提要
在工作中,经常会遇到上传文件的功能,但是当文件体积大时,如果使用把该文件直接在一个请求体中提交,会出现一些问题,以nginx为例:
- 其默认允许1MB以内的文件
- 超过1MB的文件,需要设置
client_max_body_size
放开体积限制
但是这样会存在一个问题,就是如果上传的文件体积很大,就会出现一些问题,最明显的问题是:
服务器的存储和网络带宽压力都会非常大
当服务器、产品、用户忍不了时,就需要对大文件上传进行优化。
1、大文件切片上传
逻辑梗概
- 将大文件分割成多个文件块
- 逐个上传文件块
- 服务端将文件块顺序合并成完整文件
优势分析
- 减轻服务器压力:如果一次性上传大文件,服务器的存储和网络带宽压力都会非常大,而通过切片,可以将这些压力分散到多个小文件中,减轻服务器的压力。
- 断点续传、错误重试:因为大文件被肢解了,如果因为一些原因中断、错误了,已经上传的部分就不用再重新上传了,只需要把后续的传上就好了。
前端部分
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 文件,暴露
uploadFile
和continueUpload
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地址以保证文件唯一性
待合并项可定时删除
…
欢迎大家补充!