使用nodejs的express框架实现大文件上传的功能,附完整前后端github代码

问题描述

在看本篇文章之前,建议看一下之前的笔者的大文件上传文章

思路分析

大文件分片上传流程图如下:

三个接口

  • 大文件上传的接口还是三个接口

  • 接口一:检查文件状态

    • 状态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...

相关推荐
CRPER10 分钟前
告别繁琐配置:一个现代化的 TypeScript 库开发模板,让你高效启动项目!
前端·typescript·node.js
Humbunklung14 分钟前
JavaScript 将一个带K-V特征的JSON数组转换为JSON对象
开发语言·javascript·json
coding随想22 分钟前
JavaScript中的迭代器模式:优雅遍历数据的“设计之道”
javascript
終不似少年遊*36 分钟前
【软测】node.js辅助生成测试报告
软件测试·测试工具·node.js·postman·web
咖啡の猫1 小时前
JavaScript基础-DOM事件流
开发语言·javascript·microsoft
李三岁_foucsli2 小时前
从生成器和协程的角度详解async和await,图文解析
前端·javascript
星垂野2 小时前
JavaScript 原型及原型链:深入解析核心机制
javascript·面试
zayyo3 小时前
面试官问我,后端一次性返回十万条数据,前端应该怎么处理 ?
前端·javascript·面试
xingba3 小时前
改造jsp项目的alert框和confirm框
前端·javascript·css
Elastic 中国社区官方博客3 小时前
JavaScript 中的 ES|QL:利用 Apache Arrow 工具
大数据·开发语言·javascript·elasticsearch·搜索引擎·全文检索·apache