天天看點

SpringBoot超大檔案上傳,實作秒傳!

檔案上傳是一個老生常談的話題了,在檔案相對比較小的情況下,可以直接把檔案轉化為位元組流上傳到伺服器,但在檔案比較大的情況下,用普通的方式進行上傳,這可不是一個好的辦法,畢竟很少有人會忍受,當檔案上傳到一半中斷後,繼續上傳卻隻能重頭開始上傳,這種讓人不爽的體驗。

那有沒有比較好的上傳體驗呢,答案有的,就是下邊要介紹的幾種上傳方式

一. 秒傳

1、什麼是秒傳

通俗的說,你把要上傳的東西上傳,伺服器會先做MD5校驗,如果伺服器上有一樣的東西,它就直接給你個新位址,其實你下載下傳的都是伺服器上的同一個檔案,想要不秒傳,其實隻要讓MD5改變,就是對檔案本身做一下修改(改名字不行),例如一個文本檔案,你多加幾個字,MD5就變了,就不會秒傳了。

2、本文實作的秒傳核心邏輯

  • 利用redis的set方法存放檔案上傳狀态,其中key為檔案上傳的md5,value為是否上傳完成的标志位。
  • 當标志位true為上傳已經完成,此時如果有相同檔案上傳,則進入秒傳邏輯。如果标志位為false,則說明還沒上傳完成,此時需要在調用set的方法,儲存塊号檔案記錄的路徑,其中key為上傳檔案md5加一個固定字首,value為塊号檔案記錄路徑

二. 分片上傳

1、什麼是分片上傳

分片上傳,就是将所要上傳的檔案,按照一定的大小,将整個檔案分隔成多個資料塊(我們稱之為Part)來進行分别上傳,上傳完之後再由服務端對所有上傳的檔案進行彙總整合成原始的檔案。

2、分片上傳的場景

  • 大檔案上傳
  • 網絡環境環境不好,存在需要重傳風險的場景

三. 斷點續傳

1、什麼是斷點續傳

斷點續傳是在下載下傳或上傳時,将下載下傳或上傳任務(一個檔案或一個壓縮包)人為的劃分為幾個部分,每一個部分采用一個線程進行上傳或下載下傳,如果碰到網絡故障,可以從已經上傳或下載下傳的部分開始繼續上傳或者下載下傳未完成的部分,而沒有必要從頭開始上傳或者下載下傳。本文的斷點續傳主要是針對斷點上傳場景。

2、應用場景

斷點續傳可以看成是分片上傳的一個衍生,是以可以使用分片上傳的場景,都可以使用斷點續傳。

3、實作斷點續傳的核心邏輯

在分片上傳的過程中,如果因為系統崩潰或者網絡中斷等異常因素導緻上傳中斷,這時候用戶端需要記錄上傳的進度。在之後支援再次上傳時,可以繼續從上次上傳中斷的地方進行繼續上傳。

為了避免用戶端在上傳之後的進度資料被删除而導緻重新開始從頭上傳的問題,服務端也可以提供相應的接口便于用戶端對已經上傳的分片資料進行查詢,進而使用戶端知道已經上傳的分片資料,進而從下一個分片資料開始繼續上傳。

4、實作流程步驟

a、方案一,正常步驟

  • 将需要上傳的檔案按照一定的分割規則,分割成相同大小的資料塊;
  • 初始化一個分片上傳任務,傳回本次分片上傳唯一辨別;
  • 按照一定的政策(串行或并行)發送各個分片資料塊;
  • 發送完成後,服務端根據判斷資料上傳是否完整,如果完整,則進行資料塊合成得到原始檔案。

b、方案二、本文實作的步驟

  • 前端(用戶端)需要根據固定大小對檔案進行分片,請求後端(服務端)時要帶上分片序号和大小
  • 服務端建立conf檔案用來記錄分塊位置,conf檔案長度為總分片數,每上傳一個分塊即向conf檔案中寫入一個127,那麼沒上傳的位置就是預設的0,已上傳的就是Byte.MAX_VALUE 127(這步是實作斷點續傳和秒傳的核心步驟)
  • 伺服器按照請求資料中給的分片序号和每片分塊大小(分片大小是固定且一樣的)算出開始位置,與讀取到的檔案片段資料,寫入檔案。

