如何实现大文件上传,切片上传,断点续传

背景

文件上传是个非常普遍的场景,特别是在一些资源管理相关的业务中(比如网盘)。在文件比较大的时候,普通的上传方式可能会遇到以下四个问题。

  1. 文件上传超时:原因是前端请求框架认限制最大请求时长,或者是 nginx(或其它代理/网关) 限制了最大请求时长。

  2. 文件大小超限:原因在于后端对单个请求大小做了限制,一般 nginx 和 server 都会做这个限制。

  3. 上传耗时久。

  4. 由于各种网络原因上传失败,且失败之后需要从头开始。

对于前两点,虽说可以通过一定的配置来解决,但有时候也不会那么顺利,毕竟调大这些参数会对后台造成一定的压力,需要兼顾实际场景。只是上传慢的话忍一忍是可以接受的,但是失败后重头开始,在网络环境差的时候简直就是灾难。

思路

针对遇到的这些问题,有比较成熟的解决方案。该方案可以简答的概括为切片上传 + 秒传。

切片上传

切片上传是指将一个大文件切割为若干个小文件,分为多个请求依次上传,后台再将文件碎片拼接为一个完整的文件,即使某个碎片上传失败,也不会影响其它文件碎片,只需要重新上传失败的部分就可以了。而且多个请求一起发送文件,提高了传输速度的上限

切片上传前端代码(可直接使用)

前端代码(vue)

<template>
  <div class="upload-section">
    <h3 style="display: inline-block; margin-right: 10px;">切片上传</h3>
    <input type="file" style="display: inline-block; " @change="handleFileChange($event)">
    <button @click="uploadChunkFiles()">上传</button>
  </div>
  <div v-if="uploadProgress !== null">
    <p>上传进度: {{ uploadProgress }}%</p>
  </div>
</template>

<script>
import axios from "axios";

export default {
  name: "ChunkFile",
  data() {
    return {
      uploadProgress: null,
      chunk: null,
      chunkSize: 1024 * 1024, // 1MB 切片大小
    };
  },
  methods: {
    async uploadChunkFiles() {
      const { chunkSize } = this;
      let start = 0;
      let end = chunkSize;
      let totalChunks = Math.ceil(this.chunk.file.size / chunkSize);
      let chunkIndex = 0;

      while (start < this.chunk.file.size) {
        const chunk = this.chunk.file.slice(start, end);
        const formData = new FormData();
        formData.append("file", chunk);
        formData.append("totalChunks", totalChunks);
        formData.append("chunkIndex", chunkIndex);
        formData.append("start", start);
        formData.append("fileName", this.chunk.name);

        try {
          const config = {
            headers: { "Content-Type": "multipart/form-data" },
          };

          await axios.post("http://localhost:8080/BigFile/uploadChunk", formData, config);
          this.updateTotalProgress(chunkIndex);
        } catch (error) {
          console.error(`Error uploading chunk ${chunkIndex}`, error);
          return;
        }

        start = end;
        end = start + chunkSize;
        chunkIndex++;
      }
    },
    updateTotalProgress(chunkIndex) {
      let totalUploaded = (chunkIndex + 1) * this.chunkSize;
      this.uploadProgress = Math.round((totalUploaded / this.chunk.file.size) * 100);
    },
    handleFileChange(event) {
      const file = event.target.files[0];
      this.chunk = {
        name: file.name,
        file: file
      };
    }
  }
};
</script>

<style scoped>
.upload-section {
  width: 100%;
  margin-bottom: 10px;
}

.upload-section h3 {
  margin-bottom: 10px;
  font-size: 16px;
  color: #333;
}

.upload-section input[type="file"] {
  display: inline-block;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 5px;
  background-color: #fff;
  font-size: 16px;
  box-sizing: border-box;
}

.upload-section button {
  display: inline-block;
  padding: 10px 20px;
  margin-left: 10px;
  font-size: 16px;
  cursor: pointer;
  border: 1px solid #007bff;
  background-color: #007bff;
  color: #fff;
  border-radius: 5px;
}
</style>

后端代码

@PostMapping("/uploadChunk")
    public void uploadChunk(@RequestParam("file") MultipartFile file,
                            @RequestParam("totalChunks") Integer totalChunks,
                            @RequestParam("chunkIndex") Integer chunkIndex,
                            @RequestParam("start") Long start,
                            @RequestParam("fileName") String fileName) throws IOException {

        String filePath = UPLOAD_DIR + fileName;

        // 如果是第一个块,创建新文件
        if (chunkIndex == 0) {
            try (OutputStream outputStream = new FileOutputStream(filePath)) {
                outputStream.write(file.getBytes());
            }
        } else {
            // 如果不是第一个块,追加写入到已有文件
            try (OutputStream outputStream = new FileOutputStream(filePath, true)) {
                outputStream.write(file.getBytes());
            }
        }

        // 如果是最后一个块,可以在这里进行一些收尾工作,例如关闭流
        if (chunkIndex + 1 == totalChunks) {
            // 这里可以添加额外的处理逻辑,比如合并文件块等
        }
    }

断点续传