前端:Vue2 + IView2 + Spark-md5
服务端:nest.js
分片上传
核心思路:文件切片 ---> 并发上传 ---> 对切片进行排序 ---> 合并切片
前端
分片上传的核心是利用 Blob.prototype.slice方法,该方法返回原文件的某个切片,我们将文件按照指定大小切割并放到一个数组中。
File的原型是Blob,所以可以使用该方法进行文件切片。
// 文件切片
chunkFile(file) {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
fileChunkList.push({
file: file.slice(cur, cur + CHUNK_SIZE),
});
cur += CHUNK_SIZE;
}
return fileChunkList;
},
文件切片后,为每个切片添加一个hash和fileHash。
hash的主要作用是为了标识切片的顺序,这里采用文件名 + index去标识。因为切片是并发上传的,服务端收到切片的顺序可能与我们上传的顺序不一致,有了hash服务端就可以通过index对文件切片进行排序。
fileHash则是断点续传和文件秒传需要用到,它是通过spark-md5生成的单独的文件标识,只要文件内容不变,该值就不会发生变化。
setChunkListWithHash(fileChunkList, fileName) {
return fileChunkList.map(({ file }, index) => {
return {
chunk: file,
fileHash: this.fileHash,
hash: fileName + "-" + index,
};
});
},
设置完后将每个切片都包装为一个请求,然后通过Promise.allSettled上传切片,它返回一个对象数组,包含每个切片promise的结果。
// 上传切片
async uploadChunks(chunkListWithHash, fileName) {
const requestList = chunkListWithHash
.map(({ chunk, hash, fileHash }) => {
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filename", fileName);
formData.append("fileHash", fileHash);
return { formData };
})
.map(({ formData }) => {
return axios.post(UPLOAD_URL, formData);
});
return Promise.allSettled(requestList);
}
当所有的切片均上传成功后,再调用合并切片的接口,通过query参数传入文件名(fileHash+文件后缀),通知后端进行切片的合并。
// 上传切片
const res = await this.uploadChunks(chunkListWithHash, file.name);
const successList = res.filter(promise=>promise.status==='fulfilled');
// 上传成功
if (successList.length === chunkListWithHash.length) {
// 合并切片
const res = await this.mergeChunks(`${this.fileHash}.${suffix}`);
}
// 合并切片
async mergeChunks(fileName) {
const res = await axios.get(`${MERGE_URL}?name=${fileName}`);
return res;
},
服务端
服务端需要提供两个接口:
- 文件切片上传的接口
- 文件切片合并的接口
切片上传接口
我们需要将前端传来的切片保存到一个文件夹中,为了避免文件夹 or 文件名的重复,我们使用 'chunkDir' + fileHash作为文件夹的名字,为了方便切片的排序,我们使用 fileHash + hash作为切片的名字。
在将切片拷贝并放到对应的目录后,再删掉原来的切片。
@Post('upload')
@UseInterceptors(
FilesInterceptor('chunk', 20, {
dest: 'uploads',
}),
)
uploadFiles(
@UploadedFiles() files: Array<Express.Multer.File>,
@Body() body: { hash: string; fileHash: string },
) {
// 文件夹名
const chunkDir = `uploads/chunkDir_${body.fileHash}`;
// 切片名
const fileName = body.fileHash + '_' + body.hash;
if (!fs.existsSync(chunkDir)) {
fs.mkdirSync(chunkDir);
}
fs.cpSync(files[0].path, `${chunkDir}/${fileName}`);
fs.rmSync(files[0].path);
}
切片合并接口
通过前端传来query参数中的name来找到对应的切片文件夹,然后按照文件夹中切片名的index进行排序,有了正确的顺序,再依次读取文件并写入到新的文件中,所有切片均写入完成后,再删掉切片文件夹及其中的切片。
@Get('merge')
merge(@Query('name') name: string) {
const chunkDir = `uploads/chunkDir_${name.split('.')[0]}`;
console.log(chunkDir);
const files = fs.readdirSync(chunkDir);
let count = 0;
let start = 0;
files.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
files.map((file) => {
const filePath = chunkDir + '/' + file;
const stream = fs.createReadStream(filePath);
stream
.pipe(fs.createWriteStream('uploads/' + name, { start }))
.on('finish', () => {
count++;
if (count === files.length) {
stream.close();
fs.rm(
chunkDir,
{
recursive: true,
},
() => {},
);
}
});
start += fs.statSync(filePath).size;
});
}
断点续传及文件秒传
核心思路:
- 断点续传:判断切片文件夹是否存在,如果存在则返回存在切片的
index,前端根据index筛选出不需要上传的切片,只上传切片文件夹中不存在的切片 - 文件秒传:判断文件是否存在,如果存在则无需上传直接返回上传成功的结果。
生成fileHash
文件hash的生成是使用spark-md5这个库来实现的,由于大文件计算耗时过长,如果放到主线程可能会引起UI阻塞,所以使用Web Worker在 worker 线程计算 hash。
Web Worker 是在浏览器中运行的独立 JavaScript 线程。为了确保线程的隔离性,浏览器要求 Worker 的脚本必须通过独立的网络请求加载,而不能直接从模块系统导入(如使用 import 语句)。
我们使用的是vue-cli构建的Vue2工程,在pulic文件夹建立hash.js,内容如下(参考文章):
self.importScripts("./spark-md5.min.js");
self.onmessage = (e) => {
const { fileChunkList } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
let percentage = 0;
let count = 0;
const loadNext = (index) => {
const reader = new FileReader();
reader.readAsArrayBuffer(fileChunkList[index].file);
reader.onload = (e) => {
count++;
spark.append(e.target.result);
if (count === fileChunkList.length) {
self.postMessage({
percentage: 100,
hash: spark.end(),
});
self.close();
} else {
percentage += 100 / fileChunkList.length;
self.postMessage({
percentage,
});
loadNext(count);
}
};
};
loadNext(0);
};
同时需要将spark-md5.min.js也放到该目录中。
前端
调用Worker线程生成文件hash
// 生成文件hash
calculateHash(fileChunkList) {
return new Promise((resolve) => {
const startTime = Date.now();
this.hashWorker = new Worker("/utils/hash.js");
this.hashWorker.postMessage({
fileChunkList,
});
this.hashWorker.onmessage = ({ data }) => {
const { hash } = data;
if (hash) {
const endTime = Date.now();
console.log(`总共用时${(endTime - startTime) / 1000}秒`);
resolve(hash);
}
};
});
},
验证文件是否存在,用来实现断点续传和文件秒传
// 文件秒传、断点续传
async checkFileExist(fileHash, suffix) {
const res = await axios.get(
`${VERIFY_URL}?fileHash=${fileHash}&suffix=${suffix}`
);
return res;
},
完整的流程如下:
生成切片---> 生成文件hash---> 验证文件是否上传过或存在上传过的切片---> 上传切片---> 合并切片
// 文件切片
const fileChunkList = this.chunkFile(file);
// 生成文件hash
this.fileHash = await this.calculateHash(fileChunkList);
// 合并后的文件名为 fileHash + 文件后缀
const suffix = file.name.split(".").pop();
// 验证,文件秒传和断点续传的关键点
const {
data: { shouldUpload, alreadyUpload, complete },
} = await this.checkFileExist(this.fileHash, suffix);
// 已经上传过了,直接返回
if (!shouldUpload) {
alert("文件秒传-上传成功");
return;
}
// 添加hash值
let chunkListWithHash = this.setChunkListWithHash(
fileChunkList,
file.name
);
// 需要上传的切片
if (!complete) {
chunkListWithHash = chunkListWithHash.filter(
({ hash }) => !alreadyUpload.includes(hash.slice(-1))
)
}
// 上传切片
const res = await this.uploadChunks(chunkListWithHash, file.name);
const successList = res.filter(promise=>promise.status==='fulfilled');
// 上传成功
if (successList.length === chunkListWithHash.length) {
// 合并切片
const res = await this.mergeChunks(`${this.fileHash}.${suffix}`);
console.log(res);
}
服务端
服务端需要提供验证文件 or 切片文件夹是否存在的接口,根据前端传递的query参数中的文件hash和文件后缀找到指定的文件或文件夹。
如果存在文件则返回已完成,如果存在切片文件夹则返回一个数组,包含文件切片名的index
@Get('verify')
verify(@Query('fileHash') fileHash: string, @Query('suffix') suffix: string) {
// 已上传完毕,文件秒传
if (fs.existsSync(`uploads/${fileHash}.${suffix}`)) {
return {
complete: true,
alreadyUpload: 'all',
shouldUpload: false,
};
}
// 存在切片,断点续传
if (fs.existsSync(`uploads/chunkDir_${fileHash}`)) {
const filesNameList = fs.readdirSync(`uploads/chunkDir_${fileHash}`);
return {
complete: false,
alreadyUpload: filesNameList.map((item) => item.slice(-1)),
shouldUpload: true,
};
}
// 未上传过,正常执行
return {
complete: false,
alreadyUpload: [],
shouldUpload: true,
};
}
总结
-
分片上传的流程:
- 前端:利用
Blob.prototype.slice方法将文件切割为多个小片段,并为每个切片生成一个唯一的hash和fileHash,确保文件可以并发上传并按照正确顺序合并。前端通过Promise.allSettled实现并发上传,上传成功后调用后端合并接口。 - 服务端:提供切片上传和切片合并的接口。前端上传的切片会存放到一个以
fileHash命名的文件夹中,上传完成后,服务端会按照切片的顺序进行合并,并将合并后的文件存储。
- 前端:利用
-
断点续传与文件秒传:
- 前端:通过生成文件的
fileHash来判断文件或部分切片是否已经上传,避免重复上传。断点续传根据服务端返回的切片信息,只上传缺少的部分,文件秒传则无需再次上传。 - 服务端:验证文件或切片文件夹是否存在,如果存在则返回相关信息,帮助前端判断是否需要继续上传。
- 前端:通过生成文件的
-
文件 Hash 计算:
- 使用
spark-md5生成fileHash。为避免大文件导致主线程阻塞,采用 Web Worker 在后台线程执行 hash 计算。
- 使用
参考文章: