您的当前位置:首页正文

细说分片上传与极速秒传(SpringBoot+Vue实现)

来源:筏尚旅游网

主题:分片上传 + 极速秒传 + 断点续传

第一:预期目标

  • 目标:需要突破服务端上传大小限制,实现大视频文件的上传
  • 预期:大视频文件上传不受上传大小的限制

第二:评估结果

要想实现大文件上传有两种方式:

下面主要就分片上传的方案做阐述。

第三:分片上传

前期准备

首先这里上传功能用antd的上传组件来实现,通过自定义上传动作来完成分片上传;并且做文件切片时需要记录下文件的 md5 信息,以便后续在服务端根据md5值来进行文件合并,这里需要用到spark-md5 库来做文件md5计算,同时使用的 axios 来发起请求,具体依赖如下:

前端逻辑

1)上传组件

首先是上传组件部分,使用antd的upload组件,添加一个按钮来操作上传动作,顺便添加一个进度条组件来展示上传情况,具体情况见代码:

<a-upload 
   :file-list="fileList"
   :remove="handleRemove"
   :multiple="false"
   :before-upload="beforeUpload">
  <a-button>
    <upload-outlined></upload-outlined>
    选择文件
  </a-button>
</a-upload>
<a-button
  type="primary"
  :disabled="fileList.length === 0 || !finishSlice"
  :loading="uploading"
  style="margin-top: 16px"
  @click="handleUpload">
  {{ uploading ? "上传中" : "开始上传" }}
</a-button>
<a-progress :percent="Math.round(sliceProgress/sliceCount*100)"
            :status="sliceProgress===sliceCount ? 'success':'active'" v-if="showSliceProgress"/>
<a-progress :percent="Math.round(finishCount/sliceCount*100)"
            :status="finishCount===sliceCount ? 'success':'active'" v-if="showProgress"/>

其中 fileList 代表的是上传文件列表;handleRemove 是操作删除文件选择的方法;beforeUpload 代表的是上传文件之前的预操作方法,这里可以在这里进行文件切片;handleUpload 代表的是开始上传文件的方法。

2)变量定义

接下来是上传相关逻辑的编写,这里使用的是 typescript,先看一下定义的一些变量:

// 文件列表
const fileList = ref<File[]>([]);
// 上传状态
const uploading = ref<boolean>(false);
// 分片完成情况
const finishSlice = ref<boolean>(false);
// 完成上传的分片数量
const finishCount = ref<number>(0);
// 展示上传进度条
const showProgress = ref<boolean>(false);
// 切片数量
const sliceCount = ref<number>(0);
// 切片进度条
const sliceProgress = ref<number>(0);
// 上传失败的数量
const errorCount = ref<number>(0);
// 展示切片进度条
const showSliceProgress = ref<boolean>(false);
// 切片列表
let fileChunkList: any = [];
// 发送的切片数量
const sendCount = ref<number>(0);
// 文件类型
let filetype = "";
// 文件名
let filename = "";
// 文件hash值
let hash = "";

3)文件切片

接下来是进行文件的切片操作,这里需要使用到 spark-md5。

import SparkMD5 from 'spark-md5'

这里是将文件整体读入计算md5,好处是md5碰撞的概率大大降低,缺点是计算时间会长一些;如果想计算时间短一些,不追求极致的低碰撞率的话,可以考虑读入第一个切片和最后一个切片进行md5计算。这里可以根据实际情况酌情考虑。

const beforeUpload = (file: File) => {
  message.info("开始文件切片");
  // 显示切片进度条
  showSliceProgress.value = true;
  // 文件添加到文件列表 这里只展示单文件上传
  fileList.value = [file];
  // 一些参数的初始化
  fileChunkList = [];
  finishSlice.value = false;
  finishCount.value = 0;
  sliceProgress.value = 0;
  showProgress.value = false;
  sliceCount.value = 0;
  errorCount.value = 0;
  
  return new Promise((resolve, reject) => {
    // 初始化md5工具对象
    const spark = new SparkMD5.ArrayBuffer();
    // 用于读取文件计算md5
    const fileReader = new FileReader();
    // 这里是依据.来对文件和类型进行分割
    let fileInfo = file.name.split(".")
    filename = fileInfo[0];
    // 最后一个.之前的内容都应该认定为文件名称
    if (fileInfo.length > 1) {
      filetype = fileInfo[fileInfo.length - 1];
      for (let i = 1; i < fileInfo.length - 1; i++) {
        filename = filename + "." + fileInfo[i];
      }
    }
    // 这里开始做切片
    // 设置切片大小 可以根据实际情况设置
    const chunkSize = 1024 * 1024 * 1;
    // 计算出切片数量
    sliceCount.value = Math.ceil(file.size / chunkSize);
    let curChunk = 0;
    // 切片操作的实际方法【定义】
    const sliceNext = () => {
      // 使用slice方法进行文件切片
      const chunkFile = file.slice(curChunk, curChunk + chunkSize);
      // 读取当前切片文件流【这里会触发onload方法】
      fileReader.readAsArrayBuffer(chunkFile);
      // 加入切片列表
      fileChunkList.push({
        // 切片文件信息
        chunk: chunkFile,
        // 文件名
        filename: filename,
        // 分片索引 这里直接借助sliceProgress来实现
        seq: sliceProgress.value + 1,
        // 文件类型
        type: filetype,
        // 状态信息 用于标识是否上传成功
        status: false
      });
      // 切片完成变量自增
      sliceProgress.value++;
    };
    
    // 进入方法需要进行首次切片操作
    sliceNext();
    
    // 读取文件流时会触发onload方法
    fileReader.onload = (e: any) => {
      // 将文件流加入计算md5
      spark.append(e.target.result);
      // 修改切片位移
      curChunk += chunkSize;
      // 说明还没到达最后一个切片 继续切
      if (sliceProgress.value < sliceCount.value) {
        sliceNext();
      } else {
        // 说明切片完成了
        finishSlice.value = true;
        // 读取文件hash值
        hash = spark.end();
        message.success("文件分片完成");
        // 将哈希值作为其中一个属性 写入到分片列表中
        fileChunkList.forEach((content: any) => {
          content.hash = hash;
        })
      }
    };
  })
};

