按照惯例,在正式开始之前要吟唱一段前言,这也算是武林高手里开打前的起手式了。本篇文章所用代码写在两年前,不过非常可惜这篇代码在我手里到现在还没有实际的应用场景。在开始前得提前声明一下免责事项,因为是两年前写的demo,故代码的时效性不做保证,仅供各位看官参考。废话少说,锣鼓一响好戏开场。
原理
还是按照惯例(别怪我话多,平时没人跟我说话憋的,大家见谅)开始介绍一下该功能的实现原理,文件的分片上传及续传,核心就是文件的分片,而前端能做到文件的分片依靠的就是blob对象下的slice方法。当然了,前端没办法直接获取blob文件了,我们通过type=file的input拿到的是file对象。不过File是继承于Blob对象的,mdn还特地标注了一句***File 接口还继承了 Blob 接口的方法。** *所以我们可以直接拿到file对象,然后通过file对象的slice方法对文件进行切片。
那么后端呢?这里以Node.js为例哈。后端也是可以对分片的文件进行拼接的,这里卖个关子,用到的是啥API后面会介绍,不过原理都是读取文件然后按顺序粘在一起组合成一个完整的文件。
怎么样,原理是不是非常简单,是不是已经跃跃欲试,那我们就正式开始吧。
前端部分
本来是打算做线性叙事的,对部分需要注意的点可能更好理解,但是这样步骤标题可能让人有点晕头转向,所以还是按前后端的部分划分。
step1:选择文件
既然要做文件的分片上传,第一步当然是获取文件了
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
.progress {
width: 300px;
height: 10px;
background-image: linear-gradient(90deg, pink, skyblue);
background-size: 0%;
background-repeat: no-repeat;
border: 1px solid red;
}
</style>
</head>
<body>
<input type="file" id="file" />
<div class="progress"></div>
<span class="progress-num">0</span>
<button id="btn">上传按钮</button>
</body>
通过文件输入框去选择文件,progress用于显示上传的进度,progress-num显示上传的进度数字,按钮用于触发上传。页面搭建完毕接下来就是逻辑了。接下来我们对文件进行处理。
step2: 获取文件
本来这快觉得没必要写的,想了想既然是喂给宝宝的,还是加上吧
const fileDom = document.querySelector("#file");
const btn = document.querySelector("#btn");
const progress = document.querySelector(".progress");
const progressNum = document.querySelector(".progress-num");
let formData = new FormData();
let files = null;
let percent = 0;
fileDom.addEventListener("change", (e) => {
files = e.target.files;
});
btn.addEventListener("click", function (e) {
if (files.length === 1) {
sliceUpload(files[0], 10 * 1024 * 1024);
}
});
没错,本次示例只做单文件的分片上传,至于多文件就需要大家自己开动脑筋补上了。sliceUpload方法是后面我们的重点,对文件分片及上传。
step3:文件分片及上传
文件分片及上传这里放一起单纯是因为我懒,这里其实是可以分为两个部分的——分片和上传。
1.分片
首先说分片吧,还记得上面的sliceUpload方法吗,有两个参数,一个是file用于分片的文件,还有一个是分片的大小,这里我定的是10M。首先我们获取一下要分多少片
let pieces = Math.ceil(file.size / maxChunkSize);
其实文件的分片很好做的,但是告诉后端,这个分片是不是这个分片(有点哲学那味儿了)是比较麻烦的,所以我们需要给每个分片取一个名字,类似于我们的身份证ID。这里我们用到的是spark-md5库,这个库可以根据MD5算法来根据文件生成一个唯一的hash值。这里我用的是原生的HTML,所以直接下载了js文件然后引入。
const spark = new SparkMD5.ArrayBuffer();
let index = 0; // 当前操作的文件切片索引
let tempFile = file.slice(maxChunkSize * index, maxChunkSize * ++index); //当前操作的文件切片
let fileChunkList = []; // 存放切片后文件
这里用的是SparkMD5的ArrayBuffer方法,所以我们需要使用FileReader将切片后的临时文件读取为ArrayBuffer,然后生成hash。因为这里的切片文件一定是多个,所以这里我封装了一个递归方法用于递归生成。
const loadNext = () => {
let tempFile = file.slice(maxChunkSize * index, maxChunkSize * ++index);
const reader = new FileReader();
reader.readAsArrayBuffer(tempFile);
reader.onload = (e) => {
spark.append(e.target.result);
fileChunkList.push({
file: tempFile,
hash: SparkMD5.ArrayBuffer.hash(e.target.result),
index,
});
// 递归计算下一个切片
loadNext();
};
};
2.web-worker优化处理
这里生成文件hash的操作比较耗时的,如果文件大一点的情况下尤为明显,这个时候我就想到了Web Worker。我们可以将这一步操作放在webworker中进行,等到读取完毕了以后传递消息给页面然后让用户上传:
// filemd5.js
self.importScripts("../spark-md5.min.js");
self.onmessage = (e) => {
let { file, maxChunkSize } = e.data;
let pieces = Math.ceil(file.size / maxChunkSize);
let index = 0;
let fileChunkList = [];
const spark = new SparkMD5.ArrayBuffer();
const loadNext = () => {
let tempFile = file.slice(maxChunkSize * index, maxChunkSize * ++index);
const reader = new FileReader();
reader.readAsArrayBuffer(tempFile);
reader.onload = (e) => {
spark.append(e.target.result);
fileChunkList.push({
file: tempFile,
hash: SparkMD5.ArrayBuffer.hash(e.target.result),
index,
});
if (index === pieces) {
// 切片处理完毕后发送消息给页面
self.postMessage({
hash: spark.end(),
fileChunkList,
});
self.close();
}
// 递归计算下一个切片
loadNext();
};
};
loadNext();
};
然后在页面中我们定义一个方法去使用这个worker
const getFileMd5 = (maxChunkSize) => {
return new Promise((resolve, reject) => {
let worker = new Worker("./web-worker/fileMd5.js");
worker.postMessage({ file: files[0], maxChunkSize });
worker.onmessage = (e) => {
resolve(e.data);
};
worker.onerror = (err) => {
reject(err);
};
});
};
这样我们就可以得到这个文件的切片列表以及整个文件的hash。
3.上传切片
切片处理好以后我们就可以将切片文件上传了。
let successCount = 0;
let { fileChunkList, hash } = await getFileMd5(maxChunkSize);
fileChunkList.forEach((item) => {
let formData = new FormData();
formData.append("pieceHash", item.hash + "_" + item.index); // 分片的MD5 索引用于后端进行分片合成
formData.append("fileHash", hash); // 文件MD5
formData.append("file", item.file);
formData.append("fileName", file.name); // 后端合成后的文件名称
formData.append("totalCount", pieces); // 分片总数,用于后端判断是否进行合成文件分片
const controller = new AbortController();
fileChunkList.abortController = controller;
new Promise((resolve, reject) => {
// 这里换成自己的服务地址就好了
fetch("http://10.0.0.0:9527/upload/cutFile", {
method: "post",
mode: "cors",
body: formData,
signal: controller.signal,
})
.then((res) => {
res
.json()
.then((result) => {
resolve(result);
})
.catch((err) => {
resolve(err);
});
})
.catch((err) => {
reject(err);
});
}).then((res) => {
successCount += 1;
percent = (100 / pieces) * successCount;
progressNum.innerHTML = percent;
progress.style.backgroundSize = percent + "%";
});
});
这里要注意的是我这里测试用的切片数量不多,所以可以直接用循环去上传,大家在具体使用的时候要注意浏览器的最大请求并发数。这里其实可以用一个队列去做请求的控制,虽然后面我写了一个请求队列控制的方法,但是这里就暂时这样吧,正如我上面说的,我很懒。后面有机会再写一个关于请求控制的方法吧。
前端部分到这里就差不多了,是不是非常简单?接下来我们转到后端部分。
后端部分
后端我用的是express,虽然是保姆级教程,但是搭建一个基础的express服务也实在是没有必要再写了,我们直接看代码吧。
step1:接收切片文件
首先我们需要创建一个路由,然后在这里拿到前端传过来的文件切片。这里我用的是multer中间件来处理formdata的数据(也可以用别的),注意这个中间件只能处理formdata的数据。顺便说一下,用了cors中间件来设置跨域(也可以自己写)
const multer = require("multer");
const cors = require("cors");
// 跨域配置
const corsOptions = {
// origin: "http://10.0.0.0:5500/", // 指定允许的来源
};
app.use(cors(corsOptions)); //利用cors中间件设置跨域
app.use("/static", express.static("public")); //开启静态目录
app.post("/upload/cutFile", uploadPieceHandler.array("file"), async (req, res) => {
...
});
step2: formdata数据处理
上面可以看到我们在路由的第二个参数使用到了一个中间件配置,这个配置就是multer返回的一个配置,现在我们看看这个配置是如何创建的。
const uploadPieceHandler = multer({
limits: {
fileSize: 1024 * 1024 * 10.5, // 限制文件大小为10M(0.5M误差)
},
fileFilter: (req, file, cb) => {
// 切片后的文件类型
const allowedTypes = ["application/octet-stream"];
if (!allowedTypes.includes(file.mimetype)) {
cb(new Error("Invalid file type."));
return;
}
cb(null, true);
},
// 自定义文件存储设置
storage: multer.diskStorage({
destination: (req, file, cb) => {
let defaultRootDirPath = __dirname + "/uploads/"; // 分片文件存放地址
// 如果根目录没有uploads文件夹就创建
if (!fs.existsSync("uploads")) {
fs.mkdirSync("uploads");
}
let fileDirPath = defaultRootDirPath + req.body.fileHash; // 使用整体文件hash作为文件夹名
if (!fs.existsSync(fileDirPath)) {
fs.mkdirSync(fileDirPath);
}
cb(null, fileDirPath);
},
// 使用文件hash作为单个切片的文件名
filename: (req, file, cb) => {
cb(null, `${req.body.pieceHash}`);
},
}),
});
如果有需要其他配置或者有不了解的也可以看看Multer的文档。
step3:合并文件
合并文件一共有两种方法——使用Buffer和使用流,这里更推荐使用流。
1.Buffer处理
虽然Buffer的处理方式有很多问题,但是这里还是给大家介绍下吧。使用Buffer时间我们依赖的是Buffer的concat方法,这个方法处理起来比较简单,只要我们按照顺序不停的调用Buffer的concat方法将切片文件拼在一起即可。Buffer的方式较为适合文件小的场景(文件小还分片干什么。。。),因为心智负担不重。
使用buffer;(分片大小不宜超过100kb,实测超过1M时读取的数据不全,buffer长度不正确)
// 这里传入的文件名列表是已经按之前索引排序过的,这也是为什么前端传递的时候需要在hash后面加上一个索引
const pasteFileHandle = (fileNameList, hash, fileName) => {
// 根据文件hash读取指定的文件
let dirPath = __dirname + "/uploads/" + hash;
let len = 0;
let bufferList = [];
for (let i = 0; i < fileNameList.length; i++) {
const tempBuffer = fs.readFileSync(dirPath + "/" + fileNameList[i]);
console.log(i, tempBuffer.length);
len += tempBuffer.length;
bufferList.push(tempBuffer);
}
const resultBuffer = Buffer.concat(bufferList, len);
};
2.流式处理
文件除了使用Buffer的处理方式我们还可以使用流的方式处理,而我们对切片文件的处理就是使用fs下的createWriteStream方法,这个方法会创建一个可写流。接着我们使用fs.createReadStream方法来创建可读流,然后我们再通过管道pipe进行两个流管道的链接,这样文件就被合并完成了。
const pasteFileHandle = (fileNameList, hash, fileName) => {
let dirPath = __dirname + "/uploads/" + hash;
// 使用流 (实测10M分片大小无影响)
// 创建目标写入流(这里的路径就是文件路径,最后的就是保存的文件名称)
const writeStream = fs.createWriteStream(`${dirPath}/${fileName}`);
// 递归消费每个切片并创建可读流
const streamMergeRecursive = (fileNameList) => {
if (!fileNameList.length) {
// 消费完毕关闭可写流,防止内存泄漏
writeStream.end();
console.log("写入完成");
return;
}
// 创建单个分片的可读流
const pieceStream = fs.createReadStream(dirPath + "/" + fileNameList.shift());
// 将可读流连接到可写流中
pieceStream.pipe(writeStream, { end: false });
// 完毕以后开启下一次递归
pieceStream.on("end", function () {
streamMergeRecursive(fileNameList);
});
pieceStream.on("error", function (error) {
// 监听错误事件,关闭可写流,防止内存泄漏
console.error(error);
writeStream.close();
});
};
streamMergeRecursive(fileNameList);
};
step4:逻辑处理
以上三步都算是预备工作,咱们路由的回调方法内部还是空空如也,所以接下来我们就需要根据上面的方法来完成文件的合并,接下来我们就补足路由回调的内部方法逻辑。
// 拿到参数
const param = req.body;
// 随便写的,这里最好还是逻辑处理了再返回
res.send({
code: 200,
content: {},
message: "请求成功",
});
// 因为上传顺序可能错乱,所以这里根据文件夹里的文件数量来判断是否上传完毕
const checkNeedPaste = (hash, totalCount) => {
let path = __dirname + "/uploads/" + hash;
return _fs.readdir(path);
};
let fileNameList = await checkNeedPaste(param.fileHash);
if (fileNameList.length == param.totalCount) {
// 合并前先根据索引排个序
fileNameList.sort((a, b) => {
let prevNum = +a.split("_")[1];
let nextNum = +b.split("_")[1];
return prevNum - nextNum;
});
pasteFileHandle(fileNameList, param.fileHash, param.fileName);
}
验证
到这里基本就大功告成了,我们来看看效果(不会以为我还要上传一个演示视频吧?给个图片看看差不多得了)
源文件大概是4M多,我这里的切片大小是1M,可以看到文件大小数量也是能对应上的,并且合并后的音频文件也是能正常播放的(嗯,天籁~~)。
文件续传即秒传
提前说好,这俩我都没写代码(不是因为懒,主要是为了省电环保不写闲置代码),单纯就是说下思路。
1.文件续传
文件续传一般出现在这些场景下:
- 用户关闭了上传页面
- 用户网络中断
- 系统崩溃
在这些场景中,用户的某一个文件的部分分片可能上传了部分,也可能都没开始上传。如果还没开始上传那就好说了,直接按流程走就完事了。那如果是部分分片上已经上传了,用户已经上传的这部分切片就没必要发送到服务器了。所以我们可能根据文件hash来通知客户端,哪些切片是已经上传了的。
这个时候我们可以在服务端创建一个检测路由,客户端将文件hash传递过来,然后服务端检测这个文件hash是否已经创建了文件夹。如果创建了服务端就返回该文件夹下的文件名称列表,如果没有创建就返回一个空数组。
// 随便写写,不保证能跑
const _fs = require("node:fs/promises");
app.post("/checkFile", uploadHandler.single("file"), (req, res) => {
const {hash} = req.body
if (!fs.existsSync(fileDirPath)) {
res.send({
code: 200,
content: {result:[]},
message: "请求成功",
});
}else{
res.send({
code: 200,
content: {result:_fs.readdir(__dirname + "/uploads/" + hash)},
message: "请求成功",
});
}
});
至于前端就简单了,根据接口返回的数组来判断哪些接口不需要上传即可,这里就不写了。
2.秒传
说的吓人,其实就是检查一下文件是不是已经上传过了,如果上传过了服务端直接通知客户端不用上传了,就这么回事。那这里就更好写了,我们不能直接判断hash,因为只要有一个切片这个hash都是会存在的。因为这里我们没用数据库,如果有数据库保存一个状态即可,如果这个文件hash的状态是已上传过自己返回就行,但是这里我们没用数据库咋办嘞,我们可以用文件名来读取合并后的文件,如果能读取到说明文件已经合并。
当然了,文件MD5都读取了,你用切片数量来判断是否上传完毕也是可以的。
这里的判断依据都是每次的切片大小不变、文件名不变以及使用文件名作为服务器合并后的文件名
// 客户端传文件MD5和名称
app.post("/checkFile", uploadHandler.single("file"), (req, res) => {
const {hash, fileName} = req.body
if (!fs.existsSync(fileDirPath)) {
res.send({
code: 200,
content: {result:[]},
message: "请求成功",
});
}else{
try {
const data = fs.readFileSync(filePath);
res.send({
code: 200,
content: {result:'done'}, // 不用传了
message: "请求成功",
});
} catch (error) {
if (error.code === 'ENOENT') {
console.log('文件不存在');
} else {
console.error('读取错误:', error);
}
const fileNameList = _fs.readdir(__dirname + "/uploads/" + hash)
res.send({
code: 200,
content: {result:fileNameList},
message: "请求成功",
});
}
}
});
注意事项(免责声明)
以上的代码是仅做学习交流使用的demo,代码健壮性不足以上生产,还有很多地方需要注意随便列举几项:
- 文件路径不要使用字符串拼接,最好使用
path.join - 客户端的请求并发
- 递归深度限制及引用的及时释放
- 部分分片可能在上传阶段处理不当而损坏
- 对错误的捕获处理
- .....
当然了,在实际业务中的处理肯定是更麻烦的,例如用户控制上传的暂停及恢复、断网上传中断及恢复等等,这些实现就不在这里过多赘述了,有兴趣的可以自行实现或查询相关文档。