5、分片上傳/斷點上傳代碼實作

前端采用百度提供的webuploader的插件,進行分片。因本文主要介紹服務端代碼實作,webuploader如何進行分片,具體實作可以檢視如下連結:

  • http://fex.baidu.com/webuploader/getting-started.html

後端用兩種方式實作檔案寫入,一種是用RandomAccessFile,如果對RandomAccessFile不熟悉的朋友,可以檢視如下連結:

  • https://blog.csdn.net/dimudan2015/article/details/81910690

另一種是使用MappedByteBuffer,對MappedByteBuffer不熟悉的朋友,可以檢視如下連結進行了解:

  • https://www.jianshu.com/p/f90866dcbffc

四. 後端進行寫入操作的核心代碼

1、RandomAccessFile實作方式

@UploadMode(mode = UploadModeEnum.RANDOM_ACCESS)    
@Slf4j    
public class RandomAccessUploadStrategy extends SliceUploadTemplate {    
    
  @Autowired    
  private FilePathUtil filePathUtil;    
    
  @Value("${upload.chunkSize}")    
  private long defaultChunkSize;    
    
  @Override    
  public boolean upload(FileUploadRequestDTO param) {    
    RandomAccessFile accessTmpFile = null;    
    try {    
      String uploadDirPath = filePathUtil.getPath(param);    
      File tmpFile = super.createTmpFile(param);    
      accessTmpFile = new RandomAccessFile(tmpFile, "rw");    
      //這個必須與前端設定的值一緻    
      long chunkSize = Objects.isNull(param.getChunkSize()) ? defaultChunkSize * 1024 * 1024    
          : param.getChunkSize();    
      long offset = chunkSize * param.getChunk();    
      //定位到該分片的偏移量    
      accessTmpFile.seek(offset);    
      //寫入該分片資料    
      accessTmpFile.write(param.getFile().getBytes());    
      boolean isOk = super.checkAndSetUploadProgress(param, uploadDirPath);    
      return isOk;    
    } catch (IOException e) {    
      log.error(e.getMessage(), e);    
    } finally {    
      FileUtil.close(accessTmpFile);    
    }    
   return false;    
  }    
    
}    
           

2、MappedByteBuffer實作方式

@UploadMode(mode = UploadModeEnum.MAPPED_BYTEBUFFER)    
@Slf4j    
public class MappedByteBufferUploadStrategy extends SliceUploadTemplate {    
    
  @Autowired    
  private FilePathUtil filePathUtil;    
    
  @Value("${upload.chunkSize}")    
  private long defaultChunkSize;    
    
  @Override    
  public boolean upload(FileUploadRequestDTO param) {    
    
    RandomAccessFile tempRaf = null;    
    FileChannel fileChannel = null;    
    MappedByteBuffer mappedByteBuffer = null;    
    try {    
      String uploadDirPath = filePathUtil.getPath(param);    
      File tmpFile = super.createTmpFile(param);    
      tempRaf = new RandomAccessFile(tmpFile, "rw");    
      fileChannel = tempRaf.getChannel();    
    
      long chunkSize = Objects.isNull(param.getChunkSize()) ? defaultChunkSize * 1024 * 1024    
          : param.getChunkSize();    
      //寫入該分片資料    
      long offset = chunkSize * param.getChunk();    
      byte[] fileData = param.getFile().getBytes();    
      mappedByteBuffer = fileChannel    
.map(FileChannel.MapMode.READ_WRITE, offset, fileData.length);    
      mappedByteBuffer.put(fileData);    
      boolean isOk = super.checkAndSetUploadProgress(param, uploadDirPath);    
      return isOk;    
    
    } catch (IOException e) {    
      log.error(e.getMessage(), e);    
    } finally {    
    
      FileUtil.freedMappedByteBuffer(mappedByteBuffer);    
      FileUtil.close(fileChannel);    
      FileUtil.close(tempRaf);    
    
    }    
    
    return false;    
  }    
    
}    
           

