大文件上传实现指南
在 Web 应用程序中,处理大文件上传是一个常见的挑战。本指南将介绍如何使用 Node.js 和 Vue.js 实现大文件上传,包括前端和后端的代码示例。
前端实现
在前端,我们使用 Vue.js 来构建用户界面,并使用axios
库来处理 HTTP 请求。以下是关键步骤和代码示例:
首先
- 用户界面:提供一个文件选择框和几个按钮来控制上传、播放、暂停和继续操作。
- 文件选择 :使用
<input type="file" @input="fileChange" />
来选择文件,并在fileChange
方法中处理文件。 - 初始化上传 :在
fileChange
方法中,创建一个UploadController
实例并调用init
方法来初始化上传。 - 播放文件 :点击 "播放" 按钮时,调用
getFile
方法,通过axios
获取文件的 Blob 数据,并创建一个 URL 来播放视频。 - 暂停和继续 :使用
stop
和update
方法来控制上传的暂停和继续。
js
<template>
<div class="app">
<div class="bo1x">
<input type="file" @input="fileChange" />
</div>
<button @click="getFile">播放</button>
<button @click="stop">暂停</button>
<button @click="update">继续</button>
<video autoplay v-if="url" controls width="578" height="150">
<source :src="url" type="video/mp4" />
</video>
<div style="width: 500px;">
<div class="process"></div>
</div>
</div>
</template>
<script setup>
import { UploadController, RequestStrategy } from '../api/chunks.js'
import { ref, onMounted, watchEffect } from 'vue'
import axios from 'axios'
const url = ref('')
const videoRef = ref(null)
let token = ref("")
let abc = ref({})
let uploadController = ref({})
// 选择上传文件
const fileChange = async (e) => {
let requestStrategy = new RequestStrategy(e.target.files[0].size)
let result = new UploadController(e.target.files[0], requestStrategy)
uploadController.value = result
await result.init()
token.value = result.token
}
// 播放上传的文件
const getFile = async () => {
const response = await axios({
url: `/api/files/${token.value}`,
method: 'GET',
responseType: 'blob' // 关键配置:设置响应类型为 blob
});
const blob = new Blob([response.data], { type: 'video/mp4' });
// 创建一个url地址
url.value = URL.createObjectURL(blob);
}
const stop = () => {
uploadController.value.stop()
}
const update = () => {
uploadController.value.update()
}
// 下载
// function openDownloadDialog (urlOrBlob, saveName) {
// let url;
// if (typeof urlOrBlob === "object" && urlOrBlob instanceof Blob) {
// url = URL.createObjectURL(urlOrBlob); // 创建blob地址
// } else {
// url = urlOrBlob;
// }
// const aLink = document.createElement("a");
// aLink.href = url;
// aLink.download = saveName || ""; // HTML5新增的属性,指定保存文件名,可以不要后缀,注意,file:///模式下不会生效
// let event;
// if (window.MouseEvent) {
// event = new MouseEvent("click");
// } else {
// event = document.createEvent("MouseEvents");
// event.initMouseEvent(
// "click",
// true,
// false,
// window,
// 0,
// 0,
// 0,
// 0,
// 0,
// false,
// false,
// false,
// false,
// 0,
// null
// );
// }
// aLink.dispatchEvent(event);
// }
</script>
接着创建一个chunks.js
文件,主要实现了文件分片上传的功能,以下是对文件内容的逐段解释:
ChunkSplitor 类
-
用途:用于分割文件为多个块。
-
构造函数参数:
file
:要分割的文件对象。chunkSize
:每个块的大小,默认是5MB。
-
方法:
createChunks()
:根据设定的块大小创建文件块。calcHash()
:使用Web Worker异步计算每个文件块的哈希值。
UploadController 类
-
用途:控制文件上传过程。
-
构造函数参数:
file
:待上传的文件对象。requestStrategy
:定义了如何与服务器交互的策略。
-
方法:
init()
:初始化上传流程,获取上传令牌并开始计算哈希值。stop()
:取消当前的上传任务。update()
:更新上传状态,可以用来重新启动暂停的上传。uploadChunks()
:遍历所有文件块,检查是否需要上传,并执行上传操作。mergeFile()
:当所有块上传完成后,向服务器发送请求以合并这些块为原始文件。
RequestStrategy 类
-
用途:封装了与服务器通信的具体逻辑。
-
构造函数参数:
size
:整个文件的大小。
-
方法:
createFile()
:向服务器发起创建新文件的请求,并获取上传所需的令牌。patchHash(token, hash, type)
:通知服务器特定块的哈希值,检查该块是否已经存在于服务器上。uploadChunk(token, chunk)
:上传单个文件块到服务器。
-
导入依赖:
javascriptimport axios from 'axios'; let cancelSource = null;
导入了 axios
库,用于发送 HTTP 请求,并定义了一个 cancelSource
变量,用于取消请求。cancelSource是生成一个取消请求的token令牌,暂停上传的时候会需要用到。
-
定义
ChunkSplitor
类:iniexport class ChunkSplitor { constructor(file, chunkSize = 1024 * 1024 * 5) { this.file = file; this.fileHash = ""; this.chunkSize = chunkSize; this.chunks = []; this.createChunks(); } createChunks() { for (let i = 0; i < Math.ceil(this.file.size / this.chunkSize); i++) { const start = i * this.chunkSize; const end = Math.min((i + 1) * this.chunkSize, this.file.size); this.chunks.push({ blob: this.file.slice(start, end), end, hash: '', index: i }); } } async calcHash() { let that = this; const workerPath = new URL('./worker.js', import.meta.url).href; const worker = new Worker(workerPath, { type: 'module' }); worker.postMessage(that.chunks); return new Promise((resolve, reject) => { worker.onmessage = function(e) { that.chunks = e.data; resolve(e.data); worker.terminate(); }; }); } }
这个类用于将文件分割成多个块(chunks),并计算每个块的哈希值。构造函数接收一个文件对象和一个可选的块大小参数,默认值为 5MB。
createChunks
方法根据文件大小和块大小计算出需要分割的块数,并将每个块的信息存储在chunks
数组中。calcHash
方法使用 Web Worker 来并行计算每个块的哈希值,并返回一个 Promise,当所有哈希值计算完成后 resolve。
注意:分片数量需根据实际业务场景来定
-
对于大量小分片,频繁的哈希计算可能会占用大量的系统资源,尤其是在客户端(如浏览器)中进行时,过多的分片会导致每次上传前都要等待哈希计算完成,从而增加了总的上传时间。
-
大量小分片过多会导致大量的HTTP请求,这不仅增加了网络负载,还可能导致浏览器或服务器端设置的最大并发连接数被迅速耗尽。
-
大量的小分片最终合并时的复杂度越高,尤其是在确保所有分片按正确顺序排列方面。任何丢失或损坏的分片都可能导致合并失败。
-
必须用Promise包一下等切片hash计算完成调用resolve方法, 在切片hash计算完成后再进行上传切片工作 否则会造成上传时切片hash信息计算不完成导致请求失败
-
哈希计算(尤其是对大文件或大量数据)属于计算密集型任务,它可能会占用大量的CPU资源和时间。在Web开发环境中,直接在主线程中执行这样的任务会导致页面响应变慢甚至无响应,严重影响用户体验。因此,使用
new Worker
来处理哈希计算是非常重要的。
定义 UploadController
类:
kotlin
```
export class UploadController {
constructor(file, requestStrategy) {
this.file = file;
this.requestStrategy = requestStrategy;
this.splitStrategy = new ChunkSplitor(file);
}
async init() {
this.token = await this.requestStrategy.createFile();
let result = await this.splitStrategy.calcHash();
this.uploadChunks();
}
stop() {
//暂停上传
cancelSource.cancel('Upload paused by user');
}
update() {
//继续上传
this.uploadChunks();
}
uploadChunks() {
cancelSource = axios.CancelToken.source(); // 确保每次都重新请求一个令牌
this.splitStrategy.chunks.forEach(async (chunk) => {
try {
//每次上传前都会比对hash 避免切片不会被重复上传
const resp = await this.requestStrategy.patchHash(this.token, chunk.hash, 'chunk');
if (!resp.hasFile) {
await this.requestStrategy.uploadChunk(this.token, chunk);
}
} catch (error) {
cancelSource.cancel('Upload paused by user');
console.error('Error uploading chunk:', error);
}
});
// 合并文件
// this.mergeFile();
}
async mergeFile() {
try {
const url = await this.requestStrategy.mergeFile(this.token);
console.log('File uploaded successfully:', url);
} catch (error) {
console.error('Error merging file:', error);
}
}
}
```
负责管理文件上传的整个过程。构造函数接收一个文件对象和一个请求策略对象。init
方法首先调用请求策略的 createFile
方法获取上传令牌,然后调用 splitStrategy
的 calcHash
方法计算每个块的哈希值,最后调用 uploadChunks
方法开始上传。stop
方法用于取消上传。update
方法用于更新上传状态。uploadChunks
方法遍历每个块,调用请求策略的 patchHash
方法检查块是否已上传,如果未上传则调用 uploadChunk
方法上传块。mergeFile
方法用于合并上传的块。
-
定义
RequestStrategy
类:iniexport class RequestStrategy { constructor(size) { this.uploadTotal = 0; this.size = size; this.processDom = document.querySelector('.process'); this.uploadChunkArr = []; } createFile() { // 发送 GET 请求并处理响应 return axios.post('/api/upload/create') .then(function(response) { return response.data.token; }) .catch(function(error) { console.error(error); }); } // 比对 hash 并计算上传字节数 patchHash(token, hash, type) { let that = this; // 发送 POST 请求 return axios.post(`/api/upload/hash/${token}`, { hash, type }) .then(function(response) { if (response.data.hasFile && that.uploadChunkArr.indexOf(hash) < 0) { that.uploadTotal += response.data.size; let process = Math.round((that.uploadTotal) * 100 / that.size); that.processDom.style.width = process + '%'; that.processDom.innerHTML = process + '%'; that.uploadChunkArr.push(hash); if (that.uploadTotal === that.size) { that.uploadChunkArr = []; } } return response.data; }) .catch(function(error) { console.error(error); }); } // 上传切片并计算字节数 uploadChunk(token, chunk) { let that = this; const formData = new FormData(); formData.append('chunk', chunk.blob); formData.append('hash', chunk.hash); formData.append('index', chunk.index); formData.append('end', chunk.end); let config = { cancelToken: cancelSource.token, }; return axios.post(`/api/upload/chunk/${token}`, formData, config) .then(function(response) { that.uploadTotal += response.data.size; let process = Math.round((that.uploadTotal) * 100 / that.size); that.processDom.style.width = process + '%'; that.processDom.innerHTML = process + '%'; that.uploadChunkArr.push(chunk.hash); if (that.uploadTotal === that.size) { that.uploadChunkArr = []; } }) .catch(function(error) { console.error(error); }); } async mergeFile(token) { try { const url = await axios.post(`/api/upload/merge/${token}`); console.log('File uploaded successfully:', url); return url; } catch (error) { console.error('Error merging file:', error); throw error; } } }
这个类定义了上传文件的具体策略,包括创建文件、上传块和合并文件等操作。构造函数接收文件大小,并初始化一些上传相关的状态和 DOM 元素。
createFile
方法用于创建一个新的上传文件,并返回一个上传令牌。patchHash
方法用于检查块的哈希值是否已上传,并更新上传进度。uploadChunk
方法用于上传单个块,并更新上传进度。mergeFile
方法用于合并上传的块,并返回合并后的文件 URL。
注意:
-
每次暂停需要重新生成cancelToken,否则会报错
-
计算上传进度需要服务端返回真实已上传切片字节数,利用 progress 方法监听会导致加入一些hash 或者其他参数的字节数,和文件的总字节数比对计算导致上传进度计算不准确。最终导致上传切片文件的字节数超过实际上传的字节数
在这段代码中,我们定义了一个 calcChunkHash
函数,它使用 SparkMD5
库来计算给定 blob
的哈希值。然后,我们在 self.onmessage
事件处理函数中使用这个函数来处理从主线程接收到的数据,并将处理后的数据发送回主线程。
javascript
javascript
import { default as SparkMD5 } from 'spark-md5';
function calcChunkHash(blob) {
return new Promise(resolve => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
fileReader.onload = (e) => {
spark.append(e.target.result);
resolve(spark.end());
};
fileReader.readAsArrayBuffer(blob);
});
}
self.onmessage = async function (event) { // 子线程接收主线程传递数据触发的方法
const data = event.data;
await Promise.all(data.map(async (chunk) => {
chunk.hash = await calcChunkHash(chunk.blob);
}));
this.postMessage(data); // 向主线程提交请求完成后或处理后的数据
};
在这段代码中,我们定义了一个 calcChunkHash
函数,它使用 SparkMD5
库来计算给定 blob
的哈希值。然后,我们在 self.onmessage
事件处理函数中使用这个函数来处理从主线程接收到的数据,并将处理后的数据发送回主线程。
总结:
chunks.js
文件实现了一个文件分片上传的功能,通过 ChunkSplitor
类将文件分割成多个块,并计算每个块的哈希值,然后通过 UploadController
类管理上传过程,使用 RequestStrategy
类定义具体的上传策略。
后端实现
在后端,我们使用 node.js和express 创建了一个简单的文件上传和合并服务
导入模块
express
:用于创建Web服务器。fs
和fs.promises
:用于文件系统操作。crypto
:用于生成唯一的标识符。multer
:用于处理multipart/form-data,主要用于上传文件。
配置Multer
- 使用内存存储引擎来保存上传的文件块,以便直接访问原始的Buffer数据,而不是将其写入磁盘。
模拟数据库
filesDb
:模拟的数据库,用于存储文件信息。chunksDb
:数组,用来跟踪已经接收的文件块哈希值。
API路由
-
创建文件
- 路径 :
POST /api/upload/create
- 功能 :为新文件创建一个唯一标识符(token),并初始化文件信息到
filesDb
中。
- 路径 :
-
Hash校验
- 路径 :
POST /api/upload/hash/:token
- 功能 :检查给定哈希值的文件块是否已存在于服务器上。如果是整个文件的hash,则总是返回
hasFile: false
;如果是分块且已存在,则返回该分块的信息。
- 路径 :
-
分片上传
- 路径 :
POST /api/upload/chunk/:token
- 功能 :接收客户端发送的文件块,并根据哈希值保存至本地文件系统。同时更新
filesDb
中的文件块列表。
- 路径 :
-
分片合并
- 路径 :
POST /api/upload/merge/:token
- 功能:模拟合并所有接收到的文件块,实际上只更新状态和生成下载链接。真实环境中应该实现具体的合并逻辑。
- 路径 :
-
获取文件
- 路径 :
GET /api/files/:token
- 功能:通过读取流的方式提供文件下载服务,并设置正确的响应头以支持浏览器播放或下载。
- 路径 :
-
辅助函数
mergeFilesWithStreams
和pipeStream
:帮助函数,分别负责使用流式API合并文件块和管理流之间的管道连接。
ini
// 引入 Express 框架
const express = require('express');
// 引入文件系统模块
const fs = require('fs');
// 引入加密模块
const crypto = require('crypto');
// 配置 Multer 存储引擎为内存存储,以便我们可以访问原始的 Buffer 数据
const multer = require('multer');
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });
// 引入路径处理模块
const path = require('path');
// 创建 Express 应用实例
const app = express();
// 解析 JSON 请求体
app.use(express.json());
// 模拟数据库存储分片信息
const filesDb = {};
const chunksDb = [];
// 创建文件
app.post('/api/upload/create', async (req, res) => {
// 生成一个随机的 UUID 作为文件的 token
const token = crypto.randomUUID();
// 在模拟数据库中创建一个新的文件记录,初始状态为 chunks 为空数组,size 为请求体中的 size
filesDb[token] = { chunks: [], size: req.body.size };
// 返回 token 给客户端
res.send({ token });
});
// Hash校验
app.post('/api/upload/hash/:token', async (req, res) => {
const { token } = req.params;
const { hash, type } = req.body;
let response;
// 根据 type 判断是文件还是分片
if (type === 'file') {
// 如果是文件,检查文件是否存在
response = { hasFile: false };
} else if (type === 'chunk' && chunksDb.indexOf(hash) >= 0) {
// 如果是分片,检查分片是否存在,并返回分片的大小
const stats = await fs.promises.stat(`./chunks/${hash}`);
response = { hasFile: true, size: stats.size };
} else {
// 如果既不是文件也不是分片,返回不存在
response = { hasFile: false };
}
// 返回响应
res.send(response);
});
// 分片上传
app.post('/api/upload/chunk/:token', upload.single('chunk'), async (req, res) => {
const { token } = req.params;
const { hash, index } = req.body;
// 如果分片不存在,则将分片数据写入文件,并记录分片的 hash
if (chunksDb.indexOf(hash) < 0) {
const chunkData = req.file.buffer;
await fs.promises.writeFile(`./chunks/${hash}`, chunkData);
const stats = await fs.promises.stat(`./chunks/${hash}`);
if (stats.size) {
chunksDb.push(hash)
}
}
// 将分片的 hash 记录到文件的 chunks 数组中
filesDb[token].chunks[index] = hash;
// 返回上传成功的消息和分片的大小
res.send({ msg: 'success', size: req.file.size });
});
// 分片合并
app.post('/api/upload/merge/:token', async (req, res) => {
const { token } = req.params;
const { chunks } = filesDb[token];
// 这里不执行真正的合并,仅更新状态和生成URL
// 真实环境应实现合并逻辑或流式读取逻辑
filesDb[token].url = `/files/${token}`;
// 返回合并后的文件 URL
res.send({ url: filesDb[token].url });
});
// 获取文件
app.get('/api/files/:token', async (req, res) => {
const { token } = req.params;
const { chunks } = filesDb[token];
let filePath = path.join(__dirname, 'file', token);
await mergeFilesWithStreams(chunks, filePath)
const stats = await fs.promises.stat(filePath);
const head = {
'Content-Length': stats.size,
'Content-Type': 'video/mp4' // 根据实际视频格式调整
};
res.writeHead(200, head);
const readStream = fs.createReadStream(filePath);
readStream.on('end', () => {
// 手动结束响应
res.end();
readStream.destroy();
});
readStream.pipe(res);
// 监听读取流的结束事件
// 错误处理
readStream.on('error', (err) => {
res.status(500).send(err.message);
});
});
async function mergeFilesWithStreams (filePaths, outputPath) {
const writeStream = fs.createWriteStream(outputPath);
for (const file of filePaths) {
let filePath = path.join(__dirname, 'chunks', file);
let readStream = fs.createReadStream(filePath);
await pipeStream(readStream, writeStream);
}
}
function pipeStream (readStream, writeStream) {
return new Promise((resolve, reject) => {
readStream.pipe(writeStream, { end: false });
readStream.on('end', resolve);
readStream.on('error', reject);
});
}
// 启动服务器,监听 3000 端口
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
实现了一个简单的文件上传和合并服务,其中文件被分成多个分片上传,然后在服务器端合并成一个完整的文件。这个服务可以用于上传大文件,因为它可以避免一次性上传整个文件,而是将文件分成小块上传,这样可以减少内存的使用和提高上传的效率。