背景介绍
在我们平常的业务中,任务模块及订单模块,会上传一些现场的短的视频,还有一些售前方案管理模块的方案里会涉及到结构、电器略图的上传,或者一些方案ppt的上传,这些文件大部分是大于200、300M的。
所以时常有售前工程师与维修技术人员反馈文件上传功能不好用甚至系统无法上传大文件的情况频频出现,为此我根据反馈归纳了一下,主要问题集中在以下方面:
- 文件上传着上传着就没了,经过我排查,是网络不稳定断网了,文件消失情况会复现。
- 文件在上传的途中,突然之间就是没了,经复现发现,是网络波动,因我们设备是在全国工业园区等地均有,这种情况在新疆等海拔高的地区尤为突出。
- 关机之后,重新开机的时候,没有完成上传的又得重现上传,这个很麻烦,功能需要改进。
对此,经过思考查资料等,发现问题基本就是两个专业术语描述的问题,分别是大文件断开重连网络重传、大文件断点续传。
具体思路
首先,要完成大文件上传。
因接口也有限制上传文件的内容大小,这个我们便使用切分的思想,将文件切分成若干切片,再发请求将切片给服务端并请求合并,再由服务端进行文件拼接、校验等,这样就完成一个大文件切片上传。
基本思路如下:
- 将当前文件使用
FileReader
将上传的File
对象转换成二进制的流,即以ArrayBuffer
形式表示,代码如下,当前我只做了浏览器环境下的,所以就写浏览器环境下的:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File to Binary Stream</title>
</head>
<body>
<input type="file" id="fileInput">
<script>
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', function () {
const file = this.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function (e) {
const arrayBuffer = e.target.result;
console.log('Binary stream (ArrayBuffer):', arrayBuffer);
// 这里可以进一步处理 arrayBuffer
};
reader.onerror = function () {
console.error('Error reading file');
};
// 以二进制形式读取文件
reader.readAsArrayBuffer(file);
}
});
</script>
</body>
</html>
执行结果如下:
-
基于二进制流的特性,其具有可切分性,使用
Blob.prototype.slice
将大文件进行等大小切分。至于每个分片的大小是按照业务来定义比较稳妥。 (当前假设定义为1MB) -
切片动作完成后,便可以向后端发送请求,因为http1.1和http2均有并发的特性,但是有数量限制,
http1.1
的是6个http
请求为上限,http2
是两个链接为上限,每个链接有100个http
请求。考虑并发请求完全可行。
在并发的技术方案选择上,可选择的有很多,比如使用 Promise.all
或 Promise.allSettled
、自
定义并发控制函数等等都是可以的。
我们最终的选择是使用现成的第三方库 async-pool
, 其使用方式详见:[ ]github.com/rxaviers/as…
npm install tiny-async-pool
需要注意到的是,发送请求时候需要携带字段的数据结构需要考虑清楚,以便后端可以进行文件切片的合并,所以考虑到顺序不能乱,内容要一致,且没必要重复存储同一份内容,后端进行拼接的时候并不知道文件顺序是什么样的,所以记得要将index
作为参数传递过去。故设计数据结构如下:
json
格式
{
"fileName": "example.txt",
"chunkIndex": 0,
"totalChunks": 100,
"fileIdentifier": "123456789abcdef123456789abcdef12",
"fileData": "base64 encoded chunk data"
}
- 在请求的过程中,上传切片时会出现一些卡顿,分析原因是文件确实是比较大,切片切分起来是比较耗时,阻塞了主线程,这时候使用
Web Worker
脚本专门为切片计算以及hash
生成开辟一个新的线程,最终使用postMessage
将切好的切片和计算好hash
一起返回到主线程。可行。 注意:hash
的生成选用spark-md5
插件来完成,使用SparkMD5.hash()
方法为每个切片生成唯一的哈希值作为切片的 ID。
代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Upload with async-pool and Web Worker</title>
</head>
<body>
<input type="file" id="fileInput" />
<script>
const asyncPool = require('tiny-async-pool');
const SparkMD5 = require('spark-md5');
// 检查浏览器是否支持 Web Worker
if (typeof Worker === 'undefined') {
console.error('Web Worker is not supported in this browser.');
return;
}
// 创建 Web Worker 脚本
const workerScript = `
self.onmessage = function (e) {
const file = e.data.file;
const chunkSize = e.data.chunkSize;
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const hash = SparkMD5.hash(chunk);
chunks.push({ id: hash, chunk });
start = end;
}
self.postMessage(chunks);
};
`;
const blob = new Blob([workerScript], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', async function (e) {
const file = e.target.files[0];
if (!file) return;
// 启动 Web Worker 进行切片
worker.postMessage({ file, chunkSize: 1024 * 1024 }); // 每个切片 1MB
// 等待 Web Worker 完成切片
const chunks = await new Promise((resolve) => {
worker.onmessage = function (e) {
resolve(e.data);
};
});
// 并发上传切片
const concurrency = 5; // 并发上传数量
const uploadChunk = async (chunk) => {
// 模拟上传,实际中这里应该是调用上传 API
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000));
console.log(`Chunk ${chunk.id} uploaded successfully`);
return chunk.id;
};
const uploadedChunks = [];
for await (const chunkId of asyncPool(concurrency, chunks, async (chunk) => {
return uploadChunk(chunk);
})) {
uploadedChunks.push(chunkId);
}
// 所有切片上传完毕,发送合并请求
console.log('All chunks uploaded. Sending merge request...');
// 模拟合并请求,实际中这里应该是调用合并 API
// 实际上我们使用的是 async-pool
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Merge request sent.');
});
</script>
</body>
</html>
- 这时候,还有一种情况,当切片完成之后,给后端发的时候,这时用户把浏览器关了怎么办,断开了、关机了之后想接着传,做不到。
这个问题的解决方案方案是使用
本地缓存
使用浏览器的本地数据库indexDB
。当用户每次进来的时候,去indexDB
里面巡查一下是否存在该文件的切片,如果存在,只需上传缺失的文件切片,切片上传成功后需要更新IndexDB
。代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Upload with async-pool and Web Worker</title>
</head>
<body>
<input type="file" id="fileInput" />
<script>
const asyncPool = require('tiny-async-pool');
const SparkMD5 = require('spark-md5');
// 检查浏览器是否支持 Web Worker
if (typeof Worker === 'undefined') {
console.error('Web Worker is not supported in this browser.');
return;
}
// 检查浏览器是否支持 IndexedDB
if (!window.indexedDB) {
console.error('IndexedDB is not supported in this browser.');
return;
}
// 创建 Web Worker 脚本
const workerScript = `
self.onmessage = function (e) {
const file = e.data.file;
const chunkSize = e.data.chunkSize;
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const hash = SparkMD5.hash(chunk);
chunks.push({ id: hash, chunk });
start = end;
}
self.postMessage(chunks);
};
`;
const blob = new Blob([workerScript], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', async function (e) {
const file = e.target.files[0];
if (!file) return;
const dbName = 'fileChunksDB';
const storeName = 'chunksStore';
const dbVersion = 1;
// 打开 IndexedDB 数据库
const openRequest = indexedDB.open(dbName, dbVersion);
openRequest.onupgradeneeded = function (event) {
const db = event.target.result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, { keyPath: 'id' });
}
};
const db = await new Promise((resolve, reject) => {
openRequest.onsuccess = function (event) {
resolve(event.target.result);
};
openRequest.onerror = function (event) {
reject(event.target.errorCode);
};
});
// 检查缓存中是否有该文件的切片
const cachedChunks = await new Promise((resolve) => {
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();
request.onsuccess = function (event) {
resolve(event.target.result);
};
});
const cachedChunkIds = cachedChunks.map(chunk => chunk.id);
let chunks;
if (cachedChunkIds.length === 0) {
// 启动 Web Worker 进行切片
worker.postMessage({ file, chunkSize: 1024 * 1024 }); // 每个切片 1MB
// 等待 Web Worker 完成切片
chunks = await new Promise((resolve) => {
worker.onmessage = function (e) {
resolve(e.data);
};
});
// 将切片存储到 IndexedDB 中
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
chunks.forEach(chunk => {
store.put(chunk);
});
} else {
// 启动 Web Worker 进行切片
worker.postMessage({ file, chunkSize: 1024 * 1024 }); // 每个切片 1MB
// 等待 Web Worker 完成切片
const allChunks = await new Promise((resolve) => {
worker.onmessage = function (e) {
resolve(e.data);
};
});
// 过滤出未缓存的切片
chunks = allChunks.filter(chunk => !cachedChunkIds.includes(chunk.id));
}
// 并发上传切片
const concurrency = 5; // 并发上传数量
const uploadChunk = async (chunk) => {
// 模拟上传,实际中这里应该是调用上传 API
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000));
console.log(`Chunk ${chunk.id} uploaded successfully`);
// 更新缓存
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
store.put(chunk);
return chunk.id;
};
const uploadedChunks = [];
for await (const chunkId of asyncPool(concurrency, chunks, async (chunk) => {
return uploadChunk(chunk);
})) {
uploadedChunks.push(chunkId);
}
// 所有切片上传完毕,发送合并请求
console.log('All chunks uploaded. Sending merge request...');
// 模拟合并请求,实际中这里应该是调用合并 API
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Merge request sent.');
// 关闭数据库
db.close();
});
</script>
</body>
</html>
- 最后,还是用了
WebSocket
进行实时交互和状态的跟踪,用于实时地向服务器发送上传进度信息
,让服务器随时了解上传的状态。同时,服务器也可以通过 WebSocket 实时地给客户端发送反馈,比如告知客户端上传是否成功、是否出现错误等信息
。还能实现多个客户端之间关于文件上传的实时协作和交互等功能。
-
WebSocket 连接:
- 使用
new WebSocket('ws://localhost:8080')
创建一个到指定服务器的 WebSocket 连接。 - 监听
open
事件,当连接成功时,调用startUploadSequence
函数开始上传序列。 - 监听
message
事件,当接收到服务器发送的消息时,解析消息内容。如果消息类型为upload-success
,则表示服务器确认某个切片上传成功,此时调用startUploadSequence
函数继续下一个上传请求。 - 监听
close
和error
事件,分别处理连接关闭和错误情况。
- 使用
-
请求序列控制:
- 使用
currentIndex
变量来跟踪当前正在上传的切片索引,初始值为 0。 - 使用
isUploading
变量来标记当前是否正在上传,避免同时发起多个上传请求。 startUploadSequence
函数负责检查是否可以开始下一个上传请求。如果正在上传或者已经处理完所有切片,则不进行操作。否则,标记isUploading
为true
,获取当前要上传的切片数据,调用上传函数(这里是模拟的)进行上传。- 上传成功后,发送一个消息给服务器通知上传成功,更新 IndexedDB 缓存(假设添加了
uploaded
字段标记已上传),更新currentIndex
并将isUploading
标记为false
,以便可以开始下一个上传请求。 - 上传失败时,打印错误信息并将
isUploading
标记为false
。
- 使用
-
等待所有切片上传完毕:
-
使用
allUploaded
Promise 来等待所有切片上传完成。通过setInterval
定期检查currentIndex
是否等于切片总数,如果相等则表示所有切片上传完毕,清除定时器并 resolve Promise。 -
当所有切片上传完毕后,发送合并请求。
-
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Upload with async - pool, Web Worker, IndexedDB and WebSocket</title>
</head>
<body>
<input type="file" id="fileInput" />
<script>
const asyncPool = require('tiny-async-pool');
const SparkMD5 = require('spark-md5');
// 检查浏览器是否支持 Web Worker
if (typeof Worker === 'undefined') {
console.error('Web Worker is not supported in this browser.');
return;
}
// 检查浏览器是否支持 IndexedDB
if (!window.indexedDB) {
console.error('IndexedDB is not supported in this browser.');
return;
}
// 创建 WebSocket 连接
const socket = new WebSocket('ws://localhost:8080');
// 创建 Web Worker 脚本
const workerScript = `
self.onmessage = function (e) {
const file = e.data.file;
const chunkSize = e.data.chunkSize;
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const hash = SparkMD5.hash(chunk);
chunks.push({ id: hash, chunk });
start = end;
}
self.postMessage(chunks);
};
`;
const blob = new Blob([workerScript], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', async function (e) {
const file = e.target.files[0];
if (!file) return;
const dbName = 'fileChunksDB';
const storeName = 'chunksStore';
const dbVersion = 1;
// 打开 IndexedDB 数据库
const openRequest = indexedDB.open(dbName, dbVersion);
openRequest.onupgradeneeded = function (event) {
const db = event.target.result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, { keyPath: 'id' });
}
};
const db = await new Promise((resolve, reject) => {
openRequest.onsuccess = function (event) {
resolve(event.target.result);
};
openRequest.onerror = function (event) {
reject(event.target.errorCode);
};
});
// 检查缓存中是否有该文件的切片
const cachedChunks = await new Promise((resolve) => {
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();
request.onsuccess = function (event) {
resolve(event.target.result);
};
});
const cachedChunkIds = cachedChunks.map(chunk => chunk.id);
let chunks;
if (cachedChunkIds.length === 0) {
// 启动 Web Worker 进行切片
worker.postMessage({ file, chunkSize: 1024 * 1024 }); // 每个切片 1MB
// 等待 Web Worker 完成切片
chunks = await new Promise((resolve) => {
worker.onmessage = function (e) {
resolve(e.data);
};
});
// 将切片存储到 IndexedDB 中
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
chunks.forEach(chunk => {
store.put(chunk);
});
} else {
// 启动 Web Worker 进行切片
worker.postMessage({ file, chunkSize: 1024 * 1024 }); // 每个切片 1MB
// 等待 Web Worker 完成切片
const allChunks = await new Promise((resolve) => {
worker.onmessage = function (e) {
resolve(e.data);
};
});
// 过滤出未缓存的切片
chunks = allChunks.filter(chunk =>!cachedChunkIds.includes(chunk.id));
}
// 用于控制请求序列
let currentIndex = 0;
let isUploading = false;
// 监听 WebSocket 连接打开事件
socket.addEventListener('open', () => {
console.log('WebSocket connection opened');
startUploadSequence();
});
// 监听 WebSocket 消息事件(用于接收服务器的实时通知)
socket.addEventListener('message', event => {
const message = JSON.parse(event.data);
if (message.type === 'upload-success') {
console.log(`Received success notification for chunk ${message.chunkId}`);
startUploadSequence();
}
});
// 监听 WebSocket 关闭事件
socket.addEventListener('close', () => {
console.log('WebSocket connection closed');
});
// 监听 WebSocket 错误事件
socket.addEventListener('error', error => {
console.error('WebSocket error:', error);
});
async function startUploadSequence() {
if (isUploading || currentIndex >= chunks.length) {
return;
}
isUploading = true;
const chunk = chunks[currentIndex];
try {
// 模拟上传,实际中这里应该是调用上传 API
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000));
console.log(`Chunk ${chunk.id} uploaded successfully`);
// 发送上传成功的消息给服务器
socket.send(JSON.stringify({ type: 'upload-success', chunkId: chunk.id }));
// 更新缓存(假设这里需要更新缓存状态,比如标记已上传)
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
store.put({...chunk, uploaded: true });
currentIndex++;
isUploading = false;
} catch (error) {
console.error(`Error uploading chunk ${chunk.id}:`, error);
isUploading = false;
}
}
// 所有切片上传完毕,发送合并请求
const allUploaded = new Promise((resolve) => {
const interval = setInterval(() => {
if (currentIndex === chunks.length) {
clearInterval(interval);
resolve();
}
}, 100);
});
await allUploaded;
console.log('All chunks uploaded. Sending merge request...');
// 模拟合并请求,实际中这里应该是调用合并 API
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Merge request sent.');
// 关闭数据库
db.close();
});
</script>
</body>
</html>
以上,就处理完了大文件上传,包括分片上传、断点续传等,至于秒传,其实思路很简单将文件hash
发给后端,服务端进行匹配,若存在则直接返回上传成功。
总结归纳
WebWorker
文件分片spark-md5
加密async-pool
并发请求给服务器IndexDB
存储上传更新切片,断点续传websocket
监听上传进度