文件分片上传

概要

在日常开发中上传文件是常见的功能,像使用 SpringBoot 作为服务端接收上传的文件是很方便的,但是默认情况下 SpringBoot 为我限定了单次上传文件的大小,默认是1MB,当我们单次上传的大小超过1MB的时候就会报错,这时候我们可以通过修改上传大小限制来解决这个问题,主要是这两个属性:

  • spring.servlet.multipart.max-file-size
  • spring.servlet.multipart.max-request-size

但是这对于小型项目或者能确定每次上传的文件大小大概范围的可以,对于大型项目和不确定文件大小的项目这样的操作显然不适用,试想一下,如果你设置为了100MB,那假如有100个人同时上传文件,项目的内存不就GG了吗?因此通用稳妥的解决方法是分片上传,例如上传一个100MB的文件我们可以将这个文件先分割为100分1MB的文件,然后分别上传这100份就可以了,当然这里分片的大小要根据你的服务器配置自行调节,并不是越小越好,这样既解决了文件限制大小,而且可以利用多线程更快的进行上传。

实现过程

在实现前我们首先要考虑好,如何让服务端知道多次请求上传的文件是同一个文件的分片?你可能会想让前端在发送时生成一个uid,然后每个分片上传时都携带这个uid,然后服务端在进行文件合并的时候通过这个来判断,这个思路是正确的,那这个uid怎么生成呢?这里我们可以使用 spark-md5.js 来为文件生成一个唯一的hash值,这样既可以区分分片文件也可以在后续进行文件下载时进行文件的校验,因为一个文件的hash值是固定的,只要文件被修改那么hash值就会发生改变。

但是计算文件的hash值对于小文件我们可以直接计算,但是对于大文件,我们就要分片计算然后合并,这个过程也不用担心,spark-md5.js 里有现成的方法。因此,现在大概的流程就是:选择文件,然后将文件进行分片,在分片的同时计算每一个分片的hash值,然后分片完成后合并hash获取到完整文件的hash,最后遍历分片构造上传FormData进行上传请求。

文件的分片可以使用 JavaScript 的 slice(file, start, end) 方法实现,这里再说一下 start 和 end 参数的确定,我们先定义几个变量:

  • chunks:表示分片的数量,这个是文件大小除以分片大小向下取整,可以通过Math.ceil()方法得到。
  • currentChunk:当前chunk索引,初始值设为0
  • chunkSize:分片的大小,本文中设置为了1MB

start 的值是 currentChunk*chunkSize 确定 end 参数时有两种情况:

  1. start+chunkSize >= file.size 时 end 为 file.size
  2. start+chunkSize < file.size 时 end 为 start + chunkSize

前端完整代码

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>文件分片上传/下载测试</title>
  <script src="./js/jquery.min.js"></script>
  <script src="./js/spark-md5.min.js"></script>
  <style>
    html,
    body {
      width: 100%;
      height: 100vh;
      margin: 0;
    }

    header {
      height: 30px;
      display: flex;
      align-items: center;
      margin: 0;
      padding: 10px;
      background-color: #333;
      color: white;
    }

    header>h1 {
      font-size: 18px;
    }

    main {
      height: calc(100% - 100px);
    }

    .uploadBox {
      height: 100%;
      display: flex;
      padding: 10px;
      font-size: 14px;
    }
  </style>
</head>

