切换语言为:繁体
轻量级中间件实现大文件上传:文件切片,附完整代码

轻量级中间件实现大文件上传:文件切片,附完整代码

  • 爱糖宝
  • 2024-06-08
  • 2146
  • 0
  • 0

文件上传的方案

  1. 大文件上传:将大文件切分成较小的片段(通常称为分片或块),然后逐个上传这些分片。这种方法可以提高上传的稳定性,因为如果某个分片上传失败,只需要重新上传该分片而不需要重新上传整个文件。同时,分片上传还可以利用多个网络连接并行上传多个分片,提高上传速度。

  2. 断点续传:在上传过程中,如果网络中断或上传被中止,断点续传技术可以记录已成功上传的分片信息,以便在恢复上传时继续上传未完成的部分,而不需要重新上传整个文件。这种技术可以大大减少上传失败的影响,并节省时间和带宽。

安装依赖:

  1. express:启动后端服务,并且提供接口

  2. multer:读取文件,存储

  3. cors:解决跨域

npm i express
npm i multer
npm i cors

multer中间件

  • Multer 是一个node.js中间件,用于处理 multipart/form-data类型的表单数据,主要用于上传文件。

  • enctype = "multipart/form-data"

  • Multer 不会处理任何非 multipart/form-data 类型的表单数据。

  • 不要将 Multer 作为全局中间件使用,因为恶意用户可以上传文件到一个没有预料到的路由,应该只在需要处理上传文件的路由上使用。

初始化multer

Multer是一个函数,接受一个 options 配置对象,其中最基本的是storage属性,这将告诉 Multer 将上传文件保存在哪。如果省略 options 对象,这些文件将保存在内存中,永远不会写入磁盘,options 配置如下:

属性值 描述
dest 或者 storage 在哪里存储文件
limits 限制上传数据的大小
fileFilter 文件过滤器,控制哪些文件可以被接受
preservePath 保存包含文件名的完整文件路径
  • dest:指定上传文件的存储路径。文件名默认为随机字符。如果想自定义文件名称,使用storage自定义存储引擎属性,属性值用multer.diskStorage磁盘存储引擎来配置。

let upload = multer({dest:"attachment/"});
  • storage:自定义存储引擎,可以是磁盘存储引擎,也可以是内存存储引擎。

multer.diskStorage()磁盘存储引擎,磁盘存储引擎可以控制文件的存储。它是一个函数,函数接受一个 options 配置对象,配置对象有两个属性,属性值都是函数。destination用于指定文件存储的路径;filename用于指定文件的存储名称。

import multer from 'multer' // 上传文件处理
// 自定义磁盘存储引擎
const storage = multer.diskStorage({
    // 存储文件的目录
    destination(req, file, callback) {
        console.log(file,"destination");//打印结果如下图
        callback(null, 'uploads/'); // 第一个参数是error错误对象,不需要设置为null,第二个参数是每个上传文件存储的目录
    },
    // 存储文件的名称
    filename(req, file, callback) {
        console.log(req.body,"canshu"); // 接受前端传的参数,multipart/form-data 类型
        callback(null, `${req.body.index}-${req.body.filename}`); // 文件的名称
    }
});
// 初始化multer中间件,multer是一个函数,接受的参数是一个配置对象
const uploadContainer = multer({
    storage  // 自定义存储
});

轻量级中间件实现大文件上传:文件切片,附完整代码

轻量级中间件实现大文件上传:文件切片,附完整代码

multer.memoryStorage()内存存储引擎

  • 内存存储引擎将文件存储在内存中的 Buffer 对象,它没有任何选项。

  • 当使用内存存储引擎,文件信息将包含一个 buffer 字段,里面包含了整个文件数据。

  • 当使用内存存储,上传非常大的文件,或者非常多的小文件,会导致应用程序内存溢出。

let storage = multer.memoryStorage()
let upload = multer({storage})
  • limits:用来指定一些数据大小的限制,设置 limits 可以帮助保护站点抵御拒绝服务 (DoS) 攻击。是一个对象,包含如下属性:

属性 值类型 默认值 描述
files Number 无限 上传时,文件的最大数量
fileSize Number 无限 上传时,每一个文件最大长度 (单位:bytes)
fields Number 无限 上传时,可以提交非文件的字段的数量
fieldNameSize Number 100 bytes 上传时,每一个字段名字的最大长度
fieldSize Number 1048576 bytes,即1MB 上传时,每一个字段名的属性值的最大长度
parts Number 无限 上传时,传输的最大数量(fields + files)
headerPairs Number 2000 在上传的表单类型数据中,键值对最大组数
const multer=require("multer");
let upload=multer({
      limits:{
        files:2, //最多上传2个文件
        fileSize:5120 //设置单个文件最大为 5kb
   }
});
  • fileFilter属性值是一个函数,用来控制什么文件可以上传以及什么文件应该跳过

