问题描述
- 两年前,笔者写过一篇文章 《面试官桀桀一笑:你没做过大文件上传功能?那你回去等通知吧!》
- 当时,后端是用java语言写的
- 本篇文章,就是讲解一下,后端的nodejs如何实现大文件上传
- 后端使用node的express框架写
- 完整代码在github上:github.com/shuirongshu...
在看本篇文章之前,建议看一下之前的笔者的大文件上传文章
思路分析
大文件分片上传流程图如下:
三个接口
-
大文件上传的接口还是三个接口
-
接口一:检查文件状态
- 状态1 是否完整文件已存在
- 状态2 是否文件不完整,有碎片文件(上一次没上传完)
- 状态0 文件不存在
-
接口二:分片上传接口
- 首先,创建分片存储目录
- 然后,重命名分片文件(使用索引作为文件名)
- 接着,把上传的临时文件移动到分片目录文件夹里面(上传成功了)
- 最后,返回给前端,已经上传的上传分片数用于进度计算
-
接口三:把分片文件给合并成一个整个的大文件
- 首先,我们把文件夹中的一堆文件碎片给按照索引排序(要按照索引合并,否则合并好的文件就会损坏)
- 然后,创建合并文件目录,用于存放即将合并完成的大文件
- 而后,通过管道流的形式,把文件碎片,一个又一个合并
- 最后,告知前端文件合并成功
所用到的包
js
{
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"fs-extra": "^11.3.0",
"multer": "^1.4.5-lts.2",
"path": "^0.12.7"
}
}
- cors 用来解决跨域的中间件,方便接口请求看效果
- express node框架,写起来更快,更方便
- fs-extra 是fs文件系统的升级版,强化版,比如,可以递归目录操作、文件复制或移动等,很强大
- multer 用于处理文件上传的中间件,很强大
- path 核心模块,用于处理文件夹和文件的路径相关的
基本配置
比如,配置存储合并目录,用于存放前端上传的文件以及合并的文件等
js
const express = require('express');
const multer = require('multer');
const fs = require('fs-extra');
const path = require('path');
const app = express();
const cors = require('cors');
app.use(cors()); // 启用CORS中间件,允许所有跨域请求
// 配置
const FILE_STORE_PATH = path.join(__dirname, 'uploaded_files'); // 存储路径
const mergeDir = path.join(FILE_STORE_PATH, 'merge'); // 合并文件存储目录
const upload = multer({ dest: FILE_STORE_PATH }); // multer文件上传中间件
// 创建必要目录
fs.ensureDirSync(FILE_STORE_PATH);
fs.ensureDirSync(mergeDir);
// 错误响应格式
const errorResponse = (code = -1, message = '失败') => ({ resultCode: code, message });
接口一:检查文件状态
js
// 检查文件状态接口
app.post('/bigfile/check', (req, res) => {
const fileMd5 = req.query.fileMd5;
const mergePath = path.join(mergeDir, fileMd5);
const chunkDir = path.join(FILE_STORE_PATH, fileMd5);
try {
// 检查是否完整文件已存在
if (fs.existsSync(mergePath)) {
return res.json({ resultCode: 1, message: '文件已存在' });
}
// 检查是否有分片文件
if (fs.existsSync(chunkDir)) {
const chunks = fs.readdirSync(chunkDir);
return res.json({
resultCode: 2,
resultData: chunks.map(c => parseInt(c)) // 返回已经存在的分片索引数组
});
}
// 文件不存在,全新上传
res.json({ resultCode: 0, resultData: [] });
} catch (error) {
console.error('检查文件错误:', error);
res.status(500).json(errorResponse());
}
});
接口二:分片上传接口
js
// 分片上传接口
app.post('/bigfile/upload', upload.single('file'), (req, res) => {
try {
const { chunk, chunks, name, md5 } = req.body;
const chunkIndex = parseInt(chunk);
const chunkDir = path.join(FILE_STORE_PATH, md5);
// 创建分片存储目录
fs.ensureDirSync(chunkDir);
// 重命名分片文件(使用索引作为文件名)
const oldPath = req.file.path;
const newPath = path.join(chunkDir, chunkIndex.toString());
fs.renameSync(oldPath, newPath);
// 获取已上传分片数量
const uploadedChunks = fs.readdirSync(chunkDir).length;
res.json({
resultCode: 0,
resultData: uploadedChunks // 返回已上传分片数用于进度计算
});
} catch (error) {
console.error('分片上传错误:', error);
res.status(500).json(errorResponse());
}
});
接口三:把分片文件给合并成一个整个的大文件(重点)
js
// 合并文件接口
app.post('/bigfile/merge', async (req, res) => {
const { fileName, fileMd5 } = req.query;
const chunkDir = path.join(FILE_STORE_PATH, fileMd5);
const mergeFilePath = path.join(mergeDir, fileMd5, fileName);
try {
// 检查分片目录是否存在
if (!fs.existsSync(chunkDir)) {
return res.json(errorResponse(1, '分片文件不存在'));
}
// 获取所有分片文件并按索引排序
const chunkFiles = fs.readdirSync(chunkDir)
.map(f => ({ name: f, index: parseInt(f) }))
.sort((a, b) => a.index - b.index)
.map(f => path.join(chunkDir, f.name));
// 创建合并文件目录
fs.ensureDirSync(path.dirname(mergeFilePath));
// 创建可写流
const writeStream = fs.createWriteStream(mergeFilePath);
// 增加监听器上限(可选,更好的做法是优化流处理)
// writeStream.setMaxListeners(100);
// 使用流管道逐个合并文件
await mergeChunksSequentially(chunkFiles, writeStream);
// // 清理分片文件和目录
// fs.removeSync(chunkDir);
res.json({ resultCode: 0, message: '文件合并成功' });
} catch (error) {
console.error('合并文件错误:', error);
// 清理可能存在的不完整文件
if (fs.existsSync(mergeFilePath)) {
fs.removeSync(mergeFilePath);
}
res.status(500).json(errorResponse());
}
});
- 注意,这里使用管道流,异步,合并分片文件
- 若是考虑性能,可以考虑开一个额外的线程,辅助运算合并文件碎片
- 异步合并如下:
js
// 顺序合并分片文件的辅助函数(避免同时添加过多监听器)
function mergeChunksSequentially(chunkFiles, writeStream) {
return new Promise((resolve, reject) => {
// 递归处理每个分片文件
const processNextChunk = (index) => {
if (index >= chunkFiles.length) {
// 所有分片都已处理完毕
writeStream.end();
return resolve();
}
const chunkPath = chunkFiles[index];
const readStream = fs.createReadStream(chunkPath);
readStream.on('error', (err) => {
reject(err);
});
readStream.on('end', () => {
// 此分片处理完成,继续下一个
processNextChunk(index + 1);
});
// 管道传输数据
readStream.pipe(writeStream, { end: false });
};
// 开始处理第一个分片
processNextChunk(0);
// 监听写入完成事件
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
}
至此,搞定,总结:
- 当然了,实际情况,我们的文件存储都是放在oss上的
- 也就是说,当后端把文件合并完成后
- 还有把文件存储到oss上这一步,比如存储到minio、阿里云、腾讯云上等
- 这个,根据大家的实际情况后端做处理
- 这种情况是,后端存整个文件,不存文件碎片
- 还有一种方案是,存文件碎片,不存整个文件,数据库存储关联关系
- 当用户访问请求某个文件的时候,查询此文件的关联碎片,并且通过通过数据流的方式,返回给前端
- 但是这种方案后端会稍微麻烦一些,不过这样就可以不用一下子算出来前端的大文件的hash值以后,再去发请求了
- 从用户的角度而言,这种方式更快一些
- 笔者有一个示例demo,大家可以拉代码跑起来,看一下效果:github.com/shuirongshu...
完整express代码
js
const express = require('express');
const multer = require('multer');
const fs = require('fs-extra');
const path = require('path');
const app = express();
const cors = require('cors');
app.use(cors()); // 启用CORS中间件,允许所有跨域请求
// 配置
const FILE_STORE_PATH = path.join(__dirname, 'uploaded_files'); // 存储路径
const mergeDir = path.join(FILE_STORE_PATH, 'merge'); // 合并文件存储目录
const upload = multer({ dest: FILE_STORE_PATH }); // multer文件上传中间件
// 创建必要目录
fs.ensureDirSync(FILE_STORE_PATH);
fs.ensureDirSync(mergeDir);
// 错误响应格式
const errorResponse = (code = -1, message = '失败') => ({ resultCode: code, message });
// 检查文件状态接口
app.post('/bigfile/check', (req, res) => {
const fileMd5 = req.query.fileMd5;
const mergePath = path.join(mergeDir, fileMd5);
const chunkDir = path.join(FILE_STORE_PATH, fileMd5);
try {
// 检查是否完整文件已存在
if (fs.existsSync(mergePath)) {
return res.json({ resultCode: 1, message: '文件已存在' });
}
// 检查是否有分片文件
if (fs.existsSync(chunkDir)) {
const chunks = fs.readdirSync(chunkDir);
return res.json({
resultCode: 2,
resultData: chunks.map(c => parseInt(c)) // 返回已经存在的分片索引数组
});
}
// 文件不存在,全新上传
res.json({ resultCode: 0, resultData: [] });
} catch (error) {
console.error('检查文件错误:', error);
res.status(500).json(errorResponse());
}
});
// 分片上传接口
app.post('/bigfile/upload', upload.single('file'), (req, res) => {
try {
const { chunk, chunks, name, md5 } = req.body;
const chunkIndex = parseInt(chunk);
const chunkDir = path.join(FILE_STORE_PATH, md5);
// 创建分片存储目录
fs.ensureDirSync(chunkDir);
// 重命名分片文件(使用索引作为文件名)
const oldPath = req.file.path;
const newPath = path.join(chunkDir, chunkIndex.toString());
fs.renameSync(oldPath, newPath);
// 获取已上传分片数量
const uploadedChunks = fs.readdirSync(chunkDir).length;
res.json({
resultCode: 0,
resultData: uploadedChunks // 返回已上传分片数用于进度计算
});
} catch (error) {
console.error('分片上传错误:', error);
res.status(500).json(errorResponse());
}
});
// 合并文件接口
app.post('/bigfile/merge', async (req, res) => {
const { fileName, fileMd5 } = req.query;
const chunkDir = path.join(FILE_STORE_PATH, fileMd5);
const mergeFilePath = path.join(mergeDir, fileMd5, fileName);
try {
// 检查分片目录是否存在
if (!fs.existsSync(chunkDir)) {
return res.json(errorResponse(1, '分片文件不存在'));
}
// 获取所有分片文件并按索引排序
const chunkFiles = fs.readdirSync(chunkDir)
.map(f => ({ name: f, index: parseInt(f) }))
.sort((a, b) => a.index - b.index)
.map(f => path.join(chunkDir, f.name));
// 创建合并文件目录
fs.ensureDirSync(path.dirname(mergeFilePath));
// 创建可写流
const writeStream = fs.createWriteStream(mergeFilePath);
// 增加监听器上限(可选,更好的做法是优化流处理)
// writeStream.setMaxListeners(100);
// 使用流管道逐个合并文件
await mergeChunksSequentially(chunkFiles, writeStream);
// // 清理分片文件和目录
// fs.removeSync(chunkDir);
res.json({ resultCode: 0, message: '文件合并成功' });
} catch (error) {
console.error('合并文件错误:', error);
// 清理可能存在的不完整文件
if (fs.existsSync(mergeFilePath)) {
fs.removeSync(mergeFilePath);
}
res.status(500).json(errorResponse());
}
});
// 顺序合并分片文件的辅助函数(避免同时添加过多监听器)
function mergeChunksSequentially(chunkFiles, writeStream) {
return new Promise((resolve, reject) => {
// 递归处理每个分片文件
const processNextChunk = (index) => {
if (index >= chunkFiles.length) {
// 所有分片都已处理完毕
writeStream.end();
return resolve();
}
const chunkPath = chunkFiles[index];
const readStream = fs.createReadStream(chunkPath);
readStream.on('error', (err) => {
reject(err);
});
readStream.on('end', () => {
// 此分片处理完成,继续下一个
processNextChunk(index + 1);
});
// 管道传输数据
readStream.pipe(writeStream, { end: false });
};
// 开始处理第一个分片
processNextChunk(0);
// 监听写入完成事件
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
}
// 启动服务
const PORT = 8686;
app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`);
});
A good memory is better than a bad pen. Record it down...