Spring Boot 超大文件上傳,實現秒傳

前言

文件上傳是一個老生常談的話題了,在文件相對比較小的情況下,可以直接把文件轉化爲字節流上傳到服務器,但在文件比較大的情況下,用普通的方式進行上傳,這可不是一個好的辦法,畢竟很少有人會忍受,當文件上傳到一半中斷後,繼續上傳卻只能重頭開始上傳,這種讓人不爽的體驗。那有沒有比較好的上傳體驗呢,答案有的,就是下邊要介紹的幾種上傳方式

詳細教程

秒傳

1、什麼是秒傳

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

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

a、利用 redis 的 set 方法存放文件上傳狀態,其中 key 爲文件上傳的 md5,value 爲是否上傳完成的標誌位,

b、當標誌位 true 爲上傳已經完成,此時如果有相同文件上傳,則進入秒傳邏輯。如果標誌位爲 false,則說明還沒上傳完成,此時需要在調用 set 的方法,保存塊號文件記錄的路徑,其中 key 爲上傳文件 md5 加一個固定前綴,value 爲塊號文件記錄路徑

分片上傳

1. 什麼是分片上傳

分片上傳,就是將所要上傳的文件,按照一定的大小,將整個文件分隔成多個數據塊(我們稱之爲 Part)來進行分別上傳,上傳完之後再由服務端對所有上傳的文件進行彙總整合成原始的文件。

2. 分片上傳的場景

  1. 大文件上傳

  2. 網絡環境環境不好,存在需要重傳風險的場景

斷點續傳

1、什麼是斷點續傳

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

2、應用場景

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

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

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

爲了避免客戶端在上傳之後的進度數據被刪除而導致重新開始從頭上傳的問題,服務端也可以提供相應的接口便於客戶端對已經上傳的分片數據進行查詢,從而使客戶端知道已經上傳的分片數據,從而從下一個分片數據開始繼續上傳。

4、實現流程步驟

a、方案一,常規步驟

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

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

a、前端採用百度提供的 webuploader 的插件,進行分片。因本文主要介紹服務端代碼實現,webuploader 如何進行分片,具體實現可以查看如下鏈接:

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

b、後端用兩種方式實現文件寫入,一種是用 RandomAccessFile,如果對 RandomAccessFile 不熟悉的朋友,可以查看如下鏈接:

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

另一種是使用 MappedByteBuffer,對 MappedByteBuffer 不熟悉的朋友,可以查看如下鏈接進行了解:

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

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

a、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;  
  }  
  
}

b、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;  
  }  
  
}

c、文件操作核心模板類代碼

@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 可能就不是一個好的選擇。

文末提供一個 oss 表單上傳的鏈接 demo,通過 oss 表單上傳,可以直接從前端把文件上傳到 oss 服務器,把上傳的壓力都推給 oss 服務器:

https://www.cnblogs.com/ossteam/p/4942227.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/YpIsSH85nME1D_PuPGdE5A