分片上傳則是將一個大文件分割成多個小塊分別上傳,最后再由服務器合并成完整的文件。這種做法的好處是可以并行處理多個小文件,提高上傳效率;同時,如果某一部分上傳失敗,只需要重傳這一部分,不影響其他部分。
初步實現
后端代碼
/*** 分片上傳** @param file 上傳的文件* @param start 文件開始上傳的位置* @param fileName 文件名稱* @return 上傳結果*/
@PostMapping("/fragmentUpload")
@ResponseBody
public AjaxResult fragmentUpload(@RequestParam("file") MultipartFile file, @RequestParam("start") long start, @RequestParam("fileName") String fileName) {try {// 檢查上傳目錄是否存在,如果不存在則創建File directory = new File(uploadPath);if (!directory.exists()) {directory.mkdirs();}// 設置上傳文件的目標路徑File targetFile = new File(uploadPath +File.separator+ fileName);// 創建 RandomAccessFile 對象以便進行文件的隨機讀寫操作RandomAccessFile randomAccessFile = new RandomAccessFile(targetFile, "rw");// 獲取 RandomAccessFile 對應的 FileChannelFileChannel channel = randomAccessFile.getChannel();// 設置文件通道的位置,即從哪里開始寫入文件內容channel.position(start);// 從 MultipartFile 對象的資源通道中讀取文件內容,并寫入到指定位置channel.transferFrom(file.getResource().readableChannel(), start, file.getSize());// 關閉文件通道和 RandomAccessFile 對象channel.close();randomAccessFile.close();// 返回上傳成功的響應return AjaxResult.success("上傳成功");} catch (Exception e) {// 捕獲異常并返回上傳失敗的響應return AjaxResult.error("上傳失敗");}
}/*** 檢測文件是否存在* 如果文件存在,則返回已經存在的文件大小。* 如果文件不存在,則返回 0,表示前端從頭開始上傳該文件。* @param filename* @return*/
@GetMapping("/checkFile")
@ResponseBody
public AjaxResult checkFile(@RequestParam("filename") String filename) {File file = new File(uploadPath+File.separator + filename);if (file.exists()) {return AjaxResult.success(file.length());} else {return AjaxResult.success(0L);}
}
前端
var prefix = ctx + "/kuroshiro/file-upload";// 每次上傳大小
const chunkSize = 1 * 1024 * 1024;/*** 開始上傳*/
function startUpload(type) {const fileInput = document.getElementById('fileInput');const file = fileInput.files[0];if (!file) {alert("請選擇文件");return;}if(type == 1){checkFile(filename).then(start => {uploadFile(file, start,Math.min(start + chunkSize, file.size));})}
}/*** 檢查是否上傳過* @param filename* @returns {Promise<unknown>}*/
function checkFile(filename) {return $fetch(prefix+`/checkFile?filename=${filename}`);
}/*** 開始分片上傳* @param file 文件* @param start 開始位置* @param end 結束位置*/
function uploadFile(file, start,end) {if(start < end){const chunk = file.slice(start, end);const formData = new FormData();formData.append('file', chunk);formData.append('start', start);formData.append('fileName', file.name);$fetch(prefix+'/fragmentUpload', {method: 'POST',body: formData}).then(response => {console.log(`分片 ${start} - ${end} 上傳成功`);// 遞歸調用uploadFile(file,end,Math.min(end + chunkSize, file.size))})}}function $fetch(url,requestInit){return new Promise((resolve, reject) => {fetch(url,requestInit).then(response => {if (!response.ok) {throw new Error('請求失敗');}return response.json();}).then(data => {if (data.code === 0) {resolve(data.data);} else {console.error(data.msg);reject(data.msg)}}).catch(error => {console.error(error);reject(error)});});}
以上雖然實現的分片上傳,但是它是某種意義上來說還是與整體上傳差不多,它是一段一段的上傳,某段上傳失敗后,后續的就不會再繼續上傳;不過比起整體上傳來說,它會保存之前上傳的內容,下一個上傳時,從之前上傳的位置接著上傳。不用整體上傳。下面進行優化。
優化
首先,之前的分片上傳,后端是直接寫入了一個文件中了,所以只能順序的上傳寫入,雖然可以保存上傳出錯之前的內容,但是整體上看來是速度也不行。
優化邏輯:把分片按順序單獨保存下來,等到所有分片都上傳成功后,把所有分片合并成文件。這樣上傳的時候就不用等著上一個上傳成功才上傳下一個了。
后端代碼
/**
* 分片上傳* @param file 文件* @param chunkIndex 分片下標*/
@PostMapping("/uploadChunk")
@ResponseBody
public AjaxResult uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("chunkIndex") int chunkIndex,@RequestParam("fileName") String fileName) {String uploadDirectory = chunkUploadPath+File.separator+fileName;File directory = new File(uploadDirectory);if (!directory.exists()||directory.isFile()) {directory.mkdirs();}String filePath = uploadDirectory + File.separator + fileName+ "_" + chunkIndex;try (OutputStream os = new FileOutputStream(filePath)) {os.write(file.getBytes());return AjaxResult.success("分片"+(chunkIndex+1)+"上傳成功");}catch (Exception e){// 保存失敗后如果文件建立了就刪除,下次上傳時重新保存,避免文件內容錯誤File chunkFile = new File(filePath);if(chunkFile.exists()) chunkFile.delete();e.printStackTrace();return AjaxResult.error("分片"+(chunkIndex+1)+"上傳失敗");}}/*** 檢測分片是否存在* 如果文件存在,則返回已經存在的分片下標集合。存在的就不上傳* 如果文件不存在,則返回空集合,表示前端從頭開始上傳該文件* @param fileName* @return*/
@GetMapping("/checkChunk")
@ResponseBody
public AjaxResult checkChunk(@RequestParam("fileName") String fileName) {String uploadDirectory = chunkUploadPath+File.separator+fileName;List<Integer> list = new ArrayList<>();File file = new File(uploadDirectory);// 文件目錄不存在if(!file.exists()||file.isFile()) return AjaxResult.success(list);File[] files = file.listFiles();// 文件目錄下沒有分片文件if(files == null) return AjaxResult.success(list);// 返回存在分片下標集合return AjaxResult.success(Arrays.stream(files).map(item->Integer.valueOf(item.getName().substring(item.getName().lastIndexOf("_")+1))).collect(Collectors.toList()));
}// 合并文件分片@PostMapping("/mergeChunks")@ResponseBodypublic AjaxResult mergeChunks(@RequestParam("fileName") String fileName, @RequestParam("totalChunks") int totalChunks) {String uploadDirectory = chunkUploadPath+File.separator+fileName;String mergedFilePath = uploadPath +File.separator+ fileName;try (OutputStream os = new FileOutputStream(mergedFilePath, true)) {for (int i = 0; i < totalChunks; i++) {Path chunkFilePath = Paths.get(uploadDirectory +File.separator+ fileName + "_" + i);Files.copy(chunkFilePath, os);Files.delete(chunkFilePath);}return AjaxResult.success();}catch (Exception e){e.printStackTrace();return AjaxResult.error(e.getMessage());}}
前端代碼
/*** 開始上傳*/
function startUpload(type) {const fileInput = document.getElementById('fileInput');const file = fileInput.files[0];if (!file) {alert("請選擇文件");return;}const filename = file.name;if(type == 1){checkFile(filename).then(start => {uploadFile(file, start,Math.min(start + chunkSize, file.size));})}if(type == 2){checkChunk(filename).then(arr => {uploadChunk(file, arr);})}
}/**
* 切割文件為多個分片
* @param file
* @returns {*[]}
*/
function sliceFile(file) {const chunks = [];let offset = 0;while (offset < file.size) {const chunk = file.slice(offset, offset + chunkSize);chunks.push(chunk);offset += chunkSize;}return chunks;
}
/**
* 檢查是否上傳過
* @param filename
* @returns {Promise<unknown>}
*/
function checkChunk(filename) {return $fetch(prefix+`/checkChunk?fileName=${filename}`);
}/**
* 開始分片上傳
* @param file 文件
* @param exists 存在的分片下標
*/
function uploadChunk(file,exists) {const chunkArr = sliceFile(file);Promise.all(chunkArr.map((chunk, index) => {if(!exists.includes(index)){const formData = new FormData();formData.append('file', chunk);formData.append('fileName', file.name);formData.append('chunkIndex', index);return $fetch(prefix+'/uploadChunk', {method: 'POST',body: formData});}})).then(uploadRes=> {// 合并分片const formData = new FormData();formData.append('fileName', file.name);formData.append('totalChunks', chunkArr.length);$fetch(prefix + '/mergeChunks', {method: 'POST',body:formData,}).then(mergeRes=>{console.log("合并成功")});});
}
以上優化后所有分片可以同時上傳,所有分片上傳都成功后進行合并。
最后是完整代碼
@Controller()
@RequestMapping("/kuroshiro/file-upload")
public class FileUploadController {private String prefix = "kuroshiro/fragmentUpload";// 文件保存目錄private final String uploadPath = RuoYiConfig.getUploadPath();// 分片保存目錄private final String chunkUploadPath = uploadPath+File.separator+"chunks";/*** demo* @return*/@GetMapping("/demo")public String demo() {return prefix+"/demo";}/*** 分片上傳** @param file 上傳的文件* @param start 文件開始上傳的位置* @param fileName 文件名稱* @return 上傳結果*/@PostMapping("/fragmentUpload")@ResponseBodypublic AjaxResult fragmentUpload(@RequestParam("file") MultipartFile file, @RequestParam("start") long start, @RequestParam("fileName") String fileName) {try {// 檢查上傳目錄是否存在,如果不存在則創建File directory = new File(uploadPath);if (!directory.exists()) {directory.mkdirs();}// 設置上傳文件的目標路徑File targetFile = new File(uploadPath +File.separator+ fileName);// 創建 RandomAccessFile 對象以便進行文件的隨機讀寫操作RandomAccessFile randomAccessFile = new RandomAccessFile(targetFile, "rw");// 獲取 RandomAccessFile 對應的 FileChannelFileChannel channel = randomAccessFile.getChannel();// 設置文件通道的位置,即從哪里開始寫入文件內容channel.position(start);// 從 MultipartFile 對象的資源通道中讀取文件內容,并寫入到指定位置channel.transferFrom(file.getResource().readableChannel(), start, file.getSize());// 關閉文件通道和 RandomAccessFile 對象channel.close();randomAccessFile.close();// 返回上傳成功的響應return AjaxResult.success("上傳成功");} catch (Exception e) {// 捕獲異常并返回上傳失敗的響應return AjaxResult.error("上傳失敗");}}/*** 檢測文件是否存在* 如果文件存在,則返回已經存在的文件大小。* 如果文件不存在,則返回 0,表示前端從頭開始上傳該文件。* @param filename* @return*/@GetMapping("/checkFile")@ResponseBodypublic AjaxResult checkFile(@RequestParam("filename") String filename) {File file = new File(uploadPath+File.separator + filename);if (file.exists()) {return AjaxResult.success(file.length());} else {return AjaxResult.success(0L);}}/*** 分片上傳* @param file 文件* @param chunkIndex 分片下標*/@PostMapping("/uploadChunk")@ResponseBodypublic AjaxResult uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("chunkIndex") int chunkIndex,@RequestParam("fileName") String fileName) {String uploadDirectory = chunkUploadPath+File.separator+fileName;File directory = new File(uploadDirectory);if (!directory.exists()||directory.isFile()) {directory.mkdirs();}String filePath = uploadDirectory + File.separator + fileName+ "_" + chunkIndex;try (OutputStream os = new FileOutputStream(filePath)) {os.write(file.getBytes());return AjaxResult.success("分片"+(chunkIndex+1)+"上傳成功");}catch (Exception e){// 保存失敗后如果文件建立了就刪除,下次上傳時重新保存,避免文件內容錯誤File chunkFile = new File(filePath);if(chunkFile.exists()) chunkFile.delete();e.printStackTrace();return AjaxResult.error("分片"+(chunkIndex+1)+"上傳失敗");}}/*** 檢測分片是否存在* 如果文件存在,則返回已經存在的分片下標集合。存在的就不上傳* 如果文件不存在,則返回空集合,表示前端從頭開始上傳該文件* @param fileName* @return*/@GetMapping("/checkChunk")@ResponseBodypublic AjaxResult checkChunk(@RequestParam("fileName") String fileName) {String uploadDirectory = chunkUploadPath+File.separator+fileName;List<Integer> list = new ArrayList<>();File file = new File(uploadDirectory);// 文件目錄不存在if(!file.exists()||file.isFile()) return AjaxResult.success(list);File[] files = file.listFiles();// 文件目錄下沒有分片文件if(files == null) return AjaxResult.success(list);// 返回存在分片下標集合return AjaxResult.success(Arrays.stream(files).map(item->Integer.valueOf(item.getName().substring(item.getName().lastIndexOf("_")+1))).collect(Collectors.toList()));}// 合并文件分片@PostMapping("/mergeChunks")@ResponseBodypublic AjaxResult mergeChunks(@RequestParam("fileName") String fileName, @RequestParam("totalChunks") int totalChunks) {String uploadDirectory = chunkUploadPath+File.separator+fileName;String mergedFilePath = uploadPath +File.separator+ fileName;try (OutputStream os = new FileOutputStream(mergedFilePath, true)) {for (int i = 0; i < totalChunks; i++) {Path chunkFilePath = Paths.get(uploadDirectory +File.separator+ fileName + "_" + i);Files.copy(chunkFilePath, os);Files.delete(chunkFilePath);}File chunkDir = new File(uploadDirectory);if (chunkDir.exists()) chunkDir.delete();return AjaxResult.success();}catch (Exception e){e.printStackTrace();return AjaxResult.error(e.getMessage());}}}
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org" xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
<head><th:block th:include="include :: header('分片上傳')" />
</head>
<body class="gray-bg">
<div class="container-div" id="chunk-div"><div class="row"><div class="col-sm-12 search-collapse"><form id="formId"><div class="select-list"><ul><li><label>選擇文件:</label><input type="file" id="fileInput"/></li><li><a class="btn btn-primary btn-rounded btn-sm" @click="startUpload(1)"><i class="fa fa-upload"></i> 開始上傳1</a><a class="btn btn-primary btn-rounded btn-sm" @click="startUpload(2)"><i class="fa fa-upload"></i> 開始上傳2</a></li></ul></div></form></div><div class="col-sm-12" style="padding-left: 0;"><div class="ibox"><div class="ibox-content"><h3>上傳進度</h3><ul class="sortable-list connectList agile-list" v-if="uploadMsg && uploadMsg.length>0"><li v-for="item in uploadMsg" :class="item.status+'-element'">{{item.title}}</li></ul></div></div></div></div>
</div>
<th:block th:include="include :: footer" />
<script th:inline="javascript">var prefix = ctx + "/kuroshiro/file-upload";new Vue({el: '#chunk-div',data: {// 每次上傳大小chunkSize: 1 * 1024 * 1024,uploadMsg:[],},methods: {/*** 開始上傳*/startUpload: function(type){const fileInput = document.getElementById('fileInput');const file = fileInput.files[0];if (!file) {alert("請選擇文件");return;}const filename = file.name;this.uploadMsg = [];if(type == 1){this.checkFile(filename).then(start => {this.uploadFile(file, start,Math.min(start + this.chunkSize, file.size));},err => {this.uploadMsg.push({title:`文件檢測失敗失敗:${err}`,status:"danger"});})}if(type == 2){this.checkChunk(filename).then(arr => {this.uploadChunk(file, arr);},err => {this.uploadMsg.push({title:`文件檢測失敗失敗:${err}`,status:"danger"});})}},/*** 檢查是否上傳過* @param filename* @returns {Promise<unknown>}*/checkFile: function(filename) {return this.$fetch(prefix+`/checkFile?filename=${filename}`);},/*** 開始分片上傳* @param file 文件* @param start 開始位置* @param end 結束位置*/uploadFile: function(file, start,end) {if(start < end){const chunk = file.slice(start, end);const formData = new FormData();formData.append('file', chunk);formData.append('start', start);formData.append('fileName', file.name);this.$fetch(prefix+'/fragmentUpload', {method: 'POST',body: formData}).then(response => {this.uploadMsg.push({title:`分片 ${start} - ${end} 上傳成功`,status:"info"});// 遞歸調用this.uploadFile(file,end,Math.min(end + this.chunkSize, file.size))},err=>{this.uploadMsg.push({title:`分片 ${start} - ${end} 上傳失敗:${err}`,status:"danger"});})}else{this.uploadMsg.push({title:`文件已上傳`,status:"info"});}},/*** 切割文件為多個分片* @param file* @returns {*[]}*/sliceFile: function(file) {const chunks = [];let offset = 0;while (offset < file.size) {const chunk = file.slice(offset, offset + this.chunkSize);chunks.push(chunk);offset += this.chunkSize;}return chunks;},/*** 檢查是否上傳過* @param filename* @returns {Promise<unknown>}*/checkChunk: function(filename) {return this.$fetch(prefix+`/checkChunk?fileName=${filename}`);},/*** 開始分片上傳* @param file 文件* @param exists 存在的分片下標*/uploadChunk: function(file,exists) {const chunkArr = this.sliceFile(file);Promise.all(chunkArr.map((chunk, index) => {if(!exists.includes(index)){const formData = new FormData();formData.append('file', chunk);formData.append('fileName', file.name);formData.append('chunkIndex', index);return new Promise((resolve, reject) => {this.$fetch(prefix+'/uploadChunk', {method: 'POST',body: formData}).then(res => {resolve(res)this.uploadMsg.push({title:`分片 ${index+1} 上傳成功`,status:"info"});},err => {reject(err)this.uploadMsg.push({title:`分片 ${index+1} 上傳失敗:${err}`,status:"danger"});});})}})).then(uploadRes=> {// 合并分片const formData = new FormData();formData.append('fileName', file.name);formData.append('totalChunks', chunkArr.length);this.$fetch(prefix + '/mergeChunks', {method: 'POST',body:formData,}).then(mergeRes=>{this.uploadMsg.push({title:`合并成功`,status:"info"});},err => {this.uploadMsg.push({title:`合并失敗:${err}`,status:"danger"});});});},$fetch: function(url,requestInit){return new Promise((resolve, reject) => {fetch(url,requestInit).then(response => {if (!response.ok) {throw new Error('請求失敗');}return response.json();}).then(data => {if (data.code === 0) {resolve(data.data);} else {reject(data.msg)}}).catch(error => {reject(error)});});},}});</script>
</body>
</html>