Skip to content

祖传开篇:本文简单介绍 怎么使用 express 实现大文件切片上传。

完整代码请前往 gitHub仓库

对应的 前端 文章请前往:vue, 完整代码请前往:vue GitHub仓库

作者水平有限,可能比较简陋,也或许有些错误,欢迎指正。

环境要求

node 18+

需要的依赖如下:

  "type": "module",
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.21.2",
    "multer": "1.4.5-lts.1",
    "nodemon": "^3.1.9"
  },
  "devDependencies": {
    "@types/node": "^22.10.5"
  }

目录结构

.
├─ router    路由文件夹  
│  ├─ router_handle   
│  │  └─ uploadChunk.js  路由详细方法
│  ├─ upload.js  路由注册页面
├─ uploads   文件服务器文件夹
└─ app.js    如果文件
└─ package.json

入口文件

启动一个 node 服务,用于监听请求。

js
import express from "express";
import cors from "cors";
import uploadRouter from "./router/upload.js";

const app = express();
const PORT = process.env.PORT || 3000;

// 防止跨域问题
app.use(cors());
// 使用中间件解析JSON格式的请求体
app.use(express.json());

// 使用中间件解析URL编码的请求体,设置extended为false表示使用内置的querystring库解析
app.use(express.urlencoded({extended: false}));


// 静态文件托管
app.use('/uploads', express.static('uploads'));
// 设置路由
app.use('/upload', uploadRouter);


// 错误处理中间件
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({
    message: '错误',
    error: process.env.NODE_ENV === 'production' ? {} : err.message
  });
});

// 监听端口
app.listen(PORT, () => {
  console.log(`服务已经启动: http://localhost:${PORT}`);
});

路由注册

  • chunk:切片文件上传接口
  • merge:合并切片文件接口
  • status:检查文件或切片是否存在

需要使用 multer 中间件,详细请看 multer

js
import * as path from 'node:path';
import {fileURLToPath} from 'node:url';
import multer from 'multer'
import * as fs from 'node:fs';
import express from 'express';
import {fileExists, mergeChunk, uploadChunk} from './router_handle/uploadChunk.js';
// 创建一个路由器实例
const router = express.Router()
// 获取当前文件的路径
const __filename = fileURLToPath(import.meta.url);
// 获取当前文件所在的目录路径
const __dirname = path.dirname(__filename);
// 定义上传目录路径
const UPLOAD_DIR = path.join(__dirname, '../uploads');
// 如果上传目录不存在,则创建上传目录
if (!fs.existsSync(UPLOAD_DIR)) {
  fs.mkdirSync(UPLOAD_DIR);
}

/**
 * 配置 multer 进行文件切片上传
 *
 * @param {Object} req - Express 请求对象,用于获取文件哈希
 * @param {Object} file - 当前正在处理的文件对象
 * @param {Function} cb - 回调函数,用于确定文件的存储位置和文件名
 */
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    // 从请求体中获取文件哈希值
    const {fileHash} = req.body;
    // 构建文件切片的目录路径
    const chunkDir = path.join(UPLOAD_DIR, fileHash);
    // 如果文件切片目录不存在,则创建该目录
    if (!fs.existsSync(chunkDir)) {
      fs.mkdirSync(chunkDir, {recursive: true});
    }
    // 设置文件切片的存储目录
    cb(null, chunkDir);
  },
  filename: (req, file, cb) => {
    // 从请求体中获取文件切片的哈希
    const {chunkHash} = req.body;
    // 设置文件切片的名称
    cb(null, `chunk-${chunkHash}`);
  }
});
// 初始化 multer 中间件
const upload = multer({storage});

// 分片上传接口
router.post('/chunk', upload.single('file'), uploadChunk);

// 合并上传文件
router.post('/merge', mergeChunk);

// 检查分片是否完成上传
router.post('/status', fileExists);

export default router;

upload.single('file') 的作用:

  • 指定字段名:'file' 是表单中文件字段的 name 属性值。
  • 自动处理文件:multer 会自动将文件保存到指定位置(磁盘或内存),并将文件信息挂载到 req.file。

路由详细方法

切片上传方法

因为在 设置路由的时候通过 upload.single('file') 已经处理了文件,所以这里只需要获取文件信息即可。

js
export const uploadChunk = async (req, res) => {
  
  if (!req.file) {
    return res.status(500).json({message: '没有文件上传', code: 500});
  }
  
  res.send({
    data: req.body,
    message: '切片上传完成',
    code: 200
  });
};

检验文件和切片

js
/**
 * 检查文件是否存在,检验文件切片是否存在
 * @param req
 * @param res
 * @returns {Promise<*>}
 */
