【分块解决大文件上传的最佳实践】

news/2025/2/8 19:04:04/

前言

前几天看了一篇关于大文件上传分块实现的博客,代码实现过于复杂且冗长,而且没有进行外网上传的测试。因此,我决定自己动手实现一个大文件上传,并进行优化。

实现思路

在许多应用中,大文件上传是常见的需求,比如上传视频、图片、日志文件等。常见的上传方法往往会受到文件大小和网络环境的限制,导致上传失败或上传速度慢。例如我们通过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大小文件,内网环境下直接上传更快。而在外网环境下则是分块上传明显更快。这边需要根据具体的问题场景,使用合适的解决方案才是最优解。


http://www.ppmy.cn/news/1570385.html

相关文章

低代码开发中的创新实践

在软件开发领域&#xff0c;传统开发模式长期占据主导地位&#xff0c;其严谨的流程和规范为众多大型系统的构建奠定了基础。但随着数字化浪潮的席卷&#xff0c;业务需求的快速变化和对开发效率的高要求&#xff0c;传统开发思维逐渐显露出局限性。低代码开发的兴起&#xff0…

数据库高安全—审计追踪:传统审计统一审计

书接上文数据库高安全—角色权限&#xff1a;权限管理&权限检查&#xff0c;从权限管理和权限检查方面解读了高斯数据库的角色权限&#xff0c;本篇将从传统审计和统一审计两方面对高斯数据库的审计追踪技术进行解读。 4 审计追踪 4.1 传统审计 审计内容的记录方式通…

docker常用基础

镜像 拉取 docker pull id删除 docker rmi id查看 docker images容器 运行 docker run -p 本:远 id查看 docker ps停止 docker stop id转换 导出容器&#xff08;镜像实例的文件变化&#xff09; docker export id > 名.tar导入容器&#xff08;镜像实例的文件变化…

C++多线程编程——call_once和单例模式

目录 1. 前言 2. call_once和once_flag 3. 后记 3.1 单例类的析构问题 3.2 饿汉式单例模式的线程安全问题 1. 前言 之前在讲解单例模式时&#xff0c;有提到懒汉式单例模式使用了双重检测Double-Checked Locking Pattern (DCLP)来解决多线程的安全访问问题。但是该方法也…

第30节课:前端架构与设计模式—构建高效可维护的Web应用

目录 前端架构设计前端架构的重要性前端架构设计原则模块化可维护性可扩展性性能优化 前端架构设计方法MVC&#xff08;Model-View-Controller&#xff09;MVVM&#xff08;Model-View-ViewModel&#xff09;单页应用&#xff08;SPA&#xff09; 设计模式在前端的应用设计模式…

PHP-综合3

[题目信息]&#xff1a; 题目名称题目难度PHP-综合32 [题目考点]&#xff1a; PHP综合训练[Flag格式]: SangFor{IoOvaUFeUjE1Lt2hatHL_z9uKyTLu0Cn}[环境部署]&#xff1a; docker-compose.yml文件或者docker tar原始文件。 http://分配ip:2047[题目writeup]&#xff1a;…

Oracle迁移到MySQL

Oracle迁移到MySQL业务需要全面改造适配&#xff0c;数据库对象和业务SQL语法需要一对一映射分析如何改写&#xff0c;根据业务使用实际情况评估改造适配成本较高。 目前&#xff0c;已有数据库产品能力缺少自动化迁移工具&#xff0c;需要依赖生态产品能力&#xff0c;比如云和…

leetcode_78子集

1. 题意 给定一个不含有重复数字的数列&#xff0c;求所有的子集。 2. 题解 子集型回溯&#xff0c;可以直接用dfs进行搜索&#xff1b;也可以用二进制来进行枚举。 2.1 选或不选 class Solution { public:void dfs(vector<vector<int>> &ans,vector<i…