到这里文件的切片和md5计算就完成了,一个大文件也变成了多个小文件的列表。

4)上传分片

接下来介绍的是开始分片上传的逻辑,这里需要注意不能一次性将分片全部上传,如果切片数量太大一次性发送出去会导致客户端卡死崩溃,因此采用递归调用的方式来确保同一时间等待的请求在一定数量,这里限定同时间等待请求数为10。

// 开始执行上传切片逻辑
const startUpload = () => {
  return new Promise((resolve, reject) => {
    const next = () => {
      // 递归出口 分片上传完毕
      if (finishCount.value + errorCount.value >= sliceCount.value) {
        return;
      }
      // 记录当前遍历位置
      let cur = sendCount.value++;
      // 说明越界了 直接退出
      if (cur >= sliceCount.value) {
        return;
      }
      // 获取分片信息
      let content = fileChunkList[cur];
      // 已经上传过了 直接跳过【可用于断点续传】
      if (content.status === true) {
        if (finishCount.value + errorCount.value < sliceCount.value) {
          next();
          return;
        }
      }
      // 开始填充上传数据 这里需要使用FormData来存储信息
      const formData = new FormData();
      formData.append("file", content.chunk);
      formData.append("hash", content.hash);
      formData.append("filename", content.filename);
      formData.append("seq", content.seq);
      formData.append("type", content.type);
      // 开始上传
      axios.post("http://localhost:8080/upload", formData).then((res) => {
        // 接收回调信息
        const data = res.data;
        if (data.success) {
          // 成功计数 并设置分片上传状态
          finishCount.value += 1;
          content.status = true;
        } else {
          // 失败计数
          errorCount.value += 1;
        }
        // 说明完成最后一个分片上传但上传期间出现错误
        if (errorCount.value !== 0 && errorCount.value + finishCount.value === sliceCount.value) {
          message.error("上传发生错误,请重传");
          showProgress.value = false;
          uploading.value = false;
        }
        // 说明还有分片未上传 需要继续递归
        if (finishCount.value + errorCount.value < sliceCount.value) {
          next();
        }
        // 说明所有分片上传成功了 发起合并操作
        if (finishCount.value === sliceCount.value) {
          merge();
        }
      }).catch(error => {
        // 对于图中发生的错误需要捕获并记录
        errorCount.value += 1;
        if (errorCount.value !== 0 && errorCount.value + finishCount.value === sliceCount.value) {
          message.error("上传发生错误,请重传");
          showProgress.value = false;
          uploading.value = false;
        }
        // 当前分片上传失败不应影响下面的分片
        if (finishCount.value + errorCount.value < sliceCount.value) {
          next();
        }
        console.log(error)
      })
    };
    // 只允许同时10个任务在等待
    while (sendCount.value < 10 && sendCount.value < sliceCount.value) {
      next();
    }
  });
};

5)文件合并

接下来还应该实现 merge 方法的逻辑,主要用于向服务端发送合并请求,服务端接收后进行分片合并操作,那么这里就应该将需要合并的文件的hash值传过去,才可以完成文件的定位。

const merge = () => {
  message.success('上传成功,等待服务器合并文件');
  // 发起合并请求 传入文件hash值、文件类型、文件名 
  axios.post("http://localhost:8080/merge", {
    hash: hash,
    type: filetype,
    filename: filename
  }).then((res) => {
    const data = res.data;
    if (data.success) {
      message.success(data.message);
      // 获取上传成功的文件地址
      console.log(data.content);
      // 其他业务操作...
    } else {
      message.error(data.message)
    }
    uploading.value = false;
  }).catch(e => {
    message.error('发生错误了');
    uploading.value = false;
  });
};

6)取消文件

最后完成取消选择文件的逻辑,也就是上面标注的 handleRemove 方法:

const handleRemove = (file: File) => {
  const index = fileList.value.indexOf(file);
  const newFileList = fileList.value.slice();
  let hash = "";
  newFileList.splice(index, 1);
  fileList.value = newFileList;
  // 取消之后需要进行相关变量的重新初始化
  fileChunkList = [];
  finishSlice.value = false;
  finishCount.value = 0;
  sliceProgress.value = 0;
  showProgress.value = false;
  sliceCount.value = 0;
  errorCount.value = 0;
};

7)极速秒传

实际上到这里我们已经实现了分片上传与合并的功能了,但出于节省资源与提升用户体验的考虑,我们还可以加入极速秒传的逻辑。这一块实际上就是服务端合并文件之后将(hash:file-site)信息存储起来,存储到DB或者Cache中,接下来前端在每次上传文件时都会先请求文件检查接口,如果文件存在则无需执行上传操作。

