目錄
前言
目標(biāo)
實(shí)現(xiàn)思路
大文件分片
合并分片
斷點(diǎn)續(xù)傳
代碼實(shí)現(xiàn)
1、webuploader組件中,分片上傳怎么開啟?
2、webuploader組件中,文件的md5值如何計算?
3、webuploader組件中,分片文件的md5值如何計算?
4、webuploader組件中,分片上傳的的請求在哪里觸發(fā)?
5、前端、后端如何校驗(yàn)分片是否已經(jīng)上傳?
6、后端如何處理分片上傳請求?
7、webuploader組件中,合并文件分片的請求在哪里觸發(fā)?
8、后端如何合并分片請求?
10、上傳的進(jìn)度條是怎么實(shí)現(xiàn)的?
總結(jié)
前言
在軟件工程里,在處理“大”的時候一直是一個痛點(diǎn)和難點(diǎn),如并發(fā)大、數(shù)據(jù)量大、文件大,對硬件進(jìn)行升級可以解決一些問題,但這并不最聰明的辦法,而對于老板來說,這也不是成本最小的辦法。作為開發(fā)人員來說,在面對類似極端的問題時,只可智取,不可硬剛,最大化利用好手上現(xiàn)有的資源,以更加優(yōu)雅的辦法來滿足用戶多樣化的需求才是王道。今天的主題也是一個“大”的問題,就是超大文件如何上傳和下載?其實(shí)在解決這個問題之前,有一個問題是繞不過去的:什么才是大文件?什么是超大文件?幾十兆?幾百兆?還是幾個G?這其實(shí)是一個極具爭議的問題,在不同的業(yè)務(wù)場景下,對于大文件的“大”的理解和定義可能是不同的。但這并不是本文的重點(diǎn),我想和大家分享的是,面對所謂的大文件時,如何優(yōu)雅實(shí)現(xiàn)上傳。
不管多簡單的需求,在量級達(dá)到一定程度時,就會變得異常復(fù)雜。所以這篇文章會有點(diǎn)長,有兩部分組成,如果覺得一步一步看太累,想直接看結(jié)果,文章的最后面附上了前后端的兩個關(guān)鍵文件,可以直接拿到編輯里邊調(diào)試邊一步一步看:
文章示例環(huán)境配置信息
jdk版本:1.8
開發(fā)工具:Intellij iDEA 2020.1
springboot:2.3.9.RELEASE
目標(biāo)
實(shí)現(xiàn)超大文件的上傳和斷點(diǎn)續(xù)傳。
實(shí)現(xiàn)思路
對于各種“大”的問題,很多的場景采用是分而治之的理念進(jìn)行設(shè)計,而對于大文件上傳來說,從這一理念出發(fā),具體的方法就是把大文件分片上傳,分片全部上傳后再對分片進(jìn)行合并。那什么是大文件分片呢?什么又是合并分片呢?如果分片上傳過程中,個別分片上傳失敗了,需要重新分片上傳嗎?其實(shí)文件上傳本身是一個很簡單的需求,當(dāng)量級達(dá)到一定程度時,就變得異常復(fù)雜了,需要考慮的問題點(diǎn)很多,同理其他“大”的問題也是類似的。
大文件分片
大文件分片是將要上傳的大文件,按照一定的規(guī)則,將整個文件進(jìn)行切片,即把一個大的數(shù)據(jù)文件切分成小的數(shù)據(jù)塊,然后再按照一定的策略(串行或并發(fā))進(jìn)行上傳;
合并分片
大文件分片合并是大文件分片上傳的后續(xù)操作,當(dāng)一個大文件被切分成小的數(shù)據(jù)塊上傳到服務(wù)器時,需要把這些小的數(shù)據(jù)塊按照原來的順序再進(jìn)行合并,還原成原來的大文件,這就是合并分片;
文件分片和分片合并其實(shí)很好理解,類似于現(xiàn)實(shí)生活中,如果想把一架大飛機(jī)藏在洞口比較小的山洞里,要怎么做呢?肯定是先把飛機(jī)拆成小的零件,然后運(yùn)輸?shù)缴蕉蠢锩妫俑鶕?jù)飛機(jī)的裝配圖紙把所有的零件再裝好。原理就這么簡單。
?
斷點(diǎn)續(xù)傳
大文件分片上傳的時候,個別分片有可能會碰到網(wǎng)絡(luò)故障,而導(dǎo)致一些分片文件上傳失敗,如果網(wǎng)絡(luò)恢復(fù)后,可以從上傳失敗的分片開始上傳而直接跳過上傳成功的分片文件部分,那就可以節(jié)約時間,提高上傳效率,這就是斷點(diǎn)續(xù)傳;如下圖:一個大文件被切分成了n個分片,分片上傳過程中,分片2和分片3因?yàn)槟撤N原因上傳失敗了,再次上傳的時候,會直接跳過所有上傳成功的分片,直接從失敗的分片2和分片3開始上傳,合并分片的時候檢測到所有的分片都完成了上傳,就可以合并還原成大文件本身的面目了;
實(shí)現(xiàn)思路很簡單,實(shí)現(xiàn)原理其實(shí)也很簡單,具體如下:
1、前端使用百度的WebUploader組件,選中待上傳的大文件,然后計算出文件的md5值;
2、WebUploader組件開啟分片上傳后,選中的待上傳文件會按照配置好的分片規(guī)則進(jìn)行分片,分片文件上傳前會計算出分片文件的md5值;
3、通過webuploader組件api計算出分片的md5值后,會攜帶文件md5值和分片文件的md5值,調(diào)用后臺接口檢驗(yàn)當(dāng)前分片是否已經(jīng)上傳過;若上傳過,則直接跳過;若未上傳過,則開始分片上傳;
4、前端往后端傳輸分片文件的過程可以是并發(fā)執(zhí)行,這里一定注意傳遞到后臺的分片并不一定是按照分片的順序來的,后端收到分片文件后,會保存分片文件到硬盤、網(wǎng)盤等存儲介質(zhì)上,同時也要保存分片文件md5值、文件md5值等分片參數(shù)信息;
5、待所有的分片上傳成功后,會觸發(fā)WebUploader的uploadSuccess事件,然后在這里再發(fā)起合并分片請求;
6、后端收到合并分片的請求后,再次檢查所有的分片是否上傳完整,若上傳完整,則開始合并所有分片;
7、這里要特別注意,合并分片的時候,一定要按鈕分片時的切割順序來合并,否則文件就會打不開或運(yùn)行不了;合并完所有分片文件后,分片文件就沒有用了,可以刪除了;
md5消息摘要算法,屬Hash算法一類,主要特點(diǎn)是不可逆,相同數(shù)據(jù)的md5值肯定一樣,不同數(shù)據(jù)的md5值不一樣;對于數(shù)據(jù)文件,不管文件名字是否相同,如果數(shù)據(jù)文件內(nèi)容相同,則文件的md值是相同的;webuploader組件提供了文件的md5值的計算方法,其計算過程是異步的;
?
代碼實(shí)現(xiàn)
大文件分片上傳的實(shí)現(xiàn)原理,邏輯比較清晰,那么落地到代碼實(shí)現(xiàn)上,還有幾個問題需要解決:
1、webuploader組件中,分片上傳怎么開啟?
2、webuploader組件中,文件的md5值如何計算?
3、webuploader組件中,分片文件的md5值如何計算?
4、webuploader組件中,分片上傳的的請求在哪里觸發(fā)?
5、前端、后端如何校驗(yàn)分片是否已經(jīng)上傳?
6、后端如何處理分片上傳請求?
7、webuploader組件中,合并文件分片的請求在哪里觸發(fā)?
8、后端如何合并分片請求?
9、分片上傳失敗后,如何在斷點(diǎn)處繼續(xù)上傳?
10、上傳的進(jìn)度條是怎么實(shí)現(xiàn)的?
1、webuploader組件中,分片上傳怎么開啟?
webuploader的分片上傳開啟實(shí)際上很簡單,在創(chuàng)建webuploader對象時,設(shè)置chunked為true,即表示開啟分片上傳;chunkSize可以設(shè)置分片大小,即以多大的體積進(jìn)行分片;chunkRetry可以設(shè)置重傳次數(shù),有的時候由于網(wǎng)絡(luò)原因,分片上傳的會失敗,這里即是失敗允許重的次數(shù);threads可以設(shè)置允許最大由幾個進(jìn)程發(fā)起上傳請求;
uploader = WebUploader.create({
// swf文件路徑
swf: 'http://localhost:8080/lib/Uploader.swf',
// 分片文件上傳接口
server: 'http://localhost:8080/file/upload',
// 選擇文件的按鈕??蛇x。
pick: '#picker',
fileVal: 'multipartFile',//后端用來接收上傳文件的參數(shù)名稱
chunked: true,//開啟分片上傳
chunkSize: 1024 * 1024 * 10,//設(shè)置分片大小
chunkRetry: 2,//設(shè)置重傳次數(shù),有的時候由于網(wǎng)絡(luò)原因,分片上傳的會失敗,這里即是失敗允許重的次數(shù)
threads: 3//允許同時最大上傳進(jìn)程數(shù)
});
2、webuploader組件中,文件的md5值如何計算?
文件的md5計算可以引用spark-md5.js,據(jù)傳言是javascript里md5加密計算速度最快的,當(dāng)然在webuploader.js里也有具體的api可以使用;引入webuploader.js后,調(diào)用 WebUploader.Uploader.md5File(...)即可計算文件的md5值,這里需要注意的是md5File(...),有三個參數(shù),分別是file,數(shù)據(jù)起始位置、數(shù)據(jù)結(jié)束位置,返回的是一個promise對象,要想拿到具體的值還要再調(diào)用then(function(val){}),具體步驟如下:
1、當(dāng)添加完文件后,webuploader的fileQueued事件被觸發(fā);
2、fileQueued事件觸發(fā)后,在回調(diào)函數(shù)里計算出文件的的md5值,這里注意是要計算出整個文件的md5值,而不是一部分,關(guān)鍵就在md5File(...)方法的后兩個參數(shù)數(shù)據(jù)起始位置和數(shù)據(jù)結(jié)束位置,看到很多人實(shí)際上是用錯了,只計算了文件一部分的md5值,并不是整個文件的md5值;md5的計算過程是異步操作,并且文件越大,計算用時就越長;
3、這里還用到了deferred,deferred的作用就是監(jiān)控異步計算文件md5值這個異步操作的執(zhí)行狀態(tài);文件md5值計算完成后,更新狀態(tài)為已完成,這時 deferred.done()會觸發(fā);更新md5計算標(biāo)志位為true,這時再點(diǎn)擊開始上傳按鈕時,就會再有彈窗提示:md5計算中...,請稍侯;
webuploader內(nèi)部有很多種command,其中有一個叫before-send-file,也可以在文件上傳前會觸發(fā),此時還沒有開始分片,可以用來做文件整體的md5計算;需要注意的是before-send-file的觸發(fā)時機(jī)是要晚于fileQueued事件的;before-send-file是在點(diǎn)擊開始上傳按鈕執(zhí)行uploader.upload()后才會觸發(fā);而fileQueued事件是在選擇文件后,立刻觸發(fā),不用等到點(diǎn)擊開始上傳按鈕;所以具體在哪里進(jìn)行計算,可根據(jù)實(shí)際業(yè)務(wù)酌情選擇;
/**
* 當(dāng)有文件被添加進(jìn)隊(duì)列后觸發(fā)
* 主要邏輯:1、文件被添加到隊(duì)列后,開始計算文件的md5值;
* 2、md5的計算過程是異步操作,并且文件越大,計算用時越長;
* 3、變量md5FlagMap是文件md5值計算的標(biāo)志位,計算完成后,設(shè)置當(dāng)前文件的md5Flag為true
*/
//md5FlagMap用于存儲文件md5計算完成的標(biāo)志位;多個文件時,分別設(shè)置標(biāo)志位,key是文件名,value是true或false;
var md5FlagMap = new Map();
uploader.on('fileQueued', function (file) {
md5FlagMap.set(file.name, false);//文件md5值計算的標(biāo)志位默認(rèn)為false
var deferred = WebUploader.Deferred();//deferred用于監(jiān)控異步計算文件md5值這個異步操作的執(zhí)行狀態(tài)
uploader.md5File(file, 0, file.size - 1).then(function (fileMd5) {
file.wholeMd5 = fileMd5;
file_md5 = fileMd5;
deferred.resolve(file.name);//文件md5值計算完成后,更新狀態(tài)為已完成,這時 deferred.done()會觸發(fā)
})
//文件越大,文件的md5值計算用時越長,因此md5的計算搞成異步執(zhí)行是合理的;如果異步執(zhí)行比較慢的話,會順序執(zhí)行到這里
$('#thelist').append('<div id="' + file.id + '" class="item">' +
'<h4 class="info">' + file.name + '</h4>' +
'<p class="state">開始計算大文件的md5......<br/></p>' +
'</div>')
//文件的md5計算完成,會觸發(fā)這里的回調(diào)函數(shù),
deferred.done(function (name) {
md5FlagMap.set(name, true);//更新md5計算標(biāo)志位為true
$('#' + file.id).find('p.state').append('大文件的md5計算完成<br/>');
})
return deferred.promise();
})
3、webuploader組件中,分片文件的md5值如何計算?
webuploader對象中配置好相關(guān)的開啟分片設(shè)置參數(shù)后,當(dāng)有文件被選中添加后,webuploader會幫你對文件按配置參數(shù)進(jìn)行分片;在分片文件發(fā)送到后臺之前,webuploader內(nèi)部另一個command(before-send)會觸發(fā),before-send的觸發(fā)時機(jī)在分片上傳之前,可以用作在分片發(fā)送到后端之前計算出分片文件的md5,調(diào)用后臺接口做分片是否已經(jīng)上傳的驗(yàn)證:如果分片已經(jīng)上傳成功了,直接跳過,不會再調(diào)用分片的上傳接口;如果分片未上傳,則會把分片的md5值賦給分片block上,webuploader的另一個事件‘uploadBeforeSend’在觸發(fā)的時候,其回調(diào)函數(shù)的第一個參數(shù)中就可以拿到分片的md5值,然后傳遞到后臺,如果下次再上傳時分片時,則可以用于是否已經(jīng)上傳的校驗(yàn);
WebUploader.Uploader.register({
"add-file": "addFile",
"before-send-file": "beforeSendFile",
"before-send": "beforeSend",
"after-send-file": "afterSendFile"
}, {
addFile: function (file) {
console.log('1', file)
},
beforeSendFile: function (file) {
console.log('2', file)
},
beforeSend: function (block) {
console.log(3)
var file = block.file;
var deferred = WebUploader.Base.Deferred();
(new WebUploader.Uploader()).md5File(file, block.start, block.end).then(function (value) {
$.ajax({
url: 'http://localhost:8080/file/check',//檢查當(dāng)前分片是否已經(jīng)上傳
method: 'post',
data: {chunkMd5: value, fileMd5: file_md5,chunk:block.chunk},
success: function (res) {
if (res) {
deferred.reject();
} else {
deferred.resolve(value);
}
}
});
})
deferred.done(function (value) {
console.log('分片md5:', value)
block.chunkMd5 = value;
})
return deferred;
},
afterSendFile: function (file) {
console.log('4', file)
}
})
4、webuploader組件中,分片上傳的的請求在哪里觸發(fā)?
當(dāng)選擇文件后,就開始計算整體文件的md5值了,未計算完成前,點(diǎn)擊開始上傳按鈕,會直接彈出“md5計算中...,請稍侯”;考慮到多文件上傳的情況,這里使用了map對象md5FlagMap來存儲每個文件的md5是否計算完成標(biāo)志,key是文件名稱,value是true或false,表示文件md5文件是否計算完成;
如果不想用按鈕來觸發(fā)上傳,WebUploader有一個參數(shù)是auto,默認(rèn)是false,可以改為true,選中文件后自動開始上傳;
//開始上傳按鈕被點(diǎn)擊時觸發(fā)
$('#ctlBtn').click(function () {
//md5FlagMap存儲有文件md5計算的標(biāo)志位; // 同時上傳多個文件時,上傳前要判斷一下已添加文件的md5是否計算完成,
// 如果有未計算完成的,則繼續(xù)等待計算結(jié)果; //文件上傳標(biāo)志位,如果多個文件有一個沒有完成md5計算則不能開始上傳; //這里在實(shí)際業(yè)務(wù)中可以更換成其他交互樣式,酌情優(yōu)化為哪個文件的md5計算完成,則開始哪個文件的上傳; var uploadFloag = true;
md5FlagMap.forEach(function (value, key) {
if (!value) {
uploadFloag = false;
alert('md5計算中...,請稍侯')//文件md5計算未完成,會彈出彈窗提示; }
})
if (uploadFloag) {
uploader.upload();//文件md5計算完成后,開始分片上傳; }
})
添加的文件的md5計算完成后,再次點(diǎn)擊開始上傳按鈕,調(diào)用 uploader.upload(),開始分片上傳;但是在實(shí)際開始上傳前,webuploader的command(before-send)和uploadBeforeSend事件會先后觸發(fā),這兩個地方有一個共同作用就是在分片正式上傳前可以再做點(diǎn)事情,根據(jù)官方文檔介紹,不同的是before-send可以用作分片是否已上傳的驗(yàn)證(詳見第3個問題里);uploadBeforeSend事件的作用是在分片上傳前添加一些附帶參數(shù)到后端;
// 分片模式下,當(dāng)文件的分塊在發(fā)送前觸發(fā)
uploader.on('uploadBeforeSend', function (block, data) {
var file = block.file;
//data可以攜帶參數(shù)到后端
data.originalFilename = file.originalFilename;//文件名字
data.md5Value = file.wholeMd5;//文件整體的md5值
data.start = block.start;//分片數(shù)據(jù)塊在整體文件的開始位置
data.end = block.end;//分片數(shù)據(jù)塊在整體文件的結(jié)束位置
data.chunk = block.chunk;//分片的索引位置
data.chunks = block.chunks;//整體文件總共分了多少征
data.chunkMd5 = block.chunkMd5;//分片文件md5值
});
5、前端、后端如何校驗(yàn)分片是否已經(jīng)上傳?
在webuploader內(nèi)部的一個command(before-send)已經(jīng)完成了分片文件的md5計算以及請求后臺接口來校驗(yàn)當(dāng)前分片文件是否已經(jīng)上傳(參見第3個問題),如果已上傳,那么會直接跳過當(dāng)前分片上傳接口的調(diào)用,uploadBeforeSend事件也不會再觸發(fā)(當(dāng)某個分片文件在發(fā)送前觸發(fā),主要用來詢問是否要添加附帶參數(shù),大文件在開起分片上傳的前提下此事件可能會觸發(fā)多次);
?
如果未上傳,則uploadBeforeSend事件會觸發(fā),攜帶一些分片的參數(shù)信息發(fā)起分片上傳請求;
?
那么后端是如何檢驗(yàn)分片是否上傳呢?如下:
1、在分片文件上傳接口中,分片上傳成功后,會保存分片的相關(guān)信息,如:分片文件md5、文件md5、文件大小、分片存儲位置、分片數(shù)據(jù)塊的起始結(jié)束位置、總共分片數(shù)量等,這里使用redis緩存了這些分片信息,redis用到了hash數(shù)據(jù)結(jié)構(gòu),其中key為文件的md5,hashkey是“chunk_md5_”+分片索引,value就是分片文件的md5值;(當(dāng)然也可以使用數(shù)據(jù)庫或其他存儲介質(zhì),)
2、接口被調(diào)用的時候,根據(jù)前端傳過來的當(dāng)前分片的索引位置取出分片的md5與前端傳過來的分片文件md5進(jìn)行比較,如果相同,則說明當(dāng)前分片已經(jīng)上傳成功;如果不相同,則說明未上傳過;
@PostMapping("/check")
public boolean check(String fileMd5,String chunk,String chunkMd5) {
Object o = redisTemplate.opsForHash().get(fileMd5, "chunk_md5_"+chunk);
if (chunkMd5.equals(o)) {
return true;
}
return false;
}
6、后端如何處理分片上傳請求?
后端在處理分片上傳時主要做了兩件事:
第一,把分片文件保存在磁盤上或其他的網(wǎng)絡(luò)存儲介質(zhì)上,這里需要注意一下分片文件的命名規(guī)則,盡量有規(guī)律一些,方便后面合并分片;這里分片文件的命名規(guī)則是:分片md5值+分片索引位置;
第二、保存分片相關(guān)的信息,在實(shí)際業(yè)務(wù)開發(fā)中可以考慮保存在緩存或數(shù)據(jù)庫里,這里只是作了緩存;緩存的數(shù)據(jù)結(jié)構(gòu)是hash,key是文件整體的md5值,hashKey與hashValue對應(yīng)關(guān)系如下:
/**
* 分片上傳接口
*
* @param request
* @param multipartFile
* @return
* @throws IOException
*/
@PostMapping("/upload")
public String upload(HttpServletRequest request, MultipartFile multipartFile) {
log.info("分片上傳....");
Map<String, String> requestParam = this.doRequestParam(request);
String md5Value = requestParam.get("md5Value");//整體文件的md5值
String chunkIndex = requestParam.get("chunk");//分片在所有分片文件中索引位置
String start = requestParam.get("start");//當(dāng)前分片在整個數(shù)據(jù)文件中的開始位置
String end = requestParam.get("end");//當(dāng)前分片在整個數(shù)據(jù)文件中的結(jié)束位置
String chunks = requestParam.get("chunks");//整體文件總共被分了多少片
String fileSize = requestParam.get("size");//整體文件大小
String chunkMd5 = requestParam.get("chunkMd5");//分片文件的md5值
String userDir = System.getProperty("user.dir");
String chunkFilePath = userDir + File.separator + chunkMd5 + "_" + chunkIndex;
File file = new File(chunkFilePath);
try {
multipartFile.transferTo(file);
Map<String, String> map = new HashMap<>();
map.put("chunk_location_" + chunkIndex, chunkFilePath);//分片存儲路徑
map.put("chunk_start_end_" + chunkIndex, start + "_" + end);
map.put("file_size", fileSize);
map.put("file_chunks", chunks);
map.put("chunk_md5_" + chunkIndex, chunkMd5);
redisTemplate.opsForHash().putAll(md5Value, map);
} catch (IOException e) {
e.printStackTrace();
} catch (IllegalStateException e) {
e.printStackTrace();
}
return "success";
}
7、webuploader組件中,合并文件分片的請求在哪里觸發(fā)?
webuploader組件中,有一對事件分別是uploadSuccess和uploadError,當(dāng)文件上傳成功時,uploadSuccess觸發(fā);當(dāng)文件上傳失敗時,uploadError觸發(fā);因此uploadSuccess事件剛好可以用來,向后臺發(fā)起合并分片文件的請求;
//當(dāng)文件上傳成功時觸發(fā)
uploader.on('uploadSuccess', function (file) {
//大文件的所有分片上傳成功后,請求后端對分片進(jìn)行合并
$.ajax({
url: 'http://localhost:8080/file/merge',
method: 'post',
data: {'md5Value': file.wholeMd5, 'originalFilename': file.name},
success: function (res) {
alert('大文件上傳成功!')
}
})
$('#' + file.id).find('p.state').append('文件上傳成功<br/>');
});
8、后端如何合并分片請求?
當(dāng)所有的分片文件上傳成功時會觸發(fā)webuploader的uploadSuccess事件觸發(fā)時機(jī),然后調(diào)用后臺的合并分片文件接口,合并分片文件接口的主要業(yè)務(wù)邏輯:
1、檢驗(yàn)一下所有的分片是否全部上傳完成(當(dāng)分片上傳成功時,會把分片md5值和文件整體總共分了多少片存儲在redis里,存儲時的hashKey是"chunk_md5_"+分片索引位置和file_chunks,如果存儲的分片md5的數(shù)量與文件整體分片的數(shù)量一致,則表示所有的分片均已上傳);
/**
* 合并分片前檢驗(yàn)文件整體的所有分片是否全部上傳
*
* @param key
* @return
*/
private boolean checkBeforeMerge(String key) {
Map map = redisTemplate.opsForHash().entries(key);
Object file_chunks = map.get("file_chunks");
int i = 0;
for (Object hashKey : map.keySet()) {
if (hashKey.toString().startsWith("chunk_md5_")) {
++i;
}
}
if (Integer.valueOf(file_chunks.toString())==(i)) {
return true;
}
return false;
}
2、如果當(dāng)前文件文件已經(jīng)上傳過,只是名字不同,那么md5值是相同的,直接拿出已經(jīng)上傳的文件按現(xiàn)在名字再復(fù)制一份;
3、在開始合并分片文件前,要先從redis中取出分片文件的存儲位置,這里要特別注意一下,分片合并的順序一定與索引位置的升序一致,否則合并的文件是無法打開或運(yùn)行的;因?yàn)榉制蟼鞯倪^程是并發(fā)執(zhí)行的,到達(dá)后端的順序可能每次都不一樣,但是各分片的索引位置不會變;因此可以在[0,文件分片總數(shù)量-1]之間遍歷,從redis中依次取出分片文件的存儲路徑,并依次寫入到一個新的文件里;
4、各個分片文件依次寫入完成后,關(guān)閉輸入流、輸出流,并刪除分片文件(分片合并成完整文件的時候,分片文件就沒有用了,另外緩存的分片其他相關(guān)信息也沒有用了,也可以刪除了,當(dāng)然在實(shí)際業(yè)務(wù)開發(fā)中,可根據(jù)具體的需求酌情保留);
/**
* 合并分片文件接口
*
* @param request
* @return
* @throws IOException
*/
@PostMapping("/merge")
public String merge(HttpServletRequest request) throws IOException {
log.info("合并分片...");
Map<String, String> requestParam = this.doRequestParam(request);
String md5Value = requestParam.get("md5Value");
String originalFilename = requestParam.get("originalFilename");
//校驗(yàn)切片是否己經(jīng)上傳完畢
boolean flag = this.checkBeforeMerge(md5Value);
if (!flag) {
return "切片未完全上傳";
}
//檢查是否已經(jīng)有相同md5值的文件上傳;主要是對名字不同,而實(shí)際文件相同的文件,直接對原文件進(jìn)行復(fù)制;
Object file_location = redisTemplate.opsForHash().get(md5Value, "file_location");
if (file_location != null) {
String source = file_location.toString();
File file = new File(source);
if (!file.getName().equals(originalFilename)) {
File target = new File(System.getProperty("user.dir") + File.separator + originalFilename);
Files.copy(file.toPath(), target.toPath());
return "success";
}
}
//這里要特別注意,合并分片的時候一定要按照分片的索引順序進(jìn)行合并,否則文件無法使用;
Integer file_chunks = Integer.valueOf(redisTemplate.opsForHash().get(md5Value, "file_chunks").toString());
String userDir = System.getProperty("user.dir");
File writeFile = new File(userDir + File.separator + originalFilename);
OutputStream outputStream = new FileOutputStream(writeFile);
InputStream inputStream = null;
for (int i = 0; i < file_chunks; i++) {
String tmpPath = redisTemplate.opsForHash().get(md5Value,"chunk_location_" + i).toString();
File readFile = new File(tmpPath);
inputStream = new FileInputStream(readFile);
byte[] bytes = new byte[1024 * 1024];
while ((inputStream.read(bytes) != -1)) {
outputStream.write(bytes);
}
if (inputStream != null) {
inputStream.close();
}
}
if (outputStream != null) {
outputStream.close();
}
redisTemplate.opsForHash().put(md5Value, "file_location", userDir + File.separator + originalFilename);
this.delTmpFile(md5Value);
return "success";
}
private void delTmpFile(String md5Value) throws JsonProcessingException {
Map map = redisTemplate.opsForHash().entries(md5Value);
List<String> list = new ArrayList<>();
for (Object hashKey : map.keySet()) {
if (hashKey.toString().startsWith("chunk_location")) {
String filePath = map.get(hashKey).toString();
File file = new File(filePath);
boolean flag = file.delete();
list.add(hashKey.toString());
log.info("delete:" + filePath + ",:" + flag);
}
if (hashKey.toString().startsWith("chunk_start_end_")) {
list.add(hashKey.toString());
}
if (hashKey.toString().startsWith("chunk_md5_")) {
list.add(hashKey.toString());
}
}
list.add("file_chunks");
list.add("file_size");
redisTemplate.opsForHash().delete(md5Value, list.toArray());
}
9、分片上傳失敗后,如何在斷點(diǎn)處繼續(xù)上傳?
在第3個問題、第5個問題中,已經(jīng)解決了這個問題,webuploader內(nèi)部一個command(before-send)會觸發(fā),這時計算分片文件的md5值,并攜帶分片文件的md5值調(diào)用后臺的校驗(yàn)接口;如果已上傳,那么會直接跳過當(dāng)前分片上傳接口的調(diào)用;如果未上傳,則會只上傳未上傳的的那個分片文件;
10、上傳的進(jìn)度條是怎么實(shí)現(xiàn)的?
webuploader的uploadProgress事件在上傳過程中觸發(fā),會攜帶上傳進(jìn)度參數(shù);
// 文件上傳過程中創(chuàng)建進(jìn)度條實(shí)時顯示
uploader.on('uploadProgress', function (file, percentage) {
var $li = $('#' + file.id),
$percent = $li.find('.progress .progress-bar');
if (!$percent.length) {
$percent = $('<div class="progress progress-striped active">' +
'<div class="progress-bar" role="progressbar" style="width: 0%">' +
'</div>' +
'</div>').appendTo($li).find('.progress-bar');
}
$percent.css('width', percentage * 100 + '%');
});
總結(jié)
1、對于后端來說,大部分時候?qū)懙某绦蚨际峭巾樞驁?zhí)行的,但前端的異步執(zhí)行很常見,通過這篇文章又重新學(xué)習(xí)了promise、deferred的使用;
2、不要以為看懂了一篇文章,就真的懂了,紙上得來終覺淺,絕知此事須躬行,還是得上手自己驗(yàn)證一翻,別人說的未必是對的,或者說在作者當(dāng)時的場景下是對的,如何確定你的場景和他的是否相同?所以小編這里希望,大家多提問題,共同討論,共同進(jìn)步。
下面附上所有完整的的示例文件以供小伙伴們參考:
FileController.java文章來源:http://www.zghlxwxcb.cn/news/detail-827925.html
@RestController
@RequestMapping("/file")
@Slf4j
public class FileController {
@Resource
private RedisTemplate redisTemplate;
/**
* 檢驗(yàn)分片文件是否已經(jīng)上傳過
*
* @param fileMd5 整體文件md5值
* @param chunk 當(dāng)前上傳分片在所有分片文件中索引位置
* @param chunkMd5 分片文件的md5值
* @return
*/
@PostMapping("/check")
public boolean check(String fileMd5, String chunk, String chunkMd5) {
Object o = redisTemplate.opsForHash().get(fileMd5, "chunk_md5_" + chunk);
if (chunkMd5.equals(o)) {
return true;
}
return false;
}
/**
* 分片上傳接口
*
* @param request
* @param multipartFile
* @return
* @throws IOException
*/
@PostMapping("/upload")
public String upload(HttpServletRequest request, MultipartFile multipartFile) {
log.info("分片上傳....");
Map<String, String> requestParam = this.doRequestParam(request);
String md5Value = requestParam.get("md5Value");//整體文件的md5值
String chunkIndex = requestParam.get("chunk");//分片在所有分片文件中索引位置
String start = requestParam.get("start");//當(dāng)前分片在整個數(shù)據(jù)文件中的開始位置
String end = requestParam.get("end");//當(dāng)前分片在整個數(shù)據(jù)文件中的結(jié)束位置
String chunks = requestParam.get("chunks");//整體文件總共被分了多少片
String fileSize = requestParam.get("size");//整體文件大小
String chunkMd5 = requestParam.get("chunkMd5");//分片文件的md5值
String userDir = System.getProperty("user.dir");
String chunkFilePath = userDir + File.separator + chunkMd5 + "_" + chunkIndex;
File file = new File(chunkFilePath);
try {
multipartFile.transferTo(file);
Map<String, String> map = new HashMap<>();
map.put("chunk_location_" + chunkIndex, chunkFilePath);//分片存儲路徑
map.put("chunk_start_end_" + chunkIndex, start + "_" + end);
map.put("file_size", fileSize);
map.put("file_chunks", chunks);
map.put("chunk_md5_" + chunkIndex, chunkMd5);
redisTemplate.opsForHash().putAll(md5Value, map);
} catch (IOException e) {
e.printStackTrace();
} catch (IllegalStateException e) {
e.printStackTrace();
}
return "success";
}
/**
* 合并分片文件接口
*
* @param request
* @return
* @throws IOException
*/
@PostMapping("/merge")
public String merge(HttpServletRequest request) throws IOException {
log.info("合并分片...");
Map<String, String> requestParam = this.doRequestParam(request);
String md5Value = requestParam.get("md5Value");
String originalFilename = requestParam.get("originalFilename");
//校驗(yàn)切片是否己經(jīng)上傳完畢
boolean flag = this.checkBeforeMerge(md5Value);
if (!flag) {
return "切片未完全上傳";
}
//檢查是否已經(jīng)有相同md5值的文件上傳;主要是對名字不同,而實(shí)際文件相同的文件,直接對原文件進(jìn)行復(fù)制;
Object file_location = redisTemplate.opsForHash().get(md5Value, "file_location");
if (file_location != null) {
String source = file_location.toString();
File file = new File(source);
if (!file.getName().equals(originalFilename)) {
File target = new File(System.getProperty("user.dir") + File.separator + originalFilename);
Files.copy(file.toPath(), target.toPath());
return "success";
}
}
//這里要特別注意,合并分片的時候一定要按照分片的索引順序進(jìn)行合并,否則文件無法使用;
Integer file_chunks = Integer.valueOf(redisTemplate.opsForHash().get(md5Value, "file_chunks").toString());
String userDir = System.getProperty("user.dir");
File writeFile = new File(userDir + File.separator + originalFilename);
OutputStream outputStream = new FileOutputStream(writeFile);
InputStream inputStream = null;
for (int i = 0; i < file_chunks; i++) {
String tmpPath = redisTemplate.opsForHash().get(md5Value,"chunk_location_" + i).toString();
File readFile = new File(tmpPath);
inputStream = new FileInputStream(readFile);
byte[] bytes = new byte[1024 * 1024];
while ((inputStream.read(bytes) != -1)) {
outputStream.write(bytes);
}
if (inputStream != null) {
inputStream.close();
}
}
if (outputStream != null) {
outputStream.close();
}
redisTemplate.opsForHash().put(md5Value, "file_location", userDir + File.separator + originalFilename);
this.delTmpFile(md5Value);
return "success";
}
@GetMapping("/download")
public String download(String fileName, HttpServletResponse response) throws IOException {
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, "UTF-8"));
String userDir = System.getProperty("user.dir");
File file = new File(userDir + File.separator + fileName);
InputStream inputStream = new FileInputStream(file);
byte[] bytes = new byte[1024 * 1024];
ServletOutputStream outputStream = response.getOutputStream();
while (inputStream.read(bytes) != -1) {
outputStream.write(bytes);
}
inputStream.close();
outputStream.close();
return "success";
}
private void delTmpFile(String md5Value) throws JsonProcessingException {
Map map = redisTemplate.opsForHash().entries(md5Value);
List<String> list = new ArrayList<>();
for (Object hashKey : map.keySet()) {
if (hashKey.toString().startsWith("chunk_location")) {
String filePath = map.get(hashKey).toString();
File file = new File(filePath);
boolean flag = file.delete();
list.add(hashKey.toString());
log.info("delete:" + filePath + ",:" + flag);
}
if (hashKey.toString().startsWith("chunk_start_end_")) {
list.add(hashKey.toString());
}
if (hashKey.toString().startsWith("chunk_md5_")) {
list.add(hashKey.toString());
}
}
list.add("file_chunks");
list.add("file_size");
redisTemplate.opsForHash().delete(md5Value, list.toArray());
}
private Map<String, String> doRequestParam(HttpServletRequest request) {
Map<String, String> requestParam = new HashMap<>();
Enumeration<String> parameterNames = request.getParameterNames();
while (parameterNames.hasMoreElements()) {
String paramName = parameterNames.nextElement();
String paramValue = request.getParameter(paramName);
requestParam.put(paramName, paramValue);
log.info(paramName + ":" + paramValue);
}
log.info("----------------------------");
return requestParam;
}
/**
* 合并分片前檢驗(yàn)文件整體的所有分片是否全部上傳
*
* @param key
* @return
*/
private boolean checkBeforeMerge(String key) {
Map map = redisTemplate.opsForHash().entries(key);
Object file_chunks = map.get("file_chunks");
int i = 0;
for (Object hashKey : map.keySet()) {
if (hashKey.toString().startsWith("chunk_md5_")) {
++i;
}
}
if (Integer.valueOf(file_chunks.toString())==(i)) {
return true;
}
return false;
}
}
webuploader2.html文章來源地址http://www.zghlxwxcb.cn/news/detail-827925.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script type="text/javascript" src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
<script type="text/javascript" src="http://localhost:8080/lib/webuploader.js"></script>
<link rel="stylesheet" href="lib/style.css"></link>
<link rel="stylesheet" href="lib/webuploader.css"></link>
<link rel="stylesheet" href="lib/bootstrap.min.css"></link>
<link rel="stylesheet" href="lib/bootstrap-theme.min.css"></link>
<link rel="stylesheet" href="lib/font-awesome.min.css"></link>
<!-- <script type="text/javascript" src="http://localhost:8080/lib/spark-md5.min.js"></script>-->
</head>
<body>
<div style="width: 60%">
<div id="uploader" class="wu-example">
<!--用來存放文件信息-->
<div id="thelist" class="uploader-list"></div>
<div class="btns">
<div id="picker">選擇文件</div>
<button id="ctlBtn" class="btn btn-default">開始上傳</button>
</div>
</div>
<div id="log">
</div>
</div>
</body>
<script type="text/javascript">
var file_md5 = '';
var uploader;
//md5FlagMap用于存儲文件md5計算完成的標(biāo)志位;多個文件時,分別設(shè)置標(biāo)志位,key是文件名,value是true或false;
var md5FlagMap = new Map();
WebUploader.Uploader.register({
"add-file": "addFile",
"before-send-file": "beforeSendFile",
"before-send": "beforeSend",
"after-send-file": "afterSendFile"
}, {
addFile: function (file) {
console.log('1', file)
},
beforeSendFile: function (file) {
console.log('2', file)
//
// md5FlagMap.set(file.name, false);//文件md5值計算的標(biāo)志位默認(rèn)為false
// var deferred = WebUploader.Deferred();//deferred用于監(jiān)控異步計算文件md5值這個異步操作的執(zhí)行狀態(tài)
// uploader.md5File(file, 0, file.size - 1).then(function (fileMd5) {
// file.wholeMd5 = fileMd5;
// file_md5 = fileMd5;
// deferred.resolve(file.name);//文件md5值計算完成后,更新狀態(tài)為已完成,這時 deferred.done()會觸發(fā)
// })
// //文件越大,文件的md5值計算用時越長,因此md5的計算搞成異步執(zhí)行是合理的;如果異步執(zhí)行比較慢的話,會順序執(zhí)行到這里
// $('#thelist').append('<div id="' + file.id + '" class="item">' +
// '<h4 class="info">' + file.name + '</h4>' +
// '<p class="state">開始計算大文件的md5......<br/></p>' +
// '</div>')
// //文件的md5計算完成,會觸發(fā)這里的回調(diào)函數(shù),
// deferred.done(function (name) {
// md5FlagMap.set(name, true);//更新md5計算標(biāo)志位為true
// $('#' + file.id).find('p.state').append('大文件的md5計算完成<br/>');
// })
// return deferred.promise();
},
beforeSend: function (block) {
console.log(3)
var file = block.file;
var deferred = WebUploader.Base.Deferred();
(new WebUploader.Uploader()).md5File(file, block.start, block.end).then(function (value) {
$.ajax({
url: 'http://localhost:8080/file/check',//檢查當(dāng)前分片是否已經(jīng)上傳
method: 'post',
data: {chunkMd5: value, fileMd5: file_md5, chunk: block.chunk},
success: function (res) {
if (res) {
deferred.reject();
} else {
deferred.resolve(value);
}
}
});
})
deferred.done(function (value) {
block.chunkMd5 = value;
})
return deferred;
},
afterSendFile: function (file) {
console.log('4', file)
}
})
uploader = WebUploader.create({
// swf文件路徑
swf: 'http://localhost:8080/lib/Uploader.swf',
// 分片文件上傳接口
server: 'http://localhost:8080/file/upload',
// 選擇文件的按鈕??蛇x。
pick: '#picker',
fileVal: 'multipartFile',//后端用來接收上傳文件的參數(shù)名稱
chunked: true,//開啟分片上傳
chunkSize: 1024 * 1024 * 10,//設(shè)置分片大小
chunkRetry: 2,//設(shè)置重傳次數(shù),有的時候由于網(wǎng)絡(luò)原因,分片上傳的會失敗,這里即是失敗允許重的次數(shù)
threads: 3//允許同時最大上傳進(jìn)程數(shù)
});
/**
* 當(dāng)有文件被添加進(jìn)隊(duì)列后觸發(fā)
* 主要邏輯:1、文件被添加到隊(duì)列后,開始計算文件的md5值;
* 2、md5的計算過程是異步操作,并且文件越大,計算用時越長;
* 3、變量md5FlagMap是文件md5值計算的標(biāo)志位,計算完成后,設(shè)置當(dāng)前文件的md5Flag為true
*/
uploader.on('fileQueued', function (file) {
md5FlagMap.set(file.name, false);//文件md5值計算的標(biāo)志位默認(rèn)為false
var deferred = WebUploader.Deferred();//deferred用于監(jiān)控異步計算文件md5值這個異步操作的執(zhí)行狀態(tài)
uploader.md5File(file, 0, file.size - 1).then(function (fileMd5) {
file.wholeMd5 = fileMd5;
file_md5 = fileMd5;
deferred.resolve(file.name);//文件md5值計算完成后,更新狀態(tài)為已完成,這時 deferred.done()會觸發(fā)
})
//文件越大,文件的md5值計算用時越長,因此md5的計算搞成異步執(zhí)行是合理的;如果異步執(zhí)行比較慢的話,會順序執(zhí)行到這里
$('#thelist').append('<div id="' + file.id + '" class="item">' +
'<h4 class="info">' + file.name + '</h4>' +
'<p class="state">開始計算大文件的md5......<br/></p>' +
'</div>')
//文件的md5計算完成,會觸發(fā)這里的回調(diào)函數(shù),
deferred.done(function (name) {
md5FlagMap.set(name, true);//更新md5計算標(biāo)志位為true
$('#' + file.id).find('p.state').append('大文件的md5計算完成<br/>');
})
return deferred.promise();
})
// 分片模式下,當(dāng)文件的分塊在發(fā)送前觸發(fā)
uploader.on('uploadBeforeSend', function (block, data) {
var file = block.file;
//data可以攜帶參數(shù)到后端
data.originalFilename = file.originalFilename;//文件名字
data.md5Value = file.wholeMd5;//文件整體的md5值
data.start = block.start;//分片數(shù)據(jù)塊在整體文件的開始位置
data.end = block.end;//分片數(shù)據(jù)塊在整體文件的結(jié)束位置
data.chunk = block.chunk;//分片的索引位置
data.chunks = block.chunks;//整體文件總共分了多少征
data.chunkMd5 = block.chunkMd5;//分片文件md5值
});
// 文件上傳過程中創(chuàng)建進(jìn)度條實(shí)時顯示
uploader.on('uploadProgress', function (file, percentage) {
var $li = $('#' + file.id),
$percent = $li.find('.progress .progress-bar');
if (!$percent.length) {
$percent = $('<div class="progress progress-striped active">' +
'<div class="progress-bar" role="progressbar" style="width: 0%">' +
'</div>' +
'</div>').appendTo($li).find('.progress-bar');
}
$percent.css('width', percentage * 100 + '%');
});
//當(dāng)文件上傳成功時觸發(fā)
uploader.on('uploadSuccess', function (file) {
//大文件的所有分片上傳成功后,請求后端對分片進(jìn)行合并
$.ajax({
url: 'http://localhost:8080/file/merge',
method: 'post',
data: {'md5Value': file.wholeMd5, 'originalFilename': file.name},
success: function (res) {
alert('大文件上傳成功!')
}
})
$('#' + file.id).find('p.state').append('文件上傳成功<br/>');
});
//當(dāng)文件上傳出錯時觸發(fā)
uploader.on('uploadError', function (file) {
$('#' + file.id).find('p.state').text('上傳出錯<br/>');
});
//不管成功或者失敗,文件上傳完成時觸發(fā)
uploader.on('uploadComplete', function (file) {
$('#' + file.id).find('.progress').fadeOut();
});
//開始上傳按鈕被點(diǎn)擊時觸發(fā)
$('#ctlBtn').click(function () {
//md5FlagMap存儲有文件md5計算的標(biāo)志位;
// 同時上傳多個文件時,上傳前要判斷一下文件的md5是否計算完成,
// 如果有未計算完成的,則繼續(xù)等待計算結(jié)果;
//文件上傳標(biāo)志位,如果多個文件有一個沒有完成md5計算則不能開始上傳;這里在實(shí)際業(yè)務(wù)中可以更換成其他交互樣式,酌情優(yōu)化為哪個文件的md5計算完成,則開始哪個文件的上傳;
var uploadFloag = true;
md5FlagMap.forEach(function (value, key) {
if (!value) {
uploadFloag = false;
alert('md5計算中...,請稍侯')//文件md5計算未完成,會彈出彈窗提示;
}
})
if (uploadFloag) {
uploader.upload();//文件md5計算完成后,開始分片上傳;
}
})
</script>
</html>
到了這里,關(guān)于JAVA面試題分享五百一十一:Spring Boot基于WebUploader實(shí)現(xiàn)超大文件上傳和斷點(diǎn)續(xù)傳的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!