在日常業務中經常會遇到上傳大文件的需求(如上傳一部3G大小的高清電影資源),如果大文件資源上傳不做特殊處理而直接使用小文件上傳的方式上傳到服務,可能會出現如網絡不好導致上傳一半就失敗了,服務內存不夠導致無法上傳等等一些問題。那么這個特殊處理是什么處理呢?其實就是大文件的分片上傳。
大文件分片上傳需要前后端協作來完成,前端的工作是切片和生成文件的唯一標識;后端的工作是接收文件的唯一標識,記錄文件上傳的分片信息和整合分片成完整文件。下面設計一套前后端協作方式將大文件分片上傳到MinIO上的方案。
1、生成文件的MD5值
前端需要唯一的標識一個文件,然后將唯一的標識傳給后端做文件識別,那么用什么來唯一的標識一個文件呢?目前比較成熟的方案是將文件的二進制數據采用MD5映射成一個唯一標識。
MD5的一個很大特點是文件內容有變動(即使在文件內容中加了一個空格)就會生成一個新的唯一標識。因此采用MD5的方式給文件生成一個唯一的標識。
假設現在的文件有3G大小,那么計算其MD5值的時候直接將文件的內容的讀取到內存中然后計算,此時內存可能會承受不了,所以采用分片的方式來計算MD5值。
核心代碼:
<template>
<div>
<input type="file" @change="onFileChange" />
<button @click="uploadFile">上傳</button>
</div>
</template>
<script>
import SparkMD5 from 'spark-md5';
export default {
data() {
return {
selectedFile: null,
chunks: [],
bytes: [],
MD5Value: '',
};
},
methods: {
onFileChange(e) {
this.selectedFile = e.target.files[0];
this.chunks = this.createChunks(this.selectedFile, 100 * 1024);
//計算文件的hash
this.calculateHash(this.chunks);
},
createChunks(file, chunkSize) { //文件切片
const result = [];
for (let i = 0; i < file.size; i += chunkSize) {
result.push(file.slice(i, i + chunkSize))
}
return result;
},
calculateHash(chunks) { //計算MD5值
const spark = new SparkMD5();
function readChunk(i) {
if (i >= chunks.length) {
this.MD5Value = spark.end();
console.info(this.MD5Value);
return;
}
let blob = chunks[i];
const fileReader = new FileReader();
//異步獲取文件的字節信息
fileReader.onload = e => {
//獲取到讀取的字節數組
spark.append(e.target.result);
readChunk(i + 1);
};
//讀文件的字節
fileReader.readAsArrayBuffer(blob)
}
readChunk(0);
}
}
};
</script>
如果文件過大的話,即使采用分片的方式計算文件的MD5值也是非常慢的,所以設計的時候可以使用一個進度條的方式讓用戶知道當前正在解析文件并且當前的解析進度是多少,如下設計的解析文件的進度圖:
2、判斷當前的文件上傳信息
前端計算文件的MD5值后可以唯一標識這個文件,然后前端將MD5值傳給后端,后端告訴前端當前的文件是否上傳過
后端拿到MD5的值后到Redis中查詢是否存在上傳的記錄信息:
(1)如果Redis中存在文件上傳的信息,那么需要查詢MinIO上已經上傳成功的分片,計算未上傳成功的分片信息,封裝成對象返回給前端。
(2)如果Redis中沒有數據的,查詢數據庫是否有文件的上傳信息,數據庫中要么文件已經完成上傳并有文件在MinIO上的地址信息,要么就是沒有上傳(文件首次上傳),結果封裝成對象返回給前端。
核心代碼:
/**
* 檢查當前文件的上傳情況
*
* @param md5 文件的md5標識
*/
public FileUploadInfo checkFileUploadByMd5(String md5) {
//查詢redis是否存在文件的上傳信息
FileUploadInfo fileUploadInfo = RedisUtils.get(md5);
//Redis中存在上傳信息
if (Objects.nonNull(fileUploadInfo)) {
//獲取已經上傳成功的分片信息
List<Integer> listParts = minioUtil.getListParts(fileUploadInfo.getObject(), fileUploadInfo.getUploadId());
fileUploadInfo.setListParts(listParts);
return fileUploadInfo;
}
//查詢數據庫是否有上傳記錄
File file = filesMapper.selectUpdaLoadByMd5(md5);
if (file != null) {
//標識文件之前已經上傳成功了,直接返回其在MinIO上的地址
FileUploadInfo dbFileInfo = BeanUtils.copyProperties(file, FileUploadInfo.class);
return dbFileInfo;
}
return null;
}
3、每個分片生成臨時的憑證
如果當前的文件是沒有上傳過或者斷點上傳的時候,需要攜帶md5和分片信息請求后端,后端根據MD5查詢Redis中上傳文件的信息來申請憑證,如果是斷點續傳情況,需要過濾已經上傳成功的分片再去申請憑證;憑證通過后保存數據和更新Redis,然后返回憑證信息、uploadId給前端。
核心的代碼:
//文件分片申請憑證信息
public UploadUrlsVO multipartFileUpload(FileUploadInfo fileUploadInfo) {
UploadUrlsVO uploadUrlsVO;
String filePath;
//查詢Redis是否存在上傳信息
FileUploadInfo redisFileUploadInfo = RedisUtils.get(fileUploadInfo.getMd5());
//redis存在上傳信息
if (Objects.nonNull(redisFileUploadInfo)) {
fileUploadInfo = redisFileUploadInfo;
filePath = redisFileUploadInfo.getObject();
} else { //redis中無上傳信息
//文件原始名稱
String originName = fileUploadInfo.getOriginFileName();
filePath = DateUtil.format(LocalDateTime.now(), "yyyy/MM/dd") + "/"
+ FileUtil.mainName(originName)
+ "_" + fileUploadInfo.getMd5() + "."
+ FileUtil.extName(originName);
fileUploadInfo.setObject(filePath).setType(suffix);
}
//未分片的文件上傳
if (fileUploadInfo.getChunkCount() == 1) {
uploadUrlsVO = minioUtil.uploadSingleFile(fileUploadInfo.getContentType(), filePath);
} else { // 分片上傳
uploadUrlsVO = minioUtil.multiPartFileUpload(fileUploadInfo, filePath);
}
fileUploadInfo.setUploadId(urlsVO.getUploadId());
//最新的分片信息存到redis
RedisUtils.set(fileUploadInfo.getMd5(), fileUploadInfo, minioConfigInfo.getBreakpointTime(), TimeUnit.DAYS);
return uploadUrlsVO;
}
4、前端上傳分片和請求后端合并文件
前端通過后端的憑證開始上傳分片信息,分片上傳完成之后請求后端將分片的合并成一個完整的文件,然后獲取到完成的文件地址保存到數據庫。
核心代碼:
@Override
public String mergeMultipartUpload(String md5) {
//獲取Redis中上傳文件的信息
FileUploadInfo redisFileUploadInfo = RedisUtils.get(md5);
String fileUrl = StrUtil.format("{}/{}/{}", minioConfigInfo.getEndpoint(), minioConfigInfo.getBucket(), redisFileUploadInfo.getObject());
//組裝數據庫實體
Files file = BeanUtils.copyProperties(redisFileUploadInfo, Files.class);
file.setUrl(fileUrl);
file.setBucket(minioConfigInfo.getBucket());
//分片為1時不需要合并,否則合并
if (redisFileUploadInfo.getChunkCount() == 1 ||
minioUtil.mergeMultipartFile(redisFileUploadInfo.getObject(), redisFileUploadInfo.getUploadId())) {
filesMapper.insertFile(file);
//刪除Redis中分片的信息
redisUtil.del(md5);
return fileUrl;
}
//拋出異常提示
throw new BussinessException();
}
總結:
(1)文件分片上傳需要前端生成文件的唯一標識和分片。
(2)后端根據唯一標識判斷是否存在上傳信息,如果存在就判斷是上傳完成還是斷點上傳,如果上傳完整直接返回Minio上文件的地址,如果是斷點上傳就返回哪些分片已經上傳成功的信息給前端。
(3)前端過濾上傳成功的分片,將未上傳成功的分片請求后端申請憑證,申請成功之后上傳分片,上傳完成就請求后端合并分片成完成的文件并保存文件的地址到數據庫,刪除分片記錄。
(4)秒傳是因為之前這個文件已經上傳過,數據庫中已經存儲了文件在MinIO上完整的地址。
(5)斷點續傳的原理是記錄已經完成上傳的分片,再次上傳的時候這些分片無需再次上傳,只上傳未完成上傳的分片。
該文章在 2024/6/8 22:51:46 編輯過