const handleUpload = async () => {
  if (!finishSlice.value) {
    alert("文件切片中,请稍等~");
    return;
  }
  // 进度条变更
  showSliceProgress.value = false;
  // 先检查是否已经上传过
  axios.get("http://localhost:8080/check?hash=" + hash).then((res) => {
    const data = res.data;
    if (data.success) {
      message.success(data.message);
      console.log(data.content);
    } else {
      // 开始上传逻辑 相关变量状态更迭
      uploading.value = true;
      // 这里主要是服务于断点续传 避免重复上传已成功分块
      sliceCount.value -= finishCount.value;
      errorCount.value = 0;
      finishCount.value = 0;
      sendCount.value = 0;
      showProgress.value = true;
      console.log("开始上传")
      // 调用上面写好的上传逻辑
      startUpload();
    }
  }).catch(error => {
    alert("发生异常了")
    console.log(error)
  })
}

到这里我们就完成了分片上传/极速秒传的前端逻辑,接下来就应该考虑后端的实现了。

后端逻辑

后端的基本思路是,接收到分片信息后根据hash值创建文件夹,之后将接收到的同一个hash值的分片信息都存储到同一个文件夹下【这里需要注意存储时要打好序号,才可以按序合并】,待收到合并请求后合并文件,根据合并文件的hash值与源hash值做比较,确保文件无损。
这里后端使用 SpringBoot 实现,依旧是常见的分层模型,Controller 层负责请求接口定义,Service 层负责业务逻辑的编写,由于这里不涉及到数据库的交互因而省略DAO层相关编写。
先确定下来提供的接口数,现在我们需要一个接收分片的接口,一个接受合并请求的接口,最后还要有一个接受文件检查的接口用于极速秒传,具体如下:

1)返回实体

先来看看定义的全局返回实体,目的是同一后端返回样式,方便前端获取:

import java.io.Serializable;
/**
 * @author h0ss
 * @description 用于系统业务响应数据的统一封装
 */
public class CommonResp<T> implements Serializable {
    private static final Long serialVersionUID = 205112889857456165L;
    /**
     * 业务上的成功或失败
     */
    private boolean success = true;

    /**
     * 返回信息
     */
    private String message;

    /**
     * 返回泛型的消息体数据
     */
    private T content;

    // 省略getter/setter/toString方法
}

2)上传接口

接下来是接口的具体定义与内容:

/**
 * 上传分片的接口
 *
 * @param file     : 文件信息
 * @param hash     : 文件哈希值
 * @param filename : 文件名
 * @param seq      : 分片序号
 * @param type     : 文件类型
 */
@PostMapping("/upload")
public CommonResp<String> uploadSlice(@RequestParam(value = "file") MultipartFile file,
                                      @RequestParam(value = "hash") String hash,
                                      @RequestParam(value = "filename") String filename,
                                      @RequestParam(value = "seq") Integer seq,
                                      @RequestParam(value = "type") String type) {
    try {
        // 返回上传结果
        return uploadService.uploadSlice(file.getBytes(), hash, filename, seq, type);
    } catch (IOException e) {
        // ...日志记录异常信息...
        CommonResp<String> resp = new CommonResp<>();
        resp.setSuccess(false);
        resp.setMessage("上传失败");
        return resp;
    }
}

接口的信息很简单,就是将参数预处理后调用服务方法将结果返回,接下来看看服务方法:

private static String BASE_DIR = "I:\\";

/**
 * 分片上传
 *
 * @param file     : 文件流
 * @param hash     : 哈希值
 * @param filename : 文件名
 * @param seq      : 分片序号
 * @param type     : 文件类型
*/
public CommonResp<String> uploadSlice(byte[] file, String hash, String filename, Integer seq, String type) {
    CommonResp<String> resp = new CommonResp<>();
    RandomAccessFile raf = null;
    try {
        // 创建目标文件夹
        File dir = new File(BASE_DIR + hash);
        if (!dir.exists()) {
            dir.mkdir();
        }
        // 创建空格文件 名称带seq用于标识分块信息
        raf = new RandomAccessFile(BASE_DIR + hash + "\\" + filename + "." + type + seq, "rw");
        // 写入文件流
        raf.write(file);
    } catch (IOException e) {
        // 异常处理
        // ...打印异常日志...
        resp.setSuccess(false);
    } finally {
        try {
            if (raf != null) {
                raf.close();
            }
        } catch (IOException e) {
            // ...打印异常日志...
        }
    }
    return resp;
}

这样我们就实现了分片信息的写入。

3)分片合并

接下来就应该实现分块合并的逻辑了,对于接受的请求信息我们用一个实体类来包装,免得使用 Map 造成指向不明确:

public class MergeInfo implements Serializable {
    private static Long serialVersionUID = 1351063126163421L;
    /* 文件名 */
    private String filename;
    /* 文件类型 */
    private String type;
    /* 文件哈希值 */
    private String hash;
    
    // ...省略setter/getter/toString...
}

接下来就可以写请求接口的信息了:

@PostMapping("/merge")
public CommonResp<String> merge(@RequestBody MergeInfo mergeInfo) {
    if (mergeInfo!=null) {
        String filename = mergeInfo.getFilename();
        String type = mergeInfo.getType();
        String hash = mergeInfo.getHash();
        return uploadService.uploadMerge(filename, type, hash);
    }
    CommonResp<String> resp = new CommonResp<String>();
    resp.setSuccess(false);
    resp.setMessage("文件合并失败");
    return resp;
}

接口还是只对请求参数做预处理,具体看合并的业务层代码:

