持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情
整体思路
- 选择文件,将文件按照一定的规则进行切片并标识(这里选择md5)
- 根据文件的md5和文件名查询文件状态,如果文件已存在,给出提示,如果文件不存在,检查是否存在分片,如果存在分片,返回客户端存在的分片列表
- 客户端对比获取未上传的分片
- 上传分片(分片文件夹名为文件的md5,文件名为分片序号)
- 客户端所有分片上传完成后,通知服务端进行合并
- 服务端将分片按序号合并成文件
- 暂停上传,续传
文件切片加密
md5 是文件的唯一标识,可以利用文件的md5查询文件的上传状态。
根据文件的修改时间、文件名称、最后修改时间等信息,通过 spark-md5 生成文件的 MD5。需要注意的是,大规格文件需要分片读取文件,将读取的文件内容添加到 spark-md5 的 hash 计算中,直到文件读取完毕,最后返回最终的 hash 码到 callback 回调函数里面。这里可以根据需要添加文件读取的进度条(读取不是上传)。
实现代码:
const md5File = (file) => {
return new Promise((resolve, reject) => {
// 文件截取
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
chunkSize = file?.size / 100,
chunks = 100,
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(), // 追加数组缓冲区
fileReader = new FileReader(); // 读取文件
fileReader.onload = function (e) {
console.log('read chunk nr', currentChunk + 1, 'of', chunks)
spark.append(e.target.result);
currentChunk ++;
if (currentChunk < chunks) {
loadNext();
} else {
let result = spark.end() // 完成md5的计算,返回十六进制结果
resolve(result)
}
};
fileReader.onerror = function () {
console.warn('文件读取错误');
};
const loadNext = () => {
const start = currentChunk * chunkSize,
end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
// 文件切片
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
})
}
查询文件状态
根据上面生成的文件的md5和文件名,调接口,查询文件状态 (ps:后台服务这里使用的是node,代码放在最后)
const checkFileMD5 = (fileName, fileMd5Value) => {
let url = BaseUrl + '/myupload/check/file?fileName=' + fileName + "&fileMd5Value=" + fileMd5Value
return axios.get(url)
}
根据接口返回判断,如果文件已经存在,提示已秒传(所谓秒传,就是不用传了,我有😄,你也可以给其他提示,比如:文件已存在)
const { data } = await checkFileMD5(file.name, fileMd5Value)
if (data?.file) {
// 如果文件已存在, 就秒传
uploadPercent.value = 100
uploadStatus.value = 'success'
// ElMessage.success('文件已秒传')
console.log('文件已存在,不上传了(文件已秒传)')
return
}
-
接口返回的data,如果文件已存在
-
如果文件不存在,存在切片,但是切片不全,有丢失
检查并上传切片
如果文件不存在,那就根据服务端返回的分片列表,与本地分片列表对比,上传服务端没有的切片
const chunkSize = 5 * 1024 * 1024 // 定义好切片的大小
async function checkAndUploadChunk(file, fileMd5Value, chunkList) {
let chunks = Math.ceil(file.size / chunkSize)
const requestList = []
for (let i = 0; i < chunks; i++) {
let exit = chunkList.indexOf(i + "") > -1
// 如果不存在,则上传
if (!exit) {
requestList.push(upload({ i, file, fileMd5Value, chunks }))
}
}
// 并发上传
if (requestList?.length) {
await Promise.all(requestList)
}
}
// 上传分片
function upload({ i, file, fileMd5Value, chunks }) {
uploadCurrentChunk = 0
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)
return axios({
method: 'post',
url: BaseUrl + "/myupload/upload",
data: form
}).then((res) => {
if (res.data.stat) {
uploadCurrentChunk = uploadCurrentChunk + 1
// 下面这是我使用的进度条控制,根据情况自己修改
// uploadPercent.value = Math.ceil((uploadCurrentChunk / chunks) * 100)
// uploadStatus.value = uploadPercent.value == 100 ? 'success' : 'exception'
}
})
}
服务端合并
所有的分片上传完成,调接口通知服务端合成 服务端按切片序号排序后合并,如果正常合并成功,告诉客户端上传成功,否则上传失败
// 所有的分片上传完成,通知服务端合成
function notifyServer(file, fileMd5Value) {
let url = BaseUrl + '/myupload/merge?md5=' + fileMd5Value + "&fileName=" + file.name + "&size=" + file.size
axios.get(url).then(({ data }) => {
if (data.stat) {
ElMessage.success('上传成功')
} else {
ElMessage.error('上传失败')
}
})
}
暂停上传
暂停上传,即取消当前正在上传的upload切片 实现: 这里用aixos的CancelToken 取消上传
const CancelToken = axios.CancelToken;
let source = CancelToken.source();
在使用axios上传切片时,增加参数
cancelToken:source.token
暂停的时候,执行
source.cancel("中断上传!")
续传
续传,就是再重新上传一下,上面2~6再来一遍
服务端代码
目录结构
let express = require("express");
let app = express();
let formidable = require("formidable");
let path = require("path");
let uploadDir = "nodeServer/uploads";
let fs = require("fs-extra");
let concat = require("concat-files");
// 处理跨域
app.all("*", (req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header(
"Access-Control-Allow-Headers",
"Content-Type,Content-Length, Authorization, Accept,X-Requested-With"
);
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
res.header("X-Powered-By", " 3.2.1");
if (req.method === "OPTIONS") res.send(200); /*让options请求快速返回*/
else next();
});
// 列出文件夹下所有文件
function listDir(path) {
return new Promise((resolve, reject) => {
fs.readdir(path, (err, data) => {
if (err) {
reject(err);
return;
}
// 把mac系统下的临时文件去掉
if (data && data.length > 0 && data[0] === ".DS_Store") {
data.splice(0, 1);
}
resolve(data);
});
});
}
// 文件或文件夹是否存在
function isExist(filePath) {
return new Promise((resolve, reject) => {
fs.stat(filePath, (err, stats) => {
// 文件不存在
if (err && err.code === "ENOENT") {
resolve(false);
} else {
resolve(true);
}
});
});
}
// 获取文件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: "file is exist",
};
} else {
let isFolderExist = await isExist(folderPath);
// 如果文件夹(md5值后的文件)存在, 就获取已经上传的块
let fileList = [];
if (isFolderExist) {
fileList = await listDir(folderPath);
}
result = {
stat: 1,
chunkList: fileList,
desc: "folder list",
};
}
callback(result);
}
/**
* 检查md5
*/
app.get("/myupload/check/file", (req, resp) => {
let query = req.query;
let fileName = query.fileName;
let fileMd5Value = query.fileMd5Value;
// 获取文件Chunk列表
getChunkList(
path.join(uploadDir, fileName),
path.join(uploadDir, fileMd5Value),
(data) => {
resp.send(data);
}
);
});
app.all("/myupload/upload", (req, resp) => {
const form = new formidable.IncomingForm({
uploadDir: "nodeServer/tmp",
});
form.parse(req, function (err, fields, file) {
let index = fields.index;
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.filepath, destFile).then(
(successLog) => {
resp.send({
stat: 1,
desc: index,
});
},
(errorLog) => {
resp.send({
stat: 0,
desc: "Error",
});
}
);
});
});
// 文件夹是否存在, 不存在则创建文件
function folderIsExit(folder) {
return new Promise(async (resolve, reject) => {
await fs.ensureDirSync(path.join(folder));
resolve(true);
});
}
// 把文件从一个目录拷贝到别一个目录
function copyFile(src, dest) {
let promise = new Promise((resolve, reject) => {
fs.rename(src, dest, (err) => {
if (err) {
reject(err);
} else {
resolve("copy file:" + dest + " success!");
}
});
});
return promise;
}
});
// 合并文件
async function mergeFiles(srcDir, targetDir, newFileName) {
let fileArr = await listDir(srcDir);
fileArr.sort((x, y) => {
return x - y;
});
// 把文件名加上文件夹的前缀
for (let i = 0; i < fileArr.length; i++) {
fileArr[i] = srcDir + "/" + fileArr[i];
}
concat(fileArr, path.join(targetDir, newFileName), (err) => {
if (err) {
return false;
}
return true;
});
}
// 合成
app.all("/myupload/merge", (req, resp) => {
let query = req.query;
let md5 = query.md5;
let fileName = query.fileName;
const res = mergeFiles(path.join(uploadDir, md5), uploadDir, fileName);
resp.send({
stat: res ? 1 : 0,
});
});
app.listen(2222, () => {
console.log("文件上传服务启动,端口监听2222!");
});