开始
最近遇到需要上传文件的需求,脑子里面想的就是:什么?文件上传,这能有什么难度,直接FormData包装一下,扔个后端就行,但是在开发中却总是收到反馈,上传几百兆以上的文件总是经常失败,然后重试又很慢,还非常浪费时间。就算上传成功,下次上传同样的文件又要重新开始上传,对用户体验也是非常的差。
怎么解决呢? 作为一个合格的前端,有问题当然得找后端,这事吧你看怎么解决的好,想办法搞下服务器性能。后端曰:硬件不够,有心无力,我:...。仔细一想这也没毛病,大文件传输太多的报文,丢包重传的概率也很大。既然这样,那就切片上传吧。查了一些资料,然后把自己总结的一些思路和实现分享出来。
本文会通过 前端(react+antd) 和 服务端(nodejs + express) 交互的方式,实现大文件切片上传过程。
建议配合源码阅读:fileUpload
整体思路
我们都知道Blob
它表示原始数据,也是二进制数据,同时提供数据截取的方法slice
,而 File
是Blob
的子类继承了slice
方法,所以可以利用slice
方法将文件分割成N
份,并对文件MD5
加密,然后请求接口并行上传,服务端获取当前加密后的hash
生成一个临时文件夹存放当前上传的分片。
所有分片上传完成后分片进行合并得到完整的文件,并把当前文件的hash
存进数据库(因为上传的文件可能会有存储在其他服务器中,所以为了方便,可以把上传的文件信息单独存在数据库中),下次上传时候检查当前文件是否已经上传---秒传。
当分片上传失败,可以在重新上传时进行判断,只上传上次失败的部分,减少用户的等待时间,缓解服务器压力---断点续传。
流程图如下:
实现步骤
MD5加密
秒传和续传需要用到文件的hash,MD5 则是文件的唯一标识,可以利用文件的MD5查询文件是否已经上传或者查询当前文件的上传状态,SparkMD5是md5算法的快速md5实现。
- SparkMD5 需要接收
string
或者buffer
,而我们拿到的是一个文件对象,所以需要借助浏览器提供的文件读取对象FileReader来读文件,使用方式:new FileReader().readAsArrayBuffer(file)
。 - 再利用
onload
方法就可以循环读取分割的文件。
const fileUpload = ({ file, chunkSize = 1024 * 100 }) => {
/** 读取文件输入异步操作 */
return new Promise((resolve, reject) => {
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader(),
fileChunkList = []; // 切片集合
fileReader.onload = function (e) {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve({ fileChunkList, hash: spark.end(), size: file.size, name: file.name });
}
};
fileReader.onerror = () => {
reject("文件读取失败");
}
/** 切割文件 */
function loadNext() {
let start = currentChunk * chunkSize,
end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize,
fileChunk = blobSlice.call(file, start, end);
fileChunkList.push({
fileChunk,
currentChunk,
size: fileChunk.size,
})
/** 读取文件 */
fileReader.readAsArrayBuffer(fileChunk);
}
loadNext();
})
}
现在,文件已切片完成,文件分割后我们可以得到一个包含文件分片、文件hash、大小和名字的一个对象
{ fileChunkList, hash, size, name }
文件状态
查询数据库
数据库为了方便操作,设计的相对简单:
前端得到MD5过后,先查询是否在数据库中已经存在。
const express = require('express')
const router = express.Router()
/** 根据文件hash在数据查询当前文件是否已经上传 */
router.post('/queryFileByHash', (req, res) => {
const { fileHash } = req.body;
const sql = `select * from file where fileHash = ?`
db.query(sql, [fileHash], function (err, results) {
if (err) {
res.send({ code: 500, success: false, msg: '查询失败' });
return;
}
res.send({ code: 200, success: true, data: results });
})
});
如果不存在,则下一步:
查询文件夹
- 接口会根据当前文件的
MD5
去查找是否存在文件夹并把当前文件夹下所有文件名返给前端,如果没有查询到,则返回空数组。 - 前端拿到查询数据过滤掉已经上传的切片。
- 上传过滤后的所有文件切片。
const express = require('express')
const router = express.Router()
const path = require("path");
const fs = require("fs");
const FILE_STORE_PATH = path.resolve(__dirname, '../static/'),
WRITE_PATH = path.resolve(__dirname, '../resources');
/** 根据文件hash在查询当前文件夹所有文件 */
router.post('/queryDirFileByHash', (req, res) => {
const { fileHash } = req.body;
const tempStoragePath = `${FILE_STORE_PATH}/${fileHash}`;
if (!fs.existsSync(tempStoragePath)) return res.send({ code: 200, success: true, data: [] });
const fileList = fs.readdirSync(tempStoragePath);
res.send({ code: 200, success: true, data: fileList });
});
上传分片
调用 Promise.all
并发上传所有的切片,将切片序号、切片文件、文件 MD5 传给后台。
后台接收到上传请求后,首先查询数据库里面是否存在,如果存在,则提示上传成功。,否则查询名称为文件 MD5
的文件夹是否存在,不存在则创建文件夹,后台采用了multer中间件来处理文件上传。
服务端代码如下:
const express = require('express')
const multer = require("multer");
const router = express.Router();
const path = require("path");
const fs = require("fs");
const FILE_STORE_PATH = path.resolve(__dirname, '../static/'),
WRITE_PATH = path.resolve(__dirname, '../resources');
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const { fileHash } = req.body;
/** 根据文件MD5动态生成文件夹 */
const tempStoragePath = `${FILE_STORE_PATH}/${fileHash}`;
if (!fs.existsSync(tempStoragePath)) {
fs.mkdirSync(tempStoragePath, { recursive: true });
}
cb(null, tempStoragePath);
},
filename: function (req, file, cb) {
cb(null, req.body.fileChunkName);
}
})
router.post('/upload', upload.single("file"), (req,res)=>{
res.send({ 'code': 200, success: true });
});
前端代码:
const handleUpload = () => {
fileUpload({ file: fileList[0] }).then(async (fileMsg) => {
let fileChunkList = fileMsg.fileChunkList;
try {
/** 根据文件md5查询数据库 */
const queryFileByHashRes = await queryFileByHash({ fileHash: fileMsg.hash });
if (queryFileByHashRes.data.data.length) {
message.success("文件上传成功");
return;
}
/** 根据文件md5查询文件夹下文件(秒传) */
const queryDirFileByHashRes = await queryDirFileByHash({ fileHash: fileMsg.hash });
const { data } = queryDirFileByHashRes.data;
/** 过滤掉已经上传成功的分片(断点续传) */
if(data.length){
fileChunkList = fileMsg.fileChunkList.filter(item=>!data.includes(String(item.currentChunk)));
}
} catch (error) {
message.error(error);
fileChunkList = [];
}
const reqs = fileChunkList.map((item, index) => {
let formData = new FormData();
formData.append('fileChunkName', item.currentChunk);
formData.append('fileHash', fileMsg.hash);
// 文件切片(尽量放在最后,node中间件multer可能会获取不到)
formData.append('file', item.fileChunk);
// 调用上传接口
return upload(formData, (e) => {});
})
Promise.all(reqs).then(() => {
message.success("上传成功");
// 合并分片
mergeFile({ fileName: fileMsg.name, fileHash: fileMsg.hash });
}).catch(()=>{
message.error("上传失败")
})
})
};
当全部分片上传成功,通知服务端进行合并,当有一个分片上传失败时,提示“上传失败”。在重新上传时,通过文件 MD5
得到文件的上传状态,当服务器已经有该 MD5
对应的切片时,代表该切片已经上传过,无需再次上传,当服务器找不到该 MD5
对应的切片时,代表该切片需要上传,用户只需上传这部分切片,就可以完整上传整个文件,这就是文件的断点续传。
上传进度条
文件虽然被拆分,但是全部上传完还是需要一定的时间,此时可以做一些进度条之类的交互,实时显示文件上传进度。显示进度条有两种显示方式:
- 每个文件分片上传过程中的进度都提示出来。
- 只对已经上传成功的文件分片提示总的进度。(此项目使用)。
xhr
中提供了上传进度的事件progress
,项目中使用的是axios ,axios
在配置时提供了onUploadProgress
监听原生progress
事件。
const upload = (data,onUploadProgress=()=>{})=>{
return axios({
url:'/api/upload',
data,
method:'POST',
onUploadProgress
})
}
// 进度条
const handleUpload = () => {
fileUpload({ file: fileList[0] }).then(async (fileMsg) => {
setChunkTotal(fileMsg.fileChunkList.length);
let fileChunkList = fileMsg.fileChunkList;
try {
/** 根据文件md5查询数据库 */
...
if(data.length){
fileChunkList = fileMsg.fileChunkList.filter(item=>!data.includes(String(item.currentChunk)));
setUploadChunkNum(data.length);
}
} catch (error) {
...
}
const reqs = fileChunkList.map((item, index) => {
...
return upload(formData, (e) => {
// 当前分片全部上传完成
if (e.loaded === e.total) {
setUploadChunkNum((uploadChunkNum) => uploadChunkNum + 1);
}
});
})
...
};
合并文件
上传完所有文件分片后前端通知服务器进行合并,服务端接收带通知后根据请求携带参数:文件MD5(查找当前文件下所有文件)和文件名然后合并文件。
需要注意的是:因为服务端存的分片命名是前端在切片时约定生成的(此项目根据切片的index
),所以在合并文件时为了合并的顺序准确,需要先对所有文件进行排序。
fs.readdirSync
读取文件夹下所有文件- 遍历读取的文件列表通过
fs.readFileSync
缓冲到Buffer
中 - 然后通过
fs.createWriteStream
写入Buffer
- 合并成功后删除文件夹
- 最后再把文件名和文件MD5存进数据库
const express = require('express')
const router = express.Router();
const path = require("path");
const fs = require("fs");
const fsPromise = require("fs/promises")
const db = require("../db")
const { Buffer } = require("buffer")
const FILE_STORE_PATH = path.resolve(__dirname, '../static/upload/'),
WRITE_PATH = path.resolve(__dirname, '../resources');
router.post('/mergeFile',(req,res)=>{
if (!fs.existsSync(WRITE_PATH)) fs.mkdirSync(WRITE_PATH);
const { fileName, fileHash } = req.body;
// 插入当前文件信息
const insertFileMeg = () => {
const sql = 'INSERT INTO file (fileHash,fileName) VALUES (?,?)';
db.query(sql, [fileHash, fileName], function (err, results) {
if (err) {
res.send({ code: 500, success: false, msg: '文件信息存储失败' });
return;
}
res.send({ code: 200, success: true, msg: "文件处理成功" });
})
}
try {
let len = 0
const bufferList = fs.readdirSync(FILE_STORE_PATH).sort((a, b) =>
parseInt(a.split('.')[0] - parseInt(b.split('.')[0]))).map((hash, index) => {
const buffer = fs.readFileSync(`${FILE_STORE_PATH}/${hash}`)
len += buffer.length;
return buffer;
});
const buffer = Buffer.concat(bufferList, len);
const ws = fs.createWriteStream(`${WRITE_PATH}/${fileName}`)
ws.write(buffer);
ws.close();
/** 递归删除static文件夹下所有内容 */
fsPromise.rm(path.resolve(__dirname, FILE_STORE_PATH), { recursive: true, force: true })
// 文件写入完成,把当前文件信息存进数据库
insertFileMeg();
} catch (error) {
res.send({ code: 500, msg: error, success: false })
}
})
优化
细心的读者可能会发现,当文件超过上G时,系统计算hash
就很耗时也很吃内存并且会阻塞主线程。这种问题目前web上传大文件暂时无解,某云web端上传文件大小也有限制,超过一定文件大小必须在客户端上传。
- 此时可以参考
React
的Fiber
架构,通过requestIdleCallback
来利用浏览器的空闲时间计算,也不会卡死主线程。 - 通过# Web Workers处理。
最后
大文件切片上传到此就结束了,希望本文可以对你有一些帮助,文中如果存在缺陷或者错误的地方,欢迎指出。
源码参考:github.com/javascript-… ,阅读源码过后可快速上手大文件上传,感谢阅读!