<body>
  <header>
    <h1>文件分片上传测试页面</h1>
  </header>
  <main>
    <div class="uploadBox" id="upload-box">
      <div class="chooseFile">
        <label for="file">选择文件:</label>
        <button id="selectFile">选择文件</button>
        <div class="fileInfo">
          <p id="fileName">文件名称:</p>
          <p id="fileSize">文件大小:</p>
          <label for="chunkSize">分片大小:</label><input value="1024" id="chunkSize" type="number" placeholder="分片大小"><span>
            kb</span>
        </div>
        <input hidden type="file" id="file">
        <button style="width: 100%; margin-top:10px;">开始上传</button>
        <p id="uploadInfo"></p>
      </div>
    </div>
  </main>
  <script>
    $(document).ready(function () {
      $('#selectFile').click(() => {
        $('#file').click();
      });
      $('#file').change(async (e) => {
        var chunkSize = $('#chunkSize').val() * 1024;
        if (chunkSize == 0) {
          alert('分片大小不可以为0')
          return;
        }
        var file = e.target.files[0]
        $('#fileSize').append(Math.ceil(file.size / 1024) + ' kb')
        $('#fileName').append(file.name)
        //进行文件分割并上传
        sliceFileAndUpload(e.target.files, chunkSize)
      })
    });

    function sliceFileAndUpload(files, chunkSize) {
      var filePartList = [];
      var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
        file = files[0],
        chunks = Math.ceil(file.size / chunkSize),
        currentChunk = 0,
        spark = new SparkMD5.ArrayBuffer(),
        fileReader = new FileReader();
      fileReader.onload = e => {
        spark.append(e.target.result);
        currentChunk++;
        if (currentChunk < chunks) {
          loadNext();
        } else {
          var uid = spark.end();
          filePartList.forEach(item => {
            let formData = new FormData();
            formData.append('file', item.filePart);
            formData.append('uid', uid);
            formData.append('currentIndex', item.currentIndex);
            formData.append('fileName', file.name);
            $.ajax({
              url: '/file/uploadBySlice',
              method: 'POST',
              data: formData,
              processData: false,
              contentType: false,
              success: function (e) {
                console.log(e)
                $('#uploadInfo').append(e + "<br/>")
              }
            })
          })
          alert('上传完成,请到测试文件夹验证文件是否正确')
        }
      }
      fileReader.onerror = () => {
        console.log('文件读取处理发生错误')
      }
      function loadNext() {
        var temp = {}
        var start = currentChunk * chunkSize,
          end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
        let filePart = blobSlice.call(file, start, end);
        temp.currentIndex = start;
        temp.filePart = filePart;
        filePartList.push(temp);
        fileReader.readAsArrayBuffer(filePart);
      }
      loadNext();
    }
  </script>
</body>

</html>

由于是测试项目,前端的样式没有过多的美化,见谅。

服务端的代码就比较简单了,由于要进行文件的拼接,因此需要使用 RandomAccessFile 来进行文件的操作,因为它可以在指定的位置插入内容,它的 seek 就是这个作用,你可以把它看为一个指针,每次在插入文内容前先将这个指针拨到我们想要的位置,然后进行插入。

服务端代码

java 复制代码
package vip.huhailong.slicefile.controller;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;

@RestController
@RequestMapping("/file")
public class FileController {

  @Value("${upload.dir}")
  private String uploadDir;

  @PostMapping("/uploadBySlice")
  public String uploadBySlice(MultipartFile file, String fileName, String uid, long currentIndex) throws IOException {
    mkdirUploadDir();
    String filePath = uploadDir + uid+"-"+fileName;
    try (RandomAccessFile accessFile = new RandomAccessFile(filePath, "rw")) {
      accessFile.seek(currentIndex);
      System.out.println("Thread name:"+Thread.currentThread().getName()+";currentIndex:" + currentIndex + "; fileSize:" + file.getBytes().length);
      accessFile.write(file.getBytes());
      return "Thread name:"+Thread.currentThread().getName()+";currentIndex:" + currentIndex + "; fileSize:" + file.getBytes().length;
    }
  }

  private void mkdirUploadDir() {
    File uploadDirFile = new File(uploadDir);
    if (!uploadDirFile.exists()) {
      boolean mkdirs = uploadDirFile.mkdirs();
      if (!mkdirs) {
        throw new RuntimeException("创建上传目录失败");
      }
    }
  }
}

测试截图

测试截图

以上就是分片上传文件的简单实现,可以在此基础上增加更多的附加功能,本文中的代码对应的代码仓库地址:

slicefile: 分片上传测试Demo

相关推荐
心念枕惊几秒前
.NET CORE 授权进阶-角色、策略与动态权限实现
java·前端·.netcore
Yeauty35 分钟前
渲染成图再 CLI 拼接,还是进程内直推?Rust 帧到视频的两条路
开发语言·rust·音视频
geovindu1 小时前
CSharp: Breadth First Search Algorithm and Depth First Search Algorithm
开发语言·后端·算法·c#·.net·搜索算法
景同学2 小时前
把 AI 用到线上运维:可行、有效,前提是喂足信息——一次 Full GC 排障实录
java·人工智能·后端
库克克2 小时前
【C++】set 与multiset
开发语言·c++
Wang's Blog2 小时前
Go-Zero项目开发34: 微服务超时控制与重试机制实践
开发语言·微服务·golang·go-zero
C++、Java和Python的菜鸟2 小时前
第7章 后端Web实战(Tlias系统)
java
憧憬成为java架构高手的小白2 小时前
黑马八股--spring框架学习
java·学习·spring
唐青枫3 小时前
Java Picocli 实战详解:用注解写出好用的命令行工具
java
2zcode3 小时前
项目文档:基于MATLAB神经网络的心力衰竭预测与临床辅助决策系统研究
开发语言·神经网络·matlab·心力衰竭预测\