Spring Boot超大文件上传的正确方式

1. 简介

文件上传功能是个非常常见的需求,它允许用户将本地计算机上的文件通过网络传输到远程服务器。然而,如果不对大文件的上传进行适当的控制,很可能会对服务器造成以下不良影响:

  • 网络不稳定性:大文件上传耗时较长,期间网络的任何不稳定性都可能导致上传失败,需要重新上传整个文件,这不仅耗时而且效率低下。
  • 带宽限制:在带宽有限的网络环境中,大文件上传可能会占用大量带宽,导致其他网络活动受阻,影响用户体验。
  • 服务器负担:一次性处理大量数据会给服务器带来巨大负担,尤其是在高并发的情况下,可能导致服务器响应缓慢或崩溃。

如何解决大文件上传的问题呢?接下来,我们将介绍一种有效的解决方案——分片上传

分片上传文件指的是将大文件分割成较小的部分(即分片),然后依次或并行地将这些分片上传到服务器的过程。一旦所有分片都上传完毕,服务器会将它们合并以重新创建出原始文件。

分片上传原理:

  • 在客户端将文件分割成较小的分片
  • 将每个分片单独上传到服务器
  • 所有分片上传完成后,利用这些分片重新构建出原始文件

接下来,我们将通过Spring Boot 3与Vue 3的结合来实现大文件的分片上传功能。

2. 实战案例

2.1 前端页面

我们仅为了演示文件的分片上传功能,所以设计的页面非常简洁,仅包含三个按钮,页面效果如下所示:

前端代码

<el-upload ref="upload" class="upload-demo"
  :limit="1" :auto-upload="false" :http-request="uploadFile">
  <template #trigger>
    <el-button type="primary" style="margin-right: 10px;">选择文件</el-button>
  </template>
  <el-button class="ml-3" type="success" @click="submitUpload">
    上传文件
  </el-button>
</el-upload>
<el-button class="ml-3" type="primary" @click="mergeFile">合并文件</el-button>

JavaScript代码

<script setup name="upload">
  import { ref } from 'vue'


  const upload = ref('')
  let fileName = ''
  /**拆分文件,这里估计将文件每2M进行拆分*/
  const uploadFileInChunks = file => {
    const chunkSize = 1024 * 1024 * 2
    let start = 0
    let chunkIndex = 0


    while (start < file.size) {
      const chunk = file.slice(start, start + chunkSize)
      console.log(chunk)
      fileName = file.name
      uploadChunk(chunk, chunkIndex, fileName)
      start += chunkSize
      chunkIndex++
    }
  }
  /**对每一个拆分的文件进行上传;这就就成了小文件上传*/
  const uploadChunk = (chunk, chunkIndex, fileName) => {
    const formData = new FormData()
    formData.append('chunk', chunk)
    formData.append('chunkIndex', chunkIndex)
    formData.append('fileName', fileName)


    fetch('http://localhost:8080/upload-chunk', {
      method: 'POST',
      body: formData
    }).then(resp => {
      console.log(resp)
    })
  }
  const uploadFile = (opt) => {
    uploadFileInChunks(opt.file)
  }
  const submitUpload = () => {
    upload.value.submit()
  }
  /**合并文件*/
  const mergeFile = () => {
    const formData = new FormData()
    formData.append('fileName', fileName)
    fetch('http://localhost:8080/merge-chunks', {
      method: 'POST',
      body: formData
    }).then(resp => {
      console.log(resp)
    })
  }
</script>

前端代码还是非常简单的;其核心就是拿到上传文件的File对象,然后对文件进行拆分。

2.2 文件上传接口

@RestController
public class ChunkController {
  private static final String TEMP_DIR = "d:\\upload\\";
  @PostMapping("/upload-chunk")
  public ResponseEntity<String> uploadChunk(
      @RequestParam("chunk") MultipartFile chunk,
      @RequestParam("chunkIndex") int chunkIndex, 
      @RequestParam("fileName") String fileName) throws IOException {
    File dir = new File(TEMP_DIR + fileName);
    if (!dir.exists()) {
      dir.mkdirs();
    }
    File chunkFile = new File(dir, "chunk_" + chunkIndex);
    try (OutputStream os = new FileOutputStream(chunkFile)) {
      os.write(chunk.getBytes());
    }
    return ResponseEntity.ok("Chunk " + chunkIndex + " uploaded successfully.");
  }
}

这里非常的简单与我们平时的文件上传一模一样。

接下来,我们就可以进行文件的上传了

这里选择了一个25MB大小的文件,点击上传后控制台输出:

这里拆分成了13个小文件进行上传。

最终,后台服务上的文件如下:

以上传的文件名创建了目录,存分块后的小文件。

文件上传完成后,最后我们就要对这些文件进行合并处理了。

2.3 合并文件接口

@RestController
public class ChunkController {
  private static final String TEMP_DIR = "d:\\upload\\";
  private static final String TARGET_DIR = "d:\\upload\\result\\";
  
  @PostMapping("/merge-chunks")
  public ResponseEntity<String> mergeChunks(
      @RequestParam("fileName") String fileName) throws IOException {
    File dir = new File(TEMP_DIR + fileName);
    File mergedFile = new File(TARGET_DIR + fileName);
    try (OutputStream os = new FileOutputStream(mergedFile)) {
      for (int i = 0, len = dir.listFiles().length; i < len; i++) {
        File chunkFile = new File(dir, "chunk_" + i);
        Files.copy(chunkFile.toPath(), os);
        chunkFile.delete();
      }
    }
    dir.delete();
    return ResponseEntity.ok("文件合并完成");
  }
}

这里就是遍历目录中的所有文件,然后按照顺序写入到一个目标文件中即可。这样我们就完成了文件的合并。

到此我们实现了文件的分块上传功能。