開味菜
最近接到一個新的需求,需要上傳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、「讀」方法
5、「寫」方法
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");
}
}
❝回覆
❞break
可獲取完整源碼呦!
覺得還不錯?記得一鍵四連呦👇
本文分享自微信公衆號 - Java識堂(erlieStar)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。