掘金 后端 ( ) • 2024-07-01 18:05

theme: cyanosis

前言

文件上传是许多项目都有的功能,用户上传小文件速度一般都很快,但如果是大文件几个g,几十个g的时候,上传了半天,马上就要完成的时候,网络波动一下,文件又要重新上传,抓狂🤯。那有什么办法解决解决这个问题,答案就是把文件分片,一段一段把文件拆开上传。

核心讲解

原理

分片上传:把一个完整的文件,前端把文件分成多个小块的chunk,一块一块的传递给后端,后端接收到后再把全部的块拼接起来,这样就算在某个时间点发生网络波动,那么丢失的也只有一块。

image.png

秒传:前端在把文件分片前,先计算出文件的md5值,后端拿到这个md5先去检查下是否已经有这个文件了,如果有直接给前端上传成功。这就是我们在网盘上有时候出现的文件秒传,说明已经有人跟你上传过同一份文件了。

断点续传:当网络出现异常上传中断后我们继续上传时,先去后端请求接口,拿到已经上传过的分片下标,再继续上传没有上传的分片。

整体流程

  1. 用户选择文件进行上传
  2. 前端获取文件唯一标识md5
  3. 判断文件md5是否已经保存,是则秒传
  4. 判断文件分片是否已经上传部分,是则断点续传
  5. 上传分片文件
  6. 后端合并分片
  7. 分片上传完成

image.png

功能分析

前端

前端实现的功能难点在于文件分片,和获取文件的md5。

文件分片

因为js的File对象继承自Blob,所以他也有slice方法,slice方法需要的参数有两个,一个是startByte文件起始读取的字节位置,另一个是endByte结束读取的字节位置。

let fileChunkList = []; //存放文件切片
let cur = 0;
// 分片
while(cur < file.size){
  fileChunkList.push(file.slice(cur,cur + chunkSize));
  cur += chunkSize;
}

获取文件md5

获取文件的md5,推荐使用SparkMD5的文件增量方式获取,如果直接计算文件的hash,文件过大时对浏览器负担会较大。

上传文件

通过check接口上传前先判断是否秒传和获取已经上传的分片下标。

function handleBeforeUpload(file) {
  const chunkSize = 1024 * 1024 * 10; // 10MB
  // 计算md5
  md5(file, chunkSize).then(md5 => {
    //检查是否秒传
    request({
      url: "/upload/check/" + md5,
      method: "get",
    }).then(result => {
      const isOk = result.isOk;
      const haveList2 = result.haveList; //已经上传的分片下标
      if(isOk) {
        console.log("秒传成功");
        return;
      }
      haveList.value = haveList2;
      let chunkIndex = 0;
      //上传第一个分片
      upload(fileChunkList.value, chunkIndex, md5, file);
    })
  });
  return false;
}

已经上传的这些分片下标要跳过上传

image.png

后端

分片来后端后,使用RandomAccessFile就可以在一个文件上进行操作,而不用使用创建多个临时文件最后合并的方式,通过分片下标和分片大小计算出偏移量,使用RandomAccessFile将跳到偏移开始位置存放数据。RandomAccessFile的第二个参数的model有如下;

➢ "r":以只读方式打开指定文件。 ➢ "rw":以读、写方式打开指定文件。 ➢ "rws":以读、写方式打开指定文件。相对于"rw"模式,还要求对文件的内容或元数据的每个更新都同步写入到底层存储设备。 ➢ "rwd":以读、写方式打开指定文件。相对于"rw"模式,还要求对文件内容的每个更新都同步写入到底层存储设备。

/**
 * 分片文件上传
 * @param file 文件
 * @param chunkIndex 分片下标
 * @param md5 md5
 * @param totalFileSize 文件总大小
 * @param fileName 文件名
 */
@PostMapping("/shard")
public AjaxResult shardUpload(@RequestParam MultipartFile file, @RequestParam Integer chunkIndex,
                              @RequestParam String md5, @RequestParam Long totalFileSize,
                              @RequestParam String fileName) throws Exception{
    // 存放文件目录
    String dirPath = System.getProperty("user.dir") + "/file/"+md5+"/";
    File dirFile = new File(dirPath);
    if(!dirFile.exists()){
        dirFile.mkdir();
    }
    File tempFile = new File(dirPath + fileName);
    RandomAccessFile rw = new RandomAccessFile(tempFile, "rw");
    // 定位到分片的偏移量
    rw.seek(CHUNK_SIZE * chunkIndex);
    // 写入分片数据
    rw.write(file.getBytes());
    // 关闭流
    rw.close();
    // 读取已经分片集合
    List<Object> hasChunkList;
    String hasChunkKey = CHUNK_PREFIX + md5;
    if(redisCache.hasKey(hasChunkKey)){
        hasChunkList = redisCache.getCacheList(hasChunkKey);
    } else {
        hasChunkList = new ArrayList<>();
    }
    hasChunkList.add(chunkIndex);
    // 将最新的分片下标更新到Redis中
    redisCache.addCacheListOne(hasChunkKey,chunkIndex);
    // 判断是否上传完成
    int totalNeedChunks = (int) Math.ceil((double) totalFileSize / CHUNK_SIZE);
    // 总共需要的分片数 和 已经分片上传的数量相等 则上传完成
    boolean isOk = totalNeedChunks == hasChunkList.size();
    if(isOk){
        redisCache.setCacheObject(UPLOAD_ISOK_PREFIX + md5, true);
    }
    AjaxResult ajax = AjaxResult.success();
    ajax.put("hasChunkList",hasChunkList);
    ajax.put("isOk",isOk);
    return ajax;
}

最终演示

上传完成演示

GIF 2024-7-1 17-28-29.gif

秒传演示

GIF 2024-7-1 17-28-55.gif

断点演示

GIF 2024-7-1 17-36-39.gif

待优化

  • 提供查询进度接口,前端进度条展示,增加用户体验。
  • 多线程上传,不同分片用多线程,提高下载速度。