let storage = multer.memoryStorage()
var upload = multer({
    fileFilter(req, file, cb) {
        // 通过调用cb,用boolean值来指示是否应接受该文件
        // 拒绝这个文件,使用false,像这样:
         cb(null, false)
        // 接受这个文件,使用true,像这样:
         cb(null, true)
        // 如果有问题,可以总是这样发送一个错误:
      cb(new Error('I don\'t have a clue!'))
}})

multer方法

multer(options).single(fieldname):上传单个文件,比如一次只上传一张图片。fieldname是前端传参的上传文件的字段名称。然后它会自动将文件存储到设置的路径。

轻量级中间件实现大文件上传:文件切片,附完整代码

multer(options).array(fieldname[,maxCount]):适用于同一个字段,一次上传多个文件的情况,例如用户选择多张图片发送,接受一个以 fieldname 命名的文件数组,fieldname是前端传参的上传文件的字段名称。maxCount可选参数,可以指定限制上传的最大数量。这些文件的信息保存在 req.files

//一次最多上传3个文件
let upload=multer({dest:"attachment/"}).array("photo",3);
// 前端传参格式
let formdata = new FromData();
const fileList = [fileObj1,fileObj2,fileObj3];
formdata.append('photo',fileList)

上传的数据格式如下:

轻量级中间件实现大文件上传:文件切片,附完整代码

multer(options).fields(fields):适用于上传多个字段的情况。接受指定 fields 的混合文件。这些文件的信息保存在 req.files。fields 是一个对象数组,具有 name 和可选的 maxCount 属性。

let fieldsList=[
    {name:"photo1"},
    {name:"photo2",maxCount:2}
]
let upload=multer({dest:"attachment/"}).fields(fieldsList);
// 前端传参格式
let formdata = new FromData();
const fileList = [fileObj2,fileObj3];
formdata.append('photo1',fileObj1)
formdata.append('photo2',fileList)

上传的数据格式如下:

轻量级中间件实现大文件上传:文件切片,附完整代码

multer(options).none():接收只有文本域的表单,如果上传任何文件,会返回 “LIMIT_UNEXPECTED_FILE” 错误。

let upload=multer({dest:"attachment/"}).none();

multer(options).any():接收一切上传的文件。

let upload=multer({dest:"attachment/"}).any();

错误处理机制

当遇到一个错误,multer 将会把错误发送给 express。如果想捕捉 Multer 错误,可以使用 multer 对象下的 MulterError 类 (即 err instanceof multer.MulterError)。

var multer = require("multer")
var upload = multer().single("photo")
upload(req, res, function (err) {
	if (err instanceof multer.MulterError) {
	  // 捕捉 Multer 错误
	} else if (err) {
	  // 捕捉 express 错误
	} else {
	  // 上传成功
	}
})

前端

上传部分的逻辑可以分为三部分:

  • 对每个切片进行包装:首先将数据放进 formdata 中,然后发起请求

  • 使用 Promise.all 发送请求

  • 发起 merge 请求让后端进行切片的合并

前端代码:

