在项目上遇到需要上传大文件到服务器的需求,本着好记性不如烂笔头记录(现学现卖😂)的精神,这里就记录一下哈哈哈。

一、上传文件的前端流程

我们在上传大文件中,常用方案是将一个大文件切片成多个小文件,并行请求接口进行上传,上传完成之后,再通知服务器合并。具体来说,操作流程如下图所示。

1.1 从文件分片到md5加密

我们可以通过Md5把文件加密,给定一个唯一的标识,那么这样就可以利用这个标识来查询文件的上传状态了。在前端,有一个流传甚广的md5加密插件,spark-md5,它可以根据文件的内容生成md5值,这样有一个好处,可以通过md5值判断文件是否唯一,防止用户重复上传文件,增加服务器空间压力。

不过,在加密之前,我们首先要把文件进行切割,然后再分片读取进行加密。由文档可知,Blob 对象中的 slice 方法可以对文件进行切割。而File 对象是继承 Blob 对象的,因此 File 对象也有 slice 方法,这个我们会在后面用到的。

好了,我们可以写一个加密的方法了,这里的代码格式参考spark-md5官方文档,感兴趣的可以去阅读一下。我们在这里使用JavaScript的用原生方法,当然,使用vue的话也可以。

在vue中,甚至有一个更好的插件,叫 vue-uploader ,也支持断点续传,秒传等,感兴趣也可以去读读官方Readme.

下面是代码展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

