大檔案面臨的問題
- 上傳速度慢 -- 應對: 分塊上傳
- 上傳檔案到一半中斷後,繼續上傳卻隻能重頭開始上傳 -- 應對: 斷點續傳
- 相同檔案未修改再次上傳, 卻隻能重頭開始上傳 -- 應對: 秒傳
分片上傳
1、什麼分片上傳
分片上傳,就是将所要上傳的檔案,按照一定的大小,将整個檔案分隔成多個資料塊(我們稱之為Part)來進行分别上傳,上傳完之後再由服務端對所有上傳的檔案進行彙總整合成原始的檔案
2、分片上傳适用場景
- 大檔案上傳
- 網絡環境環境不好,存在需要重傳風險的場景
3、上傳的具體流程
因為這個上傳流程和斷點續傳類似,就在下邊介紹斷點續傳中介紹
斷點續傳
1、什麼是斷點續傳
斷點續傳是在下載下傳或上傳時,将下載下傳或上傳任務(一個檔案或一個壓縮包)人為的劃分為幾個部分,每一個部分采用一個線程進行上傳或下載下傳,如果碰到網絡故障,可以從已經上傳或下載下傳的部分開始繼續上傳或者下載下傳未完成的部分,而沒有必要從頭開始上傳或者下載下傳。本文的斷點續傳主要是針對斷點上傳場景
2、應用場景
斷點續傳可以看成是分片上傳的一個衍生,是以可以使用分片上傳的場景,都可以使用斷點續傳
3、實作斷點續傳的核心邏輯
在分片上傳的過程中,如果因為系統崩潰或者網絡中斷等異常因素導緻上傳中斷,這時候用戶端需要記錄上傳的進度。在之後支援再次上傳時,可以繼續從上次上傳中斷的地方進行繼續上傳。
為了避免用戶端在上傳之後的進度資料被删除而導緻重新開始從頭上傳的問題,服務端也可以提供相應的接口便于用戶端對已經上傳的分片資料進行查詢,進而使用戶端知道已經上傳的分片資料,進而從下一個分片資料開始繼續上傳。
4、實作流程步驟
- 前端(用戶端)需要根據固定大小對檔案進行分片,請求後端(服務端)時要帶上分片序号和大小
- 服務端建立conf檔案用來記錄分塊位置,conf檔案長度為總分片數,每上傳一個分塊即向conf檔案中寫入一個127,那麼沒上傳的位置就是預設的0,已上傳的就是Byte.MAX_VALUE 127(這步是實作斷點續傳和秒傳的核心步驟)
- 伺服器按照請求資料中給的分片序号和每片分塊大小(分片大小是固定且一樣的)算出開始位置,與讀取到的檔案片段資料,寫入檔案
秒傳
1、什麼是秒傳
通俗的說,你把要上傳的東西上傳,伺服器會先做MD5校驗,如果伺服器上有一樣的東西,它就直接給你個新位址,其實你下載下傳的都是伺服器上的同一個檔案,想要不秒傳,其實隻要讓MD5改變,就是對檔案本身做一下修改(改名字不行),例如一個文本檔案,你多加幾個字,MD5就變了,就不會秒傳了
2、本文實作的秒傳核心邏輯
a、利用redis的set方法存放檔案上傳狀态,其中key為檔案上傳的md5,value為是否上傳完成的标志位,
b、當标志位true為上傳已經完成,此時如果有相同檔案上傳,則進入秒傳邏輯。如果标志位為false,則說明還沒上傳完成,此時需要在調用set的方法,儲存塊号檔案記錄的路徑,其中key為上傳檔案md5加一個固定字首,value為塊号檔案記錄路徑
WebUploader
1,什麼是 WebUploader?
WebUploader 是由百度公司團隊開發的一個以 HTML5 為主,FLASH 為輔的現代檔案上傳元件。
2,功能特點
- 分片、并發:WebUploader 采用大檔案分片并發上傳,極大的提高了檔案上傳效率。
- 預覽、壓縮:WebUploader 支援常用圖檔格式 jpg,jpeg,gif,bmp,png 預覽與壓縮,節省網絡資料傳輸。
- 多途徑添加檔案:支援檔案多選,類型過濾,拖拽(檔案 & 檔案夾),圖檔粘貼功能。
- HTML5 & FLASH:相容主流浏覽器,接口一緻,實作了兩套運作時支援,使用者無需關心内部用了什麼核心。
- MD5 秒傳:當檔案體積大、量比較多時,支援上傳前做檔案 md5 值驗證,一緻則可直接跳過。
- 易擴充、可拆分:采用可拆分機制, 将各個功能獨立成了小元件,可自由搭配。
3. 接口說明
-
此hook在檔案發送之前執行before-send-file
-
此hook在檔案分片(如果沒有啟用分片,整個檔案被當成一個分片)後,上傳之前執行。before-file
-
此hook在檔案所有分片都上傳完後,且服務端沒有錯誤傳回後執行。after-send-file
Web Uploader的所有代碼都在一個内部閉包中,對外暴露了唯一的一個變量
WebUploader
,是以完全不用擔心此架構會與其他架構沖突。
内部所有的類和功能都暴露在
WebUploader
名字空間下面。
Demo中使用的是
WebUploader.create
方法來初始化的,實際上可直接通路
WebUploader.Uploader
。
var uploader = new WebUploader.Uploader({
swf: 'path_of_swf/Uploader.swf'
// 其他配置項
});
具體有哪些内部類,請轉到
API頁面。
4. 事件
Uploader
執行個體具有Backbone同樣的事件API:
on
,
off
once
trigger
uploader.on( 'fileQueued', function( file ) {
// do some things.
});
除了通過
on
綁定事件外,
Uploader
執行個體還有一個更便捷的添加事件方式。
uploader.onFileQueued = function( file ) {
// do some things.
};
如同
Document Element
中的onEvent一樣,他的執行比
on
添加的
handler
的要晚。如果那些
handler
裡面,有一個
return false
了,此
onEvent
裡面是不會執行到的。
5. Hook
Uploader
裡面的功能被拆分成了好幾個
widget
,由
command
機制來通信合作。
如下,filepicker在使用者選擇檔案後,直接把結果
request
出去,然後負責隊列的
queue
widget,監聽指令,根據配置項中的
accept
來決定是否加入隊列。
// in file picker
picker.on( 'select', function( files ) {
me.owner.request( 'add-file', [ files ]);
});
// in queue picker
Uploader.register({
'add-file': 'addFiles'
// xxxx
}, {
addFiles: function( files ) {
// 周遊files中的檔案, 過濾掉不滿足規則的。
}
});
Uploader.regeister
方法用來說明,該
widget
要響應哪些指令,并指定由什麼方法來響應。上面的例子,當
add-file
指令派送時,内部的
addFiles
成員方法将被執行到,同一個指令,可以指定多次
handler
, 各個
handler
會按添加順序依次執行,且後續的
handler
,不能被前面的
handler
截斷。
handler
裡面可以是同步過程,也可以是異步過程。是異步過程時,隻需要傳回一個
promise
對象即可。存在異步可能的request調用者會等待此過程結束後才繼續。舉個例子,webuploader運作在flash模式下時,需要等待flash加載完畢後才能算ready了,此過程為一個異步過程,目前的做法是如下:
// uploader在初始化的時候
me.request( 'init', opts, function() {
me.state = 'ready';
me.trigger('ready');
});
// filepicker `widget`中的初始化過程。
Uploader.register({
'init': 'init'
}, {
init: function( opts ) {
var deferred = Base.Deferred();
// 加載flash
// 當flash ready執行deferred.resolve方法。
return deferred.promise();
}
});
目前webuploader内部有很多種command,在此列出比較重要的幾個。
名稱 | 參數 | 說明 |
| files: File對象或者File數組 | 用來向隊列中添加檔案。 |
| file: File對象 | 在檔案發送之前request,此時還沒有分片(如果配置了分片的話),可以用來做檔案整體md5驗證。 |
| block: 分片對象 | 在分片發送之前request,可以用來做分片驗證,如果此分片已經上傳成功了,可傳回一個rejected promise來跳過此分片上傳 |
| 在所有分片都上傳完畢後,且沒有錯誤後request,用來做分片驗證,此時如果promise被reject,目前檔案上傳會觸發錯誤。 |
代碼實作:基于SpringBoot和WebUploader
頁面效果
前端
<html>
<head>
<meta charset="utf-8">
<title>BigFile-WebUploader</title>
<link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css">
<link rel="stylesheet" href="css/webuploader.css">
<script type="text/javascript" src="http://cdn.bootcss.com/jquery/1.12.4/jquery.min.js"></script>
<script type="text/javascript" src="https://cdn.bootcss.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script type="text/javascript" src="js/webuploader.js"></script>
</head>
<body>
<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>
<button id="stopBtn" class="btn btn-default">暫停</button>
<button id="restart" class="btn btn-default">開始</button>
</div>
</div>
</body>
<!--業務js檔案-->
<script>
var $btn = $('#ctlBtn');
var $thelist = $('#thelist');
var startDate;
// HOOK 這個必須要再uploader執行個體化前面
WebUploader.Uploader.register({
// 在檔案發送之前執行
'before-send-file': 'beforeSendFile',
// 在檔案分片(如果沒有啟用分片,整個檔案被當成一個分片)後,上傳之前執行
'before-send': 'beforeSend',
// 在檔案所有分片都上傳完後,且服務端沒有錯誤傳回後執行
"after-send-file": "afterSendFile"
}, {
beforeSendFile: function (file) {
startDate = new Date();
console.log("開始上傳時間" + startDate)
console.log("beforeSendFile");
// Deferred對象在鈎子回掉函數中經常要用到,用來處理需要等待的異步操作。
var deferred = WebUploader.Deferred();
//1、計算檔案的唯一标記MD5,用于斷點續傳
uploader.md5File(file, 0, 3 * 1024 * 1024).progress(function (percentage) {
// 上傳進度
console.log('上傳進度:', percentage);
getProgressBar(file, percentage, "MD5", "MD5");
}).then(function (val) { // 完成
console.log('File MD5 Result:', val);
file.md5 = val;
file.uid = WebUploader.Base.guid();
// 判斷檔案是否上傳過,是否存在分片,斷點續傳
$.ajax({
type: "POST",
url: "bigfile/check",
async: false,
data: {
fileMd5: val
},
success: function (data) {
var resultCode = data.resultCode;
// 秒傳
if(resultCode == -1){
// 檔案已經上傳過,忽略上傳過程,直接辨別上傳成功;
uploader.skipFile(file);
file.pass = true;
}else{
//檔案沒有上傳過,下标為0
//檔案上傳中斷過,傳回目前已經上傳到的下标
file.indexcode = resultCode;
}
}, error: function () {
}
});
//擷取檔案資訊後進入下一步
deferred.resolve();
});
return deferred.promise();
},
beforeSend: function (block) {
//擷取已經上傳過的下标
var indexchunk = block.file.indexcode;
var deferred = WebUploader.Deferred();
if (indexchunk > 0) {
if (block.chunk > indexchunk) {
//分塊不存在,重新發送該分塊内容
deferred.resolve();
} else {
//分塊存在,跳過
deferred.reject();
}
} else {
//分塊不存在,重新發送該分塊内容
deferred.resolve();
}
//傳回Deferred的Promise對象。
return deferred.promise();
}
, afterSendFile: function (file) {
//如果所有分塊上傳成功,則通知背景合并分塊
$.ajax({
type: "POST",
url: "bigfile/merge",
data: {
fileName: file.name,
fileMd5: file.md5
},
success: function (data) {
}, error: function () {
}
});
}
});
// 執行個體化
var uploader = WebUploader.create({
pick: {
id: '#picker',
label: '點選選擇檔案'
},
duplicate: true,//去重, 根據檔案名字、檔案大小和最後修改時間來生成hash Key
swf: 'js/Uploader.swf',
chunked: true,
chunkSize: 10 * 1024 * 1024, // 10M 每個分片的大小限制
threads: 3,
server: 'bigfile/upload',
auto: true,
// 禁掉全局的拖拽功能。這樣不會出現圖檔拖進頁面的時候,把圖檔打開。
disableGlobalDnd: true,
fileNumLimit: 1024,
fileSizeLimit: 50 * 1024 * 1024 * 1024,//50G 驗證檔案總大小是否超出限制, 超出則不允許加入隊列
fileSingleSizeLimit: 10 * 1024 * 1024 * 1024 //10G 驗證單個檔案大小是否超出限制, 超出則不允許加入隊列
});
// 當有檔案被添加進隊列的時候
uploader.on('fileQueued', function (file) {
$thelist.append('<div id="' + file.id + '" class="item">' +
'<h4 class="info">' + file.name + '</h4>' +
'<p class="state">等待上傳...</p>' +
'</div>');
$("#stopBtn").click(function () {
uploader.stop(true);
});
$("#restart").click(function () {
uploader.upload(file);
});
});
//當某個檔案的分塊在發送前觸發,主要用來詢問是否要添加附帶參數,大檔案在開起分片上傳的前提下此事件可能會觸發多次。
uploader.onUploadBeforeSend = function (obj, data) {
//console.log("onUploadBeforeSend");
var file = obj.file;
data.md5 = file.md5 || '';
data.uid = file.uid;
};
// 上傳中
uploader.on('uploadProgress', function (file, percentage) {
getProgressBar(file, percentage, "FILE", "上傳進度");
});
// 上傳傳回結果
uploader.on('uploadSuccess', function (file) {
var endDate = new Date();
console.log("檔案上傳耗時:" + (endDate - startDate) / 1000 + "s")
var text = '已上傳';
if (file.pass) {
text = "檔案妙傳功能,檔案已上傳。"
}
$('#' + file.id).find('p.state').text(text);
});
uploader.on('uploadError', function (file) {
$('#' + file.id).find('p.state').text('上傳出錯');
});
uploader.on('uploadComplete', function (file) {
// 隐藏進度條
fadeOutProgress(file, 'MD5');
fadeOutProgress(file, 'FILE');
});
// 檔案上傳
$btn.on('click', function () {
uploader.upload();
});
/**
* 生成進度條封裝方法
* @param file 檔案
* @param percentage 進度值
* @param id_Prefix id字首
* @param titleName 标題名
*/
function getProgressBar(file, percentage, id_Prefix, titleName) {
var $li = $('#' + file.id), $percent = $li.find('#' + id_Prefix + '-progress-bar');
// 避免重複建立
if (!$percent.length) {
$percent = $('<div id="' + id_Prefix + '-progress" class="progress progress-striped active">' +
'<div id="' + id_Prefix + '-progress-bar" class="progress-bar" role="progressbar" style="width: 0%">' +
'</div>' +
'</div>'
).appendTo($li).find('#' + id_Prefix + '-progress-bar');
}
var progressPercentage = parseInt(percentage * 100) + '%';
$percent.css('width', progressPercentage);
$percent.html(titleName + ':' + progressPercentage);
}
/**
* 隐藏進度條
* @param file 檔案對象
* @param id_Prefix id字首
*/
function fadeOutProgress(file, id_Prefix) {
$('#' + file.id).find('#' + id_Prefix + '-progress').fadeOut();
}
</script>
</html>
後端
controller
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.util.*;
/**
* @Title: 大檔案上傳
* @ClassName: com.lovecyy.file.up.example3.controller.BreakPointController.java
* @Description: 斷點續傳.秒傳.分塊上傳
*
* @Copyright 2020-2021 - Powered By 研發中心
* @author: 王延飛
* @date: 2021/3/2 13:45
* @version V1.0
*/
@Controller
@RequestMapping(value = "/bigfile")
public class BigFileController {
private Logger logger = LoggerFactory.getLogger(BigFileController.class);
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${breakpoint.upload.dir}")
private String fileStorePath;
/**
* @Title: 判斷檔案是否上傳過,是否存在分片,斷點續傳
* @MethodName: checkBigFile
* @param fileMd5
* @Return com.lovecyy.file.up.example3.vo.JsonResult
* @Exception
* @Description:
* 檔案已存在,下标為-1
* 檔案沒有上傳過,下标為零
* 檔案上傳中斷過,傳回目前已經上傳到的下标
* @author: 王延飛
* @date: 2021/3/2 16:52
*/
@RequestMapping(value = "/check", method = RequestMethod.POST)
@ResponseBody
public JsonResult checkBigFile(String fileMd5) {
JsonResult jr = new JsonResult();
// 秒傳
File mergeMd5Dir = new File(fileStorePath + "/" + "merge"+ "/" + fileMd5);
if(mergeMd5Dir.exists()){
mergeMd5Dir.mkdirs();
jr.setResultCode(-1);//檔案已存在,下标為-1
return jr;
}
// 讀取目錄裡的所有檔案
File dir = new File(fileStorePath + "/" + fileMd5);
File[] childs = dir.listFiles();
if(childs==null){
jr.setResultCode(0);//檔案沒有上傳過,下标為零
}else{
jr.setResultCode(childs.length-1);//檔案上傳中斷過,傳回目前已經上傳到的下标
}
return jr;
}
/**
* 上傳檔案
*
* @param param
* @param request
* @return
* @throws Exception
*/
@RequestMapping(value = "/upload", method = RequestMethod.POST)
@ResponseBody
public void filewebUpload(MultipartFileParam param, HttpServletRequest request) {
boolean isMultipart = ServletFileUpload.isMultipartContent(request);
// 檔案名
String fileName = param.getName();
// 檔案每次分片的下标
int chunkIndex = param.getChunk();
if (isMultipart) {
File file = new File(fileStorePath + "/" + param.getMd5());
if (!file.exists()) {
file.mkdir();
}
File chunkFile = new File(
fileStorePath + "/" + param.getMd5() + "/" + chunkIndex);
try{
FileUtils.copyInputStreamToFile(param.getFile().getInputStream(), chunkFile);
}catch (Exception e){
e.printStackTrace();
}
}
logger.info("檔案-:{}的小标-:{},上傳成功",fileName,chunkIndex);
return;
}
/**
* 分片上傳成功之後,合并檔案
* @param request
* @return
*/
@RequestMapping(value = "/merge", method = RequestMethod.POST)
@ResponseBody
public JsonResult filewebMerge(HttpServletRequest request) {
FileChannel outChannel = null;
try {
String fileName = request.getParameter("fileName");
String fileMd5 = request.getParameter("fileMd5");
// 讀取目錄裡的所有檔案
File dir = new File(fileStorePath + "/" + fileMd5);
File[] childs = dir.listFiles();
if(Objects.isNull(childs)|| childs.length==0){
return null;
}
// 轉成集合,便于排序
List<File> fileList = new ArrayList<File>(Arrays.asList(childs));
Collections.sort(fileList, new Comparator<File>() {
@Override
public int compare(File o1, File o2) {
if (Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())) {
return -1;
}
return 1;
}
});
// 合并後的檔案
File outputFile = new File(fileStorePath + "/" + "merge"+ "/" + fileMd5 + "/" + fileName);
// 建立檔案
if(!outputFile.exists()){
File mergeMd5Dir = new File(fileStorePath + "/" + "merge"+ "/" + fileMd5);
if(!mergeMd5Dir.exists()){
mergeMd5Dir.mkdirs();
}
logger.info("建立檔案");
outputFile.createNewFile();
}
outChannel = new FileOutputStream(outputFile).getChannel();
FileChannel inChannel = null;
try {
for (File file : fileList) {
inChannel = new FileInputStream(file).getChannel();
inChannel.transferTo(0, inChannel.size(), outChannel);
inChannel.close();
// 删除分片
file.delete();
}
}catch (Exception e){
e.printStackTrace();
//發生異常,檔案合并失敗 ,删除建立的檔案
outputFile.delete();
dir.delete();//删除檔案夾
}finally {
if(inChannel!=null){
inChannel.close();
}
}
dir.delete(); //删除分片所在的檔案夾
// FIXME: 資料庫操作, 記錄檔案存檔位置
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
if(outChannel!=null){
outChannel.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
}
MultipartFileParam
import org.springframework.web.multipart.MultipartFile;
public class MultipartFileParam {
// 使用者id
private String uid;
//任務ID
private String id;
//總分片數量
private int chunks;
//目前為第幾塊分片
private int chunk;
//目前分片大小
private long size = 0L;
//檔案名
private String name;
//分片對象
private MultipartFile file;
// MD5
private String md5;
public String getUid() {
return uid;
}
public void setUid(String uid) {
this.uid = uid;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public int getChunks() {
return chunks;
}
public void setChunks(int chunks) {
this.chunks = chunks;
}
public int getChunk() {
return chunk;
}
public void setChunk(int chunk) {
this.chunk = chunk;
}
public long getSize() {
return size;
}
public void setSize(long size) {
this.size = size;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public MultipartFile getFile() {
return file;
}
public void setFile(MultipartFile file) {
this.file = file;
}
public String getMd5() {
return md5;
}
public void setMd5(String md5) {
this.md5 = md5;
}
@Override
public String toString() {
return "MultipartFileParam{" +
"uid='" + uid + '\'' +
", id='" + id + '\'' +
", chunks=" + chunks +
", chunk=" + chunk +
", size=" + size +
", name='" + name + '\'' +
", file=" + file +
", md5='" + md5 + '\'' +
'}';
}
}
JsonResult
public class JsonResult<T> {
private int resultCode;
private String resultMsg;
private Object resultData;
public JsonResult() {
}
public JsonResult(int resultCode, String resultMsg, Object resultData) {
this.resultCode = resultCode;
this.resultMsg = resultMsg;
this.resultData = resultData;
}
public int getResultCode() {
return this.resultCode;
}
public void setResultCode(int resultCode) {
this.resultCode = resultCode;
}
public String getResultMsg() {
return this.resultMsg;
}
public void setResultMsg(String resultMsg) {
this.resultMsg = resultMsg;
}
public Object getResultData() {
return this.resultData;
}
public void setResultData(Object resultData) {
this.resultData = resultData;
}
}
配置檔案
# Tomcat
server:
port: 8055
#開發環境
breakpoint:
upload:
dir: D:/data0/uploads/
#1024*1024=1 048 576,5M=5 242 880
chunkSize: 5 242 880
參考連結:
http://fex.baidu.com/webuploader/document.html#toc_2 https://cloud.tencent.com/developer/article/1541199 https://blog.csdn.net/qq_39474136/article/details/96188099