SpringBoot實(shí)現(xiàn)文件斷點(diǎn)續(xù)傳功能詳解
在處理大文件傳輸或網(wǎng)絡(luò)不穩(wěn)定的情況下,文件斷點(diǎn)續(xù)傳功能顯得尤為重要。本文將詳細(xì)介紹如何使用Spring Boot實(shí)現(xiàn)文件的斷點(diǎn)續(xù)傳功能,并提供完整的前后端代碼實(shí)現(xiàn)。
一、斷點(diǎn)續(xù)傳技術(shù)原理
斷點(diǎn)續(xù)傳的核心原理是將文件分片傳輸并記錄進(jìn)度,主要包括
- 客戶端將大文件分割成小塊逐一上傳
- 服務(wù)端保存已上傳塊信息
- 傳輸中斷后只需繼續(xù)傳未上傳部分
二、服務(wù)端代碼實(shí)現(xiàn)
項(xiàng)目依賴(lài)配置
<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
文件塊信息實(shí)體類(lèi)
public class FileChunkDTO { /** * 當(dāng)前文件塊,從1開(kāi)始 */ private Integer chunkNumber; /** * 分塊大小 */ private Long chunkSize; /** * 當(dāng)前分塊大小 */ private Long currentChunkSize; /** * 總大小 */ private Long totalSize; /** * 文件標(biāo)識(shí) */ private String identifier; /** * 文件名 */ private String filename; /** * 相對(duì)路徑 */ 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)類(lèi)
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; /** * 檢查文件是否已上傳過(guò) */ 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); } } } } // 清理臨時(shí)文件塊 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 // 允許跨域請(qǐng)求 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 包含逗號(hào),取第一個(gè)值 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); } }
三、前端實(shí)現(xiàn)
完整HTML頁(yè)面
<!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)聽(tīng)器 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">類(lèi)型: ${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]; } // 分塊開(kāi)始事件 resumable.on('chunkingStart', function(file) { console.log('開(kāi)始分塊:', 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('分塊完成'); }); // 上傳開(kāi)始事件 resumable.on('uploadStart', function() { console.log('開(kāi)始上傳'); uploadStatus.innerHTML = '<div class="alert alert-info">開(kāi)始上傳文件塊...</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)用過(guò),跳過(guò)'); return; } window.mergeCalled = true; uploadStatus.innerHTML = '<div class="alert alert-info">所有分塊上傳成功,正在合并文件...</div>'; // 使用FormData發(fā)送合并請(qǐng)求 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">合并請(qǐng)求出錯(cuò): ${error.message}</div>`; }); } else { uploadStatus.innerHTML = `<div class="alert alert-danger">上傳失敗: ${parsedResponse.message}</div>`; } }); // 塊上傳錯(cuò)誤事件 resumable.on('chunkUploadError', function(file, chunk, message) { console.error('塊上傳錯(cuò)誤:', chunk.offset, message); uploadStatus.innerHTML = `<div class="alert alert-warning">塊 ${chunk.offset+1}/${file.chunks.length} 上傳失敗,系統(tǒng)將重試</div>`; }); // 上傳錯(cuò)誤事件 resumable.on('fileError', function(file, response) { try { const parsedResponse = JSON.parse(response); uploadStatus.innerHTML = `<div class="alert alert-danger">上傳錯(cuò)誤: ${parsedResponse.message || '未知錯(cuò)誤'}</div>`; } catch (e) { uploadStatus.innerHTML = `<div class="alert alert-danger">上傳錯(cuò)誤: ${response || '未知錯(cuò)誤'}</div>`; } }); // 點(diǎn)擊上傳按鈕事件 uploadBtn.addEventListener('click', function() { if (!resumable.files.length) { uploadStatus.innerHTML = '<div class="alert alert-warning">請(qǐng)先選擇文件!</div>'; return; } uploadStatus.innerHTML = '<div class="alert alert-info">開(kāi)始上傳...</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">沒(méi)有文件</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>
四、核心實(shí)現(xiàn)原理詳解
文件分片:使用Resumable.js將大文件分割成多個(gè)小塊(默認(rèn)1MB),每塊單獨(dú)上傳
檢查已上傳部分:上傳前先調(diào)用/api/upload/check
檢查服務(wù)器已保存的分片
斷點(diǎn)續(xù)傳流程:
- 文件的唯一標(biāo)識(shí)符由文件名和大小計(jì)算得出
- 服務(wù)端在臨時(shí)目錄下按標(biāo)識(shí)符創(chuàng)建文件夾存儲(chǔ)分片
- 上傳完成后調(diào)用合并接口,服務(wù)端將分片按順序合并
文件合并:服務(wù)端使用RandomAccessFile
實(shí)現(xiàn)高效文件合并
五、效果演示
上傳文件一半后觸發(fā)終止
再次上傳文件
通過(guò)請(qǐng)求可以看到,紅色的8個(gè)文件塊 (32-39) 檢查(check)失敗后進(jìn)行了上傳(chunk),其他的已上傳 (1-31) 塊并沒(méi)有重新上傳。
最終結(jié)果
到此這篇關(guān)于SpringBoot實(shí)現(xiàn)文件斷點(diǎn)續(xù)傳功能詳解的文章就介紹到這了,更多相關(guān)SpringBoot文件斷點(diǎn)續(xù)傳內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- SpringBoot 中大文件(分片上傳)斷點(diǎn)續(xù)傳與極速秒傳功能的實(shí)現(xiàn)
- springboot大文件上傳、分片上傳、斷點(diǎn)續(xù)傳、秒傳的實(shí)現(xiàn)
- springboot整合vue2-uploader實(shí)現(xiàn)文件分片上傳、秒傳、斷點(diǎn)續(xù)傳功能
- SpringBoot基于Minio實(shí)現(xiàn)分片上傳、斷點(diǎn)續(xù)傳的實(shí)現(xiàn)
- springboot項(xiàng)目實(shí)現(xiàn)斷點(diǎn)續(xù)傳功能
- 在SpringBoot中實(shí)現(xiàn)斷點(diǎn)續(xù)傳的實(shí)例代碼
- springboot斷點(diǎn)上傳、續(xù)傳、秒傳實(shí)現(xiàn)方式
相關(guān)文章
淺談Java內(nèi)存區(qū)域與對(duì)象創(chuàng)建過(guò)程
下面小編就為大家?guī)?lái)一篇淺談Java內(nèi)存區(qū)域與對(duì)象創(chuàng)建過(guò)程。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-07-07三步輕松實(shí)現(xiàn)Java的SM2前端加密后端解密
SM2算法和RSA算法都是公鑰密碼算法,SM2算法是一種更先進(jìn)安全的算法,在我們國(guó)家商用密碼體系中被用來(lái)替換RSA算法,這篇文章主要給大家介紹了關(guān)于如何通過(guò)三步輕松實(shí)現(xiàn)Java的SM2前端加密后端解密的相關(guān)資料,需要的朋友可以參考下2024-01-01Java使用POI導(dǎo)出Excel(二):多個(gè)sheet
這篇文章介紹了Java使用POI導(dǎo)出Excel的方法,文中通過(guò)示例代碼介紹的非常詳細(xì)。對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-10-10解決jasperreport導(dǎo)出的pdf每頁(yè)顯示的記錄太少問(wèn)題
這篇文章主要介紹了解決jasperreport導(dǎo)出的pdf每頁(yè)顯示的記錄太少問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-06-06Java實(shí)現(xiàn)Word/Pdf/TXT轉(zhuǎn)html的實(shí)例代碼
本文主要介紹了Java實(shí)現(xiàn)Word/Pdf/TXT轉(zhuǎn)html的實(shí)例代碼,代碼簡(jiǎn)單易懂,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-02-02MyBatis工廠類(lèi)封裝與簡(jiǎn)化實(shí)現(xiàn)
工廠類(lèi)的目的是將對(duì)象的創(chuàng)建邏輯封裝在一個(gè)類(lèi)中,以便客戶端代碼無(wú)需了解具體的實(shí)現(xiàn)細(xì)節(jié),本文主要介紹了MyBatis工廠類(lèi)封裝與簡(jiǎn)化實(shí)現(xiàn),具有一定的參考價(jià)值,感興趣的可以了解一下2024-01-01Java內(nèi)部類(lèi)_動(dòng)力節(jié)點(diǎn)Java學(xué)院整理
內(nèi)部類(lèi)是指在一個(gè)外部類(lèi)的內(nèi)部再定義一個(gè)類(lèi)。下面通過(guò)本文給大家java內(nèi)部類(lèi)的使用小結(jié),需要的朋友參考下吧2017-04-04