Vue+SpringBoot上传大文件,包括暂停和继续(适用Vue2和Vue3)

最近debug发现毕设中有一个上传文件的地方,每当我上传大文件时都上传不上去,最后发现是因为大文件上传服务器太慢,超过了axios限制的50s,所以需要将大文件切片上传。

前端:vue3 + element-plus
后端:springboot

解决思路:

1. 前端在上传文件时将大文件切片向后端发送请求
2. 后端将这些文件切片存储
3. 前端将这些文件切片全部上传完成后,向后端发送merge请求
4. 后端收到merge请求后合并分片

一开始,我查资料发现有个组件vue-simple-uploader,是一个基于simple-uploader.js的Vue上传组件,支持可暂停、继续上传、错误处理、支持“快传”、支持最大并发上传分块上传支持进度、预估剩余时间、出错自动重试、重传等操作,非常符合我的需求。

但是,我按照文档试了半天,一直报错,最后才发现这个组件只能在vue2的环境中使用(我的前端框架是vue3)
如果你是vue2的环境,推荐使用这个组件:
vue-simple-uploader文档
simple-uploader.js文档
vue-simple-uploader常见问题整理

前端

前端主要参考Vue 大文件上传和断点续传

安装
npm install spark-md5 -S
上传文件button

在vue页面中使用element-ui的上传组件

<el-upload
  action="#"
  multiple
  :auto-upload="false"
  :show-file-list="true"
  :on-change="handleChange"
  drag
>
  <!-- 这个图标的书写方式,element-plus和element有区别,注意一下! -->
  <el-icon class="el-icon--upload"><upload-filled /></el-icon>
  <div class="el-upload__text">
    将文件拖到此处,或<em>点击上传</em>
  </div>
</el-upload>

因为是自定义上传,所以el-upload组件的auto-upload要设定为false
show-file-list表示显示已上传文件列表
on-change文件状态改变时的钩子函数,添加文件、上传成功和上传失败时都会被调用

处理文件状态改变
async handleChange(file) {
  if (!file) return
  this.percent = 0
  this.percentCount = 0
  this.videoUrl = ''
  // 获取文件并转成 ArrayBuffer 对象
  const fileObj = file.raw
  this.file = fileObj.name
  let buffer
  try {
    buffer = await this.fileToBuffer(fileObj)
  } catch (e) {
    console.log(e)
  }
  // 将文件按固定大小(2M)进行切片,注意此处同时声明了多个常量
  const chunkSize = 2097152,
      chunkList = [], // 保存所有切片的数组
      chunkListLength = Math.ceil(fileObj.size / chunkSize), // 计算总共多个切片
      suffix = /\.([0-9A-z]+)$/.exec(fileObj.name)[1] // 文件后缀名

  // 根据文件内容生成 hash 值
  const spark = new SparkMD5.ArrayBuffer()
  spark.append(buffer)
  const hash = spark.end()

  // 生成切片,这里后端要求传递的参数为字节数据块(chunk)和每个数据块的文件名(fileName)
   let curChunk = 0 // 切片时的初始位置
   for (let i = 0; i < chunkListLength; i++) {
     const item = {
       chunk: fileObj.slice(curChunk, curChunk + chunkSize),
       fileName: `${hash}_${i}.${suffix}` // 文件名规则按照 hash_1.jpg 命名
     }
     curChunk += chunkSize
     chunkList.push(item)
   }
   this.chunkList = chunkList // sendRequest 要用到
   this.hash = hash // sendRequest 要用到
   this.sendRequest()
 },
 // 发送请求
 sendRequest() {
   const requestList = [] // 请求集合
   this.chunkList.forEach((item, index) => {
     const fn = () => {
       const formData = new FormData()
       formData.append('chunk', item.chunk)
       formData.append('filename', item.fileName)
       return axios({
         url: '/backend-api/chunk',
         method: 'post',
         headers: { 'Content-Type': 'multipart/form-data' },
         data: formData
       }).then(res => {
         if (res.data.code === 200) { // 成功
           if (this.percentCount === 0) { // 避免上传成功后会删除切片改变 chunkList 的长度影响到 percentCount 的值
             this.percentCount = 100 / this.chunkList.length
           }
           if (this.percent >= 100) {
             this.percent = 100;
           }else {
             this.percent += this.percentCount // 改变进度
           }
           if (this.percent >= 100) {
             this.percent = 100;
           }
           this.chunkList.splice(index, 1) // 一旦上传成功就删除这一个 chunk,方便断点续传
         }
       })
     }
     requestList.push(fn)
   })

   let i = 0 // 记录发送的请求个数
   // 文件切片全部发送完毕后,需要请求 '/merge' 接口,把文件的 hash 传递给服务器
   const complete = () => {
     axios({
       url: '/backend-api/merge',
       method: 'get',
       params: { hash: this.hash, filename: this.file }
     }).then(res => {
       if (res.data.code === 200) { // 请求发送成功
         // this.videoUrl = res.data.path
         console.log(res.data)
       }
     })
   }
   const send = async () => {
     if (!this.upload) return
     if (i >= requestList.length) {
       // 发送完毕
       complete()
       return
     }
     await requestList[i]()
     i++
     send()
   }
   send() // 发送请求
 },
