基于JavaScript+SpringBoot實(shí)現(xiàn)大文件分片上傳
1. 前言
在很多 Web 應(yīng)用場(chǎng)景下,我們需要上傳體積很大的文件(視頻、鏡像包、數(shù)據(jù)包等)。一次性將整個(gè)文件上傳往往會(huì)面臨以下問(wèn)題:
- 網(wǎng)絡(luò)不穩(wěn)定時(shí)容易中斷:導(dǎo)致上傳失敗,需要重頭再來(lái)
- 服務(wù)器內(nèi)存/磁盤壓力大:一次性接收大文件可能瞬間占滿帶寬或?qū)憹M臨時(shí)目錄
- 用戶體驗(yàn)差:上傳過(guò)程中無(wú)法做到斷點(diǎn)續(xù)傳或重試
為了解決上述問(wèn)題,分片上傳(Chunked Upload)應(yīng)運(yùn)而生。它將大文件拆分成一個(gè)個(gè)小塊,按序上傳并在后臺(tái)合并,既可以實(shí)現(xiàn)斷點(diǎn)續(xù)傳,也能平滑流量、降低服務(wù)器壓力。
本文博主將帶著小伙伴了解如何基于 前端原生 JavaScript + Spring Boot 實(shí)現(xiàn)大文件分片上傳。
2. 為什么要分片
- 斷點(diǎn)續(xù)傳
每個(gè)分片上傳完成后都會(huì)得到確認(rèn),下次重試只需上傳未成功的分片,用戶體驗(yàn)更佳。 - 可控并發(fā)
前端可以設(shè)置并發(fā)上傳的分片數(shù)量(比如同時(shí) 3~5 個(gè)),既能提高吞吐量,又不至于瞬時(shí)壓垮網(wǎng)絡(luò)或服務(wù)器。 - 流量均衡
小塊數(shù)據(jù)平滑地傳輸,避免一次性大流量沖擊。 - 兼容性與安全
后端可對(duì)每個(gè)分片做校驗(yàn)(大小、哈希、格式等),在合并前即可過(guò)濾非法內(nèi)容。
分片上傳的核心優(yōu)勢(shì)
痛點(diǎn) | 分片方案 | 收益 |
---|---|---|
超時(shí)中斷 | 小片獨(dú)立上傳 | 避免整體失敗 |
內(nèi)存壓力 | 單片流式處理 | 內(nèi)存占用<10MB |
網(wǎng)絡(luò)波動(dòng) | 失敗分片重試 | 帶寬利用率提升40%+ |
大文件傳輸 | 并行上傳機(jī)制 | 速度提升3-5倍 |
意外中斷 | 斷點(diǎn)續(xù)傳支持 | 節(jié)省90%重復(fù)流量 |
3. 實(shí)現(xiàn)思路與流程
前端
用戶選中文件后,按固定大?。ㄈ?1MB)切片;
依次(或并發(fā))將每個(gè)分片通過(guò) fetch/XMLHttpRequest 上傳到后端;
上傳完所有分片后,通知后端開(kāi)始合并;
后端(Spring Boot)
接收每個(gè)分片時(shí),根據(jù)文件唯一標(biāo)識(shí)(如 MD5)與分片序號(hào),保存到臨時(shí)目錄;
接收 “合并請(qǐng)求” 時(shí),按序讀取所有分片并寫入最終文件;
合并完成后,可刪除臨時(shí)分片,返回成功。
4. 完整實(shí)現(xiàn)方案
1、前端分片邏輯實(shí)現(xiàn)
首先我們編寫前端的分片、上傳邏輯
<input type="file" id="largeFile"> <button onclick="startUpload()">開(kāi)始上傳</button> <div id="progressBar"></div> <script> async function startUpload() { const file = document.getElementById('largeFile').files[0]; if (!file) return; // 配置參數(shù) const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB分片 const TOTAL_CHUNKS = Math.ceil(file.size / CHUNK_SIZE); const FILE_ID = `${file.name}-${file.size}-${Date.now()}`; // 創(chuàng)建進(jìn)度跟蹤器 const uploadedChunks = new Set(); // 并行上傳控制(最大5并發(fā)) const parallelLimit = 5; let currentUploads = 0; let activeChunks = 0; for (let chunkIndex = 0; chunkIndex < TOTAL_CHUNKS; ) { if (currentUploads >= parallelLimit) { await new Promise(resolve => setTimeout(resolve, 500)); continue; } if (uploadedChunks.has(chunkIndex)) { chunkIndex++; continue; } currentUploads++; activeChunks++; const start = chunkIndex * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); uploadChunk(chunk, chunkIndex, FILE_ID, TOTAL_CHUNKS, file.name) .then(() => { uploadedChunks.add(chunkIndex); updateProgress(uploadedChunks.size, TOTAL_CHUNKS); }) .catch(err => console.error(`分片${chunkIndex}失敗:`, err)) .finally(() => { currentUploads--; activeChunks--; }); chunkIndex++; } // 檢查所有分片完成 const checkCompletion = setInterval(() => { if (activeChunks === 0 && uploadedChunks.size === TOTAL_CHUNKS) { clearInterval(checkCompletion); completeUpload(FILE_ID, file.name); } }, 1000); } async function uploadChunk(chunk, index, fileId, total, filename) { const formData = new FormData(); formData.append('file', chunk, filename); formData.append('chunkIndex', index); formData.append('totalChunks', total); formData.append('fileId', fileId); return fetch('/api/upload/chunk', { method: 'POST', body: formData }).then(res => { if (!res.ok) throw new Error('上傳失敗'); return res.json(); }); } async function completeUpload(fileId, filename) { return fetch('/api/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileId, filename }) }).then(res => { if (res.ok) alert('上傳成功!'); else alert('合并失敗'); }); } function updateProgress(done, total) { const percent = Math.round((done / total) * 100); document.getElementById('progressBar').innerHTML = ` <div style="width: ${percent}%; background: #4CAF50; height: 20px;"> ${percent}% </div> `; } </script>
2、SpringBoot后端實(shí)現(xiàn)
首先配置一下SpringBoot 上傳的一些限制
# application.yml spring: servlet: multipart: max-file-size: 10MB # 單片最大尺寸 max-request-size: 1000MB # 總請(qǐng)求限制 file: upload-dir: /data/upload
分片上傳控制器Controller
@RestController @RequestMapping("/api/upload") public class FileUploadController { @Value("${file.upload-dir}") // private String uploadDir; // 分片上傳接口 @PostMapping("/chunk") public ResponseEntity<?> uploadChunk( @RequestParam("file") MultipartFile file, @RequestParam("chunkIndex") int chunkIndex, @RequestParam("totalChunks") int totalChunks, @RequestParam("fileId") String fileId) { try { // 創(chuàng)建分片存儲(chǔ)目錄 String chunkDir = uploadDir + "/chunks/" + fileId; Path dirPath = Paths.get(chunkDir); if (!Files.exists(dirPath)) { Files.createDirectories(dirPath); } // 保存分片文件 String chunkFilename = chunkIndex + ".part"; Path filePath = dirPath.resolve(chunkFilename); Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); return ResponseEntity.ok().body(Map.of( "status", "success", "chunk", chunkIndex )); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "status", "error", "message", e.getMessage() )); } } // 合并文件接口 @PostMapping("/merge") public ResponseEntity<?> mergeChunks( @RequestBody MergeRequest request) { try { String fileId = request.getFileId(); String filename = request.getFilename(); Path chunkDir = Paths.get(uploadDir, "chunks", fileId); Path outputFile = Paths.get(uploadDir, filename); // 檢查分片完整性 long expectedChunks = Files.list(chunkDir).count(); if (expectedChunks != request.getTotalChunks()) { return ResponseEntity.badRequest().body( "分片數(shù)量不匹配"); } // 按序號(hào)排序分片 List<Path> chunks = Files.list(chunkDir) .sorted((p1, p2) -> { String f1 = p1.getFileName().toString(); String f2 = p2.getFileName().toString(); return Integer.compare( Integer.parseInt(f1.split("\\.")[0]), Integer.parseInt(f2.split("\\.")[0])); }) .collect(Collectors.toList()); // 合并文件 try (OutputStream out = Files.newOutputStream(outputFile, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { for (Path chunk : chunks) { Files.copy(chunk, out); } } // 清理分片目錄 FileUtils.deleteDirectory(chunkDir.toFile()); return ResponseEntity.ok().body(Map.of( "status", "success", "file", filename, "size", Files.size(outputFile) )); } catch (Exception e) { return ResponseEntity.status(500).body( "合并失敗: " + e.getMessage()); } } // 請(qǐng)求體定義 @Data public static class MergeRequest { private String fileId; private String filename; private int totalChunks; } }
3、擴(kuò)展斷點(diǎn)續(xù)傳
如果你的項(xiàng)目沒(méi)有斷點(diǎn)續(xù)傳的需求,可以直接參考 ? ?前后端代碼即可,否則可以在分片上傳接口中添加續(xù)傳支持,增加代碼如下:
// 在分片上傳接口中添加續(xù)傳支持 @GetMapping("/check") public ResponseEntity<?> checkChunks( @RequestParam("fileId") String fileId, @RequestParam("totalChunks") int totalChunks) { Path chunkDir = Paths.get(uploadDir, "chunks", fileId); if (!Files.exists(chunkDir)) { return ResponseEntity.ok().body(Map.of( "exists", false )); } try { // 獲取已上傳分片索引 Set<Integer> uploaded = Files.list(chunkDir) .map(p -> Integer.parseInt( p.getFileName().toString().split("\\.")[0])) .collect(Collectors.toSet()); return ResponseEntity.ok().body(Map.of( "exists", true, "uploaded", uploaded )); } catch (IOException e) { return ResponseEntity.status(500).body( "檢查失敗: " + e.getMessage()); } }
前端調(diào)用檢查接口:
async function checkUploadStatus(fileId, totalChunks) { const res = await fetch(`/api/upload/check?fileId=${fileId}&totalChunks=${totalChunks}`); const data = await res.json(); return data.exists ? data.uploaded : new Set(); } // 在上述前端代碼 startUpload函數(shù)中加入 const uploadedChunks = await checkUploadStatus(FILE_ID, TOTAL_CHUNKS);
5. 高級(jí)優(yōu)化方案
通過(guò)上面的代碼示例,你已經(jīng)可以輕松使用大文件的分片上傳了,如果你還有一些優(yōu)化需求,博主這里簡(jiǎn)單羅列三個(gè),供小伙伴們參考
5.1 分片秒傳優(yōu)化
// 在保存分片前計(jì)算哈希 String hash = DigestUtils.md5DigestAsHex(file.getBytes()); String chunkFilename = hash + ".part"; // 哈希作為文件名 // 檢查是否已存在相同分片 if (Files.exists(dirPath.resolve(chunkFilename))) { return ResponseEntity.ok().body(Map.of( "status", "skip", "chunk", chunkIndex )); }
5.2 并行合并加速
// 使用并行流合并文件 List<Path> chunks = ... // 排序后的分片列表 try (OutputStream out = Files.newOutputStream(outputFile)) { chunks.parallelStream().forEach(chunk -> { try { Files.copy(chunk, out); } catch (IOException e) { throw new UncheckedIOException(e); } }); }
5.3 安全增強(qiáng)措施
// 文件名安全過(guò)濾 String safeFilename = filename.replaceAll("[^a-zA-Z0-9\\.\\-]", "_"); // 文件類型檢查 String mimeType = Files.probeContentType(filePath); if (!mimeType.startsWith("video/")) { throw new SecurityException("非法文件類型"); }
結(jié)語(yǔ):構(gòu)建可靠的大文件傳輸體系
本文示例演示了一個(gè)從前端分片、并發(fā)上傳,到后端按序存儲(chǔ)與合并的完整流程。并可以按需提供斷點(diǎn)續(xù)傳,以及部分優(yōu)化的方案參考,這樣我們就提高大文件上傳的穩(wěn)定性與用戶體驗(yàn)。
通過(guò)本文實(shí)現(xiàn)的分片上傳方案,我們成功解決了大文件傳輸?shù)暮诵奶魬?zhàn):
- 穩(wěn)定性提升:分片機(jī)制有效規(guī)避了網(wǎng)絡(luò)波動(dòng)影響
- 資源優(yōu)化:內(nèi)存占用從GB級(jí)降至MB級(jí)
- 用戶體驗(yàn):進(jìn)度可視化 + 斷點(diǎn)續(xù)傳
- 擴(kuò)展能力:秒傳、并行合并等優(yōu)化空間
以上就是基于JavaScript+SpringBoot實(shí)現(xiàn)大文件分片上傳的詳細(xì)內(nèi)容,更多關(guān)于JavaScript SpringBoot文件分片上傳的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
判斷是否存在子節(jié)點(diǎn)的實(shí)現(xiàn)代碼
下面小編就為大家?guī)?lái)一篇判斷是否存在子節(jié)點(diǎn)的實(shí)現(xiàn)代碼。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2016-05-05利用ajaxfileupload插件實(shí)現(xiàn)文件上傳無(wú)刷新的具體方法
利用ajaxfileupload插件實(shí)現(xiàn)文件上傳無(wú)刷新的具體方法,需要的朋友可以參考一下2013-06-06JavaScript SHA1加密算法實(shí)現(xiàn)詳細(xì)代碼
這篇文章主要為大家詳細(xì)介紹了JavaScript SHA1加密算法實(shí)現(xiàn)代碼,具有一定的參考價(jià)值,感興趣的朋友可以參考一下2016-10-10通過(guò)javascript進(jìn)行UTF-8編碼的實(shí)現(xiàn)方法
下面小編就為大家?guī)?lái)一篇通過(guò)javascript進(jìn)行UTF-8編碼的實(shí)現(xiàn)方法。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2016-06-06JavaScript中this關(guān)鍵字用法實(shí)例分析
這篇文章主要介紹了JavaScript中this關(guān)鍵字用法,結(jié)合實(shí)例形式總結(jié)分析了javascript中this關(guān)鍵字在不同條件下的指向問(wèn)題與相關(guān)操作技巧,需要的朋友可以參考下2018-08-08