Node后端大文件上传方案-一站式教程(express+mysql+multer)

JavaScript性能优化实战 10w+人浏览 367人参与

前言

本文结合前端大文件上传 实现GB级别文件数据的全栈式教程

需要前端Vue3+TS的文件上传方案可查看主包的主页文章,快速链接如下:

前端大文件上传方案-一站式教程(hash算法+增量+webworker多线程+并发请求池)-CSDN博客

本文用到的技术栈:

  • Express+ES6语法
  • Sequelize ORM工具
  • MySQL数据库
  • multer 文件处理

实现功能:

  • 文件秒传
  • 断点续传
  • 分片上传
  • 合并文件

核心逻辑解析

  1. 文件秒传:后端通过文件hash值以及其他参数,判断文件是否存在于服务器,存在直接复用,不进行上传。

  2. 断点续传:通过文件hash值以及其他参数,判断服务器内是否存在未上传完成的切片,有则返回未上传的切片序列。

  3. 分片上传:使用multer中间件对文件进行存储(可使用第三方存储,如:123网盘、阿里云盘等)

  4. 合并文件:验证上传切片数量、大小以及完整性,对相同文件的切片进行拼接合并。

1. 数据库准备

需使用到两个数据库对文件上传进度以及文件地址进行存储。

  • files表:完整文件表,用于存储已上传的文件的路径以及对应的参数。
  • chunks表:临时文件/切片表,用于存储文件切片。

基础表格结构(可根据项目需求添加字段):

  • files表:主键、hash值标识、文件名称、文件大小、文件路径
  • chunks表:主键、hash值标识、分片序号、分片临时存储路径

表格索引:

  • chunks索引:  联合索引:hash值标识+分片序号  普通索引:hash值标识 

可对数据库内容自行创建,上述数据库字段为最基础的、最低要求的字段。可根据项目需求对数据库进行二次优化。

Sequelize迁移文件展示

files表
//create-files.cjs

'use strict'
/** @type {import('sequelize-cli').Migration} */
module.exports= {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Files', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER.UNSIGNED,
      },
      fileHash: {
        type: Sequelize.STRING(64),
        unique: true,
        allowNull: false,
        comment: '文件hash标识',
      },
      fileName: {
        type: Sequelize.STRING(255),
        allowNull: false,
        comment: '文件名称',
      },
      fileSize: {
        type: Sequelize.BIGINT.UNSIGNED,
        allowNull: false,
        comment: '文件大小',
      },
      filePath: {
        type: Sequelize.STRING(512),
        allowNull: false,
        comment: '文件路径',
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
    })
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Files')
  },
}
chunks表
//create-chunks.cjs

'use strict'
/** @type {import('sequelize-cli').Migration} */
module.exports= {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Chunks', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER.UNSIGNED,
      },
      fileHash: {
        type: Sequelize.STRING(64),
        allowNull: false, // 分片必须属于某个文件,不可为空
        comment: '关联的文件唯一哈希(对应Files表的fileHash)',
      },
      chunkIndex: {
        type: Sequelize.INTEGER.UNSIGNED,
        allowNull: false, // 分片索引必须存在,不可为空
        comment: '分片序号(从0开始)',
      },
      chunkPath: {
        type: Sequelize.STRING(512), // 分片临时存储路径,长度与Files表的filePath保持一致
        allowNull: false, // 分片必须有存储路径,不可为空
        comment: '分片在本地的临时存储路径',
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
        comment: '分片上传时间',
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
    })
    // 1. 联合唯一索引:确保同一文件的同一分片索引不会重复上传
    await queryInterface.addIndex('Chunks', {
      fields: ['fileHash', 'chunkIndex'], // 联合字段:文件哈希+分片索引
      unique: true, // 唯一约束:同文件下不允许有相同索引的分片
      name: 'idx_chunk_unique', // 索引名称
    })

    // 2. 普通索引:优化通过fileHash查询该文件所有分片的效率(高频操作)
    await queryInterface.addIndex('Chunks', {
      fields: ['fileHash'],
      name: 'idx_file_hash',
    })
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Chunks')
  },
}

2. 文件秒传接口

核心逻辑:

通过前端传递的文件的各项参数(核心为文件hash值),对files表进行查询,如果存在相同文件,则直接返回该文件的路径。

//routes/upload.js


import express from 'express'
import { Chunks, Files, sequelize } from '../../models/index.js'
import { Op } from 'sequelize'
import { success, failure } from '../../utils/respones.js' 

// 文件存在性检查   判断文件是否存在于服务器 存在直接使用 不进行上传 (文件秒传)
router.post('/file-existence-check', async (req, res, next) => {
  try {
    const { fileHash } = req.body

    // 查询条件
    const condition = {
      where: {
        fileHash: fileHash,
      },
    }
    const file = await Files.findOne(condition)
    //如果文件存在,则返回对应的文件路径
    if (file) {
        success(res, '秒传成功', { exists: true, fileUrl:file.filePath }
    } else {
      success(res, '未查询到文件', { exists: false })
    }
  } catch (error) {
    failure(res, error)
  }
})

3. 断点续传接口

核心逻辑:

通过前端传递的文件hash值和切片总数查询chunks表。

如果chunk表内存在该hash值所对应的文件,则提取所有此文件的切片下标,根据切片总数筛选出未进行上传的下标值返回给前端。

如果不存在该hash值对应的文件,则返回状态码告诉前端文件未上传。

//续步骤2 


// 断点续传  判断chunks表中缺失的index 返回给前端
router.post('/get-missing-chunks',async (req,res,next)=>{
  try {
    const { fileHash ,totalChunks} = req.body

    //1.验证参数完整
    if (!fileHash || !totalChunks) {
      throw new Error('缺少必要参数')
    }
    const totalChunksNum = Number(totalChunks);

       // 2. 查询该文件已上传的所有分片索引(仅查 chunkIndex,减少数据传输)
       const uploadedChunks = await Chunks.findAll({
        where: { fileHash: { [Op.eq]: fileHash } },
        attributes: ['chunkIndex'], // 只取需要的字段,优化性能
        raw: true, // 返回纯对象(避免 Sequelize 模型包装,简化处理)
      });
  
      
      //判断临时文件库内是否存在该文件的切片 
      if(uploadedChunks.length ===0){
        success(res,'文件未进行过上传',{ 
          fileHash,
           isMiss:false,//传递缺失判断字段
           totalChunks: totalChunksNum,//切片总数
        })
        return
      }
          // 3. 处理已上传的索引:提取为数组
      const uploadedIndexes = uploadedChunks.map(item => item.chunkIndex);
        // 4. 生成   完整的分片索引列表
    const fullIndexes = Array.from({ length: totalChunksNum }, (_, i) => i);
   
      // 5. 对比找出“缺失的索引列表”
      const missingChunks = fullIndexes.filter(index => !uploadedIndexes.includes(index));

      success(res,'缺失分片索引查询成功',{
        fileHash,
        isMiss:true, //传递缺失判断字段
        totalChunks: totalChunksNum,
        missingChunks: missingChunks, // 前端需要上传的分片索引(核心数据)
        uploadedChunks: uploadedIndexes, // 已上传的分片索引(可选,前端可用于进度展示)
      })
  } catch (error) {
    failure(res, error);
  }
})

4.分片上传接口(核心)

核心逻辑:

使用multer中间件对文件进行处理,将切片存入临时文件目录,并在chunk表内记录该切片。

注意:前端传递时需传递multipart/form-data 类型的表单数据

4.1 multer中间件编写

4.1.1 确认临时存储目录

根据项目需求,编写切片文件的临时存储目录。(可使用第三方存储)

这里我为了方便大家理解,直接使用本地的存储方式。

在utils文件夹内新建multerUploader.js文件用于编写multer中间件

// utils/multerUploader.js

import multer from 'multer'
import path from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url' // 用于将 URL 转为文件路径
const __filename = fileURLToPath(import.meta.url) // 当前文件的绝对路径(替代 __filename)
const __dirname = path.dirname(__filename) // 当前文件所在目录的绝对路径(替代 __dirname)

const storagePath = path.join(__dirname, '../temp') //临时存储路径(替换为自己的存储路径)

4.1.2 文件过滤函数:校验文件类型

相当于是multer函数的中间件函数,获取前端传递的文件MIME类型以及文件的拓展名,对文件进行验证。

当文件类型不符合标准时,则进行报错处理

当文件类型符合标准时,则允许文件上传

  // 文件过滤函数:校验文件类型
  /**
   * 
   * @param {*} req  接口的req 用户传递的值
   * @param {*} file 需要处理的文件
   * @param {*} cb   回调函数 判断文件是否允许上传
   * 
   * @param allowedMimeTypes 允许的文件MIME类型数组(由主函数提供)
   * @param originalMimeType 文件MIME类型数组(前端传递的参数)
   * @param originalExt 文件的拓展名 如.mp4 .gif等(前端传递的参数)
   */

  const fileFilter = (req, file, cb) => {
    // 1. 校验 MIME 类型
    const { originalMimeType, originalExt } = req.body

    if (!allowedMimeTypes.includes(originalMimeType)) {
      return cb(
        new Error(
          `不支持的文件类型:${originalMimeType},允许的类型:${allowedMimeTypes.join(', ')}`
        )
      )
    }
    // 2. 校验文件扩展名(增强安全性,防止 MIME 类型伪造)
    if (!allowedExtensions.includes(originalExt)) {
      return cb(
        new Error(
          `不支持的文件扩展名:${originalExt},允许的扩展名:${allowedExtensions.join(', ')}`
        )
      )
    }

    // 校验通过
    cb(null, true)
  }

4.1.3 创建上传配置参数

通过multer.diskStorage()函数对文件存放路径以及文件名称进行自定义编写

使用destination属性配置文件存放路径(可自行使用第三方存储)

使用filename属性配置文件(注意:需对相同文件的切片进行标识,推荐使用文件hash值+切片下标的方式进行标记)

/**
 * 创建 multer 上传配置
 * @param {string[]} allowedMimeTypes - 允许的 MIME 类型(如 ['image/jpeg', 'image/png'])
 * @param {string[]} [allowedExtensions] - 允许的文件扩展名(如 ['jpg', 'png'],可选)
 * @returns {multer.Multer} multer 实例
 */
export function createUploader(allowedMimeTypes, allowedExtensions) {
  // 存储配置(自定义存储路径和文件名)
  const storage = multer.diskStorage({

//文件存储路径配置
    destination: (req, file, cb) => {
      const { fileHash } = req.body
      // 按fileHash创建文件夹,集中存储同一文件的切片
      const chunkDir = path.join(storagePath, `./${fileHash}`)
      // 检查目录是否存在,不存在则创建(recursive: true 确保创建多级目录)
      if (!fs.existsSync(chunkDir)) {
        fs.mkdirSync(chunkDir, { recursive: true })
      }
      // 将文件存储到该目录
      cb(null, chunkDir)
    },

//文件名称配置
    filename: (req, file, cb) => {
      const { fileHash, chunkIndex } = req.body

      // 生成唯一文件名:文件hash值+切片下标

      const filename = `${fileHash}-${chunkIndex}`
      cb(null, filename)
    },
  })

4.1.4 创建并返回multer实例

  // 创建并返回 multer 实例
  return multer({
    storage: storage, //步骤3创建的函数
    fileFilter: fileFilter, //步骤2创建的过滤函数
    limits: {
      fileSize: 10 * 1024 * 1024, // 限制单次上传文件(切片)的大小(这里限制 10MB,可以自行修改)
    },
  })
}

4.2 接口编写

  1. 导入步骤4.1编写的multer函数
  2. 配置并创建multer实例,可配置过滤任意的文件类型(我这里仅用了图片类型进行配置)
  3. 编写接口文件,在接口的第二个参数配置multer中间件
  4. 注意:uploader.single(’chunkBlob‘)中的 chunkBlob参数为前端传递的文件内容的字段名
  5. 使用req.file获取到上传的文件实例,通过req.file.path可获取文件的绝对路径,并将其存入数据库的chunks表
//续 步骤3 
// routes/upload.js


//导入multer中间件
import { createUploader } from '#@/utils/multerUploader.js'

// 配置允许的文件类型 (我这里用图片类型进行测试,可替换为任意文件类型)
const uploader = createUploader(
  ['image/jpeg', 'image/png', 'image/gif'], // 允许的 MIME 类型
  ['jpg', 'jpeg', 'png', 'gif'] // 允许的扩展名
)

// 分片上传文件 使用multer中间件对文件进行处理
router.post('/uploadFile', uploader.single('chunkBlob'), async (req, res, next) => {
  try {
    const { fileHash, chunkIndex, totalChunks } = req.body
    
    const chunkPath = req.file.path
    // 将切片信息写入数据库(chunks表)
    // 先查询该分片是否已存在(通过唯一标识 fileHash + chunkIndex)
    const existingChunk = await Chunks.findOne({
      where: {
        fileHash: fileHash,
        chunkIndex: chunkIndex,
      },
    })

    // 2. 如果不存在数据 则插入
    if (!existingChunk) {
      await Chunks.create({
        fileHash: fileHash, // 关联的文件唯一标识
        chunkIndex: parseInt(chunkIndex, 10), // 切片索引(转数字)
        chunkPath: chunkPath, // 切片在本地的临时存储路径
      })
    }

    success(res, '单分片上传成功', { finishedIndex: chunkIndex })
  } catch (error) {
    failure(res, error)
  }
})

5. 合并文件接口(核心)

核心逻辑:

  1. 查询chunks数据库,检测已上传的切片总数是否与前端传递的切片总数相同。
  2. 查询chunks数据库中当前文件的所有切片,按照下标从小到大排序,确保合并顺序正确。
  3. 定义完整文件的存储路径(可按照文件类型进行分类存储)
  4. 确保完整文件目录存在,不存在则进行创建,确保文件合并后能够正常存放
  5. 使用stream流操作写入数据,减少服务器内存压力。遍历该文件的切片数据,依次对个切片进行拼接,并在拼接完成后关闭通道释放服务器内存。
  6. 验证文件完整性
  7. 将完整的文件信息存入files表中
  8. 清理临时资源,删除该文件的切片数据,并删除chunks数据库中的数据
  9. 返回完整文件的访问路径
//续 步骤4

// routes/upload.js

import fs from 'fs'
const fsPromises = fs.promises;
// 合并文件
router.post('/mergeFile', async (req, res, next) => {
  const { fileHash, fileName, fileSize, totalChunks,originalMimeType,originalExt} = getUploadBody(req)
  //1. 切片上传完成数量
  const chunkUploadFinishTotal = await Chunks.findAll({
    attributes: [[sequelize.fn('COUNT', sequelize.col('fileHash')), 'total']],
    where: {
      fileHash: {
        [Op.eq]: fileHash,
      },
    },
  })
  const uploadedCount = Number(chunkUploadFinishTotal[0].dataValues.total) //数据库查询的切片总数
  if (uploadedCount !== Number(totalChunks)) {
    return failure(res, '分片未上传完整,无法合并')
  }
      // 2. 查询该文件的所有分片(按 chunkIndex 升序排序,确保合并顺序正确)
  const chunks = await Chunks.findAll({
        where: { 
          fileHash:fileHash
         },
        order: [['chunkIndex', 'ASC']], // 按索引从小到大排序
        attributes: ['chunkPath', 'chunkIndex']
  });
  // 3. 定义完整文件的存储路径(正式目录 + 文件名)
  let fileDir //文件存放的父级目录
  let uploadDir  // 正式文件存放目录 
  let fullFilePath //文件路径

  // 图片存放
  if(imgMimeType.includes(originalMimeType)){  
    uploadDir=imgDir
    fileDir = fileFatherDir.image
    fullFilePath = path.join(uploadDir, `${fileHash}.${originalExt}`); // 完整文件路径(用fileHash避免重名)
  }

    // 4. 确保正式文件目录存在 如果目录不存在 则创建目录
    if (!fs.existsSync(uploadDir)) {
      await fsPromises.mkdir(uploadDir, { recursive: true });
    }
  // 5. 按顺序合并所有分片(使用流操作,避免大文件占用过多内存)
    // 创建可写流(目标完整文件)
    const writeStream = fs.createWriteStream(fullFilePath);

   // 逐个读取分片并写入完整文件
   for (const chunk of chunks) {
    const chunkPath = chunk.chunkPath; // 分片的临时存储路径
    // 验证分片文件是否存在
    if (!fs.existsSync(chunkPath)) {
      throw new Error(`分片文件缺失:${chunkPath}(索引:${chunk.chunkIndex})`);
    }
    // 创建可读流(当前分片),并通过管道写入可写流
    await new Promise((resolve, reject) => {
      const readStream = fs.createReadStream(chunkPath);
      readStream.pipe(writeStream, { end: false }); // end: false 表示写完当前分片不关闭可写流
      readStream.on('end', resolve); // 当前分片写入完成,继续下一个
      readStream.on('error', reject); // 读取分片出错
    });
  }

  // 所有分片写入完成后,关闭可写流
  await new Promise((resolve, reject) => {
    writeStream.on('finish', resolve);
    writeStream.on('error', reject);
    writeStream.end(); // 结束写入
  });

      // 6. 验证合并后的文件大小是否与前端传递的一致(确保合并完整)
      const mergedFileStats = await fsPromises.stat(fullFilePath);
      if (mergedFileStats.size !== Number(fileSize)) {
        throw new Error(`文件合并不完整(实际大小:${mergedFileStats.size},预期大小:${fileSize})`);
      }

          // 7. 将完整文件信息存入 Files 表
    await Files.create({
      fileHash,
      fileName,
      fileSize: Number(fileSize),
      filePath: fullFilePath // 存储完整文件的路径(或相对路径/URL,根据前端访问需求调整)
    });

     // 8. 清理临时资源:删除临时分片文件和 Chunks 表记录
    // 8.1 删除 Chunks 表记录
    await Chunks.destroy({ where: { fileHash } });
    // 8.2 删除临时分片文件(及所在目录)
    const tempDir = path.dirname(chunks[0].chunkPath); // 分片所在的临时目录(如 ./temp/{fileHash})
    if (fs.existsSync(tempDir)) {
      await fsPromises.rm(tempDir, { recursive: true, force: true }); // 递归删除目录及文件
    }

       // 9. 返回成功响应(包含完整文件的访问路径)

       const relativePath = `${fileDir}/${fileHash}.${originalExt}`;//文件存放位置
       const baseUrl = `${process.env.BASEURL}:${process.env.PORT}/uploads/` //服务器资源存放路径

       success(res, '文件合并成功', {
        isFinish: true,
        fileUrl: `${baseUrl}${relativePath}` // 前端可通过该URL访问文件(需配合express.static配置)
      });
})

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值