<script setup>
import { ref } from "vue";
import axios from "axios";
import { ElMessage } from "element-plus";
// 文件列表
const fileListArray = ref([]);
// 文件上传前限制文件类型和大小
const beforeUpload = (file) => {
  // const mimeTypes = ['audio/mpeg', 'audio/x-m4a', 'audio/aac', 'video/mp4', 'video/x-m4v']
  // if (!mimeTypes.includes(file.type)) {
  //   ElMessage({
  //     type: 'error',
  //     message: '只能上传 MP3、M4A、AAC、MP4、M4V 格式的文件',
  //     duration: 6000
  //   })
  //   return false
  // }
  if (file.size / 1024 / 1024 / 1024 > 1.5) {
    ElMessage({
      type: "error",
      message: "文件大小不能超过 1.5G",
      duration: 6000,
    });
    return false;
  }
  return true;
};
// 当前切片上传 AbortController
let controller = null;
// 上传进度
const percentage = ref(0);
const dialogVisible = ref(false);
// 取消上传
const cancelUpload = ref(false);
// 文件总大小
let fileTotal = 0;
// 已上传文件切片的大小
let loadedSize = 0;
// 文件的名字
let fileCancelName = '';
// 分割文件为多个切片
const sliceFile = (file, chunkSize = 1024 * 10) => {
  // file 接受文件对象,chunkSize 文件切片大小,1024*1024表示为1M,这里默认是是10kb,实际可以根据项目情况进行设定
  // 存放切片文件的数组
  let chunks = [];
  // file.size 文件对象取出文件大小,单位是字节byte,1024byte = 1kb
  for (let i = 0; i < file.size; i += chunkSize) {
    // 切割文件:[0-10kb,10kbM-20kb,20kb-30kb,...]
    // i切片开始位置,i + chunkSize切片结束位置
    chunks.push(file.slice(i, Math.min(i + chunkSize, file.size)));
  }
  return chunks;
};
// 上传单个文件切片,对每个切片进行包装并上传:首先将数据放进 formdata 中,然后发起请求
const uploadChunk = (chunk, index, filename, mimeTypes) => {
  controller = new AbortController(); // 每一次上传切片都要新生成一个 AbortController ,否则重新上传会失败
  return new Promise((resolve, reject) => {
    // 将数据放进 formdata 中
    const formData = new FormData();
    // 每个切片标识字段
    formData.append("index", index);
    // 文件名字段
    formData.append("filename", filename);
    // 文件MIME类型字段
    formData.append("type", mimeTypes);
    // 切片文件,上传的文件必须写在最后(因为后端multer模块读取参数时,当读取到文件时就会停止读取参数,其后面的参数就不会再处理了)
    formData.append("file", chunk);
    if (cancelUpload.value) {
      // 若已经取消上传,则不再上传切片
      console.log("cacel");
      return;
    }
    // 发起请求
    axios({
      url: "http://localhost:3000/upload",
      method: "POST",
      data: formData,
      // `onUploadProgress` 允许为上传处理进度事件
      onUploadProgress: (progressEvent) => {
        // progressEvent.loaded 为已上传文件字节数,progressEvent.total 为文件总字节数
        // 处理原生进度事件,计算上传进度,将每个已经上传的切片文件的字节数相加再除以文件总的大小
        loadedSize += progressEvent.loaded;
        percentage.value = Number(
          ((Math.min(fileTotal, loadedSize) / fileTotal) * 100).toFixed(2)
        );
      },
      signal: controller.signal, // 取消上传
    })
      .then(resolve)
      .catch(reject);
  });
};
// 使用Promise.all上传所有切片
const uploadFileChunks = async (chunks, filename, mimeTypes) => {
  // chunks 存储切割完成的文件切片数组 filename 上传的文件切片的文件名
  try {
    const res = await Promise.all(
      chunks.map((chunk, index) =>
        uploadChunk(chunk, index, filename, mimeTypes)
      )
    );
    // 合并文件的名字
    const fileName = res[0].data.data.filename;
    // 文件类型
    const type = res[0].data.data.type;
    console.log("All chunks uploaded successfully");
    setTimeout(() => {
      // 延迟关闭上传进度框用户体验会更好
      dialogVisible.value = false;
      ElMessage({
        message: "上传成功",
        type: "success",
      });
      axios.post("http://localhost:3000/merge", {
        filename: fileName,
        mimeTypes: type,
      }); // 调用后端合并切片接口,参数需要与后端对齐
    }, 500);
  } catch (error) {
    console.error("Error uploading chunks", error);
  }
};
// 自行实现上传文件的请求,使用Promise.all上传所有切片
const upload = async (file) => {
  percentage.value = 0; // 每次上传文件前清空进度条
  dialogVisible.value = true; // 显示上传进度
  cancelUpload.value = false; // 每次上传文件前将取消上传标识置为 false
  // 上传的文件
  const uoloadfile = file.file;
  fileTotal = uoloadfile.size;
  // 获取文件名
  const filename = uoloadfile.name.split(".")[0];
  fileCancelName = filename;
  // 获取文件的MIME类型
  const mimeTypes = uoloadfile.name.split(".")[1];
  // 将文件进行切片
  const chunks = sliceFile(uoloadfile);
  // 开始上传
  uploadFileChunks(chunks, filename, mimeTypes);
};
// 取消上传
const cancel = () => {
  dialogVisible.value = false;
  cancelUpload.value = true;
  controller?.abort();
  axios.post("http://localhost:3000/cancelUpload", { filename: fileCancelName }); // 调用后端接口,删除已上传的切片
};
</script>

<template>
  <div>
    <el-upload
     
      :before-upload="beforeUpload"
      :http-request="upload"
      :show-file-list="false"
    >
      <template #trigger>
        <el-button type="primary">选取文件</el-button>
      </template>
      <template #tip>
        <div>不能低于1M</div>
      </template>
    </el-upload>
    <el-dialog
      v-model="dialogVisible"
      :fullscreen="true"
      :show-close="false"
      custom-class="dispute-upload-dialog"
    >
      <div>
        <div class="fz-18 ellipsis">正在上传</div>
        <el-progress
          :text-inside="true"
          :stroke-width="16"
          :percentage="percentage"
        />
        <el-button @click="cancel">取消上传</el-button>
      </div>
    </el-dialog>
  </div>
</template>


<style>
.dispute-upload-dialog {
  background: none;
}
</style>

