大文件上传
我们在开发过程中遇到上传文件的时候,如果文件比较大,就发现上传很慢,还经常上传失败,失败的原因是超时。因为我们的请求有设置超时时长,超过这个时间如果接口还没有返回结果,这次请求就会因超时失败。
解决思路
将文件切片,分割成多个小文件上传,前端都上传完后通知服务端,在服务端再把所有切片合并成一个文件。
服务端接收到的切片文件那么多,要怎么区分并把同一个文件的所有切片合并起来呢?
通过文件命名,上传的文件取个特殊独立的文件名,切片序号跟在文件名后,就可以区分这一组文件切片,把这一组切片文件进行合并。
那么多上传的文件如何保证不重名?
读取文件以文件的MD5为文件名。
这个思路可以,但是读取文件计算MD5是需要时间空间的,小文件计算还行能接受,但我们这里讲的是大文件的上传,大文件要计算MD5就很耗电脑资源了,浏览器估计都得卡死退出,甚至死机。
那在前端上传做文件切片的时候,计算每个切片的MD5,并把这一组切片的MD5文件名存起来,等切片上传完,发送合并请求的时候,把这一组MD5文件名传给后端,后端根据这组MD5文件名去查找文件并执行合并操作。
也可以先切片然后依次计算每个切片得到一个MD5。用这个MD5作为文件名传给后端。spark-md5
就支持分片计算文件MD5, 把分片的文件逐个传入spark.appendBinary()
方法,最后通过spark.end()
输出MD5。
实践示例
这里实践分为三步走,第一步先实现文件上传,第二步,文件切片,第三步,切片文件合并。
文件上传
文件上传不单单是前端的数据提交,还需要服务端的接收。作为前端开发者,我们用node先搭建一个服务来接收上传的文件。
前端页面:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文件上传</title>
<style>
img{
width: 100px;
height: 100px;
display: block;
}
</style>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<input type="file" name="file" enctype="multipart/form-data">
<img src="" style="display: none">
<script>
let $file = document.querySelector('input');
let $preview = document.querySelector('img');
$file.addEventListener('change', function(e){
let file = e.target.files[0];
let formData = new FormData();
formData.append('file',file);
axios.post('/api/upload', formData)
.then(function (res) {
$preview.src = res.data.url;
$preview.style.display = 'block';
})
.catch(function (error) {
console.log(error);
});
});
</script>
</body>
</html>
服务端代码:
const express = require('express');
const path = require("path");
const fs = require("fs");
const multer = require("multer");
const app = express();
const http = require('http').Server(app);
//实例化multer,传递的参数对象,dest表示上传文件的存储路径
let objMulter = multer({ dest: "./public/upload" });
//any表示任意类型的文件
app.use(objMulter.any())
app.use('/public', express.static('public'));
app.get('/index', function(req, res){
res.sendFile(__dirname+'/views/index.html');
});
app.post("/api/upload", (req, res) => {
let oldName = req.files[0].path;
let newName = req.files[0].path + path.parse(req.files[0].originalname).ext;
fs.renameSync(oldName, newName);
res.send({
err: 0,
url: "http://localhost/public/upload/" + req.files[0].filename + path.parse(req.files[0].originalname).ext
});
});
http.listen(80, function(){
console.log('listening on *:80');
});
Multer 是一个 node.js 中间件,用于处理 multipart/form-data 类型的表单数据,它主要用于上传文件。Multer 不会处理任何非 multipart/form-data 类型的表单数据。
Multer 接受一个 options 对象,其中最基本的是 dest 属性,这将告诉 Multer 将上传文件保存在哪。如果你省略 options 对象,这些文件将保存在内存中,永远不会写入磁盘。
文件切片
文件可以通过File.slice来进行分割切片,File 接口没有定义任何方法,但是它从 Blob 接口继承了以下方法:Blob.slice([start[, end[, contentType]]])
,返回一个新的 Blob 对象,它包含有源 Blob 对象中指定范围内的数据。
接上篇文章node文件上传提到的解决大文件上传思路,将文件切片,分割成多个小文件上传,前端都上传完后通知服务端,在服务端再把所有切片合并成一个文件。
具体分析,我们要先思考一些问题。
服务端接收到的切片文件那么多,要怎么区分并把同一个文件的所有切片合并起来呢?
通过文件命名,上传的文件取个特殊独立的文件名,切片序号跟在文件名后,就可以区分这一组文件切片,把这一组切片文件进行合并。
那么多上传的文件如何保证不重名?
读取文件以文件的MD5为文件名。
这个思路可以,但是读取文件计算MD5是需要时间空间的,小文件计算还行能接受,但我们这里讲的是大文件的上传,大文件要计算MD5就很耗电脑资源了,浏览器估计都得卡死退出,甚至死机。
那在前端上传做文件切片的时候,计算每个切片的MD5,并把这一组切片的MD5文件名存起来,等切片上传完,发送合并请求的时候,把这一组MD5文件名传给后端,后端根据这组MD5文件名去查找文件并执行合并操作。
也可以先切片然后依次计算每个切片得到一个MD5。用这个MD5作为文件名传给后端。spark-md5
就支持分片计算文件MD5, 把分片的文件逐个传入spark.appendBinary()
方法,最后通过spark.end()
输出MD5。
实践操作
<script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
class BigFileUpload{
constructor(selector, chunkSize = 1, uploadSuccess=()=>{}, uploadFail=()=>{}){
this.chunkSize = chunkSize * 1024;
this.chunksNum = 0;
this.currentChunk = 0;
this.fileMd5 = '';
this.chunkList = [];
this.fileName = '';
this.fileExt = '';
this.uploadedCount = 0;
this.uploadSuccess = uploadSuccess;
this.uploadFail = uploadFail;
this.init(selector);
}
init(selector){
this.$file = document.querySelector(selector);
this.$file.addEventListener('change', (e)=>{
this.currentChunk = 0;
this.uploadedCount = 0;
let file = e.target.files[0];
this.fileName = file.name;
this.fileExt = file.name.substr(file.name.lastIndexOf('.'));
this.createFilesChunk(file);
});
}
// 切片,并计算出文件的hash
createFilesChunk(file){
let _this = this;
_this.chunksNum = Math.ceil(file.size / _this.chunkSize);
let spark = new SparkMD5();
let fileReader = new FileReader();
fileReader.onload = (e) => {
let _chunk = e.target.result;
spark.appendBinary(_chunk); // Append a binary string
_this.currentChunk++;
if (_this.currentChunk < _this.chunksNum) {
loadNext();
} else {
_this.fileMd5 = spark.end();
_this.chunkList.forEach(v=>{
_this.uploadFile(v);
});
}
};
fileReader.onerror = function () {
console.warn('oops, something went wrong.');
};
function loadNext() {
let start = _this.currentChunk * _this.chunkSize,
end = ((start + _this.chunkSize) >= file.size) ? file.size : start + _this.chunkSize;
let chunk = file.slice(start, end);
_this.chunkList.push({
file: chunk,
index: _this.currentChunk
});
fileReader.readAsBinaryString(chunk);
}
loadNext();
}
// 上传切片
uploadFile(chunk){
let _this = this;
let formData = new FormData();
formData.append('file', chunk.file);
formData.append('hash', this.fileMd5);
formData.append('fileExt', this.fileExt);
formData.append('index', chunk.index);
axios.post('/api/upload', formData)
.then(function (res) {
if(res.data.err == 0){
_this.uploadedCount += 1;
if(_this.uploadedCount == _this.chunksNum){
_this.fetchMergeFile();
}
}
})
.catch(function (error) {
console.log(error);
});
}
// 切片上传完,发送合并切片请求
fetchMergeFile(){
let _this = this;
let params = {
hash: this.fileMd5,
fileExt: this.fileExt,
count: this.uploadedCount
};
axios.post('/api/mergeFile', params)
.then(res => {
if(res.data.err == 0){
_this.uploadSuccess(res.data);
}
})
.catch(error => {
console.log(error);
_this.uploadFail(error);
});
}
}
let upload = new BigFileUpload('input', 50, (res)=>{
let $preview = document.querySelector('img');
$preview.src = res.url;
$preview.style.display = 'block';
}, err=>{
});
</script>
大文件切片上传,通过切片,分片上传后,最后需要服务端对所有的文件切片进行合并,生成一个文件。
前端上传方法示例:
uploadFile(chunk){
let _this = this;
let formData = new FormData();
formData.append('file', chunk.file);
formData.append('hash', this.fileMd5);
formData.append('fileExt', this.fileExt);
formData.append('index', chunk.index);
axios.post('/api/upload', formData)
.then(function (res) {
if(res.data.err == 0){
_this.uploadedCount += 1;
if(_this.uploadedCount == _this.chunksNum){
_this.fetchMergeFile();
}
}
})
.catch(function (error) {
console.log(error);
});
}
服务端上传文件接口示例:
app.post('/api/upload', upload.single('file'), (req, res) => {
const hash = req.body.hash;
const index = req.body.index;
const fileExt = req.body.fileExt;
const folderName = `${hash}`;
const fileName = `${hash}_${index}${fileExt}`;
const folderPath = path.join(__dirname, 'public', 'upload', folderName);
if (!fs.existsSync(folderPath)) {
fs.mkdirSync(folderPath, { recursive: true });
}
const filePath = path.join(folderPath, fileName);
fs.renameSync(req.file.path, filePath);
res.json({
err: 0,
message: 'ok'
});
});
从请求体中获取 hash 值、文件索引和文件扩展名,并将它们拼接在一起形成文件夹名字和文件名字。
判断是否需要创建新的目录,如果目录不存在,则使用 fs.mkdirSync() 方法创建目录。
将上传的文件重命名为 hash + '_' + index + fileExt 的格式,并将其移动到目标文件夹中。
返回一个 JSON 响应,表示文件上传成功。
注意:代码中的目标目录为 'upload/',因此需要确保当前工作目录下存在一个名为 'upload' 的文件夹。同时也需要注意在请求体中传递的文件索引应该为字符串类型,例如 '0'、'1'、'2'。
当所有切片文件都上传完后,前端需要再发起一个请求,告诉服务端合并切片文件。
fetchMergeFile(){
let _this = this;
let params = {
hash: this.fileMd5,
fileExt: this.fileExt,
count: this.uploadedCount
};
axios.post('/api/mergeFile', params)
.then(res => {
if(res.data.err == 0){
_this.uploadSuccess(res.data);
}
})
.catch(error => {
console.log(error);
_this.uploadFail(error);
});
}
在服务端定义目标文件的写入流,用于将合并后的数据写入目标文件中。
读取目标文件夹下的所有文件,并根据文件名中的索引值对它们进行排序。
遍历排序后的文件列表,依次将每个文件的数据读取到目标文件中。
在所有数据都写入目标文件后,删除源文件夹,并返回一个 JSON 响应,表示文件合并成功,并携带合并后文件的地址。
定义一个函数 deleteFolderRecursive,用于递归删除一个文件夹及其目录下的所有文件。
function deleteFolderRecursive(folderPath) {
if (fs.existsSync(folderPath)) {
fs.readdirSync(folderPath).forEach((file, index) => {
const curPath = path.join(folderPath, file);
if (fs.lstatSync(curPath).isDirectory()) {
deleteFolderRecursive(curPath);
} else {
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(folderPath);
}
}
app.post("/api/mergeFile", (req, res) => {
const hash = req.body.hash;
const fileExt = req.body.fileExt;
const count = parseInt(req.body.count);
const folderName = `${hash}`;
const fileName = `${hash}${fileExt}`;
const folderPath = path.join(__dirname, 'public', 'upload', folderName);
const filePath = path.join(__dirname, 'public', 'upload', fileName);
const writeStream = fs.createWriteStream(filePath);
const files = fs.readdirSync(folderPath);
const sortedFiles = files
.filter(file => file.endsWith(fileExt))
.sort((a, b) => {
const indexA = parseInt(a.split('_')[1]);
const indexB = parseInt(b.split('_')[1]);
return indexA - indexB;
});
sortedFiles.forEach(file => {
const readStream = fs.createReadStream(path.join(folderPath, file));
readStream.pipe(writeStream);
});
writeStream.on('finish', () => {
deleteFolderRecursive(folderPath);
res.json({
err: 0,
url: "http://localhost/public/upload/" + fileName
});
});
});
注意:在以上代码中,合并后的文件被保存到了目录 public/upload 下,并返回了 /upload/${fileName} 的地址。在实际项目中,可以根据需求自行修改目标目录和返回的文件地址。