//该函数需要返回文件的md5值
function md5File(file) {
//利用promise把返回值封装
return new Promise((resolve, reject) => {
//这里是固定写法,创建一个包含数据的blob对象
var blobSlice =
File.prototype.slice ||
File.prototype.mozSlice ||
File.prototype.webkitSlice;
//分片大小,file是文件的属性,里面包含size
var chunkSize = file.size / 100, //注意,这里是逗号
//分片数量,切成100块是为了方便进度条,当然这里没有添加进度条
chunks = 100,
//当前的分块
currentChunk = 0,
//固定写法
spark = new SparkMD5.ArrayBuffer(),
//浏览器提供的fileReader接口,读取文件file的属性
fileReader = new FileReader();
/*
*这个是fr的读取事件,onload表示成功时触发该函数
*想要更多了解,大家可以看这篇官方文档
*https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader
*/
fileReader.onload = function (e) {
//onload后会有一个result,现在把result进行md5加密
spark.append(e.target.result);
//当前的分块加一,处理下一个分块
currentChunk++;
if (currentChunk < chunks) {
//继续往下读取,直到完成
loadNext();
} else {
//完成全部的文件分块校验读取,把加密后的值返回出去
console.log("finished loading");
let result = spark.end();
resolve(result);
}
};
//当然,也存在文件读取失败的情况,我们返回出去即可
fileReader.onerror = function (e) {
console.warn("oops, something went wrong.");
reject(e.target.result);
};

function loadNext() {
//加密文件分块
var start = currentChunk * chunkSize,
end = start + chunkSize >= file.size ? file.size : start + chunkSize;
/*
*注意,这里blobSlice是Blob对象的方法,我们前面有定义。具体可以参考
*https://blog.csdn.net/qq_33718648/article/details/105846562
*这里是将文件进行分割读取
*/
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
//继续往下读取,这是第一次读取
loadNext();
});

1.2 与服务器文件进行比对

我们在本地对文件都做了md5加密处理,文件都有了唯一的标识,那么接下来,就对服务器上的文件进行比对,看看是否都有,有的话就不需要上传了,直接给用户一个秒传,当然,缺少的,就需要上传了。

前端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function checkFileMD5(fileName, fileMd5Value) {
//函数传入两个值,分别为文件名字和它的md5值
return new Promise((resolve, reject) => {
//这个接口是和后端约定好的,当然,你可以换成你喜欢的
let url =
baseUrl +
"/check/file?fileName=" +
fileName +
"&fileMd5Value=" +
fileMd5Value;
//这里用了jQuery的Ajax请求,你也可以用axios封装,对返回值进行处理
$.getJSON(url, function (data) {
resolve(data);
});
});
}//如果有返回值,则说明该文件已经存在,我们只需要前端做一下判断即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
*比如这里,是对后端请求后返回来的data,这里表示该文件已经存在,我们拿到isExist判断即可
*{
* "stat": 1,
* "file": {
* "isExist": true,
* "name": "nodeServer\\uploads\\《起风了》- 买辣椒也用券(原版立体声)高音质! - 1.合成 *1(Av797827895,P1).mp3"
* },
* "desc": "file is exist"
*}

*这里是表示文件不存在,后端返回过来的data
*{
* "stat": 1,
* "chunkList": [], // 分块列表为空,表示该文件没有上传到一半就中断
* "desc": "folder list"
*}
*/
1
2
3
4
5
6
let result = await checkFileMD5(file.name, fileMd5Value);
// 如果文件已存在, 就秒传
if (result.file) {
alert("文件已秒传");
return;
}

1.3 上传分片至服务器

当完成第一步把文件加密,第二步文件进行比对之后,没有出现特别的情况,我们就可以把文件上传至服务器了,在上传之前,我们检查一下返回过来的chunklist,从而判断是否需要断点续传。如果不需要,我们进行下一步。

这里就要用到前面我们提到的Blob 对象中silce方法对文件进行切割。然后分片上传至服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*这一步检查服务器的文件是否有缺失,有的话就上传缺失的*/
async function checkAndUploadChunk(fileMd5Value, chunkList) {
//最后一块的大小一般小于或等于正常块,利用math.ceil保证能够成块 如下图所示
var chunks = Math.ceil(fileSize / chunkSize);
//hasUploaded已经上传了的,通过后端返回过来的数组判断 如下返回的代码所示
var hasUploaded = chunkList.length;
//请求uplaod函数,开始循环上传数据块
for (let i = 0; i < chunks; i++) {
let exit = chunkList.indexOf(i + "") > -1;
// 如果已经存在, 则不用再上传当前块
if (!exit) {
let index = await upload(i, fileMd5Value, chunks);
hasUploaded++;
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//后端返回的文件信息
{
"stat": 1,
"chunkList": [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9"
], //表示文件里已经存在了10块,这前面10块可以不用传
"desc": "folder list"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//上传分片,上一个函数对其调用
function upload(i, fileMd5Value, chunks) {
return new Promise((resolve, reject) => {
//构造一个表单,这里是基于FormData上传的,我看网上还有一种是居于base64格式上传的
let end =(i + 1) * chunkSize >= file.size ? file.size : (i + 1) * chunkSize;
let form = new FormData();
form.append("data", file.slice(i * chunkSize, end)); //file对象的slice方法用于切出文件的一部分
form.append("total", chunks); //总片数
form.append("index", i); //当前是第几片
form.append("fileMd5Value", fileMd5Value);
//发送请求,这里用了jQuery的请求,你可以换成aixos
$.ajax({
url: baseUrl + "/upload",
type: "POST",
data: form, //刚刚构建的form数据对象
async: true, //异步提高效率
processData: false, //很重要,告诉jquery不要对form进行处理
contentType: false, //很重要,指定为false才能形成正确的Content-Type
success: function (data) {
resolve(data.desc); //这里会接到服务器返回的参数,desc是描述,这个和后端约定好的
},
});
});
}

1.5 给一个上传进度

对前端来说,有必要把视觉效果搞得好好的,把无交互搞得少少的。我们可监听hasUploadedchunks的值,从而制作上传进度的数值显示条。

1
2
3
4
	//radio是上传的百分比	
//hasUpload是已经上传了的块,
let radio = Math.ceil((hasUploaded / chunks) * 100);

1. 6 通知服务器进行文件合并

当上传完毕之后,给服务器发送一个合并请求。同时,服务器也会返回来的合并成功通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
function notifyServer(fileMd5Value) {
let url =
baseUrl +
"/merge?md5=" +
fileMd5Value +
"&fileName=" +
file.name +
"&size=" +
file.size;
$.getJSON(url, function (data) {
alert("上传成功");
});
}

二、上传文件的后端流程

后端需要对接收到的数据进行处理,例如当时空数据的时候,需要返回相对应的值,从而让前端方便处理。

这里通过node.js进行演示,大部分是伪码

首先,我们要处理前端上传的发送的请求,分别是:

  • 检查服务端是否有该文件

  • 接收发送的文件

  • 合并发送的文件

那么,作为服务器,我们需要按照各个接口的要求,对数据进行处理

  • 检查需要上传文件的MD5,看看是否在服务器中存在
  • 检查分块,是不是都齐全
  • 新上传的文件用新路径存储,并创建文件夹
  • 设立缓存目录,将新上传的文件暂存于此
  • 排序,准备合并新上传的文件
  • 合并文件
  • 将合并好的新上传的文件移动到固定的文件路径,删除缓存,告知浏览器上传成功

这里附上部分代码:

响应函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 检查文件的MD5
app.get("/check/file", (req, resp) => {
let query = req.query; //query是前端上传过来时候,带的参数。
let fileName = query.fileName;
let fileMd5Value = query.fileMd5Value;
// 获取文件Chunk列表函数,通过比对服务器本地存储的块,从而知道是否需要再传
getChunkList(
path.join(uploadDir, fileName),
path.join(uploadDir, fileMd5Value),
(data) => {
resp.send(data);
}
);
});
1
2
3
4
5
6
7
8
9
10
11
//响应合并文件接口
app.all("/merge", (req, resp) => {
let query = req.query;
let md5 = query.md5;
let size = query.size;
let fileName = query.fileName;
mergeFiles(path.join(uploadDir, md5), uploadDir, fileName, size);
resp.send({
stat: 1,//表示合并成功,返回给浏览器
});
});
1
2
3
4
5
//浏览器请求时返回
app.get("/", function (req, resp) {
let query = req.query;
resp.send("success!!");
});
1
2
// 处理静态资源
app.use(express.static(path.join(__dirname)));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 检查分块的MD5
app.get("/check/chunk", (req, resp) => {
let query = req.query;
let chunkIndex = query.index;
let md5 = query.md5;

fs.stat(path.join(uploadDir, md5, chunkIndex), (err, stats) => {
if (stats) {
resp.send({
stat: 1,
exit: true,
desc: "Exit 1",
});
} else {
resp.send({
stat: 1,
exit: false,
desc: "Exit 0",
});
}
});
});

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//上传
app.all("/upload", (req, resp) => {
var form = new formidable.IncomingForm({
//文件temp位置
uploadDir: "nodeServer/tmp",
});

form.parse(req, function (err, fields, file) {
let index = fields.index;
let total = fields.total;
let fileMd5Value = fields.fileMd5Value;
let folder = path.resolve(__dirname, "nodeServer/uploads", fileMd5Value);
folderIsExit(folder).then((val) => {
let destFile = path.resolve(folder, fields.index);
copyFile(file.data.path, destFile).then(
(successLog) => {
resp.send({
stat: 1,
desc: index,
});
},
(errorLog) => {
resp.send({
stat: 0,
desc: "Error",
});
}
);
});
});
});

处理函数

1
2
3
4
5
6
7
8
9
10
11
12
13
// 文件或文件夹是否存在
function isExist(filePath) {
return new Promise((resolve, reject) => {
fs.stat(filePath, (err, stats) => {
// 文件不存在,ENOENT
if (err && err.code === "ENOENT") {
resolve(false);
} else {
resolve(true);
}
});
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 获取文件Chunk列表
async function getChunkList(filePath, folderPath, callback) {
let isFileExit = await isExist(filePath);
let result = {};
// 如果文件已在存在, 不用再继续上传, 真接秒传
if (isFileExit) {
result = {
stat: 1,
file: {
isExist: true,
name: filePath,
},
desc: "文件已存在",
};
} else {
let isFolderExist = await isExist(folderPath);
console.log(folderPath);
// 如果文件夹(md5值后的文件)存在, 就获取已经上传的块
let fileList = [];
if (isFolderExist) {
//这里遍历本地存在的文件块
fileList = await listDir(folderPath);
}
result = {
stat: 1,
chunkList: fileList,
desc: "文件块已存在",
};
}
callback(result);
}
1
2
3
4
5
6
7
8
9
10
11
12
// 列出文件夹下所有文件
function listDir(path) {
return new Promise((resolve, reject) => {
fs.readdir(path, (err, data) => {
if (err) {
reject(err);
return;
}
resolve(data);
});
});
}
1
2
3
4
5
6
7
8
9
10
11
// 文件夹是否存在, 不存在则创建文件
function folderIsExit(folder) {
console.log("folderIsExit", folder);
return new Promise(async (resolve, reject) => {
//这里是fs-extra插件,创建一个新的文件夹,感兴趣可以去看看API
//https://www.jianshu.com/p/d6990a03d610
let result = await fs.ensureDirSync(path.join(folder));
console.log("result----", result);
resolve(true);
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 把文件从一个目录拷贝到别一个目录
function copyFile(src, dest) {
let promise = new Promise((resolve, reject) => {
//fs API
fs.rename(src, dest, (err) => {
if (err) {
reject(err);
} else {
resolve(`复制文件${dest} 成功!`);
}
});
});
return promise;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//合并文件 
async function mergeFiles(srcDir, targetDir, newFileName, size) {
console.log(...arguments);
let targetStream = fs.createWriteStream(path.join(targetDir,newFileName));
let fileArr = await listDir(srcDir);
//文件从小到大排序,方便合并
fileArr.sort((x, y) => {
return x - y;
});
// 把文件名加上文件夹的前缀,这里是hash值
for (let i = 0; i < fileArr.length; i++) {
fileArr[i] = srcDir + "/" + fileArr[i];
}
//concat() 方法用于连接两个或多个数组
concat(fileArr, path.join(targetDir, newFileName), () => {
console.log("合并成功。");
});
}


总结

  • Blob.slice 将文件切片,并发上传多个切片,所有切片上传后告知服务器合并,实现大文件分片上传;
  • 通过spark-md5 根据文件内容算出文件 MD5,得到文件唯一标识,与文件上传状态绑定;
  • 分片上传前通过文件 MD5 查询已上传切片列表,上传时只上传未上传过的切片,实现断点续传。

最后,附上前后端代码地址

https://github.com/Chuyuxuan0v0/bigFileUpload

需要的小伙伴可自行研究,代码稀烂,大佬们轻点喷