一個(gè)Demo搞定前后端大文件分片上傳、斷點(diǎn)續(xù)傳、秒傳
1前言
文件上傳在項(xiàng)目開(kāi)發(fā)中再常見(jiàn)不過(guò)了,大多項(xiàng)目都會(huì)涉及到圖片、音頻、視頻、文件的上傳,通常簡(jiǎn)單的一個(gè)Form表單就可以上傳小文件了,但是遇到大文件時(shí)比如1GB以上,或者用戶(hù)網(wǎng)絡(luò)比較慢時(shí),簡(jiǎn)單的文件上傳就不能適用了,用戶(hù)辛苦傳了好幾十分鐘,到最后發(fā)現(xiàn)上傳失敗,這樣的系統(tǒng)用戶(hù)體驗(yàn)是非常差的。
或者用戶(hù)上傳到一半時(shí),把應(yīng)用退出了,下次進(jìn)來(lái)再次上傳,如果讓他從頭開(kāi)始傳也是不合理的。本文主要通過(guò)一個(gè)Demo從前端、后端用實(shí)戰(zhàn)代碼演示小文件上傳、大文件分片上傳、斷點(diǎn)續(xù)傳、秒傳的開(kāi)發(fā)原理。
2小文件上傳
小文件小傳非常的簡(jiǎn)單,本項(xiàng)目后端我們使用SrpingBoot 3.1.2 + JDK17,前端我們使用原生的JavaScript+spark-md5.min.js實(shí)現(xiàn)。
后端代碼
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" name="fileInput" id="fileInput">
<input type="button" value="上傳" onclick="uploadFile()">
</form>
上傳結(jié)果
<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; // 沒(méi)有選擇文件
var xhr = new XMLHttpRequest();
// 處理上傳進(jìn)度
xhr.upload.onprogress = function(event) {
var percent = 100 * event.loaded / event.total;
uploadResult.innerHTML='上傳進(jìn)度:' + percent + '%';
};
// 當(dāng)上傳完成時(shí)調(diào)用
xhr.onload = function() {
if (xhr.status === 200) {
uploadResult.innerHTML='上傳成功'+ xhr.responseText;
}
}
xhr.onerror = function() {
uploadResult.innerHTML='上傳失敗';
}
// 發(fā)送請(qǐng)求
xhr.open('POST', '/upload', true);
var formData = new FormData();
formData.append('file', file);
xhr.send(formData);
}
</script>
</body>
</html>
注意事項(xiàng)
在上傳過(guò)程會(huì)報(bào)文件大小限制錯(cuò)誤,主要有三個(gè)參數(shù)需要設(shè)置:
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配置項(xiàng),默認(rèn)大小分別是1M和10M,肯定不能滿(mǎn)足我們上傳需求的。
spring.servlet.multipart.max-file-size=1024MB
spring.servlet.multipart.max-request-size=1024MB
如果使用nginx報(bào) 413狀態(tài)碼413 Request Entity Too Large,Nginx默認(rèn)最大上傳1MB文件,需要在nginx.conf配置文件中的 http{ }添加配置項(xiàng):client_max_body_size 1024m。
3大文件分片上傳
前端
前端上傳流程
大文件分片上傳前端主要有三步:
前端上傳代碼計(jì)算文件MD5值用了spark-md5這個(gè)庫(kù),使用也是比較簡(jiǎn)單的。這里為什么要計(jì)算MD5簡(jiǎn)單說(shuō)一下,因?yàn)槲募趥鬏攲?xiě)入過(guò)程中可能會(huì)出現(xiàn)錯(cuò)誤,導(dǎo)致最終合成的文件可能和原文件不一樣,所以要對(duì)比一下前端計(jì)算的MD5和后端計(jì)算的MD5是不是一樣,保證上傳數(shù)據(jù)的一致性。
<!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" name="fileInput" id="fileInput">
<input type="button" value="計(jì)算文件MD5" onclick="calculateFileMD5()">
<input type="button" value="上傳" onclick="uploadFile()">
<input type="button" value="檢測(cè)文件完整性" onclick="checkFile()">
</form>
<p>
文件MD5:
<span id="fileMd5"></span>
</p>
<p>
上傳結(jié)果:
<span id="uploadResult"></span>
</p>
<p>
檢測(cè)文件完整性:
<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);
//保存文件名稱(chēng)
let fileName = file.name;
fileArr.forEach((e, i) => {
//創(chuàng)建formdata對(duì)象
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);
})
}
/**
* 計(jì)算文件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();
// 當(dāng)上傳完成時(shí)調(diào)用
xhr.onload = function () {
if (xhr.status === 200) {
uploadResult.append( '上傳成功分片:' +data.get("chunkNumber")+'\t' ) ;
}
}
xhr.onerror = function () {
uploadResult.innerHTML = '上傳失敗';
}
// 發(fā)送請(qǐng)求
xhr.open('POST', '/uploadBig', true);
xhr.send(data);
}
function checkFile() {
var xhr = new XMLHttpRequest();
// 當(dāng)上傳完成時(shí)調(diào)用
xhr.onload = function () {
if (xhr.status === 200) {
checkFileRes.innerHTML = '檢測(cè)文件完整性成功:' + xhr.responseText;
}
}
xhr.onerror = function () {
checkFileRes.innerHTML = '檢測(cè)文件完整性失敗';
}
// 發(fā)送請(qǐng)求
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>
前端注意事項(xiàng)
前端調(diào)用uploadBig接口有四個(gè)參數(shù):
計(jì)算大文件的MD5可能會(huì)比較慢,這個(gè)可以從流程上進(jìn)行優(yōu)化,比如上傳使用異步去計(jì)算文件MD5、不計(jì)算整個(gè)文件MD5而是計(jì)算每一片的MD5保證每一片數(shù)據(jù)的一致性。
后端
后端就兩個(gè)接口/uploadBig用于每一片文件的上傳和/checkFile檢測(cè)文件的MD5。
/uploadBig接口設(shè)計(jì)思路
接口總體流程:
這里需要注意的:
-
MD5.conf每一次檢測(cè)文件不存在里創(chuàng)建個(gè)空文件,使用
byte[] bytes = new byte[totalNumber];將每一位狀態(tài)設(shè)置為0,從0位天始,第N位表示第N個(gè)分片的上傳狀態(tài),0-未上傳 1-已上傳,當(dāng)每將上傳成功后使用randomAccessConfFile.seek(chunkNumber)將對(duì)就設(shè)置為1。 -
randomAccessFile.seek(chunkNumber * chunkSize);可以將光標(biāo)移到文件指定位置開(kāi)始寫(xiě)數(shù)據(jù),每一個(gè)文件每將上傳分片編號(hào)chunkNumber都是不一樣的,所以各自寫(xiě)自己文件塊,多線(xiàn)程寫(xiě)同一個(gè)文件不會(huì)出現(xiàn)線(xiàn)程安全問(wèn)題。 -
大文件寫(xiě)入時(shí)用
RandomAccessFile可能比較慢,可以使用MappedByteBuffer內(nèi)存映射來(lái)加速大文件寫(xiě)入,不過(guò)使用MappedByteBuffer如果要?jiǎng)h除文件可能會(huì)存在刪除不掉,因?yàn)閯h除了磁盤(pán)上的文件,內(nèi)存的文件還是存在的。
MappedByteBuffer寫(xiě)文件的用法:
FileChannel fileChannel = randomAccessFile.getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, chunkNumber * chunkSize, fileData.length);
mappedByteBuffer.put(fileData);
/checkFile接口設(shè)計(jì)思路
/checkFile接口流程:
大文件上傳完整JAVA代碼:
@RestController
public class UploadController {
public static final String UPLOAD_PATH = "D:\\upload\\";
/**
* @param chunkSize 每個(gè)分片大小
* @param chunkNumber 當(dāng)前分片
* @param md5 文件總MD5
* @param file 當(dāng)前分片文件數(shù)據(jù)
* @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);
//第一次創(chuàng)建分片記錄文件
//創(chuàng)建目錄
File dir = new File(dstFile).getParentFile();
if (!dir.exists()) {
dir.mkdir();
//所有分片狀態(tài)設(shè)置為0
byte[] bytes = new byte[totalNumber];
Files.write(Path.of(confFile), bytes);
}
//隨機(jī)分片寫(xiě)入文件
try (RandomAccessFile randomAccessFile = new RandomAccessFile(dstFile, "rw");
RandomAccessFile randomAccessConfFile = new RandomAccessFile(confFile, "rw");
InputStream inputStream = file.getInputStream()) {
//定位到該分片的偏移量
randomAccessFile.seek(chunkNumber * chunkSize);
//寫(xiě)入該分片數(shù)據(jù)
randomAccessFile.write(inputStream.readAllBytes());
//定位到當(dāng)前分片狀態(tài)位置
randomAccessConfFile.seek(chunkNumber);
//設(shè)置當(dāng)前分片上傳狀態(tài)為1
randomAccessConfFile.write(1);
}
return ResponseEntity.ok(Map.of("path", dstFile));
}
/**
* 獲取文件分片狀態(tài),檢測(cè)文件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目錄不存在文件從未上傳過(guò)
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));
}
//所有分片上傳完成計(jì)算文件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) {
//計(jì)算文件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 {
//文件未上傳完成,反回每個(gè)分片狀態(tài),前端將未上傳的分片繼續(xù)上傳
return ResponseEntity.ok(Map.of("chucks", stringBuilder.toString()));
}
}
}
配合前端上傳演示分片上傳,依次按如下流程點(diǎn)擊按鈕:
斷點(diǎn)續(xù)傳
有了上面的設(shè)計(jì)做斷點(diǎn)續(xù)傳就比較簡(jiǎn)單的,后端代碼不需要改變,只要修改前端上傳流程就好了:
用/checkFile接口,文件里如果有未完成上傳的分片,接口返回chunks字段對(duì)就的位置值為0,前端將未上傳的分片繼續(xù)上傳,完成后再調(diào)用/checkFile就完成了斷點(diǎn)續(xù)傳
{
"chucks": "111111111100000000001111111111111111111111111"
}
秒傳
秒傳也是比較簡(jiǎn)單的,只要修改前端代碼流程就好了,比如張三上傳了一個(gè)文件,然后李四又上傳了同樣內(nèi)容的文件,同一文件的MD5值可以認(rèn)為是一樣的(雖然會(huì)存在不同文件的MD5一樣,不過(guò)概率很小,可以認(rèn)為MD5一樣文件就是一樣),10萬(wàn)不同文件MD5相同概率為110000000000000000000000000000\frac{1}{10000000000000000000000000000}100000000000000000000000000001,福利彩票的中頭獎(jiǎng)的概率一般為11000000\frac{1}{1000000}10000001,具體計(jì)算方法可以參考走近消息摘要--Md5產(chǎn)生重復(fù)的概率,所以MD5沖突的概率可以忽略不計(jì)。
當(dāng)李四調(diào)用/checkFile接口后,后端直接返回了李四上傳的文件路徑,李四就完成了秒傳。大部分云盤(pán)秒傳的思路應(yīng)該也是這樣,只不過(guò)計(jì)算文件HASH算法更為復(fù)雜,返回給用戶(hù)文件路徑也更為安全,要防止被別人算出文件路徑了。
秒傳前端代碼流程:
4總結(jié)
本文從前端和后端兩個(gè)方面介紹了大文件的分片上傳、斷點(diǎn)繼續(xù)、秒傳設(shè)計(jì)思路和實(shí)現(xiàn)代碼,所有代碼都是親測(cè)可以直接使用。
來(lái)源:juejin.cn/post/7266265543412351030
往期推薦:
Netty+SpringBoot 打造一個(gè) TCP 長(zhǎng)連接通訊方案
7k Star,一款開(kāi)源的 Kafka 管理平臺(tái),功能齊全、頁(yè)面美觀!
SpringBoot+RabbitMQ+Redis 開(kāi)發(fā)一個(gè)秒殺系統(tǒng),細(xì)節(jié)打滿(mǎn)(附源碼)
一套干凈的企業(yè)數(shù)據(jù)管理系統(tǒng),拿來(lái)直接用
基于 Spring Boot 的車(chē)牌識(shí)別系統(tǒng)(附項(xiàng)目地址)
騰訊低代碼神器開(kāi)源!拖拽開(kāi)發(fā),爽的飛起~
