关于大文件上传

28 阅读9分钟

背景介绍

在我们平常的业务中,任务模块及订单模块,会上传一些现场的短的视频,还有一些售前方案管理模块的方案里会涉及到结构、电器略图的上传,或者一些方案ppt的上传,这些文件大部分是大于200、300M的。

所以时常有售前工程师与维修技术人员反馈文件上传功能不好用甚至系统无法上传大文件的情况频频出现,为此我根据反馈归纳了一下,主要问题集中在以下方面:

  1. 文件上传着上传着就没了,经过我排查,是网络不稳定断网了,文件消失情况会复现。
  2. 文件在上传的途中,突然之间就是没了,经复现发现,是网络波动,因我们设备是在全国工业园区等地均有,这种情况在新疆等海拔高的地区尤为突出。
  3. 关机之后,重新开机的时候,没有完成上传的又得重现上传,这个很麻烦,功能需要改进。

对此,经过思考查资料等,发现问题基本就是两个专业术语描述的问题,分别是大文件断开重连网络重传大文件断点续传

具体思路

首先,要完成大文件上传。

因接口也有限制上传文件的内容大小,这个我们便使用切分的思想,将文件切分成若干切片,再发请求将切片给服务端并请求合并,再由服务端进行文件拼接、校验等,这样就完成一个大文件切片上传。

基本思路如下:

  1. 将当前文件使用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>

执行结果如下:

image.png

  1. 基于二进制流的特性,其具有可切分性,使用Blob.prototype.slice大文件进行等大小切分。至于每个分片的大小是按照业务来定义比较稳妥。 (当前假设定义为1MB)

  2. 切片动作完成后,便可以向后端发送请求,因为http1.1和http2均有并发的特性,但是有数量限制,http1.1的是6个http请求为上限,http2是两个链接为上限,每个链接有100个http请求。考虑并发请求完全可行。

image.png

在并发的技术方案选择上,可选择的有很多,比如使用 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"
}
  1. 在请求的过程中,上传切片时会出现一些卡顿,分析原因是文件确实是比较大,切片切分起来是比较耗时,阻塞了主线程,这时候使用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>
  1. 这时候,还有一种情况,当切片完成之后,给后端发的时候,这时用户把浏览器关了怎么办,断开了、关机了之后想接着传,做不到。 这个问题的解决方案方案是使用本地缓存使用浏览器的本地数据库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>
  1. 最后,还是用了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监听上传进度