一個 Demo 搞定前後端大文件分片上傳、斷點續傳、秒傳
1 前言
文件上傳在項目開發中再常見不過了,大多項目都會涉及到圖片、音頻、視頻、文件的上傳,通常簡單的一個 Form 表單就可以上傳小文件了,但是遇到大文件時比如 1GB 以上,或者用戶網絡比較慢時,簡單的文件上傳就不能適用了,用戶辛苦傳了好幾十分鐘,到最後發現上傳失敗,這樣的系統用戶體驗是非常差的。
或者用戶上傳到一半時,把應用退出了,下次進來再次上傳,如果讓他從頭開始傳也是不合理的。本文主要通過一個 Demo 從前端、後端用實戰代碼演示小文件上傳、大文件分片上傳、斷點續傳、秒傳的開發原理。
2 小文件上傳
小文件小傳非常的簡單,本項目後端我們使用 SpringBoot 3.1.2 + JDK17,前端我們使用原生的 JavaScript+spark-md5.min.js 實現。
後端代碼
POM.xml 使用 springboot3.1.2JAVA 版本使用 JDK17
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>uploadDemo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>uploadDemo</name>
<description>uploadDemo</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
JAVA 接文件接口:
@RestController
public class UploadController {
public static final String UPLOAD_PATH = "D:\\upload\\";
@RequestMapping("/upload")
public ResponseEntity<Map<String, String>> upload(@RequestParam MultipartFile file) throws IOException {
File dstFile = new File(UPLOAD_PATH, String.format("%s.%s", UUID.randomUUID(), StringUtils.getFilename(file.getOriginalFilename())));
file.transferTo(dstFile);
return ResponseEntity.ok(Map.of("path", dstFile.getAbsolutePath()));
}
}
前端代碼
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>upload</title>
</head>
<body>
upload
<form enctype="multipart/form-data">
<input type="file" >
<input type="button" value="上傳" onclick="uploadFile()">
</form>
上傳結果
<span id="uploadResult"></span>
<script>
var uploadResult=document.getElementById("uploadResult")
function uploadFile() {
var fileInput = document.getElementById('fileInput');
var file = fileInput.files[0];
if (!file) return; // 沒有選擇文件
var xhr = new XMLHttpRequest();
// 處理上傳進度
xhr.upload.onprogress = function(event) {
var percent = 100 * event.loaded / event.total;
uploadResult.innerHTML='上傳進度:' + percent + '%';
};
// 當上傳完成時調用
xhr.onload = function() {
if (xhr.status === 200) {
uploadResult.innerHTML='上傳成功'+ xhr.responseText;
}
}
xhr.onerror = function() {
uploadResult.innerHTML='上傳失敗';
}
// 發送請求
xhr.open('POST', '/upload', true);
var formData = new FormData();
formData.append('file', file);
xhr.send(formData);
}
</script>
</body>
</html>
注意事項
在上傳過程會報文件大小限制錯誤,主要有三個參數需要設置:
org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException: the request was rejected because its size (46302921) exceeds the configured maximum (10485760)
這裏需在 springboot 的 application.properties 或者 application.yml 中添加max-file-size
和max-request-size
配置項,默認大小分別是 1M 和 10M,肯定不能滿足我們上傳需求的。
spring.servlet.multipart.max-file-size=1024MB
spring.servlet.multipart.max-request-size=1024MB
如果使用 nginx 報 413 狀態碼413 Request Entity Too Large
,Nginx 默認最大上傳 1MB 文件,需要在 nginx.conf 配置文件中的 http{ }
添加配置項:client_max_body_size 1024m
。
3 大文件分片上傳
前端
前端上傳流程
大文件分片上傳前端主要有三步:
前端上傳代碼計算文件 MD5 值用了 spark-md5 這個庫,使用也是比較簡單的。這裏爲什麼要計算 MD5 簡單說一下,因爲文件在傳輸寫入過程中可能會出現錯誤,導致最終合成的文件可能和原文件不一樣,所以要對比一下前端計算的 MD5 和後端計算的 MD5 是不是一樣,保證上傳數據的一致性。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>分片上傳</title>
<script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
</head>
<body>
分片上傳
<form enctype="multipart/form-data">
<input type="file" >
<input type="button" value="計算文件MD5" onclick="calculateFileMD5()">
<input type="button" value="上傳" onclick="uploadFile()">
<input type="button" value="檢測文件完整性" onclick="checkFile()">
</form>
<p>
文件MD5:
<span id="fileMd5"></span>
</p>
<p>
上傳結果:
<span id="uploadResult"></span>
</p>
<p>
檢測文件完整性:
<span id="checkFileRes"></span>
</p>
<script>
//每片的大小
var chunkSize = 1 * 1024 * 1024;
var uploadResult = document.getElementById("uploadResult")
var fileMd5Span = document.getElementById("fileMd5")
var checkFileRes = document.getElementById("checkFileRes")
var fileMd5;
function calculateFileMD5(){
var fileInput = document.getElementById('fileInput');
var file = fileInput.files[0];
getFileMd5(file).then((md5) => {
console.info(md5)
fileMd5=md5;
fileMd5Span.innerHTML=md5;
})
}
function uploadFile() {
var fileInput = document.getElementById('fileInput');
var file = fileInput.files[0];
if (!file) return;
if (!fileMd5) return;
//獲取到文件
let fileArr = this.sliceFile(file);
//保存文件名稱
let fileName = file.name;
fileArr.forEach((e, i) => {
//創建formdata對象
let data = new FormData();
data.append("totalNumber", fileArr.length)
data.append("chunkSize", chunkSize)
data.append("chunkNumber", i)
data.append("md5", fileMd5)
data.append("file", new File([e],fileName));
upload(data);
})
}
/**
* 計算文件md5值
*/
function getFileMd5(file) {
return new Promise((resolve, reject) => {
let fileReader = new FileReader()
fileReader.onload = function (event) {
let fileMd5 = SparkMD5.ArrayBuffer.hash(event.target.result)
resolve(fileMd5)
}
fileReader.readAsArrayBuffer(file)
})
}
function upload(data) {
var xhr = new XMLHttpRequest();
// 當上傳完成時調用
xhr.onload = function () {
if (xhr.status === 200) {
uploadResult.append( '上傳成功分片:' +data.get("chunkNumber")+'\t' ) ;
}
}
xhr.onerror = function () {
uploadResult.innerHTML = '上傳失敗';
}
// 發送請求
xhr.open('POST', '/uploadBig', true);
xhr.send(data);
}
function checkFile() {
var xhr = new XMLHttpRequest();
// 當上傳完成時調用
xhr.onload = function () {
if (xhr.status === 200) {
checkFileRes.innerHTML = '檢測文件完整性成功:' + xhr.responseText;
}
}
xhr.onerror = function () {
checkFileRes.innerHTML = '檢測文件完整性失敗';
}
// 發送請求
xhr.open('POST', '/checkFile', true);
let data = new FormData();
data.append("md5", fileMd5)
xhr.send(data);
}
function sliceFile(file) {
const chunks = [];
let start = 0;
let end;
while (start < file.size) {
end = Math.min(start + chunkSize, file.size);
chunks.push(file.slice(start, end));
start = end;
}
return chunks;
}
</script>
</body>
</html>
前端注意事項
前端調用 uploadBig 接口有四個參數:
計算大文件的 MD5 可能會比較慢,這個可以從流程上進行優化,比如上傳使用異步去計算文件 MD5、不計算整個文件 MD5 而是計算每一片的 MD5 保證每一片數據的一致性。
後端
後端就兩個接口 / uploadBig 用於每一片文件的上傳和 / checkFile 檢測文件的 MD5。
/uploadBig 接口設計思路
接口總體流程:
這裏需要注意的:
-
MD5.conf 每一次檢測文件不存在裏創建個空文件,使用
byte[] bytes = new byte[totalNumber];
將每一位狀態設置爲 0,從 0 位天始,第 N 位表示第 N 個分片的上傳狀態,0 - 未上傳 1 - 已上傳,當每將上傳成功後使用randomAccessConfFile.seek(chunkNumber)
將對就設置爲 1。 -
randomAccessFile.seek(chunkNumber * chunkSize);
可以將光標移到文件指定位置開始寫數據,每一個文件每將上傳分片編號 chunkNumber 都是不一樣的,所以各自寫自己文件塊,多線程寫同一個文件不會出現線程安全問題。 -
大文件寫入時用
RandomAccessFile
可能比較慢,可以使用MappedByteBuffer
內存映射來加速大文件寫入,不過使用MappedByteBuffer
如果要刪除文件可能會存在刪除不掉,因爲刪除了磁盤上的文件,內存的文件還是存在的。
MappedByteBuffer
寫文件的用法:
FileChannel fileChannel = randomAccessFile.getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, chunkNumber * chunkSize, fileData.length);
mappedByteBuffer.put(fileData);
/checkFile 接口設計思路
/checkFile 接口流程:
大文件上傳完整 JAVA 代碼:
@RestController
public class UploadController {
public static final String UPLOAD_PATH = "D:\\upload\\";
/**
* @param chunkSize 每個分片大小
* @param chunkNumber 當前分片
* @param md5 文件總MD5
* @param file 當前分片文件數據
* @return
* @throws IOException
*/
@RequestMapping("/uploadBig")
public ResponseEntity<Map<String, String>> uploadBig(@RequestParam Long chunkSize, @RequestParam Integer totalNumber, @RequestParam Long chunkNumber, @RequestParam String md5, @RequestParam MultipartFile file) throws IOException {
//文件存放位置
String dstFile = String.format("%s\\%s\\%s.%s", UPLOAD_PATH, md5, md5, StringUtils.getFilenameExtension(file.getOriginalFilename()));
//上傳分片信息存放位置
String confFile = String.format("%s\\%s\\%s.conf", UPLOAD_PATH, md5, md5);
//第一次創建分片記錄文件
//創建目錄
File dir = new File(dstFile).getParentFile();
if (!dir.exists()) {
dir.mkdir();
//所有分片狀態設置爲0
byte[] bytes = new byte[totalNumber];
Files.write(Path.of(confFile), bytes);
}
//隨機分片寫入文件
try (RandomAccessFile randomAccessFile = new RandomAccessFile(dstFile, "rw");
RandomAccessFile randomAccessConfFile = new RandomAccessFile(confFile, "rw");
InputStream inputStream = file.getInputStream()) {
//定位到該分片的偏移量
randomAccessFile.seek(chunkNumber * chunkSize);
//寫入該分片數據
randomAccessFile.write(inputStream.readAllBytes());
//定位到當前分片狀態位置
randomAccessConfFile.seek(chunkNumber);
//設置當前分片上傳狀態爲1
randomAccessConfFile.write(1);
}
return ResponseEntity.ok(Map.of("path", dstFile));
}
/**
* 獲取文件分片狀態,檢測文件MD5合法性
*
* @param md5
* @return
* @throws Exception
*/
@RequestMapping("/checkFile")
public ResponseEntity<Map<String, String>> uploadBig(@RequestParam String md5) throws Exception {
String uploadPath = String.format("%s\\%s\\%s.conf", UPLOAD_PATH, md5, md5);
Path path = Path.of(uploadPath);
//MD5目錄不存在文件從未上傳過
if (!Files.exists(path.getParent())) {
return ResponseEntity.ok(Map.of("msg", "文件未上傳"));
}
//判斷文件是否上傳成功
StringBuilder stringBuilder = new StringBuilder();
byte[] bytes = Files.readAllBytes(path);
for (byte b : bytes) {
stringBuilder.append(String.valueOf(b));
}
//所有分片上傳完成計算文件MD5
if (!stringBuilder.toString().contains("0")) {
File file = new File(String.format("%s\\%s\\", UPLOAD_PATH, md5));
File[] files = file.listFiles();
String filePath = "";
for (File f : files) {
//計算文件MD5是否相等
if (!f.getName().contains("conf")) {
filePath = f.getAbsolutePath();
try (InputStream inputStream = new FileInputStream(f)) {
String md5pwd = DigestUtils.md5DigestAsHex(inputStream);
if (!md5pwd.equalsIgnoreCase(md5)) {
return ResponseEntity.ok(Map.of("msg", "文件上傳失敗"));
}
}
}
}
return ResponseEntity.ok(Map.of("path", filePath));
} else {
//文件未上傳完成,反回每個分片狀態,前端將未上傳的分片繼續上傳
return ResponseEntity.ok(Map.of("chucks", stringBuilder.toString()));
}
}
}
配合前端上傳演示分片上傳,依次按如下流程點擊按鈕:
斷點續傳
有了上面的設計做斷點續傳就比較簡單的,後端代碼不需要改變,只要修改前端上傳流程就好了:
用 / checkFile 接口,文件裏如果有未完成上傳的分片,接口返回 chunks 字段對就的位置值爲 0,前端將未上傳的分片繼續上傳,完成後再調用 / checkFile 就完成了斷點續傳
{
"chucks": "111111111100000000001111111111111111111111111"
}
秒傳
秒傳也是比較簡單的,只要修改前端代碼流程就好了,比如張三上傳了一個文件,然後李四又上傳了同樣內容的文件,同一文件的 MD5 值可以認爲是一樣的(雖然會存在不同文件的 MD5 一樣,不過概率很小,可以認爲 MD5 一樣文件就是一樣),10 萬不同文件 MD5 相同概率爲110000000000000000000000000000\frac{1}{10000000000000000000000000000}100000000000000000000000000001
,福利彩票的中頭獎的概率一般爲11000000\frac{1}{1000000}10000001
,具體計算方法可以參考走近消息摘要 --Md5 產生重複的概率,所以 MD5 衝突的概率可以忽略不計。
當李四調用 / checkFile 接口後,後端直接返回了李四上傳的文件路徑,李四就完成了秒傳。大部分雲盤秒傳的思路應該也是這樣,只不過計算文件 HASH 算法更爲複雜,返回給用戶文件路徑也更爲安全,要防止被別人算出文件路徑了。
秒傳前端代碼流程:
4 總結
本文從前端和後端兩個方面介紹了大文件的分片上傳、斷點繼續、秒傳設計思路和實現代碼,所有代碼都是親測可以直接使用。
來源:juejin.cn/post/7266265543412351030
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/eFPpLwR_0L0NGS0OxAFOxw