天天看點

實戰:斷點續傳?檔案秒傳?手撸大檔案上傳

作者:java易

最近接到一個新的需求,需要上傳2G左右的視訊檔案,用測試環境的OSS試了一下,上傳需要十幾分鐘,再考慮到公司的資源問題,果斷放棄該方案。

一提到大檔案上傳,我最先想到的就是各種網盤了,現在大家都喜歡将自己收藏的小電影上傳到網盤進行儲存。網盤一般都支援斷點續傳和檔案秒傳功能,減少了網絡波動和網絡帶寬對檔案的限制,大大提高了使用者體驗,讓人愛不釋手。

說到這,大家先來了解一下這幾個概念:

  • 檔案分塊:将大檔案拆分成小檔案,将小檔案上傳\下載下傳,最後再将小檔案組裝成大檔案;
  • 斷點續傳:在檔案分塊的基礎上,将每個小檔案采用單獨的線程進行上傳\下載下傳,如果碰到網絡故障,可以從已經上傳\下載下傳的部分開始繼續上傳\下載下傳未完成的部分,而沒有必要從頭開始上傳\下載下傳;
  • 檔案秒傳:資源伺服器中已經存在該檔案,其他人上傳時直接傳回該檔案的URI。

RandomAccessFile

平時我們都會使用FileInputStream,FileOutputStream,FileReader以及FileWriter等IO流來讀取檔案,今天我們來了解一下RandomAccessFile。

它是一個直接繼承Object的獨立的類,底層實作中它實作的是DataInput和DataOutput接口。該類支援随機讀取檔案,随機通路檔案類似于檔案系統中存儲的大位元組數組。

它的實作基于檔案指針(一種遊标或者指向隐含數組的索引),檔案指針可以通過getFilePointer方法讀取,也可以通過seek方法設定。

輸入時從檔案指針開始讀取位元組,并使檔案指針超過讀取的位元組,如果寫入超過隐含數組目前結尾的輸出操作會導緻擴充數組。該類有四種模式可供選擇:

  • r:以隻讀方式打開檔案,如果執行寫入操作會抛出IOException;
  • rw:以讀、寫方式打開檔案,如果檔案不存在,則嘗試建立檔案;
  • rws:以讀、寫方式打開檔案,要求對檔案内容或中繼資料的每次更新都同步寫入底層儲存設備;
  • rwd:以讀、寫方式打開檔案,要求對檔案内容的每次更新都同步寫入底層儲存設備;

在rw模式下,預設是使用buffer的,隻有cache滿的或者使用RandomAccessFile.close()關閉流的時候才真正的寫到檔案。

API

1、void seek(long pos):設定下一次讀取或寫入時的檔案指針偏移量,通俗點說就是指定下次讀檔案資料的位置。

偏移量可以設定在檔案末尾之外,隻有在偏移量設定超出檔案末尾後,才能通過寫入更改檔案長度;

2、native long getFilePointer():傳回目前檔案的光标位置;

3、native long length():傳回目前檔案的長度;

4、讀方法

實戰:斷點續傳?檔案秒傳?手撸大檔案上傳

img

5、寫方法

實戰:斷點續傳?檔案秒傳?手撸大檔案上傳

img

6、readFully(byte[] b):這個方法的作用就是将文本中的内容填滿這個緩沖區b。如果緩沖b不能被填滿,那麼讀取流的過程将被阻塞,如果發現是流的結尾,那麼會抛出異常;

7、FileChannel getChannel():傳回與此檔案關聯的唯一FileChannel對象;

8、int skipBytes(int n):試圖跳過n個位元組的輸入,丢棄跳過的位元組;

RandomAccessFile的絕大多數功能,已經被JDK1.4的NIO的記憶體映射檔案取代了,即把檔案映射到記憶體後再操作,省去了頻繁磁盤io。

主菜