@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 合并文件的业务代码
*
* @param filename : 文件名
* @param hash     : 文件哈希值
* @param type     : 文件类型
*/
public CommonResp<String> uploadMerge(String filename, String type, String hash) {
    CommonResp<String> resp = new CommonResp<>();
    // 判断hash对应文件夹是否存在
    File dir = new File(BASE_DIR + hash);
    if (!dir.exists()) {
        resp.setSuccess(false);
        resp.setMessage("合并失败,请稍后重试");
        System.out.println(resp);
    }
    // 这里通过FileChannel来实现信息流复制
    FileChannel out = null;
    // 获取目标channel
    try (FileChannel in = new RandomAccessFile(BASE_DIR + filename + '.' + type, "rw").getChannel()) {
        // 分片索引递增
        int index = 1;
        // 开始流位置
        long start = 0;
        while (true) {
            // 分片文件名
            String sliceName = BASE_DIR + hash + '\\' + filename + '.' + type + index;
            // 到达最后一个分片 退出循环
            if (!new File(sliceName).exists()) {
                break;
            }
            // 分片输入流
            out = new RandomAccessFile(sliceName, "r").getChannel();
            // 写入目标channel
            in.transferFrom(out, start, start + out.size());
            // 位移量调整
            start += out.size();
            out.close();
            out = null;
            // 分片索引调整
            index++;
        }
        // 文件合并完毕
        in.close();
        // ...执行本地存储服务/第三方存储服务上传 返回文件地址...
        // 这里假设是fileSite
        String fileSite = "";
        resp.setContent(fileSite);
        resp.setMessage("上传成功");
        // 地址存入redis 实现秒传
        stringRedisTemplate.opsForValue().set("upload:finish:hash:" + hash, fileSite);
        return resp;
    } catch (IOException e) {
        // ...记录日志..
    } finally {
        if (out != null) {
            try {
                out.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    resp.setSuccess(false);
    resp.setMessage("上传失败,请稍后重试");
    return resp;
}

这样我们就实现了接收分片上传与分片合并的请求了。

4)极速秒传

除此之外还有极速秒传的检查接口,逻辑比较简单,只要判断 Redis 是否存在该文件 hash 值的 key 即可,具体逻辑如下:

/**
 * 极速秒传接口
 *
 * @param hash : 文件哈希值
 */
@Override
public CommonResp<String> fastUpload(String hash) {
    return uploadService.fastUpload(hash);
}

/**
 * 极速秒传业务代码
 *
 * @param hash : 文件哈希值
 */
public CommonResp<String> fastUpload(String hash) {
    CommonResp<String> resp = new CommonResp<>();
    String key = "upload:finish:hash:" + hash;
    String fileSite = stringRedisTemplate.opsForValue().get(key);
    // 文件已存在 直接返回地址
    if (fileSite != null) {
        resp.setSuccess(true);
        resp.setContent(fileSite);
        resp.setMessage("极速秒传成功");
    } else {
        resp.setSuccess(false);
        resp.setContent("");
        resp.setMessage("极速秒传失败");
    }
    return resp;
}

至此,我们就实现了后端的分片上传合并以及极速秒传的逻辑,到这里前后端代码就可以联调,开始测试了。

总结

  • 1)文件切片时需要注意计算出文件的 hash 值,以便后续进行合并识别;
  • 2)对于分片需要记录下分片的索引信息,否则组装时可能会乱序造成文件损坏;
  • 3)文件信息可暂存在 Redis 中,但建议最终还是持久化到 DB。
  • 4)以上方法不适合 上传文件到其他服务器,只适合当前应用服务器;如果想构建文件服务器,建议使用 MinIO ,但是MinIO 有可能会出现安全问题,结合项目场景使用; 请参考:https://juejin.cn/post/7381476230775701558?searchId=2024102816410291F89E518BF3630719E1

结合自己项目使用情况改造

以下是本项目情况使用改造,思路都是一样的:检查文件是否已存在+大文件分片+分片上传+合并分片

