基于SpringBoot和Vue實(shí)現(xiàn)分片上傳系統(tǒng)
最近想做一個關(guān)于文件上傳的個人小網(wǎng)盤,一開始嘗試使用了OSS的方案,但是該方案對于大文件來說并不友好,一個是OSS云服務(wù)廠商費(fèi)用高昂的問題,另外一個是大文件速度較慢。于是看了網(wǎng)絡(luò)上的帖子以及工作室小伙伴的推薦,開始嘗試分片上傳方案的探索,目前整個項(xiàng)目已經(jīng)完成,本人認(rèn)為使用的技術(shù)都是最簡單且高效的方法,主要采用自己編寫的方案,在應(yīng)用層比較少使用到第三方的技術(shù),主要用到的技術(shù)有Vue+SpringBoot+MySQL+Redis。此次展示分享上傳部分,感興趣的小伙伴們可以點(diǎn)贊評論,我會在后面及時更新!
首先第一步是整個上傳過程中最重要的一環(huán),對文件內(nèi)容而并非標(biāo)題進(jìn)行一個md5編碼,基于每一個文件一個唯一的字符串,后續(xù)所有文件相關(guān)的處理都需要使用到該字符串。這里的計(jì)算過程中,采取了黑馬在知乎上一篇文章的建議,對文件第一個分片和最后一個分片進(jìn)行全部計(jì)算,其他地方采用前中后兩個字節(jié)進(jìn)行計(jì)算,這樣子可以減少計(jì)算量,加快我們的編碼速度,此步驟據(jù)說也有開源的框架可以代替,這樣子可靠性也更高,有興趣的小伙伴可以自己了解,下面附上自己實(shí)現(xiàn)的。
async calculateHash(fileChunks) {
return new Promise(resolve => {
const spark = new sparkMD5.ArrayBuffer()
const chunks = []
const CHUNK_SIZE = this.CHUNK_SIZE
fileChunks.forEach((chunk, index) => {
if (index === 0 || index === fileChunks.length - 1) {
// 1. 第一個和最后一個切片的內(nèi)容全部參與計(jì)算
chunks.push(chunk.file)
} else {
// 2. 中間剩余的切片我們分別在前面、后面和中間取2個字節(jié)參與計(jì)算
// 前面的2字節(jié)
chunks.push(chunk.file.slice(0, 2))
// 中間的2字節(jié)
chunks.push(chunk.file.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2))
// 后面的2字節(jié)
chunks.push(chunk.file.slice(CHUNK_SIZE - 2, CHUNK_SIZE))
}
})
const reader = new FileReader()
reader.readAsArrayBuffer(new Blob(chunks))
reader.onload = (e) => {
spark.append(e.target.result)
resolve(spark.end())
}
})
}在計(jì)算完畢之后,我們可以在上傳之前可以先做一次檢查,返回我們需要得到的信息,包括但不限于文件是否存在于系統(tǒng)中,文件沒有上傳的話,那么是否有已經(jīng)上傳了的分片,可以返回一個索引數(shù)組。如果已經(jīng)有該文件存在的話則直接進(jìn)行保存文件信息就好了,后者有利于實(shí)現(xiàn)我們的斷點(diǎn)續(xù)傳工作,第二次上傳只要上傳還沒有上傳的部分即可,主要是后端實(shí)現(xiàn)為主。
async uploadCheck() {
let r;
await axios.get('/api/file//uploadCheck?fileMd5=' + this.key).then(res => {
r = res.data.flag;
this.existCheck=[];
//存在部分分片則返回存在的文件信息
if (r == false){
this.existCheck=res.data.data;
}
})
return r;
}后端代碼分為接口和服務(wù)層代碼,分別給出:
/**
* 文件整體的查重校驗(yàn)
* @param fileMd5
* @return
*/
@GetMapping("/uploadCheck")
public Result uploadCheck(String fileMd5,HttpServletRequest httpServletRequest){
String user = JwtUtil.getId(httpServletRequest.getHeader("token"));
if (fileService.uploadCheck(fileMd5,user)){
return new Result(true,true);
}else {
//查找文件是否有分片上傳過到系統(tǒng)中
Integer arr[] = fileService.existCheck(fileMd5);
return new Result(false,arr);
}
}上傳時候如果在數(shù)據(jù)庫中發(fā)現(xiàn),已經(jīng)有用戶或者本用戶在系統(tǒng)中已經(jīng)成功上傳過該文件的話,那么我們可以直接插入數(shù)據(jù)返回保存完畢即可了,無需真正意義上的上傳。不存在則在redis中看一下那些索引已經(jīng)上傳過了,將索引數(shù)組返回,前端后續(xù)上傳跳過即可。
@Override
public Boolean uploadCheck(String fileMd5, String userId) {
//判斷文件是否存在
MyFile myFile = fileMapper.getFileByMd5(fileMd5);
//如果文件存在直接給用戶插入數(shù)據(jù)記錄即可
if (myFile != null) {
MyFile newMyFile = new MyFile();
newMyFile.setId(userId + DateTimeUtil.getTimeStamp());
newMyFile.setFileName(myFile.getFileName());
newMyFile.setUser(userId);
newMyFile.setFileMd5(fileMd5);
newMyFile.setFileSize(myFile.getFileSize());
newMyFile.setTime(LocalDateTime.parse(DateTimeUtil.getDateTime(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
Integer num = fileMapper.insert(newMyFile);
if (num == 1) {
return true;
}
}
return false;
}檢查完畢之后我們可以開始上傳文件啦!上傳過程中我認(rèn)為依然是前端占了打頭的,后端只要接收文件不斷磁盤寫入就好了,雖然說不建議那么多的io次數(shù),但是實(shí)際上測試下來還可以,4M帶寬的學(xué)生服務(wù)器都可以做到20秒左右上傳100M,本地的話更加是快的不得了,反而前端如果分片分的太小的話,觸發(fā)的網(wǎng)絡(luò)請求數(shù)量過多,這時候速度上才容易出事,前端分片不要設(shè)置太小的話,io次數(shù)的話也可以小一點(diǎn)。
const formDatas = data.map(({chunk, fileHash, index, filename, chunkSize}) => {
const formData = new FormData()
// 切片文件
formData.append('file', chunk)
// 大文件hash
formData.append('fileMd5', fileHash)
//切片的索引
formData.append('currentIndex', index)
// 大文件的文件名
formData.append('fileName', filename)
// 分片大小
formData.append('chunkCount', chunkSize)
return formData
})
let index = 0;
const max = 6; // 并發(fā)請求數(shù)量
const taskPool = []// 請求隊(duì)列
let t = this.existCheck.length;
while (index < formDatas.length) {
//出現(xiàn)重復(fù)的切片,跳過
if (this.existCheck.includes(index)){
index++;
continue;
}
const task = axios.post('/api/file/uploadBySlice', formDatas[index])
//splice方法會刪除數(shù)組中第一個匹配的元素,參數(shù)搭配使用findIndex可以找到第一個匹配的元素的索引
task.then(() => {
taskPool.splice(taskPool.findIndex((item) => item === task))
t=t+1;
this.percentage = Math.floor((t / formDatas.length * 100) * (1.0))-1
})
taskPool.push(task);
if (taskPool.length === max) {
// 當(dāng)請求隊(duì)列中的請求數(shù)達(dá)到最大并行請求數(shù)的時候,得等之前的請求完成再循環(huán)下一個
await Promise.race(taskPool)
}
index++
}
await Promise.all(taskPool)
}后端代碼實(shí)現(xiàn),接口層簡單,只展示服務(wù)層即可。后端主要負(fù)責(zé)的工作,包括分片寫入磁盤,并且在redis中保存已經(jīng)上傳好的文件索引號。
/**
* 上傳分片、文件
*
* @param file
* @param fileMd5
* @param currentIndex
* @return
*/
@Override
public Integer uploadFile(MultipartFile file, String fileMd5, Integer currentIndex) {
//在redis中查詢該分片是否已經(jīng)存在
if (redisTemplate.opsForSet().isMember(fileMd5, currentIndex)) {
return currentIndex;
}
// 生成分片的臨時路徑
String filePath = tempPath + fileMd5 + "_" + currentIndex + ".tmp";
//保存文件分片到本地的目標(biāo)路徑
File targetFile = new File(filePath);
try {
RandomAccessFile raf = new RandomAccessFile(targetFile, "rw");
byte[] data = file.getBytes();
raf.write(data);
raf.close();
//在redis中保存該分片的索引
redisTemplate.opsForSet().add(fileMd5, currentIndex);
return currentIndex;
} catch (IOException e) {
// 處理異常
throw new ServiceException("文件上傳失敗");
}
}那么,如果我們我們上傳一次中間不小心刷新或者網(wǎng)絡(luò)中斷后,我們應(yīng)該如何處理呢?其實(shí)在前面的時候我們已經(jīng)解決了,因?yàn)槲覀兩蟼鳈z查的時候,已經(jīng)返回了已經(jīng)上傳過的索引號,所以這一次上傳的時候自動跳過即可了,在上面前端的上傳區(qū)域可以看見有跳過的代碼設(shè)置!
最后文件分片都上傳完畢了,我們就是最后一步了,等待前端所有的上傳任務(wù)執(zhí)行完畢,我們執(zhí)行一次發(fā)送合并指令即可了,當(dāng)然在后端其實(shí)也可以做,前端就不需要發(fā)送合并指令了。
//發(fā)起文件合并請求
merge() {
axios.get('/api/file/merge' + '?fileMd5=' + this.key + '&fileName=' + this.filename + '&chunkCount=' + this.fileChunks.length).then((resp) => {
if (resp.data.flag == true) {
this.$message({
message: '文件上傳成功',
type: 'success'
});
this.percentage = 100;
this.query();
this.uploadRefresh=false;
}
})
}后端此處代碼比較長,但是實(shí)際上也比較簡單的,主要是做了合并故障的處理,和剛才前端上傳故障處理的思路類似,如果出現(xiàn)故障,下一次合并從斷點(diǎn)繼續(xù)就好了,這一次的邏輯從前端搬到了后端來做,也是通過redis來記錄。
/**
* 合并分片文件
*
* @param fileName
* @param chunkCount
* @return
*/
@Override
public String mergeTmpFiles(String fileMd5, String fileName, Integer chunkCount, String userId) throws IOException {
//記錄本次合并的字節(jié)數(shù)
long count = 0;
//獲取分片索引號的起始地址
int start = 0 ;
String countKey = fileMd5+"-count";
if (!redisTemplate.hasKey(countKey)) {
redisTemplate.opsForHash().put(countKey, "count", "0");
}else {
start = Integer.parseInt(redisTemplate.opsForHash().get(countKey, "count").toString());
start++;
}
//記錄分片文件的總大小
for (int i = start; i < chunkCount; i++) {
//讀取分片文件
String filePath = tempPath + fileMd5 + "_" + i + ".tmp";
File file = new File(filePath);
if (!file.exists()) {
//需要排除redis造成的異常情況
redisTemplate.opsForSet().remove(fileMd5, i);
log.info("缺失索引編號", i);
throw new ServiceException("文件分片缺失");
} else {
count += file.length();
}
//使用緩沖流讀取到內(nèi)存中
byte[] data = new byte[(int) file.length()];
FileInputStream inputStream = new FileInputStream(file);
inputStream.read(data);
inputStream.close();
//保存文件到文件夾中
file = new File(endPath + fileMd5 + "." + getFileExtension(fileName));
FileOutputStream outputStream = new FileOutputStream(file, true);
outputStream.write(data);
outputStream.close();
//刪除碎片文件
File temp = new File(filePath);
temp.delete();
//記錄合并進(jìn)度
redisTemplate.opsForHash().put(countKey, "count", i);
}
//記錄文件保存數(shù)據(jù)
MyFile myFile = new MyFile();
myFile.setId(userId + DateTimeUtil.getTimeStamp());
myFile.setFileName(fileName);
myFile.setUser(userId);
myFile.setFileMd5(fileMd5);
File file = new File(endPath + fileMd5 + "." + getFileExtension(fileName));
myFile.setFileSize(file.length());
myFile.setTime(LocalDateTime.parse(DateTimeUtil.getDateTime(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
fileMapper.insert(myFile);
//刪除各類緩存數(shù)據(jù)
redisTemplate.delete(countKey);
redisTemplate.delete(fileMd5);
//返回處理結(jié)果
return fileName + " 此次合并:" + count + "字節(jié)";
}好啦!分片上傳,斷點(diǎn)上傳,秒傳等功能已經(jīng)全部實(shí)現(xiàn)啦!合并文件或者故障處理等都已經(jīng)自己在后端打斷點(diǎn)測試過,可靠性較高。在本地跑用的是8核+24G配置,沒有出過什么故障,但是上傳到本人2核+2G的機(jī)器上,超過100M的文件,偶爾合并會出現(xiàn)故障,但是通過合并的故障處理,我們可以讓前端如果合并失敗的話,再次發(fā)起合并請求即可,目前還沒有出現(xiàn)過連續(xù)合并請求兩次都不成功的,而且第二次合并請求也是在斷點(diǎn)的基礎(chǔ)上進(jìn)行的,沒有白費(fèi)消耗。
以上就是基于SpringBoot和Vue實(shí)現(xiàn)的分片上傳系統(tǒng)的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot Vue分片上傳系統(tǒng)的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
springboot項(xiàng)目讀取resources目錄下的文件的9種方式
本文主要介紹了springboot項(xiàng)目讀取resources目錄下的文件的9種方式,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-04-04
Java實(shí)戰(zhàn)個人博客系統(tǒng)的實(shí)現(xiàn)流程
讀萬卷書不如行萬里路,只學(xué)書上的理論是遠(yuǎn)遠(yuǎn)不夠的,只有在實(shí)戰(zhàn)中才能獲得能力的提升,本篇文章手把手帶你用java+springboot+mybatis+redis+vue+elementui+Mysql實(shí)現(xiàn)一個個人博客系統(tǒng),大家可以在過程中查缺補(bǔ)漏,提升水平2022-01-01

