美文网首页
细说分片上传与极速秒传(SpringBoot+Vue实现)

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

作者: h0ss | 来源:发表于2022-04-26 14:41 被阅读0次

    预期目标

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

    • 预期:大视频文件上传不受上传大小的限制

    评估结果

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

    1)调大服务端的文件上传限制:在一定长度上可以缓解上传限制问题,但并不是最优解。一方面无限制地调大上传大小会加大服务端的压力;一方面这个限制值调成多少是个需要考量的问题。

    2)假设服务端的限制是10M,需要上传的文件是20M,直接上传显然是不可以的,那么分两次呢?把文件切分到符合限制的大小分批发送,这样就可以突破限制,这也就是分片上传。

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

    分片上传

    前期准备

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

    依赖 版本
    vue ^3.0.0
    ant-design-vue ^2.2.8
    axios ^0.24.0
    spark-md5 ^3.0.2

    1、前端逻辑

    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)
      })
    }
    

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

    2、后端逻辑

    后端的基本思路是,接收到分片信息后根据hash值创建文件夹,之后将接收到的同一个hash值的分片信息都存储到同一个文件夹下【这里需要注意存储时要打好序号,才可以按序合并】,待收到合并请求后合并文件,根据合并文件的hash值与源hash值做比较,确保文件无损。

    这里后端使用 SpringBoot 实现,依旧是常见的分层模型,Controller 层负责请求接口定义,Service 层负责业务逻辑的编写,由于这里不涉及到数据库的交互因而省略DAO层相关编写。

    先确定下来提供的接口数,现在我们需要一个接收分片的接口,一个接受合并请求的接口,最后还要有一个接受文件检查的接口用于极速秒传,具体如下:

    接口 接口描述
    uploadSlice 接收上传切片的接口
    merge 接收合并切片请求的接口
    checkUpload 检查文件上传状态的接口

    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.getBytes());
        } 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。

    相关文章

      网友评论

          本文标题:细说分片上传与极速秒传(SpringBoot+Vue实现)

          本文链接:https://www.haomeiwen.com/subject/itacyrtx.html