3、檔案操作核心模闆類代碼

@Slf4j    
public abstract class SliceUploadTemplate implements SliceUploadStrategy {    
    
  public abstract boolean upload(FileUploadRequestDTO param);    
    
  protected File createTmpFile(FileUploadRequestDTO param) {    
    
    FilePathUtil filePathUtil = SpringContextHolder.getBean(FilePathUtil.class);    
    param.setPath(FileUtil.withoutHeadAndTailDiagonal(param.getPath()));    
    String fileName = param.getFile().getOriginalFilename();    
    String uploadDirPath = filePathUtil.getPath(param);    
    String tempFileName = fileName + "_tmp";    
    File tmpDir = new File(uploadDirPath);    
    File tmpFile = new File(uploadDirPath, tempFileName);    
    if (!tmpDir.exists()) {    
      tmpDir.mkdirs();    
    }    
    return tmpFile;    
  }    
    
  @Override    
  public FileUploadDTO sliceUpload(FileUploadRequestDTO param) {    
    
    boolean isOk = this.upload(param);    
    if (isOk) {    
      File tmpFile = this.createTmpFile(param);    
      FileUploadDTO fileUploadDTO = this.saveAndFileUploadDTO(param.getFile().getOriginalFilename(), tmpFile);    
      return fileUploadDTO;    
    }    
    String md5 = FileMD5Util.getFileMD5(param.getFile());    
    
    Map<Integer, String> map = new HashMap<>();    
    map.put(param.getChunk(), md5);    
    return FileUploadDTO.builder().chunkMd5Info(map).build();    
  }    
    
  /**    
   * 檢查并修改檔案上傳進度    
   */    
  public boolean checkAndSetUploadProgress(FileUploadRequestDTO param, String uploadDirPath) {    
    
    String fileName = param.getFile().getOriginalFilename();    
    File confFile = new File(uploadDirPath, fileName + ".conf");    
    byte isComplete = 0;    
    RandomAccessFile accessConfFile = null;    
    try {    
      accessConfFile = new RandomAccessFile(confFile, "rw");    
      //把該分段标記為 true 表示完成    
      System.out.println("set part " + param.getChunk() + " complete");    
      //建立conf檔案檔案長度為總分片數,每上傳一個分塊即向conf檔案中寫入一個127,那麼沒上傳的位置就是預設0,已上傳的就是Byte.MAX_VALUE 127    
      accessConfFile.setLength(param.getChunks());    
      accessConfFile.seek(param.getChunk());    
      accessConfFile.write(Byte.MAX_VALUE);    
    
      //completeList 檢查是否全部完成,如果數組裡是否全部都是127(全部分片都成功上傳)    
      byte[] completeList = FileUtils.readFileToByteArray(confFile);    
      isComplete = Byte.MAX_VALUE;    
      for (int i = 0; i < completeList.length && isComplete == Byte.MAX_VALUE; i++) {    
        //與運算, 如果有部分沒有完成則 isComplete 不是 Byte.MAX_VALUE    
        isComplete = (byte) (isComplete & completeList[i]);    
        System.out.println("check part " + i + " complete?:" + completeList[i]);    
      }    
    
    } catch (IOException e) {    
      log.error(e.getMessage(), e);    
    } finally {    
      FileUtil.close(accessConfFile);    
    }    
 boolean isOk = setUploadProgress2Redis(param, uploadDirPath, fileName, confFile, isComplete);    
    return isOk;    
  }    
    