// 将 File 对象转为 ArrayBuffer
fileToBuffer(file) {
  return new Promise((resolve, reject) => {
    const fr = new FileReader()
    fr.onload = e => {
      resolve(e.target.result)
    }
    fr.readAsArrayBuffer(file)
    fr.onerror = () => {
      reject(new Error('转换文件格式发生错误'))
    }
  })
}

handleChange(file)函数中,当上传文件后,将文件转成ArrayBuffer对象,并且按照一定的大小切片(此处是2M),同时将这些切片文件分别命名为文件名规则按照hash值_id.文件后缀名命名,例如:f889c389ef0afe9a58ec0afcf92e23d1_0.tar。然后将这些文件切片放在chunkList中,以便后续的分片上传。

sendRequest()中按照chunkList,将这些文件切片放在一个请求集合requestList中,一次向后端发送上传的请求。当文件分片全部上传完成后,发送文件合并请求。

需要注意的是:当文件分片在上传时,我们还需要增加进度条percent,每次都增加percentCount大小。

<!-- 进度显示 -->
<span>上传进度:{{ percent.toFixed() }}%</span>
if (this.percentCount === 0) { // 避免上传成功后会删除切片改变 chunkList 的长度影响到 percentCount 的值
  this.percentCount = 100 / this.chunkList.length
}
// 在this.percent+=this.percentCount的前后都判断一次this.percent是否大于等于100
if (this.percent >= 100) {
  this.percent = 100;
} else {
  this.percent += this.percentCount // 改变进度
}
if (this.percent >= 100) {
  this.percent = 100;
}
暂停和继续
<!-- 进度显示 -->
<el-button type="primary" size="small" @click="handleClickBtn">{{ upload ? '暂停' : '继续'}}</el-button>
// 按下暂停按钮
handleClickBtn() {
  this.upload = !this.upload
  // 如果不暂停则继续上传
  if (this.upload) this.sendRequest()
},

前端完整代码可以查看我的代码仓库project-file-upload-frontend

后端

在controller中主要有两个方法(由于是自己写的一个小demo,就没有用到数据库、service、mapper等等,大家可以按需自行添加~)

上传文件分片
@Value("${file.path}") // 在application.properties中设置了对应的路径
private String dirPath;

@PostMapping("/chunk")
public ResponseMessage upLoadChunk(@RequestParam("chunk") MultipartFile chunk,
                                   @RequestParam("filename") String filename) {
   	// 用于存储文件分片的文件夹
    File folder = new File(dirPath);
    if (!folder.exists() && !folder.isDirectory())
        folder.mkdirs();

    // 文件分片的路径
    String filePath = dirPath + File.separator + filename;

    try {
        File saveFile = new File(filePath);
        // 写入文件中
        FileOutputStream fileOutputStream = new FileOutputStream(saveFile);
        fileOutputStream.write(chunk.getBytes());
        fileOutputStream.close();
        chunk.transferTo(saveFile);
        System.out.println(filename);
        return ResponseMessage.ok();
    } catch (Exception e) {
        e.printStackTrace();
    }
    return ResponseMessage.ok();
}
上传合并文件分片
@GetMapping("/merge")
public ResponseMessage MergeChunk(@RequestParam("hash") String hash,
                                  @RequestParam("filename") String filename) {
    // 文件分片所在的文件夹
    File chunkFileFolder = new File(dirPath);
    // 合并后的文件的路径
    File mergeFile = new File(dirPath + File.separator + filename);
    // 得到文件分片所在的文件夹下的所有文件
    File[] chunks = chunkFileFolder.listFiles();
    assert chunks != null;
    // 按照hash值过滤出对应的文件分片
    // 排序
    File[] files = Arrays.stream(chunks)
            .filter(file -> file.getName().startsWith(hash))
            // 分片文件命名为"hash值_id.文件后缀名"
            // 按照id值排序
            .sorted(Comparator.comparing(o -> Integer.valueOf(o.getName().split("\\.")[0].split("_")[1])))
            .toArray(File[]::new);

    try {
    	// 合并文件
        RandomAccessFile randomAccessFileWriter = new RandomAccessFile(mergeFile, "rw");
        byte[] bytes = new byte[1024];
        for (File chunk : files) {
            RandomAccessFile randomAccessFileReader = new RandomAccessFile(chunk, "r");
            int len;
            while ((len = randomAccessFileReader.read(bytes)) != -1) {
                randomAccessFileWriter.write(bytes, 0, len);
            }
            randomAccessFileReader.close();
        }
        randomAccessFileWriter.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
    System.out.println(hash);
    return ResponseMessage.ok(mergeFile);
}

后端完整代码可以查看我的代码仓库project-file-upload-backend

Logo

前往低代码交流专区

更多推荐