<style scoped>
.center {
  color: #fff;
  width: 50%;
  text-align: center;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
</style>

轻量级中间件实现大文件上传:文件切片,附完整代码

轻量级中间件实现大文件上传:文件切片,附完整代码

轻量级中间件实现大文件上传:文件切片,附完整代码

上传切片:

轻量级中间件实现大文件上传:文件切片,附完整代码

取消上传:

轻量级中间件实现大文件上传:文件切片,附完整代码

上传完成

轻量级中间件实现大文件上传:文件切片,附完整代码

后端(node.js)

后端主要提供两个接口

  1. upload:用来存储切片,用multer中间件帮忙处理文件的存储

  2. merge:用于合并切片,根据前端上传的文件名合并文件切片,并按照切片的索引从小到大进行合并。合并完的切片需要删除切片并将合并的文件存储。

后端代码:

import fs from 'node:fs' // 文件操作
import path from 'node:path' // 路劲操作
import express from 'express' // 提供接口服务
import multer from 'multer' // 上传文件处理
import cors from 'cors' // 解决跨域
// 自定义磁盘存储引擎
const storage = multer.diskStorage({
    // 存储文件的目录
    destination(req, file, callback) {
        callback(null, 'uploads/'); // 第一个参数是error错误对象,不需要设置为null,第二个参数是每个上传文件存储的目录
    },
    // 存储文件的名称
    filename(req, file, callback) {
        // req.body接受前端传的参数,multipart/form-data 类型
        callback(null, `${req.body.index}-${req.body.filename}`); // 文件的名称
    }
});
// 初始化multer中间件,multer是一个函数,接受的参数是一个配置对象
const uploadContainer = multer({
    storage  // 自定义存储
});
const app = express();
// 注册跨域中间件,支持跨域
app.use(cors());
app.use(express.json());
// 上传切片接口,在上传文件的接口加上multer中间件
app.post('/upload', uploadContainer.single('file'), (req, res) => {
    // 前端传入的字段
    const { filename, type } = req.body;
    res.send({
        succes: true,
        msg: '切片上传成功',
        data: {
            filename,
            type
        }
    });
});
// 合并切片接口
app.post('/merge', async (req, res) => {
    // 读取存放切片的目录:process.cwd()会返回当前文件的工作目录的绝对路径;path.join()使用系统的分隔符将路径片段进行拼接
    const uploadDir = path.join(process.cwd(), 'uploads');
    console.log(uploadDir, 'path join');
    // 读取存放切片目录下的所有切片文件,返回的是一个数组,但是数组是乱序的
    let files = fs.readdirSync(uploadDir);
    console.log(files, 'files Array');
    // 给数组排序,按照切片的索引进行升序排序
    files = files.sort((a, b) => a.split('-')[0] - b.split('-')[0]);
    console.log(files, 'files Array by order');
    // 合并切片后完整的文件存放的路径
    const writePath = path.join(process.cwd(), `finallyFile`, `${req.body.filename}.${req.body.mimeTypes}`);
    console.log(writePath, 'writePath');
    // 遍历切片数组,合并切片
    files.forEach((item) => {
        // 合并切片
        // readFileSync()读取文件,appendFile()以追加方式写文件
        // writePath:写入文件的路径
        // data:要写入文件的数据此处是fs.readFileSync(path.join(uploadDir, item))读取出来的每个切片
        // path.join(uploadDir, item)):拼接的每个切片的路径
        fs.appendFileSync(writePath, fs.readFileSync(path.join(uploadDir, item)));
        // 删除合并完的切片
        fs.unlinkSync(path.join(uploadDir, item));
    });
    res.send({
        succes: true,
        msg: '文件上传成功',
    });
});
// 删除切片
app.post('/cancelUpload', (req, res) => {
    // 读取存放切片的目录:process.cwd()会返回当前文件的工作目录的绝对路径;path.join()使用系统的分隔符将路径片段进行拼接
    const uploadDir = path.join(process.cwd(), 'uploads');
    // 读取存放切片目录下的所有切片文件,返回的是一个数组
    let files = fs.readdirSync(uploadDir);
    // 需要删除的切片数组
    const unlinkArr = files.filter(item => {
        // 前端传入的文件名
        if (item.indexOf(req.body.filename) != -1) {
            return true;
        }
    });
    unlinkArr.forEach(item => {
        // 删除需要删除的切片
        fs.unlinkSync(path.join(uploadDir, item));
    });
    res.send({
        succes: true,
        msg: '文件取消上传成功',
    });
});
app.listen(3000, () => {
    console.log('3000 port is running');
});


轻量级中间件实现大文件上传:文件切片,附完整代码

轻量级中间件实现大文件上传:文件切片,附完整代码

轻量级中间件实现大文件上传:文件切片,附完整代码

0条评论

您的电子邮件等信息不会被公开,以下所有项均必填

OK! You can skip this field.