預(yù)期目標(biāo)
目標(biāo):需要突破服務(wù)端上傳大小限制事甜,實(shí)現(xiàn)大視頻文件的上傳
預(yù)期:大視頻文件上傳不受上傳大小的限制
評估結(jié)果
要想實(shí)現(xiàn)大文件上傳有兩種方式:
1)調(diào)大服務(wù)端的文件上傳限制:在一定長度上可以緩解上傳限制問題,但并不是最優(yōu)解铸鹰。一方面無限制地調(diào)大上傳大小會加大服務(wù)端的壓力;一方面這個限制值調(diào)成多少是個需要考量的問題堕汞。
2)假設(shè)服務(wù)端的限制是10M玛追,需要上傳的文件是20M披诗,直接上傳顯然是不可以的甲献,那么分兩次呢宰缤?把文件切分到符合限制的大小分批發(fā)送,這樣就可以突破限制晃洒,這也就是分片上傳慨灭。
下面主要就分片上傳的方案做闡述。
分片上傳
前期準(zhǔn)備
首先這里上傳功能用antd的上傳組件來實(shí)現(xiàn)球及,通過自定義上傳動作來完成分片上傳氧骤;并且做文件切片時需要記錄下文件的 md5 信息,以便后續(xù)在服務(wù)端根據(jù)md5值來進(jìn)行文件合并吃引,這里需要用到spark-md5 庫來做文件md5計(jì)算筹陵,同時使用的 axios 來發(fā)起請求刽锤,具體依賴如下:
依賴 | 版本 |
---|---|
vue | ^3.0.0 |
ant-design-vue | ^2.2.8 |
axios | ^0.24.0 |
spark-md5 | ^3.0.2 |
1、前端邏輯
1)上傳組件
首先是上傳組件部分惶翻,使用antd的upload組件姑蓝,添加一個按鈕來操作上傳動作鹅心,順便添加一個進(jìn)度條組件來展示上傳情況吕粗,具體情況見代碼:
<a-upload
:file-list="fileList"
:remove="handleRemove"
:multiple="false"
:before-upload="beforeUpload">
<a-button>
<upload-outlined></upload-outlined>
選擇文件
</a-button>
</a-upload>
<a-button
type="primary"
:disabled="fileList.length === 0 || !finishSlice"
:loading="uploading"
style="margin-top: 16px"
@click="handleUpload">
{{ uploading ? "上傳中" : "開始上傳" }}
</a-button>
<a-progress :percent="Math.round(sliceProgress/sliceCount*100)"
:status="sliceProgress===sliceCount ? 'success':'active'" v-if="showSliceProgress"/>
<a-progress :percent="Math.round(finishCount/sliceCount*100)"
:status="finishCount===sliceCount ? 'success':'active'" v-if="showProgress"/>
其中 fileList 代表的是上傳文件列表;handleRemove 是操作刪除文件選擇的方法旭愧;beforeUpload 代表的是上傳文件之前的預(yù)操作方法颅筋,這里可以在這里進(jìn)行文件切片;handleUpload 代表的是開始上傳文件的方法输枯。
2)變量定義
接下來是上傳相關(guān)邏輯的編寫议泵,這里使用的是 typescript,先看一下定義的一些變量:
// 文件列表
const fileList = ref<File[]>([]);
// 上傳狀態(tài)
const uploading = ref<boolean>(false);
// 分片完成情況
const finishSlice = ref<boolean>(false);
// 完成上傳的分片數(shù)量
const finishCount = ref<number>(0);
// 展示上傳進(jìn)度條
const showProgress = ref<boolean>(false);
// 切片數(shù)量
const sliceCount = ref<number>(0);
// 切片進(jìn)度條
const sliceProgress = ref<number>(0);
// 上傳失敗的數(shù)量
const errorCount = ref<number>(0);
// 展示切片進(jìn)度條
const showSliceProgress = ref<boolean>(false);
// 切片列表
let fileChunkList: any = [];
// 發(fā)送的切片數(shù)量
const sendCount = ref<number>(0);
// 文件類型
let filetype = "";
// 文件名
let filename = "";
// 文件hash值
let hash = "";
3)文件切片
接下來是進(jìn)行文件的切片操作桃熄,這里需要使用到 spark-md5先口。
import SparkMD5 from 'spark-md5'
這里是將文件整體讀入計(jì)算md5,好處是md5碰撞的概率大大降低瞳收,缺點(diǎn)是計(jì)算時間會長一些碉京;如果想計(jì)算時間短一些,不追求極致的低碰撞率的話螟深,可以考慮讀入第一個切片和最后一個切片進(jìn)行md5計(jì)算谐宙。這里可以根據(jù)實(shí)際情況酌情考慮。
const beforeUpload = (file: File) => {
message.info("開始文件切片");
// 顯示切片進(jìn)度條
showSliceProgress.value = true;
// 文件添加到文件列表 這里只展示單文件上傳
fileList.value = [file];
// 一些參數(shù)的初始化
fileChunkList = [];
finishSlice.value = false;
finishCount.value = 0;
sliceProgress.value = 0;
showProgress.value = false;
sliceCount.value = 0;
errorCount.value = 0;
return new Promise((resolve, reject) => {
// 初始化md5工具對象
const spark = new SparkMD5.ArrayBuffer();
// 用于讀取文件計(jì)算md5
const fileReader = new FileReader();
// 這里是依據(jù).來對文件和類型進(jìn)行分割
let fileInfo = file.name.split(".")
filename = fileInfo[0];
// 最后一個.之前的內(nèi)容都應(yīng)該認(rèn)定為文件名稱
if (fileInfo.length > 1) {
filetype = fileInfo[fileInfo.length - 1];
for (let i = 1; i < fileInfo.length - 1; i++) {
filename = filename + "." + fileInfo[i];
}
}
// 這里開始做切片
// 設(shè)置切片大小 可以根據(jù)實(shí)際情況設(shè)置
const chunkSize = 1024 * 1024 * 1;
// 計(jì)算出切片數(shù)量
sliceCount.value = Math.ceil(file.size / chunkSize);
let curChunk = 0;
// 切片操作的實(shí)際方法【定義】
const sliceNext = () => {
// 使用slice方法進(jìn)行文件切片
const chunkFile = file.slice(curChunk, curChunk + chunkSize);
// 讀取當(dāng)前切片文件流【這里會觸發(fā)onload方法】
fileReader.readAsArrayBuffer(chunkFile);
// 加入切片列表
fileChunkList.push({
// 切片文件信息
chunk: chunkFile,
// 文件名
filename: filename,
// 分片索引 這里直接借助sliceProgress來實(shí)現(xiàn)
seq: sliceProgress.value + 1,
// 文件類型
type: filetype,
// 狀態(tài)信息 用于標(biāo)識是否上傳成功
status: false
});
// 切片完成變量自增
sliceProgress.value++;
};
// 進(jìn)入方法需要進(jìn)行首次切片操作
sliceNext();
// 讀取文件流時會觸發(fā)onload方法
fileReader.onload = (e: any) => {
// 將文件流加入計(jì)算md5
spark.append(e.target.result);
// 修改切片位移
curChunk += chunkSize;
// 說明還沒到達(dá)最后一個切片 繼續(xù)切
if (sliceProgress.value < sliceCount.value) {
sliceNext();
} else {
// 說明切片完成了
finishSlice.value = true;
// 讀取文件hash值
hash = spark.end();
message.success("文件分片完成");
// 將哈希值作為其中一個屬性 寫入到分片列表中
fileChunkList.forEach((content: any) => {
content.hash = hash;
})
}
};
})
};
到這里文件的切片和md5計(jì)算就完成了界弧,一個大文件也變成了多個小文件的列表凡蜻。
4)上傳分片
接下來介紹的是開始分片上傳的邏輯,這里需要注意不能一次性將分片全部上傳垢箕,如果切片數(shù)量太大一次性發(fā)送出去會導(dǎo)致客戶端卡死崩潰划栓,因此采用遞歸調(diào)用的方式來確保同一時間等待的請求在一定數(shù)量,這里限定同時間等待請求數(shù)為10条获。
// 開始執(zhí)行上傳切片邏輯
const startUpload = () => {
return new Promise((resolve, reject) => {
const next = () => {
// 遞歸出口 分片上傳完畢
if (finishCount.value + errorCount.value >= sliceCount.value) {
return;
}
// 記錄當(dāng)前遍歷位置
let cur = sendCount.value++;
// 說明越界了 直接退出
if (cur >= sliceCount.value) {
return;
}
// 獲取分片信息
let content = fileChunkList[cur];
// 已經(jīng)上傳過了 直接跳過【可用于斷點(diǎn)續(xù)傳】
if (content.status === true) {
if (finishCount.value + errorCount.value < sliceCount.value) {
next();
return;
}
}
// 開始填充上傳數(shù)據(jù) 這里需要使用FormData來存儲信息
const formData = new FormData();
formData.append("file", content.chunk);
formData.append("hash", content.hash);
formData.append("filename", content.filename);
formData.append("seq", content.seq);
formData.append("type", content.type);
// 開始上傳
axios.post("http://localhost:8080/upload", formData).then((res) => {
// 接收回調(diào)信息
const data = res.data;
if (data.success) {
// 成功計(jì)數(shù) 并設(shè)置分片上傳狀態(tài)
finishCount.value += 1;
content.status = true;
} else {
// 失敗計(jì)數(shù)
errorCount.value += 1;
}
// 說明完成最后一個分片上傳但上傳期間出現(xiàn)錯誤
if (errorCount.value !== 0 && errorCount.value + finishCount.value === sliceCount.value) {
message.error("上傳發(fā)生錯誤茅姜,請重傳");
showProgress.value = false;
uploading.value = false;
}
// 說明還有分片未上傳 需要繼續(xù)遞歸
if (finishCount.value + errorCount.value < sliceCount.value) {
next();
}
// 說明所有分片上傳成功了 發(fā)起合并操作
if (finishCount.value === sliceCount.value) {
merge();
}
}).catch(error => {
// 對于圖中發(fā)生的錯誤需要捕獲并記錄
errorCount.value += 1;
if (errorCount.value !== 0 && errorCount.value + finishCount.value === sliceCount.value) {
message.error("上傳發(fā)生錯誤,請重傳");
showProgress.value = false;
uploading.value = false;
}
// 當(dāng)前分片上傳失敗不應(yīng)影響下面的分片
if (finishCount.value + errorCount.value < sliceCount.value) {
next();
}
console.log(error)
})
};
// 只允許同時10個任務(wù)在等待
while (sendCount.value < 10 && sendCount.value < sliceCount.value) {
next();
}
});
};
5)文件合并
接下來還應(yīng)該實(shí)現(xiàn) merge 方法的邏輯月匣,主要用于向服務(wù)端發(fā)送合并請求钻洒,服務(wù)端接收后進(jìn)行分片合并操作,那么這里就應(yīng)該將需要合并的文件的hash值傳過去锄开,才可以完成文件的定位素标。
const merge = () => {
message.success('上傳成功,等待服務(wù)器合并文件');
// 發(fā)起合并請求 傳入文件hash值萍悴、文件類型头遭、文件名
axios.post("http://localhost:8080/merge", {
hash: hash,
type: filetype,
filename: filename
}).then((res) => {
const data = res.data;
if (data.success) {
message.success(data.message);
// 獲取上傳成功的文件地址
console.log(data.content);
// 其他業(yè)務(wù)操作...
} else {
message.error(data.message)
}
uploading.value = false;
}).catch(e => {
message.error('發(fā)生錯誤了');
uploading.value = false;
});
};
6)取消文件
最后完成取消選擇文件的邏輯寓免,也就是上面標(biāo)注的 handleRemove 方法:
const handleRemove = (file: File) => {
const index = fileList.value.indexOf(file);
const newFileList = fileList.value.slice();
let hash = "";
newFileList.splice(index, 1);
fileList.value = newFileList;
// 取消之后需要進(jìn)行相關(guān)變量的重新初始化
fileChunkList = [];
finishSlice.value = false;
finishCount.value = 0;
sliceProgress.value = 0;
showProgress.value = false;
sliceCount.value = 0;
errorCount.value = 0;
};
7)極速秒傳
實(shí)際上到這里我們已經(jīng)實(shí)現(xiàn)了分片上傳與合并的功能了,但出于節(jié)省資源與提升用戶體驗(yàn)的考慮计维,我們還可以加入極速秒傳的邏輯袜香。這一塊實(shí)際上就是服務(wù)端合并文件之后將(hash:file-site)信息存儲起來,存儲到DB或者Cache中鲫惶,接下來前端在每次上傳文件時都會先請求文件檢查接口蜈首,如果文件存在則無需執(zhí)行上傳操作。
const handleUpload = async () => {
if (!finishSlice.value) {
alert("文件切片中欠母,請稍等~");
return;
}
// 進(jìn)度條變更
showSliceProgress.value = false;
// 先檢查是否已經(jīng)上傳過
axios.get("http://localhost:8080/check?hash=" + hash).then((res) => {
const data = res.data;
if (data.success) {
message.success(data.message);
console.log(data.content);
} else {
// 開始上傳邏輯 相關(guān)變量狀態(tài)更迭
uploading.value = true;
// 這里主要是服務(wù)于斷點(diǎn)續(xù)傳 避免重復(fù)上傳已成功分塊
sliceCount.value -= finishCount.value;
errorCount.value = 0;
finishCount.value = 0;
sendCount.value = 0;
showProgress.value = true;
console.log("開始上傳")
// 調(diào)用上面寫好的上傳邏輯
startUpload();
}
}).catch(error => {
alert("發(fā)生異常了")
console.log(error)
})
}
到這里我們就完成了分片上傳/極速秒傳的前端邏輯欢策,接下來就應(yīng)該考慮后端的實(shí)現(xiàn)了。
2赏淌、后端邏輯
后端的基本思路是踩寇,接收到分片信息后根據(jù)hash值創(chuàng)建文件夾,之后將接收到的同一個hash值的分片信息都存儲到同一個文件夾下【這里需要注意存儲時要打好序號六水,才可以按序合并】俺孙,待收到合并請求后合并文件,根據(jù)合并文件的hash值與源hash值做比較掷贾,確保文件無損睛榄。
這里后端使用 SpringBoot 實(shí)現(xiàn),依舊是常見的分層模型胯盯,Controller 層負(fù)責(zé)請求接口定義懈费,Service 層負(fù)責(zé)業(yè)務(wù)邏輯的編寫,由于這里不涉及到數(shù)據(jù)庫的交互因而省略DAO層相關(guān)編寫博脑。
先確定下來提供的接口數(shù)憎乙,現(xiàn)在我們需要一個接收分片的接口,一個接受合并請求的接口叉趣,最后還要有一個接受文件檢查的接口用于極速秒傳泞边,具體如下:
接口 | 接口描述 |
---|---|
uploadSlice | 接收上傳切片的接口 |
merge | 接收合并切片請求的接口 |
checkUpload | 檢查文件上傳狀態(tài)的接口 |
1)返回實(shí)體
先來看看定義的全局返回實(shí)體,目的是同一后端返回樣式疗杉,方便前端獲日笱琛:
import java.io.Serializable;
/**
* @author h0ss
* @description 用于系統(tǒng)業(yè)務(wù)響應(yīng)數(shù)據(jù)的統(tǒng)一封裝
*/
public class CommonResp<T> implements Serializable {
private static final Long serialVersionUID = 205112889857456165L;
/**
* 業(yè)務(wù)上的成功或失敗
*/
private boolean success = true;
/**
* 返回信息
*/
private String message;
/**
* 返回泛型的消息體數(shù)據(jù)
*/
private T content;
// 省略getter/setter/toString方法
}
2)上傳接口
接下來是接口的具體定義與內(nèi)容:
/**
* 上傳分片的接口
*
* @param file : 文件信息
* @param hash : 文件哈希值
* @param filename : 文件名
* @param seq : 分片序號
* @param type : 文件類型
*/
@PostMapping("/upload")
public CommonResp<String> uploadSlice(@RequestParam(value = "file") MultipartFile file,
@RequestParam(value = "hash") String hash,
@RequestParam(value = "filename") String filename,
@RequestParam(value = "seq") Integer seq,
@RequestParam(value = "type") String type) {
try {
// 返回上傳結(jié)果
return uploadService.uploadSlice(file.getBytes(), hash, filename, seq, type);
} catch (IOException e) {
// ...日志記錄異常信息...
CommonResp<String> resp = new CommonResp<>();
resp.setSuccess(false);
resp.setMessage("上傳失敗");
return resp;
}
}
接口的信息很簡單,就是將參數(shù)預(yù)處理后調(diào)用服務(wù)方法將結(jié)果返回烟具,接下來看看服務(wù)方法:
private static String BASE_DIR = "I:\\";
/**
* 分片上傳
*
* @param file : 文件流
* @param hash : 哈希值
* @param filename : 文件名
* @param seq : 分片序號
* @param type : 文件類型
*/
public CommonResp<String> uploadSlice(byte[] file, String hash, String filename, Integer seq, String type) {
CommonResp<String> resp = new CommonResp<>();
RandomAccessFile raf = null;
try {
// 創(chuàng)建目標(biāo)文件夾
File dir = new File(BASE_DIR + hash);
if (!dir.exists()) {
dir.mkdir();
}
// 創(chuàng)建空格文件 名稱帶seq用于標(biāo)識分塊信息
raf = new RandomAccessFile(BASE_DIR + hash + "\\" + filename + "." + type + seq, "rw");
// 寫入文件流
raf.write(file.getBytes());
} catch (IOException e) {
// 異常處理
// ...打印異常日志...
resp.setSuccess(false);
} finally {
try {
if (raf != null) {
raf.close();
}
} catch (IOException e) {
// ...打印異常日志...
}
}
return resp;
}
這樣我們就實(shí)現(xiàn)了分片信息的寫入梢什。
3)分片合并
接下來就應(yīng)該實(shí)現(xiàn)分塊合并的邏輯了,對于接受的請求信息我們用一個實(shí)體類來包裝朝聋,免得使用 Map 造成指向不明確:
public class MergeInfo implements Serializable {
private static Long serialVersionUID = 1351063126163421L;
/* 文件名 */
private String filename;
/* 文件類型 */
private String type;
/* 文件哈希值 */
private String hash;
// ...省略setter/getter/toString...
}
接下來就可以寫請求接口的信息了:
@PostMapping("/merge")
public CommonResp<String> merge(@RequestBody MergeInfo mergeInfo) {
if (mergeInfo!=null) {
String filename = mergeInfo.getFilename();
String type = mergeInfo.getType();
String hash = mergeInfo.getHash();
return uploadService.uploadMerge(filename, type, hash);
}
CommonResp<String> resp = new CommonResp<String>();
resp.setSuccess(false);
resp.setMessage("文件合并失敗");
return resp;
}
接口還是只對請求參數(shù)做預(yù)處理嗡午,具體看合并的業(yè)務(wù)層代碼:
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 合并文件的業(yè)務(wù)代碼
*
* @param filename : 文件名
* @param hash : 文件哈希值
* @param type : 文件類型
*/
public CommonResp<String> uploadMerge(String filename, String type, String hash) {
CommonResp<String> resp = new CommonResp<>();
// 判斷hash對應(yīng)文件夾是否存在
File dir = new File(BASE_DIR + hash);
if (!dir.exists()) {
resp.setSuccess(false);
resp.setMessage("合并失敗,請稍后重試");
System.out.println(resp);
}
// 這里通過FileChannel來實(shí)現(xiàn)信息流復(fù)制
FileChannel out = null;
// 獲取目標(biāo)channel
try (FileChannel in = new RandomAccessFile(BASE_DIR + filename + '.' + type, "rw").getChannel()) {
// 分片索引遞增
int index = 1;
// 開始流位置
long start = 0;
while (true) {
// 分片文件名
String sliceName = BASE_DIR + hash + '\\' + filename + '.' + type + index;
// 到達(dá)最后一個分片 退出循環(huán)
if (!new File(sliceName).exists()) {
break;
}
// 分片輸入流
out = new RandomAccessFile(sliceName, "r").getChannel();
// 寫入目標(biāo)channel
in.transferFrom(out, start, start + out.size());
// 位移量調(diào)整
start += out.size();
out.close();
out = null;
// 分片索引調(diào)整
index++;
}
// 文件合并完畢
in.close();
// ...執(zhí)行本地存儲服務(wù)/第三方存儲服務(wù)上傳 返回文件地址...
// 這里假設(shè)是fileSite
String fileSite = "";
resp.setContent(fileSite);
resp.setMessage("上傳成功");
// 地址存入redis 實(shí)現(xiàn)秒傳
stringRedisTemplate.opsForValue().set("upload:finish:hash:" + hash, fileSite);
return resp;
} catch (IOException e) {
// ...記錄日志..
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
resp.setSuccess(false);
resp.setMessage("上傳失敗冀痕,請稍后重試");
return resp;
}
這樣我們就實(shí)現(xiàn)了接收分片上傳與分片合并的請求了荔睹。
4)極速秒傳
除此之外還有極速秒傳的檢查接口狸演,邏輯比較簡單,只要判斷 Redis 是否存在該文件 hash 值的 key 即可僻他,具體邏輯如下:
/**
* 極速秒傳接口
*
* @param hash : 文件哈希值
*/
@Override
public CommonResp<String> fastUpload(String hash) {
return uploadService.fastUpload(hash);
}
/**
* 極速秒傳業(yè)務(wù)代碼
*
* @param hash : 文件哈希值
*/
public CommonResp<String> fastUpload(String hash) {
CommonResp<String> resp = new CommonResp<>();
String key = "upload:finish:hash:" + hash;
String fileSite = stringRedisTemplate.opsForValue().get(key);
// 文件已存在 直接返回地址
if (fileSite != null) {
resp.setSuccess(true);
resp.setContent(fileSite);
resp.setMessage("極速秒傳成功");
} else {
resp.setSuccess(false);
resp.setContent("");
resp.setMessage("極速秒傳失敗");
}
return resp;
}
至此宵距,我們就實(shí)現(xiàn)了后端的分片上傳合并以及極速秒傳的邏輯,到這里前后端代碼就可以聯(lián)調(diào)吨拗,開始測試了满哪。
總結(jié)
1)文件切片時需要注意計(jì)算出文件的 hash 值,以便后續(xù)進(jìn)行合并識別丢胚;
2)對于分片需要記錄下分片的索引信息翩瓜,否則組裝時可能會亂序造成文件損壞受扳;
3)文件信息可暫存在 Redis 中携龟,但建議最終還是持久化到 DB。