前端代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>upload</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
  </head>
  <body>
    upload

    <form enctype="multipart/form-data">
      <input type="file" name="fileInput" id="fileInput" />
      <input type="button" value="上传" onclick="uploadFile()" />
    </form>

    <script>
      //每片的大小
      var CHUNK_SIZE = 1 * 1024 * 1024
      var bigUploadResult = document.getElementById('bigUploadResult')
      var fileMd5Span = document.getElementById('fileMd5')
      var checkFileRes = document.getElementById('checkFileRes')
      var fileMd5
      var uploadResult = document.getElementById('uploadResult')
      var checkUrl =
        'http://127.0.0.1:8080/api/app/gateway/V2/file/upload/checkUpload'
      var sliceUrl =
        'http://127.0.0.1:8080/api/app/gateway/V2/file/upload/slice'
      var mergeUrl =
        'http://127.0.0.1:8080/api/app/gateway/V2/file/upload/uploadMerge'
      var pageData = {
        headers: {
          // token: sessionStorage.getItem('token'),
          token: '4a0c152540fe9d0a040d51983d9a3845',
        },
      }

      let fileType = ''
      let fileHash = ''
      let fileName = ''
      function uploadFile() {
        var fileInput = document.getElementById('fileInput')
        var file = fileInput.files[0]
        if (!file) return // 没有选择文件
        console.log(file)
        handleUpload(file)
      }

      async function handleUpload(file) {
        //文件分片
        const chunks = createChunks(file)

        //hash计算
        const result = await calculateHash(chunks)
        fileHash = result

        //查询是否存在
        checkIsHave(chunks)
      }

      //文件分片
      function createChunks(file) {
        let cur = 0
        let chunks = []
        while (cur < file.size) {
          const blob = file.slice(cur, cur + CHUNK_SIZE)
          chunks.push(blob)
          cur += CHUNK_SIZE
        }
        return chunks
      }

      function calculateHash(chunks) {
        return new Promise((resolve) => {
          //1.第一个和最后一个参与计算
          //2.中间切片只前两个,中间两个,最后两个字节计算
          const targets = [] //存储所有切片

          const spark = new SparkMD5.ArrayBuffer()
          const fileReader = new FileReader()
          chunks.forEach((chunk, index) => {
            if (index == 0 || index == chunks.length - 1) {
              //1.第一个和最后一个参与计算
              targets.push(chunk)
            } else {
              //2.中间切片只前两个,中间两个,最后两个字节计算
              targets.push(chunk.slice(0, 2))
              targets.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2))
              targets.push(chunk.slice(CHUNK_SIZE / 2 - 2, CHUNK_SIZE / 2))
            }
          })
          fileReader.readAsArrayBuffer(new Blob(targets))
          fileReader.onload = (e) => {
            const bytes = e.target?.result // 读取到字节组
            spark.append(bytes)
            resolve(spark.end())
          }
        })
      }

      async function uploadChunks(chunks) {
        const data = chunks.map((chunk, index) => {
          return {
            fileHash: fileHash,
            chunkHash: `${fileHash} - ${index}`,
            size: chunk.size,
            chunk,
            index,
          }
        })

        const formDatas = data.map((item, index) => {
          const formData = new FormData()
          //数量
          formData.append('totalNumber', data.length)
          //大小
          // formData.append('chunkSize', CHUNK_SIZE as any)
          //序列
          formData.append('seq', index)
          //大文件的文件名
          // formData.append('fileName', fileName.value)
          //大文件hash
          formData.append('hash', fileHash)
          //切片文件
          // formData.append('file', item.chunk)
          formData.append('file', new File([item.chunk], fileName))

          return formData
        })

        let index = 0
        const max = 1 //并发请求数量
        const taskPool = [] //请求队列

        while (index < formDatas.length) {
          const task = axios.post(sliceUrl, formDatas[index], pageData
		  , {
			  headers: {
				'Content-Type': 'multipart/form-data',
				'Origin': 'http://127.0.0.1:8080/' // 添加Origin请求头
			  }
			}
			)
          taskPool.push(task)
          task
            .then((res) => {
              console.log("分片:")
			   console.log(res)
              index++
              // submitList.value[submitList.value.length - 1].percentage = Number(
              //   ((index / formDatas.length) * 100).toFixed(0)
              // )
              taskPool.splice(taskPool.findIndex((item) => item === task))
              if (index == formDatas.length) {
                margeRequest()
              }
            })
            .catch((error) => {
              // 对于图中发生的错误需要捕获并记录
              // uploadError.value = true
            })
          if (taskPool.length === max) {
            await Promise.race(taskPool)
          }
        }
        await Promise.all(taskPool)
      }

      function margeRequest() {
        const formData = new FormData()
        //类型
        formData.append('type', fileType)
        //大文件的文件名
        formData.append('fileName', fileName)
        //大文件hash
        formData.append('hash', fileHash)

        //发送请求合并
        axios.post(mergeUrl, formData, pageData, {
		  headers: {
			'Content-Type': 'multipart/form-data',
			'Origin': 'http://127.0.0.1:8080/' // 添加Origin请求头
		  }
		}).then((res) => {
          const data = res.data
        })
      }

      function checkIsHave(chunks) {
        // 进度条变更
        // 先检查是否已经上传过
        const formData = new FormData()
        formData.append('hash', fileHash)

        axios.post(checkUrl, formData,pageData, {
		  headers: {
			'Content-Type': 'multipart/form-data',
			'Origin': 'http://127.0.0.1:8080/' // 添加Origin请求头
		  }
		})
          .then((res) => {
            const data = res.data
            console.log(data)
            if (data.data.statusCode == '0') {
              //没有存在
              uploadChunks(chunks)
            } else {
            }
          })
          .catch((error) => {
            console.log(error)
          })
      }
    </script>
  </body>
</html>

后端代码

文件对象

package com.wonders.ss.comm.dto;


import lombok.Data;

/**
 *
 * FileInfo数据域:文件基本信息DTO类
 * Desc: 文件基本信息
 *
 * @author hcday
 * @author <a href="mailto:hcday@qq.com">hcday soo</a>
 * CreateTime:3/14/20 3:33 PM
 *
 * @version 1.0
 * @since 1.0
 *
 */
@Data
public class FileInfo {
    /**
     * 原文件名
     */
    private String originFilename;
    /**
     * 保存文件名
     */
    private String filename;
    /**
     * 文件类型
     */
    private String fileType;
    /**
     * 保存路径
     */
    private String fileUrl;
    /**
     * 保存路径,对外使用的Http或Https路径
     */
    private String fileHttpUrl;

    /**
     * 是否上传成功
     */
    private String statusCode;

}

分片上传接口

