SpringBoot實現(xiàn)文件斷點(diǎn)續(xù)傳功能詳解
在處理大文件傳輸或網(wǎng)絡(luò)不穩(wěn)定的情況下,文件斷點(diǎn)續(xù)傳功能顯得尤為重要。本文將詳細(xì)介紹如何使用Spring Boot實現(xiàn)文件的斷點(diǎn)續(xù)傳功能,并提供完整的前后端代碼實現(xiàn)。
一、斷點(diǎn)續(xù)傳技術(shù)原理
斷點(diǎn)續(xù)傳的核心原理是將文件分片傳輸并記錄進(jìn)度,主要包括
- 客戶端將大文件分割成小塊逐一上傳
- 服務(wù)端保存已上傳塊信息
- 傳輸中斷后只需繼續(xù)傳未上傳部分
二、服務(wù)端代碼實現(xiàn)
項目依賴配置
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.7.18</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.1</version>
</dependency>
</dependencies>
application.yaml配置
file:
upload:
dir: D:/tmp
spring:
servlet:
multipart:
enabled: true
max-file-size: 100MB
max-request-size: 100MB
file-size-threshold: 2KB
文件塊信息實體類
public class FileChunkDTO {
/**
* 當(dāng)前文件塊,從1開始
*/
private Integer chunkNumber;
/**
* 分塊大小
*/
private Long chunkSize;
/**
* 當(dāng)前分塊大小
*/
private Long currentChunkSize;
/**
* 總大小
*/
private Long totalSize;
/**
* 文件標(biāo)識
*/
private String identifier;
/**
* 文件名
*/
private String filename;
/**
* 相對路徑
*/
private String relativePath;
/**
* 總塊數(shù)
*/
private Integer totalChunks;
public Integer getChunkNumber() {
return chunkNumber;
}
public void setChunkNumber(Integer chunkNumber) {
this.chunkNumber = chunkNumber;
}
public Long getChunkSize() {
return chunkSize;
}
public void setChunkSize(Long chunkSize) {
this.chunkSize = chunkSize;
}
public Long getCurrentChunkSize() {
return currentChunkSize;
}
public void setCurrentChunkSize(Long currentChunkSize) {
this.currentChunkSize = currentChunkSize;
}
public Long getTotalSize() {
return totalSize;
}
public void setTotalSize(Long totalSize) {
this.totalSize = totalSize;
}
public String getIdentifier() {
return identifier;
}
public void setIdentifier(String identifier) {
this.identifier = identifier;
}
public String getFilename() {
return filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
public String getRelativePath() {
return relativePath;
}
public void setRelativePath(String relativePath) {
this.relativePath = relativePath;
}
public Integer getTotalChunks() {
return totalChunks;
}
public void setTotalChunks(Integer totalChunks) {
this.totalChunks = totalChunks;
}
}
通用響應(yīng)類
public class FileUploadResponse {
private boolean success;
private String message;
private Object data;
public FileUploadResponse(boolean success, String message, Object data) {
this.success = success;
this.message = message;
this.data = data;
}
public static FileUploadResponse success(String message, Object data) {
return new FileUploadResponse(true, message, data);
}
public static FileUploadResponse success(String message) {
return new FileUploadResponse(true, message, null);
}
public static FileUploadResponse error(String message) {
return new FileUploadResponse(false, message, null);
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
}
文件上傳服務(wù)
import cn.hutool.core.io.FileUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
@Service
public class FileUploadService {
private static final Logger logger = LoggerFactory.getLogger(FileUploadService.class);
@Value("${file.upload.dir}")
private String uploadDir;
/**
* 檢查文件是否已上傳過
*/
public boolean checkFileExists(FileChunkDTO chunk) {
String storeChunkPath = uploadDir + File.separator + "chunks" + File.separator + chunk.getIdentifier() + File.separator + chunk.getChunkNumber();
File storeChunk = new File(storeChunkPath);
return storeChunk.exists() && chunk.getChunkSize() == storeChunk.length();
}
/**
* 上傳文件塊
*/
public FileUploadResponse uploadChunk(FileChunkDTO chunk, MultipartFile file) {
try {
if (file.isEmpty()) {
return FileUploadResponse.error("文件塊為空");
}
// 創(chuàng)建塊文件目錄
String chunkDirPath = uploadDir + File.separator + "chunks" + File.separator + chunk.getIdentifier();
File chunkDir = new File(chunkDirPath);
if (!chunkDir.exists()) {
chunkDir.mkdirs();
}
// 保存分塊
String chunkPath = chunkDirPath + File.separator + chunk.getChunkNumber();
file.transferTo(new File(chunkPath));
return FileUploadResponse.success("文件塊上傳成功");
} catch (IOException e) {
logger.error(e.getMessage(),e);
return FileUploadResponse.error("文件塊上傳失敗: " + e.getMessage());
}
}
/**
* 合并文件塊
*/
public FileUploadResponse mergeChunks(String identifier, String filename, Integer totalChunks) {
try {
String chunkDirPath = uploadDir + File.separator + "chunks" + File.separator + identifier;
if(!FileUtil.exist(chunkDirPath)){
return FileUploadResponse.error("文件合并失敗, 目錄不存在" );
}
File chunkDir = new File(chunkDirPath);
// 創(chuàng)建目標(biāo)文件
String filePath = uploadDir + File.separator + filename;
File destFile = new File(filePath);
if (destFile.exists()) {
destFile.delete();
}
// 使用RandomAccessFile合并文件塊
try (RandomAccessFile randomAccessFile = new RandomAccessFile(destFile, "rw")) {
byte[] buffer = new byte[1024];
for (int i = 1; i <= totalChunks; i++) {
File chunk = new File(chunkDirPath + File.separator + i);
if (!chunk.exists()) {
return FileUploadResponse.error("文件塊" + i + "不存在");
}
try (java.io.FileInputStream fis = new java.io.FileInputStream(chunk)) {
int len;
while ((len = fis.read(buffer)) != -1) {
randomAccessFile.write(buffer, 0, len);
}
}
}
}
// 清理臨時文件塊
FileUtil.del(chunkDir);
return FileUploadResponse.success("文件合并成功", filePath);
} catch (IOException e) {
logger.error(e.getMessage(),e);
return FileUploadResponse.error("文件合并失敗: " + e.getMessage());
}
}
}
控制器
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/upload")
@CrossOrigin // 允許跨域請求
public class FileUploadController {
private static Logger logger = LoggerFactory.getLogger(FileDownloadService.class);
@Autowired
private FileUploadService fileUploadService;
/**
* 檢查文件或文件塊是否已存在
*/
@GetMapping("/check")
public ResponseEntity<Void> checkFileExists(FileChunkDTO chunk) {
boolean exists = fileUploadService.checkFileExists(chunk);
if (exists) {
// 分片存在,返回 200
return ResponseEntity.ok().build();
} else {
// 分片不存在,返回 404
return ResponseEntity.notFound().build();
}
}
/**
* 上傳文件塊
*/
@PostMapping("/chunk")
public FileUploadResponse uploadChunk(
@RequestParam(value = "chunkNumber") Integer chunkNumber,
@RequestParam(value = "chunkSize") Long chunkSize,
@RequestParam(value = "currentChunkSize") Long currentChunkSize,
@RequestParam(value = "totalSize") Long totalSize,
@RequestParam(value = "identifier") String identifier,
@RequestParam(value = "filename") String filename,
@RequestParam(value = "totalChunks") Integer totalChunks,
@RequestParam("file") MultipartFile file) {
String identifierName = identifier;
// 如果 identifierName 包含逗號,取第一個值
if (identifierName.contains(",")) {
identifierName = identifierName.split(",")[0];
}
FileChunkDTO chunk = new FileChunkDTO();
chunk.setChunkNumber(chunkNumber);
chunk.setChunkSize(chunkSize);
chunk.setCurrentChunkSize(currentChunkSize);
chunk.setTotalSize(totalSize);
chunk.setIdentifier(identifierName);
chunk.setFilename(filename);
chunk.setTotalChunks(totalChunks);
return fileUploadService.uploadChunk(chunk, file);
}
/**
* 合并文件塊
*/
@PostMapping("/merge")
public FileUploadResponse mergeChunks(
@RequestParam("identifier") String identifier,
@RequestParam("filename") String filename,
@RequestParam("totalChunks") Integer totalChunks) {
return fileUploadService.mergeChunks(identifier, filename, totalChunks);
}
}
三、前端實現(xiàn)
完整HTML頁面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文件斷點(diǎn)續(xù)傳示例</title>
<link rel="external nofollow" rel="stylesheet">
<style>
.upload-container, .download-container {
margin-top: 30px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
}
.progress {
margin-top: 10px;
height: 25px;
}
.file-list {
margin-top: 20px;
}
.file-item {
padding: 10px;
margin-bottom: 5px;
border: 1px solid #eee;
border-radius: 5px;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
</head>
<body>
<div class="container">
<h1 class="mt-4 mb-4">文件斷點(diǎn)續(xù)傳示例</h1>
<!-- 上傳區(qū)域 -->
<div class="upload-container">
<h3>文件上傳(支持?jǐn)帱c(diǎn)續(xù)傳)</h3>
<div class="mb-3">
<label for="fileUpload" class="form-label">選擇文件</label>
<input class="form-control" type="file" id="fileUpload">
</div>
<button id="uploadBtn" class="btn btn-primary">上傳文件</button>
<div class="progress d-none" id="uploadProgress">
<div class="progress-bar" role="progressbar" style="width: 0%;" id="uploadProgressBar">0%</div>
</div>
<div id="uploadStatus" class="mt-2"></div>
</div>
<div class="download-container">
<h3>文件列表</h3>
<button id="refreshBtn" class="btn btn-secondary mb-3">刷新文件列表</button>
<div id="fileList" class="file-list">
<div class="alert alert-info">點(diǎn)擊刷新按鈕獲取文件列表</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/resumablejs@1.1.0/resumable.min.js"></script>
<script>
// 基礎(chǔ)配置
const API_BASE_URL = 'http://localhost:8080/api';
// DOM元素
const uploadBtn = document.getElementById('uploadBtn');
const fileUpload = document.getElementById('fileUpload');
const uploadProgress = document.getElementById('uploadProgress');
const uploadProgressBar = document.getElementById('uploadProgressBar');
const uploadStatus = document.getElementById('uploadStatus');
const refreshBtn = document.getElementById('refreshBtn');
const fileList = document.getElementById('fileList');
const downloadProgress = document.getElementById('downloadProgress');
const downloadProgressBar = document.getElementById('downloadProgressBar');
const downloadStatus = document.getElementById('downloadStatus');
// 初始化resumable.js
const resumable = new Resumable({
target: `${API_BASE_URL}/upload/chunk`,
query: {},
chunkSize: 1 * 1024 * 1024, // 分片大小為1MB
simultaneousUploads: 3,
testChunks: true,
throttleProgressCallbacks: 1,
chunkNumberParameterName: 'chunkNumber',
chunkSizeParameterName: 'chunkSize',
currentChunkSizeParameterName: 'currentChunkSize',
totalSizeParameterName: 'totalSize',
identifierParameterName: 'identifier',
fileNameParameterName: 'filename',
totalChunksParameterName: 'totalChunks',
method: 'POST',
headers: {
'Accept': 'application/json'
},
testMethod: 'GET',
testTarget: `${API_BASE_URL}/upload/check`
});
// 分配事件監(jiān)聽器
resumable.assignBrowse(fileUpload);
// 文件添加事件 - 顯示文件名
resumable.on('fileAdded', function(file) {
console.log('File added:', file);
// 顯示已選擇的文件名
uploadStatus.innerHTML = `<div class="alert alert-info">已選擇文件: ${file.fileName} (${formatFileSize(file.size)})</div>`;
// 顯示文件信息卡片
const fileInfoDiv = document.createElement('div');
fileInfoDiv.className = 'card mt-2 mb-2';
fileInfoDiv.innerHTML = `
<div class="card-body">
<h5 class="card-title">文件信息</h5>
<p class="card-text">文件名: ${file.fileName}</p>
<p class="card-text">大小: ${formatFileSize(file.size)}</p>
<p class="card-text">類型: ${file.file.type || '未知'}</p>
<p class="card-text">分片數(shù): ${file.chunks.length}</p>
</div>
`;
// 清除舊的文件信息
const oldFileInfo = document.querySelector('.card');
if (oldFileInfo) {
oldFileInfo.remove();
}
// 插入到uploadStatus之前
uploadStatus.parentNode.insertBefore(fileInfoDiv, uploadStatus);
});
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 分塊開始事件
resumable.on('chunkingStart', function(file) {
console.log('開始分塊:', file.fileName);
});
// 分塊進(jìn)度事件
resumable.on('chunkingProgress', function(file, ratio) {
console.log('分塊進(jìn)度:', Math.floor(ratio * 100) + '%');
});
// 分塊完成事件
resumable.on('chunkingComplete', function(file) {
console.log('分塊完成');
});
// 上傳開始事件
resumable.on('uploadStart', function() {
console.log('開始上傳');
uploadStatus.innerHTML = '<div class="alert alert-info">開始上傳文件塊...</div>';
window.mergeCalled = false;
});
// 上傳進(jìn)度事件
resumable.on('fileProgress', function(file) {
const progress = Math.floor(file.progress() * 100);
uploadProgress.classList.remove('d-none');
uploadProgressBar.style.width = `${progress}%`;
uploadProgressBar.textContent = `${progress}%`;
// 顯示當(dāng)前上傳塊信息
const uploadedChunks = file.chunks.filter(chunk => chunk.status === 2).length;
const totalChunks = file.chunks.length;
uploadStatus.innerHTML = `<div class="alert alert-info">正在上傳: ${uploadedChunks}/${totalChunks} 塊 (${progress}%)</div>`;
});
// 總體進(jìn)度事件
resumable.on('progress', function() {
console.log('總體進(jìn)度:', Math.floor(resumable.progress() * 100) + '%');
});
// 上傳成功事件
resumable.on('fileSuccess', function(file, response) {
console.log('文件上傳成功,準(zhǔn)備合并');
const parsedResponse = JSON.parse(response);
if (parsedResponse.success) {
// 避免重復(fù)調(diào)用合并接口
if (window.mergeCalled) {
console.log('合并已經(jīng)調(diào)用過,跳過');
return;
}
window.mergeCalled = true;
uploadStatus.innerHTML = '<div class="alert alert-info">所有分塊上傳成功,正在合并文件...</div>';
// 使用FormData發(fā)送合并請求
const formData = new FormData();
formData.append('identifier', file.uniqueIdentifier);
formData.append('filename', file.fileName);
formData.append('totalChunks', file.chunks.length);
axios.post(`${API_BASE_URL}/upload/merge`, formData)
.then(function(response) {
if (response.data.success) {
uploadStatus.innerHTML = `<div class="alert alert-success">文件上傳并合并成功!</div>`;
// 刷新文件列表
refreshFileList();
} else {
uploadStatus.innerHTML = `<div class="alert alert-danger">文件合并失敗: ${response.data.message}</div>`;
}
})
.catch(function(error) {
uploadStatus.innerHTML = `<div class="alert alert-danger">合并請求出錯: ${error.message}</div>`;
});
} else {
uploadStatus.innerHTML = `<div class="alert alert-danger">上傳失敗: ${parsedResponse.message}</div>`;
}
});
// 塊上傳錯誤事件
resumable.on('chunkUploadError', function(file, chunk, message) {
console.error('塊上傳錯誤:', chunk.offset, message);
uploadStatus.innerHTML = `<div class="alert alert-warning">塊 ${chunk.offset+1}/${file.chunks.length} 上傳失敗,系統(tǒng)將重試</div>`;
});
// 上傳錯誤事件
resumable.on('fileError', function(file, response) {
try {
const parsedResponse = JSON.parse(response);
uploadStatus.innerHTML = `<div class="alert alert-danger">上傳錯誤: ${parsedResponse.message || '未知錯誤'}</div>`;
} catch (e) {
uploadStatus.innerHTML = `<div class="alert alert-danger">上傳錯誤: ${response || '未知錯誤'}</div>`;
}
});
// 點(diǎn)擊上傳按鈕事件
uploadBtn.addEventListener('click', function() {
if (!resumable.files.length) {
uploadStatus.innerHTML = '<div class="alert alert-warning">請先選擇文件!</div>';
return;
}
uploadStatus.innerHTML = '<div class="alert alert-info">開始上傳...</div>';
resumable.upload();
});
// 獲取文件列表
function refreshFileList() {
axios.get(`${API_BASE_URL}/download/files`)
.then(function(response) {
if (response.data.length > 0) {
let html = '';
response.data.forEach(function(fileName) {
html += `
<div class="file-item">
<span>${fileName}</span>
</div>
`;
});
fileList.innerHTML = html;
} else {
fileList.innerHTML = '<div class="alert alert-info">沒有文件</div>';
}
})
.catch(function(error) {
fileList.innerHTML = `<div class="alert alert-danger">獲取文件列表失敗: ${error.message}</div>`;
});
}
// 刷新按鈕事件
refreshBtn.addEventListener('click', refreshFileList);
// 初始加載文件列表
document.addEventListener('DOMContentLoaded', function() {
refreshFileList();
});
</script>
</body>
</html>
四、核心實現(xiàn)原理詳解
文件分片:使用Resumable.js將大文件分割成多個小塊(默認(rèn)1MB),每塊單獨(dú)上傳
檢查已上傳部分:上傳前先調(diào)用/api/upload/check檢查服務(wù)器已保存的分片
斷點(diǎn)續(xù)傳流程:
- 文件的唯一標(biāo)識符由文件名和大小計算得出
- 服務(wù)端在臨時目錄下按標(biāo)識符創(chuàng)建文件夾存儲分片
- 上傳完成后調(diào)用合并接口,服務(wù)端將分片按順序合并
文件合并:服務(wù)端使用RandomAccessFile實現(xiàn)高效文件合并
五、效果演示

上傳文件一半后觸發(fā)終止

再次上傳文件

通過請求可以看到,紅色的8個文件塊 (32-39) 檢查(check)失敗后進(jìn)行了上傳(chunk),其他的已上傳 (1-31) 塊并沒有重新上傳。
最終結(jié)果

到此這篇關(guān)于SpringBoot實現(xiàn)文件斷點(diǎn)續(xù)傳功能詳解的文章就介紹到這了,更多相關(guān)SpringBoot文件斷點(diǎn)續(xù)傳內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- SpringBoot 中大文件(分片上傳)斷點(diǎn)續(xù)傳與極速秒傳功能的實現(xiàn)
- springboot大文件上傳、分片上傳、斷點(diǎn)續(xù)傳、秒傳的實現(xiàn)
- springboot整合vue2-uploader實現(xiàn)文件分片上傳、秒傳、斷點(diǎn)續(xù)傳功能
- SpringBoot基于Minio實現(xiàn)分片上傳、斷點(diǎn)續(xù)傳的實現(xiàn)
- springboot項目實現(xiàn)斷點(diǎn)續(xù)傳功能
- 在SpringBoot中實現(xiàn)斷點(diǎn)續(xù)傳的實例代碼
- springboot斷點(diǎn)上傳、續(xù)傳、秒傳實現(xiàn)方式
相關(guān)文章
淺談Java內(nèi)存區(qū)域與對象創(chuàng)建過程
下面小編就為大家?guī)硪黄獪\談Java內(nèi)存區(qū)域與對象創(chuàng)建過程。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-07-07
Java使用POI導(dǎo)出Excel(二):多個sheet
這篇文章介紹了Java使用POI導(dǎo)出Excel的方法,文中通過示例代碼介紹的非常詳細(xì)。對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-10-10
解決jasperreport導(dǎo)出的pdf每頁顯示的記錄太少問題
這篇文章主要介紹了解決jasperreport導(dǎo)出的pdf每頁顯示的記錄太少問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-06-06
Java實現(xiàn)Word/Pdf/TXT轉(zhuǎn)html的實例代碼
本文主要介紹了Java實現(xiàn)Word/Pdf/TXT轉(zhuǎn)html的實例代碼,代碼簡單易懂,非常不錯,具有一定的參考借鑒價值,需要的朋友可以參考下2020-02-02
Java內(nèi)部類_動力節(jié)點(diǎn)Java學(xué)院整理
內(nèi)部類是指在一個外部類的內(nèi)部再定義一個類。下面通過本文給大家java內(nèi)部類的使用小結(jié),需要的朋友參考下吧2017-04-04

