前言
前几天看了一篇关于大文件上传分块实现的博客,代码实现过于复杂且冗长,而且没有进行外网上传的测试。因此,我决定自己动手实现一个大文件上传,并进行优化。
实现思路
在许多应用中,大文件上传是常见的需求,比如上传视频、图片、日志文件等。常见的上传方法往往会受到文件大小和网络环境的限制,导致上传失败或上传速度慢。例如我们通过spring boot直接上传1GB的大文件,如果直接进行上传那么会特别的慢,同时也很大可能会失败。特别是如果1GB的大文件快上传成功了,出现网络波动。那么要进行重新上传。这显然不是我们需要的。
为了提高上传的效率和稳定性,我们可以采用分块上传的方式。
实现原理
java">(1)将大文件分成多个小块:通过设定每个块的大小(例如,10MB),将大文件拆分为多个小文件块。
(2)逐块上传:每次上传一个文件块,直到文件上传完毕。
(3)服务器接收并合并文件块:服务器端接收每个文件块并在所有块上传完成后合并成一个完整的文件。
原理很简单,就是将一个大文件分成多个小块逐次上传,最后合并。这个过程与数据导出类似:如果要导出数百万条数据,通常也会采用分页的方式,每次查询一部分数据,分批导出。
上传文件信息
我看到的博主将文件信息与分块上传放在同一个接口中,但这样每次上传都会传递重复的文件数据。为避免这种情况,我将文件的基础信息单独提取出来,只在第一次上传时保存,以减少冗余数据传输。
java"> @PostMapping(value = "saveFileMetadata")public ResponseEntity<String> saveFileMetadata(@RequestBody FileMetadata fileMetadata) {log.info("fileMetadata文件名:{}", fileMetadata.getFileName());fileMetadata.setFileName(FileChunkUtil.generateFileName(fileMetadata.getFileName()));fileMetadata.setChunksUploaded(new boolean[fileMetadata.getChunkCount()]);FileMetadataRepository.put(fileMetadata.getFileName(), fileMetadata);log.info("返回fileMetadata文件名:{}", fileMetadata.getFileName());// 设置响应的Content-Type为UTF-8编码return ResponseEntity.ok().header("Content-Type", "application/json;charset=UTF-8").body(fileMetadata.getFileName());}
chunksUploaded 文件上传状态,是将每个分块的上传状态做个标识。分块上传成功,更新该分块索引标识。只有所有分块都上传成功,才能进行分块合并。
java">public class FileMetadata {/*** 文件的大小(单位:字节)*/private Long fileSize;/*** 文件名*/private String fileName;/*** 文件的总块数*/private Integer chunkCount;/*** 文件块的上传状态* 每个元素表示对应块是否上传完成*/private boolean[] chunksUploaded;
}
分块上传
这边分块上传将文件分割,然后再上传。
客户端实现
java">@Testpublic void fileUploadChunkTest() throws IOException {final StopWatch stopWatch = new StopWatch("fileUploadTest");stopWatch.start("saveFileMetadata");File file = new File("C:\\Newand\\file\\上传测试文件\\1GB文件.zip"); // 输入文件路径int chunkSizeInBytes = 10 * 1024 * 1024; // 每个分片的大小为10MB// 获取文件大小long fileSize = file.length();final RestTemplate restTemplate = new RestTemplate();final String resultFileName = saveFileMetadata(file, chunkSizeInBytes, fileSize);log.info(resultFileName);stopWatch.stop();stopWatch.start("chunk");// 打开输入流try (FileInputStream fis = new FileInputStream(file)) {byte[] buffer = new byte[chunkSizeInBytes];int chunkNumber = 0;// 循环读取文件并创建分片while (fis.read(buffer) != -1) {String chunkFileName = file.getName() + ".part" + chunkNumber;File chunkFile = new File(file.getParent(), chunkFileName);// 将当前分片写入磁盘try (FileOutputStream fos = new FileOutputStream(chunkFile)) {fos.write(buffer);}// 上传MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();body.add("file", new FileSystemResource(chunkFile));body.add("chunkNumber", chunkNumber);body.add("filename", resultFileName);HttpHeaders headers = new HttpHeaders();headers.setContentType(MediaType.MULTIPART_FORM_DATA);HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);String serverUrl = "http://localhost:8099/file/chunk";ResponseEntity<String> response = restTemplate.postForEntity(serverUrl, requestEntity, String.class);System.out.println("Response code: " + response.getStatusCode() + " Response body: " + response.getBody());// 清空缓冲区,以便下次读取buffer = new byte[(int) chunkSizeInBytes];chunkNumber++;}}stopWatch.stop();stopWatch.start("merge");final String result2 = restTemplate.getForObject("http://localhost:8099/file/merge?filename=" + resultFileName, String.class);System.out.println("合并完成:" + result2);stopWatch.stop();System.out.println("用时:" + stopWatch.prettyPrint(TimeUnit.MILLISECONDS));}private String saveFileMetadata(File file, int chunkSizeInBytes, long fileSize) {final long chunkCount = (fileSize + chunkSizeInBytes - 1) / chunkSizeInBytes;final JSONObject jsonObject = new JSONObject();jsonObject.put("fileSize", fileSize);jsonObject.put("fileName", file.getName());jsonObject.put("chunkCount", chunkCount);final String resultFileName = new RestTemplate().postForObject("http://localhost:8099/file/saveFileMetadata", jsonObject, String.class);return resultFileName;}
服务端实现
控制层
java">package com.ji.helper.controller.file;import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;/*** 分片上传* @author jisl on 2025/1/24 14:40**/
@RestController
@RequestMapping("/file")
@Slf4j
public class FileController {@PostMapping(value = "saveFileMetadata")public ResponseEntity<String> saveFileMetadata(@RequestBody FileMetadata fileMetadata) {log.info("fileMetadata文件名:{}", fileMetadata.getFileName());fileMetadata.setFileName(FileChunkUtil.generateFileName(fileMetadata.getFileName()));fileMetadata.setChunksUploaded(new boolean[fileMetadata.getChunkCount()]);FileMetadataRepository.put(fileMetadata.getFileName(), fileMetadata);log.info("返回fileMetadata文件名:{}", fileMetadata.getFileName());// 设置响应的Content-Type为UTF-8编码return ResponseEntity.ok().header("Content-Type", "application/json;charset=UTF-8").body(fileMetadata.getFileName());}/*** 分块上传文件** @param chunk 文件块信息* @return 响应*/@PostMapping(value = "/chunk")public ResponseEntity<String> chunk(FileUploadChunk chunk) {log.info("分块文件:" + chunk.getFilename());FileChunkUtil.saveFile(chunk);return ResponseEntity.ok("File Chunk Upload Success");}/*** 文件合并** @param filename 文件名* @return 响应*/@GetMapping(value = "merge")public ResponseEntity<Void> merge(@RequestParam String filename) {log.info("merge文件名:{}", filename);FileChunkUtil.merge(filename);return ResponseEntity.ok().build();}// /**
// * 获取指定文件
// *
// * @param filename 文件名称
// * @return 文件
// */
// @GetMapping("/files/{filename:.+}")
// public ResponseEntity<String> getFile(@PathVariable("filename") String filename) {
// return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,
// "attachment; filename=\"" + filename + "\"").body("");
// }
}
文件工具类
java">package com.ji.helper.controller.file;import cn.hutool.core.date.DateTime;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ArrayUtil;
import lombok.extern.slf4j.Slf4j;import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;/*** @author jisl on 2025/1/24 14:41**/
@Slf4j
public class FileChunkUtil {public static final String LOCAL_ROOT_DICT = "C:/Newand/file/data";public static final String ROOT_DICT = "/data/fileUpload";public static void saveFile(FileUploadChunk chunk) {final String tmpFilePath = getTmpFilePath(chunk.getFilename(), chunk.getChunkNumber());log.info("目标文件路径:{}", tmpFilePath);final FileMetadata fileMetadata = FileMetadataRepository.get(chunk.getFilename());try {// 创建目标文件对象File dest = new File(Paths.get(tmpFilePath).toString());checkAndCreateParentDir(dest);chunk.getFile().transferTo(dest);log.info("保存文件执行完毕");} catch (IOException e) {log.error("保存文件失败:", e);throw new RuntimeException("保存文件失败:" + e.getMessage());}
// 设置该块文件 为已上传fileMetadata.getChunksUploaded()[chunk.getChunkNumber()] = true;}private static void checkAndCreateParentDir(File dest) {// 检查父目录是否存在,如果不存在则创建File parentDir = dest.getParentFile();if (parentDir != null && !parentDir.exists()) {parentDir.mkdirs(); // 创建父目录}}public static String generateFileName(String fileName) {return new DateTime().toString("yyyyMMdd") + File.separator + fileName;}public static void merge(String filename) {final FileMetadata fileMetadata = FileMetadataRepository.get(filename);Assert.isFalse(ArrayUtil.contains(fileMetadata.getChunksUploaded(), false));File mergeFile = new File(getRootDict(), fileMetadata.getFileName());if (mergeFile.exists()) {mergeFile.delete();}// 检查父目录是否存在,如果不存在则创建checkAndCreateParentDir(mergeFile);try (BufferedOutputStream outputStream = new BufferedOutputStream(Files.newOutputStream(mergeFile.toPath()))) {for (int i = 0; i < fileMetadata.getChunkCount(); i++) {File chunkFile = new File(getTmpFilePath(fileMetadata.getFileName(), i));Files.copy(chunkFile.toPath(), outputStream);}} catch (IOException e) {log.info("File [{}] merge failed", filename, e);throw new RuntimeException(e);}}private static String getTmpFilePath(String fileName, int chunkNumber) {return getRootDict() + File.separator + "tmp" + File.separator + fileName + ".part" + chunkNumber;}private static boolean isWin() {String os = System.getProperty("os.name");return os.toLowerCase().startsWith("win");}private static String getRootDict() {return isWin() ? LOCAL_ROOT_DICT : ROOT_DICT;}}
文件信息类
java">package com.ji.helper.controller.file;import lombok.Data;@Data
public class FileMetadata {/*** 文件的大小(单位:字节)*/private Long fileSize;/*** 文件名*/private String fileName;/*** 文件的总块数*/private Integer chunkCount;/*** 文件块的上传状态* 每个元素表示对应块是否上传完成*/private boolean[] chunksUploaded;
}
文件类
java">package com.ji.helper.controller.file;import lombok.Data;
import org.springframework.web.multipart.MultipartFile;@Data
public class FileUploadChunk {/*** 当前文件块,从0开始*/private Integer chunkNumber;/*** 当前块的实际大小(单位:字节)*/private Long currentChunkSize;/*** 文件名*/private String filename;/*** 当前分块的文件内容*/private MultipartFile file;// getters and setters
}
文件信息保存仓库
java">package com.ji.helper.controller.file;import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;/*** @author jisl on 2025/1/23 10:50*/
public class FileMetadataRepository {private static final Map<String, FileMetadata> FILE_METADATA_CACHE = new ConcurrentHashMap<>(16);private FileMetadataRepository() {}public static FileMetadata put(String fileName, FileMetadata channel) {return FILE_METADATA_CACHE.put(fileName, channel);}public static FileMetadata get(String fileName) {return FILE_METADATA_CACHE.get(fileName);}public static Boolean containsKey(String fileName) {return FILE_METADATA_CACHE.containsKey(fileName);}public static void remove(String fileName) {FILE_METADATA_CACHE.remove(fileName);}public static int size() {return FILE_METADATA_CACHE.size();}public static Map<String, FileMetadata> getCache() {return FILE_METADATA_CACHE;}}
结语
分块上传,这边比较适合大文件和网络环境不稳定情景下。这边我尝试在内网环境和外网环境传输1GB大小文件,内网环境下直接上传更快。而在外网环境下则是分块上传明显更快。这边需要根据具体的问题场景,使用合适的解决方案才是最优解。