/** 会员用户上传附件
     * @param sasPubUser    用户信息
     * @param request       请求对象
     * @param response      返回对象JdbcUtils
     * @return
     */
    @FrontOpLog(value="会员用户大文件切片上传",
            type = "S40",
            funcOp ="工作用户管理", funcOpSub = "用户上传附件")
    @PostMapping("/file/upload/slice")
    @Encrypt
    public ResponseResult<FileInfo> fileUploadSlice(@AuthLogin SasPubUser sasPubUser, HttpServletRequest request, HttpServletResponse response,
                                                    @RequestParam(value = "hash") String hash,
                                                    @RequestParam(value = "totalNumber") Integer totalNumber,
                                                    @RequestParam(value = "seq") Integer seq) {
        //根据请求头部token判断用户是否登录
        if (sasPubUser == null) {
           //未登录
            return ResponseResult.error(HttpStatusEnum.TOKEN_ERROR.getCode(), HttpStatusEnum.TOKEN_ERROR.getMsg());
        }
        if (StringUtils.isBlank(ftpId)){
            return null;
        }
        try {
            //登录上传分片文件
            MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;//获取请求文件流
            MultipartFile multipartFile = multipartRequest.getFileMap().entrySet().iterator().next().getValue();//转化成文件
            //开始上传服务器切片文件
            String ext = FilenameUtils.getExtension(multipartFile.getOriginalFilename()).toLowerCase(Locale.ENGLISH);//文件扩展名
            if (ResourceType.FILE_SUFFIX.contains(ext)){//判断文件类型 在白名单内的才可以进行上传 防止病毒上传
                //String context = request.getContextPath();//获取上传服务器前缀路径
                FileInfo fileInfo = fileUploadSlice(multipartFile, ftpId, hash, seq, totalNumber);
                return ResponseResult.ok().setResultData(fileInfo);
            }
            return ResponseResult.error(HttpStatusEnum.BAD_REQUEST.getCode(), "文件上传类型错误");
        } catch (Exception e) {
            e.printStackTrace();
            logger.error("文件上传失败!" + e.getMessage());
            return ResponseResult.error(HttpStatusEnum.BAD_REQUEST.getCode(), "文件上传失败");
        }
    }

    /** 大文件上传本地服务器
     * @param file
     * @param hash     : 哈希值
     * @param seq      : 分片序号
     * @param totalNumber      : 总分片数量
     * @return
     * @throws Exception
     *
     *
     */
    public FileInfo fileUploadSlice(MultipartFile file, String ftpId, String hash, Integer seq, Integer totalNumber)
            throws Exception{
        if (StringUtils.isBlank(ftpId)){
            return null;
        }
        GlobalFtp ftp = globalFtpService.findById(ftpId);
        if (ObjectUtils.isEmpty(file) || ObjectUtils.isEmpty(hash)
                || ObjectUtils.isEmpty(seq) || ObjectUtils.isEmpty(totalNumber)
                || ObjectUtils.isEmpty(ftp) || ObjectUtils.isEmpty(ftp.getFtpPath()) ){
            return null;
        }
        //设置返回数据
        FileInfo fileInfo = new FileInfo();
        //文件扩展名
        String filenameExtension = org.springframework.util.StringUtils.getFilenameExtension(file.getOriginalFilename());
        //String origName = file.getOriginalFilename();//获取源文件名
        //String ext = FilenameUtils.getExtension(origName).toLowerCase(Locale.ENGLISH);//获取扩展名称
        //设置redis 实现秒传
        String resRedis = redisService.get("bigFileUpload:finish:hash:" + hash + ":seq" + seq);
        if(resRedis != null && "200".equals(resRedis)){//表示已经完成了上传直接跳过
            fileInfo.setOriginFilename(file.getOriginalFilename());
            fileInfo.setFilename(file.getOriginalFilename());
            fileInfo.setFileType(filenameExtension);
            fileInfo.setStatusCode("1");
        }else{
            fileInfo = uploadSliceImpl(file, hash, seq, totalNumber, ftp.getFtpPath(),filenameExtension);
        }
        fileInfo.setFileUrl(ftp.getFtpPath());
        fileInfo.setFileHttpUrl(ftp.getHttpUrl() + File.separator  + File.separator + ftp.getFtpPath() + File.separator + fileInfo.getFilename());
        return fileInfo;
    }


    /**
     * 分片上传
     *
     * @param file     : 文件流
     * @param hash     : 哈希值
     * @param seq      : 分片序号
     * @param totalNumber      : 总分片数量
     * @param uploadPath      : 上传路径
     * @return : cn.gpnusz.ucloudteachentity.common.CommonResp<java.lang.String>
     * @author h0ss
     */

    private static final Lock lock = new ReentrantLock();
    public FileInfo uploadSliceImpl (MultipartFile file, String hash, Integer seq, Integer totalNumber,String uploadPath,String filenameExtension) {
        RandomAccessFile raf = null;
        FileInfo fileInfo = new FileInfo();//返回对象
        fileInfo.setStatusCode("0");
        try {
            lock.lock();
            try {
                // 创建目标文件夹
                File uploadPathDir = new File(uploadPath);
                log.info("数据库文件上传地址:" + uploadPathDir);
                if (!uploadPathDir.exists()) {
                    log.info("数据库文件上传地址不存在创建");
                    uploadPathDir.mkdir();//如果数据库文件上传地址不存在 则创建
                }

                // 文件存放位置
                String dstFile = String.format("%s%s%s%s%s.%s", uploadPath, File.separator, hash, File.separator, seq, filenameExtension);

                // 上传分片信息存放位置
                String confFile = String.format("%s%s%s%s%s.conf", uploadPath, File.separator, hash, File.separator, hash);

                String confName = " totalNumber: " + totalNumber.toString() ;//conf内容
                // 创建目标文件夹
                File dir = new File(dstFile).getParentFile();
                log.info("文件上传地址:" + dir);
                if (!dir.exists()) {
                    dir.mkdir();
                    log.info("分片文件上传-创建文件地址");
                    // 所有分片状态设置为0
                    byte[] bytes = confName.getBytes(StandardCharsets.UTF_8); // 使用UTF-8编码
                    Files.write(Paths.get(confFile), bytes);
                }

                // 创建空格文件 名称带seq用于标识分块信息
                raf = new RandomAccessFile(dstFile, "rw");
                // 写入文件流
                log.info("分片文件上传-写入文件流");
                raf.write(file.getBytes());
                redisService.set("bigFileUpload:finish:hash:" + hash + ":seq" + seq, "200", 3600l);
                fileInfo.setOriginFilename(file.getOriginalFilename());
                fileInfo.setFilename(file.getOriginalFilename());
                fileInfo.setFileType(filenameExtension);
                fileInfo.setStatusCode("1"); // 上传成功标记
            } finally {
                lock.unlock();
            }
        } catch (IOException e) {
            // 异常处理
            // ...打印异常日志...
            log.info("文件上传失败:" + e.getMessage());
        } finally {
            try {
                if (raf != null) {
                    raf.close();
                }
            } catch (IOException e) {
                // ...打印异常日志...
                log.info("文件流关闭失败:" + e.getMessage());
            }
            log.info("返回结果FileInfo:" + JsonUtils.encode(fileInfo));
            return fileInfo;
        }
    }