總結經驗,砥砺前行:之前的實戰文章中過多的粘貼了源碼,影響了各位小夥伴的閱讀感受。經過大佬的點撥,以後将展示部分關鍵代碼,供各位賞析

檔案分塊需要在前端進行處理,可以利用強大的js庫或者現成的元件進行分塊處理。需要确定分塊的大小和分塊的數量,然後為每一個分塊指定一個索引值。

為了防止上傳檔案的分塊與其它檔案混淆,采用檔案的md5值來進行區分,該值也可以用來校驗伺服器上是否存在該檔案以及檔案的上傳狀态。

  • 如果檔案存在,直接傳回檔案位址;
  • 如果檔案不存在,但是有上傳狀态,即部分分塊上傳成功,則傳回未上傳的分塊索引數組;
  • 如果檔案不存在,且上傳狀态為空,則所有分塊均需要上傳。
fileRederInstance.readAsBinaryString(file);
fileRederInstance.addEventListener("load", (e) => {
    let fileBolb = e.target.result;
    fileMD5 = md5(fileBolb);
    const formData = new FormData();
    formData.append("md5", fileMD5);
    axios
        .post(http + "/fileUpload/checkFileMd5", formData)
        .then((res) => {
            if (res.data.message == "檔案已存在") {
                //檔案已存在不走後面分片了,直接傳回檔案位址到前台頁面
                success && success(res);
            } else {
                //檔案不存在存在兩種情況,一種是傳回data:null代表未上傳過 一種是data:[xx,xx] 還有哪幾片未上傳
                if (!res.data.data) {
                    //還有幾片未上傳情況,斷點續傳
                    chunkArr = res.data.data;
                }
                readChunkMD5();
            }
        })
        .catch((e) => {});
});
           

在調用上傳接口前,通過slice方法來取出索引在檔案中對應位置的分塊。

const getChunkInfo = (file, currentChunk, chunkSize) => {
       //擷取對應下标下的檔案片段
       let start = currentChunk * chunkSize;
       let end = Math.min(file.size, start + chunkSize);
       //對檔案分塊
       let chunk = file.slice(start, end);
       return { start, end, chunk };
   };
           

之後調用上傳接口完成上傳。

斷點續傳、檔案秒傳

後端基于spring boot開發,使用redis來存儲上傳檔案的狀态和上傳檔案的位址。

如果檔案完整上傳,傳回檔案路徑;部分上傳則傳回未上傳的分塊數組;如果未上傳過傳回提示資訊。

在上傳分塊時會産生兩個檔案,一個是檔案主體,一個是臨時檔案。臨時檔案可以看做是一個數組檔案,為每一個分塊配置設定一個值為127的位元組。

校驗MD5值時會用到兩個值:

  • 檔案上傳狀态:隻要該檔案上傳過就不為空,如果完整上傳則為true,部分上傳傳回false;
  • 檔案上傳位址:如果檔案完整上傳,傳回檔案路徑;部分上傳傳回臨時檔案路徑。
/**
 * 校驗檔案的MD5
 **/
public Result checkFileMd5(String md5) throws IOException {
    //檔案是否上傳狀态:隻要該檔案上傳過該值一定存在
    Object processingObj = stringRedisTemplate.opsForHash().get(UploadConstants.FILE_UPLOAD_STATUS, md5);
    if (processingObj == null) {
        return Result.ok("該檔案沒有上傳過");
    }
    boolean processing = Boolean.parseBoolean(processingObj.toString());
    //完整檔案上傳完成時為檔案的路徑,如果未完成傳回臨時檔案路徑(臨時檔案相當于數組,為每個分塊配置設定一個值為127的位元組)
    String value = stringRedisTemplate.opsForValue().get(UploadConstants.FILE_MD5_KEY + md5);
    //完整檔案上傳完成是true,未完成傳回false
    if (processing) {
        return Result.ok(value,"檔案已存在");
    } else {
        File confFile = new File(value);
        byte[] completeList = FileUtils.readFileToByteArray(confFile);
        List<Integer> missChunkList = new LinkedList<>();
        for (int i = 0; i < completeList.length; i++) {
            if (completeList[i] != Byte.MAX_VALUE) {
                //用空格補齊
                missChunkList.add(i);
            }
        }
        return Result.ok(missChunkList,"該檔案上傳了一部分");
    }
}
           