export const fileExists = async (req, res) => {
      const {fileName, fileHash, chunkHash} = req.body
      
      const filePath = path.join(UPLOAD_DIR, fileName); // 最终文件路径
      const chunkDir = path.join(UPLOAD_DIR, fileHash); // 分片文件夹路径
      
      try {
        // 检查文件是否存在
        if (fs.existsSync(filePath)) {
          const existingFileHash = calculateFileHash(filePath);
          // 如果哈希值一致则不用上传
          if (existingFileHash === fileHash) {
            return res.send({
              code: 200,
              data: {
                fileName,
                pathUrl: `/uploads/${fileName}`
              },
              message: '文件已存在'
            });
          } else {
            // 如果不一致则更改本次上传的文件名称,通知前端继续上传
            // 如果文件哈希值不一致,生成新的文件名
            const nameArr = fileName.split('.');
            let baseName, extension;
            
            if (nameArr.length > 1) {
              baseName = nameArr.slice(0, -1).join('.');
              extension = nameArr[nameArr.length - 1];
            } else {
              baseName = fileName;
              extension = '';
            }
            
            // 生成新的文件名
            const newFileName = `${baseName}-${Date.now()}.${extension}`
            return res.send({
              code: 200,
              data: {
                fileName: newFileName
              },
              message: '文件名重复,已修改'
            })
          }
        }
        
        // 检查是否用该哈希值的文件夹
        if (!fs.existsSync(chunkDir)) {
          return res.send({
            code: 200,
            message: '允许上传',
            data: []
          });
        }
        
        // 如果文件夹存在,则对比子文件和 chunkHash , 返回已经上传的切片的哈希值
        const chunks = fs.readdirSync(chunkDir);
        const uploadedChunks = chunks
            .filter((chunk) => chunk.startsWith('chunk-'))
            .map((chunk) => chunk.split('-')[1]); // 提取分片的哈希值
        
        return res.send({
          code: 200,
          data: {
            uploadedChunks
          },
          message: '分片已存在'
        })
      
      } catch (e) {
        console.error('检查分片状态出错:', e);
        return res.status(500).send({
          code: 500,
          message: '服务器错误',
          error: e.message
        });
      }
    };

/**
 * 计算文件的哈希值
 * @param filePath
 * @returns {string}
 */
function calculateFileHash(filePath) {
  const fileBuffer = fs.readFileSync(filePath);
  return crypto.createHash('md5').update(fileBuffer).digest('hex');
}

合并文件

js
/**
 * 合并文件
 * @param req
 * @param res
 * @returns {Promise<*>}
 */
export const mergeChunk = async (req, res) => {
      const {fileName, fileHash, totalChunks, chunk} = req.body;
      const chunkDir = path.join(UPLOAD_DIR, fileHash);
      const finalPath = path.join(UPLOAD_DIR, fileName);
      
      try {
        // 读取分片目录中的所有文件
        const chunks = await fs.promises.readdir(chunkDir)
        // 过滤并排序分片,确保它们按正确顺序合并
        const sortedChunks = sortFileChunk(chunks.filter(file => file.startsWith('chunk-')), chunk)
        // 如果分片数量不匹配,返回错误响应
        if (sortedChunks.length !== totalChunks) {
          return res.send({
            message: '分片不完整',
            code: 500
          });
        }
        // 使用流式方式合并文件
        const writeStream = fs.createWriteStream(finalPath, {flags: 'a'});
        for (const chunk of sortedChunks) {
          const chunkPath = path.join(chunkDir, chunk);
          
          // 异步检查文件是否存在
          await fs.promises.access(chunkPath, fs.constants.F_OK);
          // 创建读取流并合并到最终文件中
          const readStream = fs.createReadStream(chunkPath);
          
          await new Promise((resolve, reject) => {
            readStream.pipe(writeStream, {end: false}); // 不自动结束写入流
            readStream.on('end', resolve);
            readStream.on('error', reject);
          });
          // 删除已经合并的分片
          await fs.promises.unlink(chunkPath);
        }
        
        
        // 结束写入
        writeStream.end();
        
        // 删除分片文件夹
        await fs.promises.rmdir(chunkDir);
        
        return res.send({
          message: '文件上传成功',
          data: {
            fileName,
            pathUrl: `/uploads/${fileName}`
          },
          code: 200
        });
      
      } catch (error) {
        console.error('合并出现错误:', error);
        return res.status(500).send({
          message: '合并文件失败',
          error: error.message,
          code: 500
        });
      }
    };

/**
 * 对文件切片进行排序
 * @param fileList
 * @param chunk
 * @returns {*[]}
 */
function sortFileChunk(fileList, chunk) {
  let chunkList = []
  let chunkSort = chunk.sort((a, b) => a.index - b.index)
  chunkSort.forEach(item => {
    let fileItem = fileList.find(file => file.startsWith(`chunk-${item.fileHash}`))
    chunkList.push(fileItem)
  })
  return chunkList
}