分片合并接口

/** 会员用户上传附件
     * @param fileName 文件名
     * @param type 文件类型
     * @param hash 文件hash值
     * @return
     */
    @FrontOpLog(value="会员用户大文件切片上传",
            type = "S40",
            funcOp ="工作用户管理", funcOpSub = "用户上传附件")
    @PostMapping("/file/upload/uploadMerge")
    @RepeatSubmit
    @Encrypt
    public ResponseResult<FileInfo> uploadMerge(@AuthLogin SasPubUser sasPubUser, HttpServletRequest request, HttpServletResponse response,
                                                    @RequestParam(value = "fileName") String fileName,
                                                    @RequestParam(value = "type") String type,
                                                    @RequestParam(value = "hash") String hash) {
        //根据请求头部token判断用户是否登录
        if (sasPubUser == null) {
            //未登录
            return ResponseResult.error(HttpStatusEnum.TOKEN_ERROR.getCode(), HttpStatusEnum.TOKEN_ERROR.getMsg());
        }
        if (StringUtils.isBlank(ftpId)){
            return null;
        }
        try {
            //登录上传分片文件
            //String context = request.getContextPath();//获取上传服务器前缀路径
            FileInfo fileInfo = uploadMerge(ftpId,fileName,type,hash);
            return ResponseResult.ok().setResultData(fileInfo);
        } catch (Exception e) {
            e.printStackTrace();
            logger.error("文件上传失败!" + e.getMessage());
            return ResponseResult.error(HttpStatusEnum.BAD_REQUEST.getCode(), "文件上传失败");
        }
    }