說到這,你肯定會問:當這個檔案的所有分塊上傳完成之後,該怎麼得到完整的檔案呢?接下來我們就說一下分塊合并的問題。

分塊上傳、檔案合并

上邊我們提到了利用檔案的md5值來維護分塊和檔案的關系,是以我們會将具有相同md5值的分塊進行合并,由于每個分塊都有自己的索引值,是以我們會将分塊按索引像插入數組一樣分别插入檔案中,形成完整的檔案。

分塊上傳時,要和前端的分塊大小、分塊數量、目前分塊索引等對應好,以備檔案合并時使用,此處我們采用的是磁盤映射的方式來合并檔案。

//讀操作和寫操作都是允許的
RandomAccessFile tempRaf = new RandomAccessFile(tmpFile, "rw");
//它傳回的就是nio通信中的file的唯一channel
FileChannel fileChannel = tempRaf.getChannel();

//寫入該分片資料   分片大小 * 第幾塊分片擷取偏移量
long offset = CHUNK_SIZE * multipartFileDTO.getChunk();
//分片檔案大小
byte[] fileData = multipartFileDTO.getFile().getBytes();
//将檔案的區域直接映射到記憶體
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, offset, fileData.length);
mappedByteBuffer.put(fileData);
// 釋放
FileMD5Util.freedMappedByteBuffer(mappedByteBuffer);
fileChannel.close();
           

每當完成一次分塊的上傳,還需要去檢查檔案的上傳進度,看檔案是否上傳完成。

RandomAccessFile accessConfFile = new RandomAccessFile(confFile, "rw");
//把該分段标記為 true 表示完成
accessConfFile.setLength(multipartFileDTO.getChunks());
accessConfFile.seek(multipartFileDTO.getChunk());
accessConfFile.write(Byte.MAX_VALUE);

//completeList 檢查是否全部完成,如果數組裡是否全部都是(全部分片都成功上傳)
byte[] completeList = FileUtils.readFileToByteArray(confFile);
byte 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]);
}
accessConfFile.close();
           

然後更新檔案的上傳進度到Redis中。

//更新redis中的狀态:如果是true的話證明是已經該大檔案全部上傳完成
if (isComplete == Byte.MAX_VALUE) {
    stringRedisTemplate.opsForHash().put(UploadConstants.FILE_UPLOAD_STATUS, multipartFileDTO.getMd5(), "true");
    stringRedisTemplate.opsForValue().set(UploadConstants.FILE_MD5_KEY + multipartFileDTO.getMd5(), uploadDirPath + "/" + fileName);
} else {
    if (!stringRedisTemplate.opsForHash().hasKey(UploadConstants.FILE_UPLOAD_STATUS, multipartFileDTO.getMd5())) {
        stringRedisTemplate.opsForHash().put(UploadConstants.FILE_UPLOAD_STATUS, multipartFileDTO.getMd5(), "false");
    }
    if (!stringRedisTemplate.hasKey(UploadConstants.FILE_MD5_KEY + multipartFileDTO.getMd5())) {
        stringRedisTemplate.opsForValue().set(UploadConstants.FILE_MD5_KEY + multipartFileDTO.getMd5(), uploadDirPath + "/" + fileName + ".conf");
    }
}
           

最後說一句(别白嫖,求關注)

每一篇文章都是精心輸出,如果這篇文章對你有所幫助,或者有所啟發的話,幫忙點贊、在看、轉發、收藏,你的支援就是我堅持下去的最大動力!

原文連結;https://mp.weixin.qq.com/s/qQf4ZnJlTaQeVn6KBdm9jA

繼續閱讀