  /**    
   * 把上傳進度資訊存進redis    
   */    
  private boolean setUploadProgress2Redis(FileUploadRequestDTO param, String uploadDirPath,    
      String fileName, File confFile, byte isComplete) {    
    
    RedisUtil redisUtil = SpringContextHolder.getBean(RedisUtil.class);    
    if (isComplete == Byte.MAX_VALUE) {    
      redisUtil.hset(FileConstant.FILE_UPLOAD_STATUS, param.getMd5(), "true");    
      redisUtil.del(FileConstant.FILE_MD5_KEY + param.getMd5());    
      confFile.delete();    
      return true;    
    } else {    
      if (!redisUtil.hHasKey(FileConstant.FILE_UPLOAD_STATUS, param.getMd5())) {    
        redisUtil.hset(FileConstant.FILE_UPLOAD_STATUS, param.getMd5(), "false");    
        redisUtil.set(FileConstant.FILE_MD5_KEY + param.getMd5(),    
            uploadDirPath + FileConstant.FILE_SEPARATORCHAR + fileName + ".conf");    
      }    
    
      return false;    
    }    
  }    
/**    
   * 儲存檔案操作    
   */    
  public FileUploadDTO saveAndFileUploadDTO(String fileName, File tmpFile) {    
    
    FileUploadDTO fileUploadDTO = null;    
    
    try {    
    
      fileUploadDTO = renameFile(tmpFile, fileName);    
      if (fileUploadDTO.isUploadComplete()) {    
        System.out    
            .println("upload complete !!" + fileUploadDTO.isUploadComplete() + " name=" + fileName);    
        //TODO 儲存檔案資訊到資料庫    
    
      }    
    
    } catch (Exception e) {    
      log.error(e.getMessage(), e);    
    } finally {    
    
    }    
    return fileUploadDTO;    
  }    
/**    
   * 檔案重命名    
   *    
   * @param toBeRenamed 将要修改名字的檔案    
   * @param toFileNewName 新的名字    
   */    
  private FileUploadDTO renameFile(File toBeRenamed, String toFileNewName) {    
    //檢查要重命名的檔案是否存在,是否是檔案    
    FileUploadDTO fileUploadDTO = new FileUploadDTO();    
    if (!toBeRenamed.exists() || toBeRenamed.isDirectory()) {    
      log.info("File does not exist: {}", toBeRenamed.getName());    
      fileUploadDTO.setUploadComplete(false);    
      return fileUploadDTO;    
    }    
    String ext = FileUtil.getExtension(toFileNewName);    
    String p = toBeRenamed.getParent();    
    String filePath = p + FileConstant.FILE_SEPARATORCHAR + toFileNewName;    
    File newFile = new File(filePath);    
    //修改檔案名    
    boolean uploadFlag = toBeRenamed.renameTo(newFile);    
    
    fileUploadDTO.setMtime(DateUtil.getCurrentTimeStamp());    
    fileUploadDTO.setUploadComplete(uploadFlag);    
    fileUploadDTO.setPath(filePath);    
    fileUploadDTO.setSize(newFile.length());    
    fileUploadDTO.setFileExt(ext);    
    fileUploadDTO.setFileId(toFileNewName);    
    
    return fileUploadDTO;    
  }    
}    
           

五. 總結

在實作分片上傳的過程,需要前端和後端配合,比如前後端的上傳塊号的檔案大小,前後端必須得要一緻,否則上傳就會有問題。其次檔案相關操作正常都是要搭建一個檔案伺服器的,比如使用fastdfs、hdfs等。

本示例代碼在電腦組態為4核記憶體8G情況下,上傳24G大小的檔案,上傳時間需要30多分鐘,主要時間耗費在前端的md5值計算,後端寫入的速度還是比較快。

如果項目組覺得自建檔案伺服器太花費時間,且項目的需求僅僅隻是上傳下載下傳,那麼推薦使用阿裡的oss伺服器,其介紹可以檢視官網:

https://help.aliyun.com/product/31815.html

阿裡的oss它本質是一個對象存儲伺服器,而非檔案伺服器,是以如果有涉及到大量删除或者修改檔案的需求,oss可能就不是一個好的選擇。

來源:https://mp.weixin.qq.com/s/05HQ11d1JAAvqlDS7hChHA