ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java后端实现网页文件夹分块上传:目录保留与断点续传详解

Java后端实现网页文件夹分块上传:目录保留与断点续传详解 “网页端传文件夹还得保留目录结构还要分块”这需求我太熟了。搞过网盘、企业盘、内容管理后台的朋友应该都懂浏览器默认的文件选择框一次只能选一批文件文件夹树形结构一传就散。就算你硬着头皮把文件夹里几千个文件全选进来单个文件只要超过几十上百兆后端接收直接报“request body too large”Nginx那边先拦一道Spring Boot这边再拦一道用户传着传着就断最后怪你做的功能不行。那JAVA后端到底怎么配合网页端把文件夹的目录结构原样搬上去同时还能稳得住大文件这篇我把这套方案的完整思路、前端遍历分块、后端接收合并、断点续传和秒传的实现细节全拆开讲中间穿插我在实际项目里踩过的坑。想做这个功能但还在用“把文件全读进内存再一股脑写盘”这种方案的看完这篇可以换姿势了。1. 整体思路拆解为什么要分块、为什么目录结构容易丢1.1 目录结构丢失的根源浏览器端拿文件夹靠的是input typefile上的webkitdirectory属性。加了这个属性之后用户选择的是整个文件夹浏览器会把文件夹里所有文件暴露成一个FileList。关键信息是File对象上的webkitRelativePath字段比如你传一个叫资料的文件夹里面有个路径是资料/2025年文档/季度报告.docx那这个字段的值就是资料/2025年文档/季度报告.docx。很多人把目录结构弄丢是因为压根没用这个字段。他们在前端遍历文件列表的时候直接拿file.name当成最终文件名后端起个随机目录就存传到服务器以后就是一堆平铺的文件目录层级全没了。所以目录结构恢复的核心就一条前端把file.webkitRelativePath完整地传给后端后端在后端存储根路径下逐级创建对应的目录再把文件塞进最里层。听起来简单但和分块上传叠加在一起之后就有很多细节要处理。1.2 分块上传解决的三类老难题不搞分块一个文件从头传到尾最大的问题是“单点失败”。传了80%网络闪断重来。玩过老式断点下载的都懂那叫一个绝望。分块之后每块独立上传哪块失败就重传哪块其他块不用动。第二个问题是服务器端限制。Tomcat对POST请求大小有默认上限Spring Boot的spring.servlet.multipart.max-file-size默认是1MBmax-request-size默认是10MB。就算你调大一个几十GB的文件夹一次性塞进请求体Nginx和Tomcat的缓冲区全部被撑爆内存直接拉满。而分块上传每块就几MB请求体小服务器压力就小。第三个问题是并发。文件夹里几百个文件如果串行一个接一个传耗时长到用户怀疑人生。分块之后可以控制并发数比如同时间最多传5个分块既能跑满带宽又不会把后端压垮。1.3 方案选型原生XHR还是成熟框架我之前用的方案比较朴素前端纯原生JavaScript用XMLHttpRequest发分块没用axios等额外依赖。为什么因为文件夹分块上传对请求的“精细控制”要求比较高比如随时能取消某个文件的某个分块请求实时读取上传进度每个分块失败后单独重试原生XHR在这几件事上不吃亏反而很顺手。而且对入门者来说原生写法更容易看清整个流程不用先被封装层的源码绕晕。后端我用的是Spring Boot这是目前Java后端做文件上传最主流的选择。利用MultipartFile接收分块用文件流做合并再加少量的定时清理任务就能把一个完整可用的文件夹上传服务跑起来。2. 前端实现核心细节目录遍历、分块切片与进度上报2.1 用webkitdirectory拿到文件夹先看最基础的前端代码。一个文件夹选择框核心就是两个属性webkitdirectory告诉浏览器这里选的是文件夹multiple允许选多个。input typefile idfolderInput webkitdirectory multiple /然后监听change事件遍历拿到的FileListdocument.getElementById(folderInput).addEventListener(change, function (e) { const files Array.from(e.target.files); for (const file of files) { // file.webkitRelativePath 就是文件夹内的相对路径 console.log(file.webkitRelativePath); console.log(file.size); console.log(file.type); } });webkitRelativePath是Chrome和Edge都支持的。Safari新版也支持。Firefox在文件夹选择上也支持字段名一致。兼容性上基本不用太担心但在老项目里还是建议做个判断拿不到webkitRelativePath就直接把file.name当成相对路径处理。另外要注意e.target.files里实际上不包含空文件夹。也就是说你在电脑上建了一个空目录里面啥也没有浏览器在文件夹选择的时候根本不会把这个目录枚举出来。后端也无从得知这个空目录的存在。如果业务上确实需要保留空目录结构前端只能额外做一层处理用FileSystemDirectoryReader或者WebKit的webkitGetAsEntry()递归读取目录树把空目录标记单独提交给后端创建。2.2 分块切片file.slice能切出什么形状File对象继承自Blob所以天然有slice方法。分块大小我一般定在5MB到10MB之间。定太小分块数量多HTTP请求次数暴涨光是握手开销就拖慢速度定太大又失去了分块上传的意义接近“整块上传”。我常用的分块逻辑是这样const CHUNK_SIZE 5 * 1024 * 1024; // 5MB function splitFile(file) { const chunks []; let start 0; while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size); chunks.push({ blob: file.slice(start, end), index: Math.floor(start / CHUNK_SIZE), start: start, end: end }); start end; } return chunks; }这个切法有个好处就是每个分块带一个index从0开始的序号。后端合并的时候只需要按index排序就行了不用去猜哪个块在前哪个块在后。给分块起个合理的变量名也很重要。我自己用这组参数标识一个分块参数名示例值作用文件夹名资料区分不同任务相对路径资料/2025年文档/季度报告.docx还原目录结构文件大小23592960前端判断文件是否完整分块索引0按序合并总块数5判断所有分块是否传完分块校验值md5秒传/断点续传判断关于MD5我要多说一句。给超大文件算MD5也是耗时操作几十GB的文件硬算可能要几十分钟。所以我现在一般不在一开始就给整个文件算MD5而是给每个分块算一个小md5用“分块md5列表”去标识这个文件。这样既保留了内容校验能力又不会在开启上传前先卡半天计算。2.3 分块上传请求FormData里装什么切片归切片真正上传的时候Blob对象是要放进FormData里发出去的。服务端MultipartFile接收的就是这个切片数据。除此之外我还会在FormData里塞上上面的标识参数function uploadChunk(chunk, file, folderName) { const formData new FormData(); formData.append(file, chunk.blob, file.name); formData.append(chunkIndex, chunk.index); formData.append(chunkTotal, totalChunks); formData.append(relativePath, file.webkitRelativePath); formData.append(folderName, folderName); formData.append(fileSize, file.size); return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload/chunk, true); xhr.upload.addEventListener(progress, (e) { if (e.lengthComputable) { // 这里拿到的是单个分块的进度的进度 const percent Math.round((e.loaded / e.total) * 100); updateChunkProgress(chunk.index, percent); } }); xhr.onload function () { if (xhr.status 200) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error(分块${chunk.index}上传失败HTTP ${xhr.status})); } }; xhr.onerror function () { reject(new Error(分块${chunk.index}网络异常)); }; xhr.send(formData); }); }有两点值得说明。第一formData.append(file, chunk.blob, file.name)的第三个参数是文件名。这个其实就是告诉浏览器这个字段在请求里对应的文件名是什么。后端用MultipartFile.getOriginalFilename()拿到的就是这个名字不过我们真正靠的还是relativePath这个字段文件名的作用更多只是占位。第二xhr.upload.addEventListener(progress)监听的是上传字节的进度。对于单个5MB分块来说这个数值跳得很快通常不到一秒就100%了。真正有意义的进度是“整个文件夹的上传进度”这个需要前端自己算。我自己的做法是维护一个Map以relativePath _ chunkIndex为key记录每个分块的上传状态然后定时把所有已完成分块大小累加除以所有文件总大小算出整体百分比。let uploadedBytes 0; let totalBytes files.reduce((sum, f) sum f.size, 0); // 每完成一个分块 uploadedBytes chunk.blob.size; const overallPercent Math.round((uploadedBytes / totalBytes) * 100);这个整体进度比单分块进度有意义得多用户看着也舒服。2.4 并发控制同一时间发多少分块合适如果文件夹里全是几MB的小文件且分块大小是5MB那整个文件夹可能会有几百上千个分块。如果一股脑全发出去浏览器会同时建立大量TCP连接不仅排队严重还可能触发浏览器并发连接数上限反而更慢。我一般用一个简单的并发池来控制最高同时5个分块上传async function uploadAllChunks(chunks, limit 5) { const queue [...chunks]; const workers []; for (let i 0; i limit; i) { workers.push(runWorker(queue)); } await Promise.all(workers); } async function runWorker(queue) { while (queue.length 0) { const task queue.shift(); try { await uploadChunk(task.chunk, task.file, task.folderName); // 标记该分块完成 } catch (err) { // 记录失败重试或终止 } } }并发数不是固定杀死的要根据网络环境和用户带宽调整。我在内网环境实测并发10个分块跑得很稳走公网、带宽只有2Mbps时并发5个都有点拥挤。所以我一般把limit5当默认值给前端留一个配置入口。3. 后端JAVA接收与合并Spring Boot下的完整实现3.1 项目目录结构设计做这个功能之前建议先把后端工程目录理清楚。Spring Boot的标准结构Controller管接收、Service管逻辑、DTO管参数、工具类管杂活。我这次功能涉及的文件和职责如下表目录/类职责controller/UploadController.java暴露/api/upload/chunk和/api/upload/merge接口service/UploadService.java处理分块保存、合并、校验service/FileChunkRepository.java分块在磁盘上的存储与查找config/UploadProperties.java上传根目录、分块大小、并发数配置dto/ChunkDto.java接收前端传来的分块元数据dto/MergeDto.java接收合并请求的参数util/Md5Util.java分块MD5校验工具这个结构不复杂但职责分得很清楚。分块存储和最终合并的写盘逻辑我放在独立Service里不直接堆在Controller里这样后面如果想扩展“断点续传”或“秒传”的逻辑不需要动Controller的接口层。3.2 分块接收接口认准relativePath存储后端接收分块不需要马上把文件合并到最终位置因为一个文件可能有若干分块还在路上。统一的处理流程是接到分块后先落盘到“临时分块目录”等所有分块齐了再触发合并。Controller的代码骨架RestController RequestMapping(/api/upload) public class UploadController { private final UploadService uploadService; public UploadController(UploadService uploadService) { this.uploadService uploadService; } PostMapping(/chunk) public ResponseEntity? uploadChunk( RequestParam(file) MultipartFile file, RequestParam(chunkIndex) int chunkIndex, RequestParam(chunkTotal) int chunkTotal, RequestParam(relativePath) String relativePath, RequestParam(folderName) String folderName, RequestParam(fileSize) long fileSize) { uploadService.saveChunk(file, chunkIndex, chunkTotal, relativePath, folderName, fileSize); return ResponseEntity.ok(new ResultBean(分块接收成功)); } }Service里做这几件事用folderName和relativePath联合生成一个“分块存储路径”。这个路径放在上传总目录的temp子目录下文件名格式尽量包含上传文件的唯一标识和时间戳避免不同用户上传同名文件夹时互相覆盖。把分块数据file.getBytes()直接写到该路径下。分块文件命名就是{chunkIndex}.part。记录分块元数据到内存或数据库。public void saveChunk(MultipartFile file, int chunkIndex, int chunkTotal, String relativePath, String folderName, long fileSize) throws IOException { // 用相对路径作为任务标识但是要做安全过滤防止路径穿越 String safeRelativePath sanitizePath(relativePath); // 任务目录 上传根目录/temp/任务ID String taskId DigestUtils.md5DigestAsHex((folderName _ safeRelativePath).getBytes(StandardCharsets.UTF_8)); Path chunkDir Paths.get(uploadRoot, temp, taskId).toAbsolutePath(); Files.createDirectories(chunkDir); // 分块文件名chunk_0.part, chunk_1.part Path chunkFile chunkDir.resolve(chunk_ chunkIndex .part); try (InputStream in file.getInputStream()) { Files.copy(in, chunkFile, StandardCopyOption.REPLACE_EXISTING); } }这里有一个非常重要、容易忽略的点relativePath这个参数来自前端是用户输入的一部分绝对不能直接拼接到文件系统路径里。攻击者可以传一个类似../../etc/passwd的相对路径如果不做过滤分块文件就可能被写到服务器任意位置。我用的sanitizePath至少会做三件事将反斜杠\统一替换为/将连续的/压缩为单个/过滤掉所有..路径片段private String sanitizePath(String path) { String normalized path.replace(\\, /); while (normalized.contains(//)) { normalized normalized.replace(//, /); } // 去掉所有以..开头的片段和纯目录跳级 ListString parts Arrays.stream(normalized.split(/)) .filter(part - !part.isEmpty() !...equals(part.trim())) .collect(Collectors.toList()); return String.join(/, parts); }这段代码建议直接抄进你的项目因为它不是为了功能是为了安全。文件夹上传这种功能一旦开放到公网路径穿越漏洞被利用可能直接导致服务器被写马。3.3 合并接口按最终目录写盘并清分块分块接收完了前端最后调用合并接口。合并请求把该文件的分块信息全带过来PostMapping(/merge) public ResponseEntity? mergeChunks(RequestBody MergeDto mergeDto) throws IOException { String mergedFilePath uploadService.merge(mergeDto); return ResponseEntity.ok(new ResultBean(合并完成, mergedFilePath)); }MergeDto里关键字段是字段作用relativePath最终保存的路径folderName上传根目录下建的一级目录名chunkTotal分块总数判断不齐fileSize文件大小合并后校验长度md5List分块MD5列表可选用于二次校验合并逻辑根据relativePath定位到temp目录下对应的分块目录。按chunk_0.part、chunk_1.part的顺序依次读取追加写入最终文件。写完后检查最终文件大小是否等于前端声明的fileSize。校验通过后删除临时分块目录。public String merge(MergeDto dto) throws IOException { Path relativePath Paths.get(sanitizePath(dto.getRelativePath())); Path targetDir Paths.get(uploadRoot, dto.getFolderName()).resolve(relativePath).getParent(); Files.createDirectories(targetDir); Path targetFile targetDir.resolve(relativePath.getFileName()); String taskId DigestUtils.md5DigestAsHex( (dto.getFolderName() _ sanitizePath(dto.getRelativePath())).getBytes(StandardCharsets.UTF_8)); Path chunkDir Paths.get(uploadRoot, temp, taskId).toAbsolutePath(); try (OutputStream out Files.newOutputStream(targetFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { for (int i 0; i dto.getChunkTotal(); i) { Path part chunkDir.resolve(chunk_ i .part); if (!Files.exists(part)) { throw new IOException(缺少分块 part); } Files.copy(part, out); } } // 校验文件大小 long actualSize Files.size(targetFile); if (actualSize ! dto.getFileSize()) { Files.deleteIfExists(targetFile); throw new IOException(合并文件大小异常期望 dto.getFileSize() 实际 actualSize); } deleteDirectory(chunkDir); return targetFile.toString(); }合并的时候有个容易踩的坑Files.copy(part, out)在部分JDK版本上如果源文件在复制过程中被其他线程短暂打开可能会抛异常。这在大文件夹并发上传时经常遇到。我的做法是给合并操作加上重试机制比如失败后先检查分块是否存在存在则进行二次复制尝试。合并完成之后分块目录要及时清理。如果不清理上传几次大文件夹之后temp目录会积累几百GB垃圾文件。我用了一个Scheduled定时任务每过24小时扫描temp目录删除超过48小时未更新的目录。3.4 秒传与断点续传的落地方式秒传的经典做法是文件唯一标识比对。前面说过了大文件整体MD5太重我用分块MD5列表作为文件指纹。前端在上传前先调用一个接口把分块MD5列表提交给后端后端查数据库如果该文件已经存在就直接返回“秒传完成”前端不再上传任何分块。断点续传就更有意思了。前端在正式上传前调用“查询已上传分块”的接口带上任务ID。后端扫描temp目录下已经存在的.part文件返回序号列表。前端收到序号后把已存在的分块从上传队列里剔掉只传缺失的分块。这两个功能组合起来实实在在解决了我在实际运维中遇到的两个高频问题用户上传到一半网断了重新刷新页面又要全部重传用户直接开骂。多人传同一份素材库几百GB的内容每个人都要传一遍服务器带宽和存储都被消耗到爆炸。所以千万不要把“能传上去”当成功能终点。加上秒传和断点续传体验才像一个正儿八经的企业级上传模块。4. 常见问题与排查技巧实录卡了三天的问题都在这里4.1 前端文件列表为空或目录结构拿不到这个问题的原因我在前面稍微提过但在实际排查中它出现的频率很高。常见情形是用户用了input typefile webkitdirectory之后在部分浏览器上依然只能单选文件。排查方向确认multiple属性存在。有些浏览器在没有multiple时会忽略webkitdirectory。确认浏览器版本。老旧的Chrome 44以下版本对webkitRelativePath支持不全。确认拿的是e.target.files而不是e.target.files[0]。文件夹属性打开后files是一个类数组。4.2 Tomcat默认限制导致的“上传失败 413”前端分块了后端也接收了还是出现413。大概率问题在Tomcat或Nginx的请求体限制。Spring Boot配置里要显式放开spring: servlet: multipart: enabled: true max-file-size: 10MB max-request-size: 10MB注意max-file-size和max-request-size都要设置。如果只设max-file-size一次请求包含多个文件时仍然会撞max-request-size。Nginx侧的配置项是client_max_body_sizeserver { client_max_body_size 20m; }这个大小要略微大于一个分块的最大尺寸防止上传紧贴临界值时被Nginx提前拦截。4.3 合并后文件损坏打开提示格式错误这种问题通常不是合并代码写错而是分块顺序乱了。排查步骤检查前端chunkIndex是否正确。如果从1开始编号而后端遍历时从0开始合并就会错位。检查后端合并时是否按序号排序。我见过有人用Files.list()遍历临时目录然后按文件名String排序合并导致chunk_10.part排在chunk_2.part前面。这是经典错误。检查分块大小是否恒定。最后一个分块通常小于前面分块后端要按“前n-1块大小固定最后一块是剩余长度”的逻辑做预期不要期待所有分块一样大。如果合并完的文件和源文件大小一致但内容有错位那基本就是排序问题。我用的是for循环按i从小到大遍历固定文件名从根本上规避了这个问题。4.4 并发下分块写盘冲突多个分块同时到达后端各自写入自己的.part文件理论上不会冲突。但实际项目中如果分块的临时目录设计得太粗比如按“用户名文件夹名”硬拼两个用户上传同一个文件夹时就会指向同一个temp任务目录造成文件互相覆盖。解决办法就是我在代码里那样任务ID必须带上相对路径的MD5摘要让每个文件的每个分块都有独立目录。4.5 大文件夹上传内存被撑爆不要在后端用file.getBytes()接分块。5MB的分块不算大但如果你每次都把整个分块读进byte[]再写出去几十个并发一来内存就爆了。正确姿势是用InputStream流式写入一边读一边落盘全程不把分块数据完整保留在JVM堆内存里。前端也一样不要提前把整个文件夹所有分块一次性切出来放在内存里。文件一多几GB的Blob切片引用全堆在内存中浏览器会崩溃。合理的方式是维护一个上传队列当前需要上传的分块才用slice()切割。4.6 特小文件或0字节文件的分块处理这个坑特别隐蔽。文件夹里如果有0字节的空文件前端切分时file.slice(0, 0)得到的Blob大小为0整个chunks数组是空的。上传请求发出去后端MultipartFile拿到一个空文件file.getBytes()返回空数组没有大碍但分块数就是0合并时找不到任何分块。我的处理方式是前端对0字节文件走单独逻辑不上传分块直接调用合并接口后端发现文件大小为0且分块总数为0直接创建一个空文件并返回成功。if (dto.getChunkTotal() 0 dto.getFileSize() 0) { Files.createFile(targetFile.resolve(relativePath.getFileName())); return 空文件已创建; }4.7 路径过长导致服务器写入失败Windows系统默认路径长度限制是260字符Linux是4096字符。文件夹目录层级如果特别深比如资料/2025/项目A/子项目/方案/终版/终版2/.../文档.docx加上服务器端的上传根目录路径很容易超过Windows上限。如果服务器是Windows环境建议启动时开启长路径支持或者把上传根目录设置到磁盘根部比如D:\uploads尽量缩短前缀长度。Linux环境一般不需要担心。5. 我的一些补充建议与扩展思路很多朋友抄完代码就跑通了但过了一阵子又会遇到一些业务层面的问题这里集中给几个建议。分块上传不只是把文件“切碎再拼起来”它和网盘的在线预览、共享协作可以结合起来做。比如你说想做一个轻量化网页端平台支持用户发布信息的场景文件夹上传能力完全可以作为附件系统的基础。用户上传的目录结构经过后端还原之后可以按照原本的目录树展示在网页上形成素材库或资料库。这时候前端拿到的不再是平铺的文件列表而是一棵动态渲染出的树。我实际做的项目中在上传完成后会在前端用相对路径反推目录树再配合Element UI的Tree组件或原生递归渲染把“原样还原”从文件层面升级到展示层面用户体感非常直观。另外我给分块上传加过一层服务端“分块数量校验”。具体来说当第一个分块到达时我会把chunkTotal写进任务元数据之后每一个分块到达时不仅保存分块数据还实时检查已到达的分块数量是否已经满足chunkTotal。如果已经满足后端可以主动触发合并省去前端再发一次合并请求的等待时间。不过这个方案要谨慎用因为网络乱序到达是常态靠“数量到了就合并”可能会在个别分块未到达但系统已完成合并的情况下出错。稳妥起见我最后还是保留了“前端通知后端合并”的机制。如果你后续想把性能优化再做一档可以考虑给分块上传加上“并行合并”。比如一个5GB的文件切成1000个5MB分块全部上传完后端再单线程顺序合并耗时在几秒到十几秒之间尚可接受。但如果一个文件夹里存的是几十个5GB的大文件单线程合并就会成为瓶颈。这时候后端可以用ExecutorService对多个文件的合并操作做线程池化每个文件一个合并任务并行写盘配合SSD磁盘等待时间会肉眼可见地缩短。最后说一个我在生产环境里踩过的坑定时清理临时分块的任务千万不要在用户上传期间扫到正在活跃的任务目录。我的做法是在分块元数据内存或数据库中记录每个任务目录的“最后写入时间”清理任务只处理“最后写入时间超过6小时”的目录这样就算有用户传一半去吃饭了返回后也可以断点续传不会被清理掉。这整套方案做下来我的体会是文件夹分块上传的核心其实是“状态管理”——前端要清楚哪些分块传完了后端要清楚哪些分块到了两边靠一套清晰的任务标识对账。只要把对账逻辑设计清楚剩下都是体力活。目录结构的还原反而不是最难的难的是一堆边角情况空文件、超长路径、并发覆盖、定时清理误删。希望这篇文章能帮你把这些坑都填平。
返回列表