断点上传前后端的思路及功能的简单实现
前端
- 切片上传功能, 中断上传功能;
- 限制同一时间内能够同时调用的请求数量;
- 断网续传。
切片上传
- fileInput.files[0] 获取 file;
- slice 将 file 切片;
- FormData 传递数据;
- 遍历, 发送 fetch 请求。
const file = fileInput.files[0];
const url = '/upload';
const chunkSize = 1024 * 1024; // 分片大小 1MB
const totalChunks = Math.ceil(file.size/chunkSize);
abortController = new AbortController();
const signal = abortController.signal;
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, fileSize);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('index', i);
formData.append('filename', file.name);
formData.append('totalChunks', totalChunks);
formData.append('chunk', chunk);
fetch(url, {
signal,
method: 'POST',
headers: {
'Content-Range': `bytes ${start}-${end - 1}/${fileSize}`,
},
body: formData,
})
}
中断上传
- AbortController 创建 controller;
- fetch 请求时将 controller.signal 传入;
- 通过 controller.abort() 中断请求。
控制并发请求数量
- 建立 Scheduler 类;
- 通过 add 方法添加 promise 对象, 并返回 promise;
- 同一时间内只能进行 5 次请求;
- 其中一个请求完成后, 再进行下一个请求;
- 修改前面的 fetch 请求。
function Scheduler() {
this.queue = [];
this.run = [];
this.max = 5;
}
Scheduler.prototype.add = function(task) {
this.queue.push(task);
return this.scheduler();
};
Scheduler.prototype.scheduler = function() {
if (this.queue.length && this.run.length < this.max) {
const promise = this.queue.shift();
this.run.push(promise);
promise.then(() => {
this.run.splice(this.run.indexOf(promise), 1);
});
return promise;
} else {
return Promise.race(this.run)
.then(() => {
return this.scheduler();
});
}
};
schedule.add(
fetch(url, {
signal,
method: 'POST',
headers: {
'Content-Range': `bytes ${start}-${end - 1}/${fileSize}`,
},
body: formData,
})
)
.then(() => {
... do something ...
});
断网续传
- window.addEventListener('online', handler) 监听网络恢复事件;
- 前端记录已上传切片的 index;
- 网络连接后, 再次遍历, 根据记录的 index 判断是否已经上传。
后端
- /upload 所需参数:
interface Params {
filename: string;
index: string;
totalChunks: string;
chunk: Buffer;
}
- 使用 express 的 multer 中间件处理多格式的 formData;
- multer.diskStorage 完全控制保存到磁盘的切片文件名;
- 切片文件命名规则:
${filename}-${index}; - 所有切片上传完成后通过 stream 形式合并成一个大文件, 并删除切片。
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// 设置文件存储的目录
cb(null, './uploads/');
},
filename: (req, file, cb) => {
const {filename, index} = req.body;
const chunkFilePath = `${filename}-${index}`;
cb(null, chunkFilePath); // 定义文件名, 会覆盖同名文件
}
});
const upload = multer({ storage });
app.post('/upload', upload.single("chunk"), (req, res) => {
const { filename, index, totalChunks } = req.body;
if (+index === totalChunks - 1) {
mergeChunks(filename);
res.status(200).send('File upload complete');
} else {
res.status(200).send('Chunk uploaded');
}
});
async function mergeChunks(fileName) {
const chunkFiles = fs.readdirSync(UPLOAD_DIR)
.filter(file => file.startsWith(fileName + '-'))
.sort((a, b) => {
const aIndex = a.slice(fileName.length + 1);
const bIndex = b.slice(fileName.length + 1);
return aIndex - bIndex;
});
const filePath = path.join(UPLOAD_DIR, fileName);
const writeStream = fs.createWriteStream(filePath, {
flags: 'w',
encoding: 'binary',
});
for (let chunkFile of chunkFiles) {
const chunkFilePath = path.join(UPLOAD_DIR, chunkFile);
const readStream = fs.createReadStream(chunkFilePath);
await new Promise((resolve, reject) => {
readStream.pipe(writeStream, {end: false});
readStream.on('end', () => resolve());
readStream.on('error', error => reject(error));
});
try {
fs.unlinkSync(chunkFilePath); // 删除切片文件
console.info(chunkFilePath + '删除成功');
} catch (e) {
console.info(e);
}
}
writeStream.end();
console.info('File merge done');
}
总结
可以看到后端逻辑简单, 需要做的事情不多, 需要注意的就是 node V8 内存大小限制, 使用 stream 处理文件。
前端需要做的事情更多, 较为繁琐: 文件切片、并发控制、中断上传、记录已传输的 index 等。