大文件上传逻辑梳理

大文件上传逻辑梳理

背景

如果文件太大,比如一个视频几个 G,直接上传,可能出现连接超时,也存在超过服务器允许上传文件的大小限制。

为了解决这个问题,我们可以将大文件进行分片上传,每次只用上传很小的一部分,然后再有后端去组装这些分片,就可以形成一个完整的文件。

方案

  • 前端将大文件切片,拆分成一个一个的chunk
  • 将切片传递给后端,每个切片都带有唯一标识(hash)和索引值(index),以便于后端处理。
  • 后端将切片进行组合。

实现过程

文件采用 Blob 格式,它表示原始数据,也就是二进制数据,同时提供了对数据截取的方法slice,而 File 继承了Blob的功能,所以可以直接使用此方法对数据进行分段。

整体流程:

  • 将大文件进行分段,发送到服务器时携带一个标志,用于标识一个完整的文件。
  • 服务端保存各段文件。
  • 浏览器所有分片上传完成,给服务器发送一个合并文件的请求。
  • 服务器根据文件标识、类型、各分片顺序进行文件合并。
  • 删除分片文件。

前端逻辑实现

前端布局

1
2
3
<input type="file" id="input" name="file" />
<br />
<button id="upload">上传</button>

文件切片

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
let input = document.getElementById("input");
let upload = document.getElementById("upload");
let files = {}; //创建一个文件对象
let chunkList = []; //存放切片的数组
/**
* 创建切片
* @param {*} file 大文件
* @param {*} size 切片文件大小
*/
const createChunk = (file, size = 2 * 1024 * 1024) => {
const chunkList = [];
let cur = 0;

// 切出大小为size的切片
while (cur < file.size) {
chunkList.push({
file: file.slice(cur, cur + size),
});

cur += size;
}

return chunkList;
};

// 读取文件
input.addEventListener("change", (e) => {
files = e.target.files[0];
// 创建切片
chunkList = createChunk(files);
});

上传切片

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
// 数据处理,将切片数据包装成表单类型数据,才能传递给后端
const uploadFile = async (list) => {
const requestList = list
.map(({ file, fileName, chunkName, totalPart, currentPart }) => {
// 创建表单类型数据,便于传给后端
const formData = new FormData();
formData.append("fileName", fileName);
formData.append("chunkName", chunkName);
formData.append("totalPart", totalPart);
formData.append("currentPart", currentPart);
formData.append("file", file);
return formData;
})
.map((formData) => {
return axios.post("http://localhost:5000/upload", formData, {
headers: "Content-Type:application/x-www-form-urlencoded",
});
});
};

upload.addEventListener("click", () => {
// 每个切片都需要做处理,添加相关信息,这个也就是需要上传的切片
const uploadList = chunkList.map(({ file }, index) => {
return {
file, // 切片信息
size: file.size, // 切片大小
fileName: files.name, // 完整大文件的名称
chunkName: `${files.name}-${index}`, // 切片名称
totalPart: chunkList.length, // 总共有多少个chunk
currentPart: index + 1, // 当前切片索引
};
});

// 执行上传函数
uploadFile(uploadList);
});

后端逻辑实现

整体过程:

  • 创建写入流
  • 将切片转换成流
  • 将切片流追加到写入流中
  • 删除已经读取过的切片
  • 将合并完成后的写入流生成对应的文件

将前端传过来的切片保存到磁盘
这里采用了multernpm 包,更好的去处理FormData数据。

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
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// 存储到uploads目录
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// 获取文件名和切片编号
const { fileName, currentPart } = req.body;
cb(null, `${fileName}.part-${currentPart}`);
},
});

const upload = multer({ storage });

// upload.single("file") 表示存储FormData中名为"file"的文件
app.post("/upload", upload.single("file"), (req, res) => {
const { fileName, totalPart, currentPart } = req.body;

// 如果是最后一个切片,开始合并文件
if (parseInt(totalPart) === parseInt(currentPart)) {
mergeFileChunk(fileName, totalPart);
}

res.status(200).json({ message: `当前第${currentPart}切片上传完毕!!!` });
});

读取切片、转换成文件流、将切片流追加到写入流

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
async function mergeFileChunk(fileName, totalPart) {
const writeStream = fse.createWriteStream(path.join(uploadDir, fileName));
let partIndex = 1;

while (partIndex <= totalPart) {
const partFilePath = path.join(uploadDir, `${fileName}.part-${partIndex}`);
if (fse.existsSync(partFilePath)) {
const partStream = fse.createReadStream(partFilePath);
partStream.pipe(writeStream, { end: false });

// 等待当前切片完成后,再处理下一个切片
await new Promise((resolve, reject) => {
partStream.on("end", () => {
// 此切片已经读取了(或者说已经合并完成),需要删除
fse.unlinkSync(partFilePath);
resolve();
});

partStream.on("error", reject);
});

partIndex++;
} else {
// 某个切片不存在,跳出循环
break;
}
}

// 合并完成
writeStream.end(() => {
console.log("合并完成!!!");
});
}

优化方案

  • 前端切片:主线程去做切片卡顿,可以借助web-worker多线程切片,处理完成后交给主线程发送。
  • 切片完成后,发送给后端,并将blob存储到IndexedDB(为了防止切片完成,用户关闭了浏览器,导致切片丢失),用户下次进来之后,可以嗅探一下是否存在未上传的切片,如果有就继续上传。

待更新:断点续传、秒传

参考文章: