前言
本文结合前端大文件上传 实现GB级别文件数据的全栈式教程
需要前端Vue3+TS的文件上传方案可查看主包的主页文章,快速链接如下:
前端大文件上传方案-一站式教程(hash算法+增量+webworker多线程+并发请求池)-CSDN博客
本文用到的技术栈:
- Express+ES6语法
- Sequelize ORM工具
- MySQL数据库
- multer 文件处理
实现功能:
- 文件秒传
- 断点续传
- 分片上传
- 合并文件
核心逻辑解析
-
文件秒传:后端通过文件hash值以及其他参数,判断文件是否存在于服务器,存在直接复用,不进行上传。
-
断点续传:通过文件hash值以及其他参数,判断服务器内是否存在未上传完成的切片,有则返回未上传的切片序列。
-
分片上传:使用multer中间件对文件进行存储(可使用第三方存储,如:123网盘、阿里云盘等)
-
合并文件:验证上传切片数量、大小以及完整性,对相同文件的切片进行拼接合并。
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 接口编写
- 导入步骤4.1编写的multer函数
- 配置并创建multer实例,可配置过滤任意的文件类型(我这里仅用了图片类型进行配置)
- 编写接口文件,在接口的第二个参数配置multer中间件
- 注意:uploader.single(’chunkBlob‘)中的 chunkBlob参数为前端传递的文件内容的字段名
- 使用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. 合并文件接口(核心)
核心逻辑:
- 查询chunks数据库,检测已上传的切片总数是否与前端传递的切片总数相同。
- 查询chunks数据库中当前文件的所有切片,按照下标从小到大排序,确保合并顺序正确。
- 定义完整文件的存储路径(可按照文件类型进行分类存储)
- 确保完整文件目录存在,不存在则进行创建,确保文件合并后能够正常存放
- 使用stream流操作写入数据,减少服务器内存压力。遍历该文件的切片数据,依次对个切片进行拼接,并在拼接完成后关闭通道释放服务器内存。
- 验证文件完整性
- 将完整的文件信息存入files表中
- 清理临时资源,删除该文件的切片数据,并删除chunks数据库中的数据
- 返回完整文件的访问路径
//续 步骤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配置)
});
})