/**
     * 合并分片请求
     * @param ftpId
     * @param fileName
     * @param type
     * @param hash
     * @return
     * @throws Exception
     */
    public FileInfo uploadMerge(String ftpId,String fileName, String type, String hash)
            throws Exception{
        if (StringUtils.isBlank(ftpId)){
            return null;
        }
        GlobalFtp ftp = globalFtpService.findById(ftpId);
        if ( ObjectUtils.isEmpty(hash)
                || ObjectUtils.isEmpty(fileName) || ObjectUtils.isEmpty(type)
                || ObjectUtils.isEmpty(ftp) || ObjectUtils.isEmpty(ftp.getFtpPath()) ){
            return null;
        }
        FileInfo fileInfo = new FileInfo();//返回对象
        //设置返回数据
        fileInfo = uploadMergeImp(fileName,type,hash,ftp.getFtpPath());
        fileInfo.setFileHttpUrl(ftp.getHttpUrl() + File.separator  + ftp.getFtpPath() + File.separator + hash + File.separator + fileInfo.getFilename());
        return fileInfo;
    }

    /**
     * 合并文件的业务代码
     * @param filename : 文件名
     * @param hash     : 文件哈希值
     * @param type     : 文件类型
     */
    public FileInfo uploadMergeImp(String filename, String type, String hash,String uploadPath) {
        FileInfo fileInfo = new FileInfo();//返回对象
        fileInfo.setStatusCode("0");
        // 判断hash对应文件夹是否存在
        File dir = new File(uploadPath + File.separator + hash);
        log.info("文件合并文件hash地址:"+ dir);
        if (!dir.exists()) {
            System.out.println("合并失败,请稍后重试");
            return fileInfo;
        }
        // 这里通过FileChannel来实现信息流复制
        FileChannel out = null;
        //文件存放位置
        String dstFile = String.format("%s%s%s%s%s", uploadPath, File.separator, hash,File.separator, filename);
        log.info("文件合并文件地址:"+ dstFile);
        // 获取目标channel
        try (FileChannel in = new RandomAccessFile(dstFile, "rw").getChannel()) {
            // 分片索引递增
            int index = 0;
            // 开始流位置
            long start = 0;

            while (true) {
                //分片文件名
                String sliceName = String.format("%s%s%s%s%s.%s", uploadPath, File.separator,hash, File.separator, index, type);
                log.info("文件合并文件分片:"+ sliceName);
                // 到达最后一个分片 退出循环
                if (!new File(sliceName).exists()) {
                    break;
                }
                // 分片输入流
                out = new RandomAccessFile(sliceName, "r").getChannel();
                // 写入目标channel
                in.transferFrom(out, start, start + out.size());
                // 位移量调整
                start += out.size();
                out.close();
                out = null;
                // 分片索引调整
                index++;
            }
            // 将 FileChannel 转换为 InputStream
            InputStream inputStream = Channels.newInputStream(in);
            String md5pwd = DigestUtils.md5DigestAsHex(inputStream);
            // 上传分片信息存放位置
            String confFile = String.format("%s%s%s%s%s.conf", uploadPath, File.separator, hash, File.separator, hash);
            // 将MD5哈希值追加到文件末尾
            try (FileOutputStream fos = new FileOutputStream(confFile, true); // true 表示追加模式
                 BufferedOutputStream bos = new BufferedOutputStream(fos);
                 PrintWriter pw = new PrintWriter(bos)) {
                pw.print(" md5pwd: " + md5pwd);
            }

            // 文件合并完毕
            in.close();

            // 删除分片文件
            for (int i = 0; i < index; i++) {
                String sliceName = String.format("%s%s%s%s%s.%s", uploadPath, File.separator, hash, File.separator, i, type);
                File sliceFile = new File(sliceName);
                if (sliceFile.exists()) {
                    sliceFile.delete();
                }
                redisService.delete("bigFileUpload:finish:hash:" + hash + ":seq" + i);
            }
            fileInfo.setOriginFilename(filename);
            fileInfo.setFilename(filename);
            fileInfo.setFileType(type);
            fileInfo.setFileUrl(uploadPath + File.separator + hash + File.separator + filename);
            fileInfo.setStatusCode("1");
        } catch (IOException e) {
            // ...记录日志..
            log.info("文件合并失败:"+ e.getMessage());
        } finally {
            if (out != null) {
                try {
                    out.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            //返回结果
            return fileInfo;
        }
    }

检查接口

/** 会员用户上传附件
     * @param sasPubUser    用户信息
     * @param request       请求对象
     * @param response      返回对象JdbcUtils
     * @return
     */
    @FrontOpLog(value="会员用户大文件切片上传",
            type = "S40",
            funcOp ="工作用户管理", funcOpSub = "用户上传附件")
    @PostMapping("/file/upload/checkUpload")
    @RepeatSubmit
    @Encrypt
    public ResponseResult<FileInfo> checkUpload(@AuthLogin SasPubUser sasPubUser, HttpServletRequest request, HttpServletResponse response,
                                                    @RequestParam(value = "hash") String hash) {
        //根据请求头部token判断用户是否登录
        if (sasPubUser == null) {
            //未登录
            return ResponseResult.error(HttpStatusEnum.TOKEN_ERROR.getCode(), HttpStatusEnum.TOKEN_ERROR.getMsg());
        }
        try {
            if (StringUtils.isBlank(ftpId)){
                return null;
            }
            String context = request.getContextPath();//获取上传服务器前缀路径
            FileInfo fileInfo = checkUpload(ftpId,hash);
            return ResponseResult.ok().setResultData(fileInfo);
        } catch (Exception e) {
            e.printStackTrace();
            logger.error("文件上传失败!" + e.getMessage());
            return ResponseResult.error(HttpStatusEnum.BAD_REQUEST.getCode(), "文件上传失败");
        }
    }

    /**
     * 合并分片请求
     * @param ftpId
     * @param hash
     * @return
     * @throws Exception
     */
    public FileInfo checkUpload(String ftpId, String hash)
            throws Exception{
        if (StringUtils.isBlank(ftpId)){
            return null;
        }
        GlobalFtp ftp = globalFtpService.findById(ftpId);
        if ( ObjectUtils.isEmpty(hash) || ObjectUtils.isEmpty(ftp) || ObjectUtils.isEmpty(ftp.getFtpPath()) ){
            return null;
        }
        //设置返回数据
        return checkUploadImp( hash,ftp.getFtpPath());
    }

    /**
     * 检查秒传
     *
     * @param hash : 文件hash
     * @param uploadPath : 文件上传路径
     * @return : cn.gpnusz.ucloudteachentity.common.CommonResp<java.lang.String>
     * @author h0ss
     */
    public FileInfo checkUploadImp(String hash,String uploadPath) throws Exception {
        //判断文件是否上传成功
        FileInfo fileInfo = new FileInfo();//返回对象
        fileInfo.setStatusCode("0");
        String uploadPathAll = String.format("%s%s%s%s%s.conf", uploadPath, File.separator, hash, File.separator, hash);
        log.info("文件上传检查全地址:"+ uploadPathAll);
        Path confPath  = Paths.get(uploadPathAll);
        //MD5目录不存在文件从未上传过
        if (!Files.exists(confPath.getParent())) {
            log.info("conf文件地址不存在!!!");
            return fileInfo;
        }
        // 读取conf文件内容
        List<String> strings = Files.readAllLines(confPath, StandardCharsets.UTF_8);
        //所有分片上传完成计算文件MD5
        File file = new File(String.format("%s%s%s", uploadPath, File.separator, hash));
        File[] files = file.listFiles();
        String filePath = "";
        String filename = "";
        String type = "";

        // 找到最大的文件
        File largestFile = null;
        for (File f : files) {
            if (!f.getName().contains("conf")) {
                if (f.isFile() && (largestFile == null || f.length() > largestFile.length())) {
                    largestFile = f;
                }
            }
        }

        // 获取最大文件的名称和类型
        if (largestFile != null) {
            filePath = largestFile.getAbsolutePath();
            filename = largestFile.getName();
            if (filename.lastIndexOf(".") != -1 && filename.lastIndexOf(".") != 0) {
                type = filename.substring(filename.lastIndexOf(".") + 1);
            }
            try (InputStream inputStream = new FileInputStream(largestFile)) {
                String md5pwd = DigestUtils.md5DigestAsHex(inputStream);
                log.info("文件上传后台计算md5:"+ md5pwd);
                if (strings.stream().anyMatch(line -> line.contains(md5pwd))) {
                    fileInfo.setOriginFilename(filename);
                    fileInfo.setFilename(filename);
                    fileInfo.setFileType(type);
                    fileInfo.setFileUrl(filePath);
                    fileInfo.setFileHttpUrl(filePath);
                    fileInfo.setStatusCode("1");
                }
            }
        }
        return fileInfo;
    }


好了,朋友们,今天就到这里了,喜欢的朋友们还请留下您的小赞赞,小弟会更加努力更新!!如果感兴趣的话,欢迎关注小弟【科技脉搏】公众号!!!🥳🥳🥳

因篇幅问题不能全部显示,请点此查看更多更全内容