如果你维护过一个用JSP/Servlet写的管理后台,一定遇到过这种需求:用户要把整个产品资料文件夹、合同归档目录、项目图纸打包上传。文件夹里几百个文件、好几个GB,浏览器直接卡死,Tomcat直接报内存溢出,前端等半天最后给你一个网络错误。我前两年给一家单位做知识库管理模块,就卡在这件事上整整一个星期,试过各种插件和方案,最后才琢磨出一套稳定的做法,这篇文章把完整的实现思路和踩坑过程都写出来,给同样被“JSP怎么传超大文件夹”折磨的人一个参考。
先说清楚这套方案到底解决什么问题:基于JSP/Servlet这种传统Java Web技术栈,不引入Spring Boot、不换OSS,用HTML5的File API加切片上传,实现文件夹整体的断点续传、并发加速和进度展示。适合老项目改造、内网系统、以及必须用JSP做服务端渲染的场景。
1. 传统JSP上传为什么扛不住超大文件夹
1.1 一次性提交方案的三个硬伤
很多人第一反应是用 <input type="file" name="file"> 加 enctype="multipart/form-data",后端用Commons FileUpload或Servlet 3.0的Part接口接文件。这套方案对付单个几MB的文件完全没问题,但一碰超大文件夹就原形毕露。
第一个硬伤是内存和临时文件压力。整个请求会把所有文件拼成一个multipart流一次性发送,Tomcat默认的 maxPostSize 是2MB,除非在server.xml里调大,否则请求直接拒绝。就算调大了,几GB的数据要么全部载入内存(不现实),要么落盘到Tomcat的临时目录,磁盘IO直接打满。我测试过一个1.2GB的文件夹,用传统方式传,浏览器一直在转圈,Tomcat日志里全是OutOfMemoryError。
第二个硬伤是不可控。一次性提交意味着请求发出之后,服务端只能被动等待,没有进度条、没有重试机制、中途断网就得从头再来。用户看着浏览器右下角的转圈,根本不知道是快了还是死了。这对内网系统来说尤其致命。
第三个硬伤是文件夹结构丢失。原生input拿到的File对象列表只是文件平铺,webkitRelativePath这个属性倒是能拿到相对路径,但传统上传根本不会利用它,传到服务端全部变成扁平文件名列表,重名文件直接互相覆盖。
1.2 为什么放弃ActiveX插件,改用HTML5分片方案
十几年前,老OA系统处理大文件普遍用ActiveX控件,像NTKO那种,我早期也接过这类活儿。ActiveX方案在Windows + IE时代确实能用,但问题很明显:只能Windows、只能IE内核、还要在客户端装插件、权限配置麻烦、360浏览器搞兼容模式的时候经常加载不出来。现在连IE都停服了,再让用户装控件纯属自虐。
HTML5给File对象增加了 slice() 方法,可以把一个文件切成多个Blob片段,再配合 XMLHttpRequest 逐个上传,服务端接收齐所有分片后按顺序合并回完整文件。这套思路绕开了“一次性大请求”,每个分片就是一个普通的multipart请求,单次请求体积可控,Tomcat不用调夸张的参数,内存占用小,还能做断点续传、并发上传、实时进度。浏览器原生支持,跨平台,用户无感。
我一直认为,很多所谓“现代”的优化方案,核心原理在W3C标准里躺着,只是大家习惯性去搜插件、框架,忽略了原生的能力。这次既然要给JSP老项目做改造,用原生File API + Servlet分片接口,是最稳妥、可控、没有额外依赖的路线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:切片、并发与目录结构保存
2.1 文件夹的文件怎么交出来:webkitdirectory的原理
让浏览器交出文件夹,靠的是 <input type="file" webkitdirectory> 属性。这是Chrome/Safari/Firefox支持的做法,把input标记为目录选择模式,用户选定整个文件夹后,input.files 里会得到一个Flat的File对象数组,每个File对象带有 webkitRelativePath 属性,内容类似 产品手册/第1章/架构说明.pdf。
注意,这本质上仍然是“文件选择”,不是浏览器把文件夹对象直接传给你。所以我们需要自己遍历这个数组,用 webkitRelativePath 来重建目录结构。有些读者可能问,为什么不用 directory API(showDirectoryPicker)?那个接口更现代,能拿到真正的目录句柄,但兼容性有限,老项目用户浏览器版本参差不齐,webkitdirectory 反而最通用。
这里有个容易踩的坑:webkitRelativePath 在部分国产浏览器里可能为空字符串或者不标准,我建议前端做一次校验,拿不到相对路径就用文件名加随机后缀兜底,避免人为制造bug。
2.2 分片大小如何设定:不是越细越好
分片的本质是把一次大请求拆成多个小请求,但分片大小和并发数之间存在一个最优解,不是切得越碎越好。
分片太细(比如256KB),会导致请求数量爆炸。1GB文件切256KB需要4096个分片,每个分片都要走一次HTTP握手、multipart编码、服务端IO,光请求开销就占了大量时间。分片太粗(比如50MB),又退化回大请求,超时风险上升。
我的实践经验是:常规网络环境取2MB到5MB,内网高带宽环境可以放宽到10MB,公网低带宽环境建议1MB到2MB。以4MB分片为例,1GB文件切成256个分片,并发控制在4到6路,耗时、服务端压力、断点续传粒度都比较平衡。
另外,最后一个分片往往小于设定值,前端要处理 end 大于等于 file.size 的情况:
javascript复制const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
2.3 并发控制:给上传队列加一个“信号量”
如果不加控制,直接把几千个分片全部 new XMLHttpRequest() 发出,浏览器会瞬间建立大量连接,路由器和小型交换机先扛不住,服务端Tomcat线程池也会被打满。实测中,我见过并发拉满时,nginx直接报 connection reset by peer,后端Servlet日志一片红。
正确的做法是做一个简单的并发池:维护一个待上传任务队列,同时只运行N个上传任务,每当有一个任务结束,就从队列里取下一个。这个“信号量”逻辑不复杂,我在后面的代码里给出一个具体实现,N的值取4到6比较稳妥。如果网络质量差,可以降到3;如果内网十分稳定,可以试到8,需要实测调优。
2.4 断点续传与秒传的落地思路
分片方案天然适合断点续传,因为每个分片之间是独立的,前端可以记录哪些分片成功了。
最简单的实现:前端内存里维护一个 completedChunks 集合,上传成功就加进去,上传过程中如果断网或者页面没关,重新开始时跳过已经成功的分片。如果要把续传能力做到跨页面、跨浏览器,就需要在 localStorage 里记录文件指纹和分片完成状态。
文件指纹一般用文件标识,最简单的是“文件名 + 文件大小 + 最后修改时间”拼接。更严格的做法是前端计算第一个分片和最后一个分片的MD5,或者用SparkMD5计算整个文件hash,但超大文件全量MD5计算耗时挺长,用户体验差,通常只有“秒传”需求时才值得做。
秒传的逻辑则是:前端计算好文件标识后,先发一个请求问服务端“这个文件你已有吗”,服务端查存储记录,如果已存在同名同大小同哈希的文件,直接返回成功,不用再传。这个功能对重复上传同一批资料时特别有用,建议二期再叠加。
3. 实操实现:前端JSP页面 + 后端Servlet完整代码
3.1 前端实现:文件夹选择、切片与并发上传队列
先写上传页面upload.jsp的核心部分。一个文件选择按钮、一个进度条、一个日志区域,JS逻辑重点在Uploader类的设计。
html复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>超大文件夹上传</title>
<style>
.progress-wrap { width: 600px; height: 24px; border: 1px solid #ccc; margin: 20px 0; }
.progress-bar { height: 100%; width: 0; background: #4caf50; transition: width 0.2s; }
#log { width: 600px; height: 200px; overflow: auto; background: #f7f7f7; padding: 8px; font-size: 13px; }
</style>
</head>
<body>
<h3>选择文件夹后自动上传(超大附件分片上传)</h3>
<input type="file" id="folderInput" webkitdirectory multiple />
<div class="progress-wrap"><div class="progress-bar" id="progressBar"></div></div>
<div>当前进度:<span id="progressText">0%</span></div>
<div id="log"></div>
<script src="upload.js"></script>
</body>
</html>
接下来是upload.js。我把上传逻辑封装成一个类,核心包括:文件收集、任务分片、并发池、重试机制、进度上报。
javascript复制const CHUNK_SIZE = 4 * 1024 * 1024; // 4MB
const CONCURRENCY = 4; // 并发数
const MAX_RETRY = 3; // 单个分片最大重试次数
class FolderUploader {
constructor() {
this.tasks = []; // 分片任务队列
this.completedCount = 0;
this.totalChunks = 0;
this.fileIdMap = new Map(); // File对象 -> 唯一标识
}
// 入口:input change时调用
init(fileList) {
const files = Array.from(fileList);
if (!files.length) return;
// 统计文件与分片,构造任务队列
for (const file of files) {
const fileId = this.generateFileId(file);
this.fileIdMap.set(file, fileId);
const chunkTotal = Math.ceil(file.size / CHUNK_SIZE);
const relativePath = file.webkitRelativePath || file.name;
for (let i = 0; i < chunkTotal; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
this.tasks.push({
fileId, file, chunk,
chunkIndex: i,
chunkTotal,
relativePath,
retry: 0
});
}
}
this.totalChunks = this.tasks.length;
this.completedCount = 0;
this.log('共 ' + files.length + ' 个文件,' + this.totalChunks + ' 个分片');
this.addLog('开始上传...');
this.run();
}
// 并发池调度
run() {
const workerCount = Math.min(CONCURRENCY, this.tasks.length);
for (let i = 0; i < workerCount; i++) {
this.nextTask();
}
}
nextTask() {
if (this.tasks.length === 0) {
if (this.completedCount === this.totalChunks) {
this.addLog('所有分片上传完成,请求合并...');
this.requestMerge();
}
return;
}
const task = this.tasks.shift();
this.uploadChunk(task);
}
// 上传单个分片
uploadChunk(task) {
const formData = new FormData();
formData.append('fileId', task.fileId);
formData.append('chunk', task.chunk, 'chunk.part');
formData.append('chunkIndex', task.chunkIndex);
formData.append('chunkTotal', task.chunkTotal);
formData.append('fileName', task.file.name);
formData.append('relativePath', task.relativePath);
const xhr = new XMLHttpRequest();
xhr.open('POST', 'upload', true);
xhr.timeout = 60000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
// 这个进度是单个分片内的进度,整体进度会在onload里统计
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const resp = JSON.parse(xhr.responseText);
if (resp.code === 0) {
this.completedCount++;
const percent = Math.floor(this.completedCount / this.totalChunks * 100);
this.updateProgress(percent);
this.nextTask();
} else {
this.handleError(task);
}
} else {
this.handleError(task);
}
};
xhr.onerror = () => this.handleError(task);
xhr.ontimeout = () => this.handleError(task);
xhr.send(formData);
}
handleError(task) {
if (task.retry < MAX_RETRY) {
task.retry++;
this.addLog('分片 ' + task.fileId + '-' + task.chunkIndex + ' 失败,重试 ' + task.retry + '/ ' + MAX_RETRY);
this.uploadChunk(task);
} else {
this.addLog('分片 ' + task.fileId + '-' + task.chunkIndex + ' 重试次数用尽,停止上传');
// 这里可以上报失败状态,也可以跳过
}
}
// 合并请求
requestMerge() {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'merge', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onload = () => {
if (xhr.status === 200) {
const resp = JSON.parse(xhr.responseText);
if (resp.code === 0) {
this.addLog('上传成功,服务端路径:' + resp.data.dirPath);
} else {
this.addLog('合并失败:' + resp.msg);
}
}
};
xhr.send('fileIds=' + JSON.stringify([...this.fileIdMap.values()]));
}
generateFileId(file) {
return file.size + '-' + file.lastModified + '-' + file.name.replace(/[\\/:*?"<>|]/g, '_');
}
updateProgress(percent) {
document.getElementById('progressBar').style.width = percent + '%';
document.getElementById('progressText').textContent = percent + '%';
}
addLog(msg) {
const log = document.getElementById('log');
const line = document.createElement('div');
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
}
const uploader = new FolderUploader();
document.getElementById('folderInput').addEventListener('change', function () {
if (this.files.length) {
uploader.init(this.files);
}
});
注意事项:这段代码为了篇幅做了精简,实际生产建议补充 localStorage 持久化已完成分片列表,页面刷新后自动过滤;另外还要处理并发池中任务失败后禁止新增任务的问题,避免程序空转。
3.2 后端实现:Servlet接收分片与临时文件管理
后端有两个接口:/upload 接收单个分片,/merge 触发合并。我用了Servlet 3.0的 Part API,不需要引入任何第三方jar,这在老项目里是最省事的。先看接收分片的UploadServlet:
java复制package com.example.upload;
import javax.servlet.ServletException;
import javax.servlet.annotation.MultipartConfig;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import java.io.*;
import java.nio.file.*;
import java.util.UUID;
@WebServlet("/upload")
@MultipartConfig(maxFileSize = 20 * 1024 * 1024, maxRequestSize = 30 * 1024 * 1024)
public class UploadServlet extends HttpServlet {
private static final String UPLOAD_ROOT = "/data/upload_temp"; // 分片临时目录
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
resp.setContentType("application/json;charset=UTF-8");
String fileId = req.getParameter("fileId");
String chunkIndex = req.getParameter("chunkIndex");
String chunkTotal = req.getParameter("chunkTotal");
String fileName = req.getParameter("fileName");
String relativePath = req.getParameter("relativePath");
// 文件名过滤,防止路径穿越
fileName = sanitizeFileName(fileName);
relativePath = sanitizePath(relativePath);
Part filePart = req.getPart("chunk");
if (filePart == null || fileId == null || chunkIndex == null) {
writeResult(resp, 1, "参数不完整");
return;
}
// 分片目录结构:UPLOAD_ROOT/fileId/chunkIndex.part
String chunkDir = UPLOAD_ROOT + File.separator + fileId;
File dir = new File(chunkDir);
if (!dir.exists() && !dir.mkdirs()) {
writeResult(resp, 1, "创建临时目录失败");
return;
}
Path chunkPath = Paths.get(chunkDir, chunkIndex + ".part");
try (InputStream in = filePart.getInputStream()) {
Files.copy(in, chunkPath, StandardCopyOption.REPLACE_EXISTING);
}
writeResult(resp, 0, "分片上传成功");
}
private String sanitizeFileName(String name) {
if (name == null) return UUID.randomUUID().toString();
return name.replaceAll("[\\\\/:*?\"<>|]", "_").replaceAll("\\.\\.", "_");
}
private String sanitizePath(String path) {
if (path == null) return "";
// 去掉前后斜杠和点号,防止路径穿越
path = path.replace("\\", "/");
path = path.replaceAll("\\.\\./", "");
while (path.startsWith("/")) {
path = path.substring(1);
}
return path;
}
private void writeResult(HttpServletResponse resp, int code, String msg) throws IOException {
resp.getWriter().write("{\"code\":" + code + ",\"msg\":\"" + msg + "\"}");
}
}
这里有两个重要细节。第一,@MultipartConfig 的 maxFileSize 只限制单个Part的大小,因为我们是分片上传,单个Part就是4MB左右,这里给20MB是为了留有冗余,如果前端调了分片大小这里要跟着调。第二,Files.copy 是直接流式落盘,不会把分片内容载入内存,这是保证服务端内存不爆的关键。
关于临时目录的清理,我在实际项目里写了一个定时任务,每小时删除超过24小时的 .part 文件,因为用户上传一半放弃是常态。Linux下用crontab,Windows下可以用计划任务,脚本很简单:
bash复制find /data/upload_temp -name "*.part" -mmin +120 -exec rm -rf {} \;
注意,只删除 .part 文件,不要直接删目录,否则可能误删另一个还在上传中的任务的临时目录。
3.3 合并分片并恢复文件夹结构
MergeServlet的逻辑是:拿到fileId列表,遍历每个fileId对应的临时分片目录,按chunkIndex顺序读回写入最终文件。最终目录结构按照relativePath还原。
java复制package com.example.upload;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@WebServlet("/merge")
public class MergeServlet extends HttpServlet {
private static final String UPLOAD_ROOT = "/data/upload_temp";
private static final String FINAL_ROOT = "/data/upload_final";
// 防止同一个fileId并发合并
private static final Set<String> MERGE_LOCKS = ConcurrentHashMap.newKeySet();
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
resp.setContentType("application/json;charset=UTF-8");
String fileIdsJson = req.getParameter("fileIds");
// 解析JSON... 简化处理:直接按逗号分隔
String[] fileIds = fileIdsJson.replaceAll("[\\[\\]\" ]", "").split(",");
PrintWriter out = resp.getWriter();
String firstError = null;
String firstDir = null;
for (String fileId : fileIds) {
if (fileId.isEmpty()) continue;
if (MERGE_LOCKS.contains(fileId)) {
out.write("{\"code\":2,\"msg\":\"该文件正在合并中\"}");
return;
}
MERGE_LOCKS.add(fileId);
try {
String dirPath = mergeFile(fileId);
if (firstDir == null) firstDir = dirPath;
} catch (Exception e) {
e.printStackTrace();
firstError = e.getMessage();
break;
} finally {
MERGE_LOCKS.remove(fileId);
}
}
if (firstError == null) {
out.write("{\"code\":0,\"msg\":\"合并完成\",\"data\":{\"dirPath\":\"" + firstDir + "\"}}");
} else {
out.write("{\"code\":1,\"msg\":\"合并失败: " + firstError + "\"}");
}
}
private String mergeFile(String fileId) throws IOException {
Path chunkDir = Paths.get(UPLOAD_ROOT, fileId);
if (!Files.exists(chunkDir)) {
throw new IOException("临时目录不存在: " + fileId);
}
// 读取分片元数据:从文件名、文件大小等推导
// 这里约定第一节分片上传时把完整文件名写入 manifest.properties
Properties props = new Properties();
Path manifest = chunkDir.resolve("manifest.properties");
if (Files.exists(manifest)) {
try (InputStream in = Files.newInputStream(manifest)) {
props.load(in);
}
}
String relativePath = props.getProperty("relativePath", "unnamed");
String fileName = props.getProperty("fileName", "unnamed");
// 清理相对路径,最终落地到 FINAL_ROOT/relativePath
Path dest = Paths.get(FINAL_ROOT, relativePath).normalize();
if (!dest.startsWith(Paths.get(FINAL_ROOT))) {
throw new IOException("非法路径");
}
Files.createDirectories(dest.getParent());
// 收集分片
List<Integer> indexes = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(chunkDir, "*.part")) {
for (Path p : stream) {
String name = p.getFileName().toString();
indexes.add(Integer.parseInt(name.substring(0, name.lastIndexOf("."))));
}
}
Collections.sort(indexes);
// 按顺序写入
try (OutputStream out = Files.newOutputStream(dest)) {
for (Integer idx : indexes) {
Path part = chunkDir.resolve(idx + ".part");
Files.copy(part, out);
}
}
// 合并成功后删除临时分片目录
deleteRecursively(chunkDir.toFile());
return dest.toString();
}
private void deleteRecursively(File f) {
if (f.isDirectory()) {
File[] children = f.listFiles();
if (children != null) {
for (File c : children) deleteRecursively(c);
}
}
f.delete();
}
}
你会发现一个关键点:manifest.properties 是哪来的?我在上面的UploadServlet中只存了分片没写manifest,实际生产需要加一段逻辑——当 chunkIndex == 0 时,把文件名和相对路径写入manifest。这个文件不属于分片,但可以在合并时提供上下文信息。如果你不想多文件,也可以在fileId里直接编码文件名,但文件名可能超长、含特殊字符,不建议。推荐的做法就是manifest独立文件,用独立的临时目录存储。
合并过程最需要注意的就是分片顺序。我经历过一次合并出的PDF打不开,排查半天发现是文件系统 DirectoryStream 返回的路径顺序不是按数字排列的,必须显式排序 chunkIndex,否则文件内容就拼错了。这个坑,代码里已经规避。
3.4 服务器与环境配置要点
JSP/Servlet项目跑在Tomcat下,有几个配置项必须检查:
第一,Tomcat的 maxSwallowSize。默认是2MB,当请求体超出后Tomcat会在响应阶段尝试吞掉剩余数据,大分片会造成响应延迟,建议在server.xml的Connector上配置:
xml复制<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
maxSwallowSize="-1"
maxPostSize="-1" />
maxSwallowSize=-1 表示不限制,maxPostSize=-1 表示不限制POST大小。既然已经分片了,理论上每个分片很小,但放宽总没错。
第二,JVM内存。合并大文件时用的是流式复制,不会占用大量JVM堆,但如果你的代码写成 byte[] data = readAll(part) 再写,一个4GB文件就能炸掉堆内存。务必使用流式API,这点我的代码里已经遵守。
第三,磁盘空间。合并会在 /data/upload_final 目录生成完整文件,临时目录放分片,两者加起来是双倍空间。我在项目里用 df -h 监控,低于20%就告警。这一点是很多人忽略的,传一半磁盘满了,整个合并失败,用户还会以为是自己网络问题。
4. 常见问题与排查技巧实录
4.1 上传过程中报“网络请求错误”,请求直接中断
这是我收到最多的反馈,现象五花八门:有的报 error: 上传失败:网络请求错误,有的是 (async upload fail error: 网络请求错误),还有的隔一会儿就 connection reset。
排查步骤我建议固定一套:第一步看浏览器Network面板,分片请求是完全没有发送,还是发送后返回异常。如果完全没发送,通常是前端并发池逻辑问题,比如并发数过高导致Socket耗尽。如果发送后断,再看服务端日志,Tomcat有没有打印异常。
实际项目里,常见原因有三个:一是前端并发数开太高,公司路由器连接数限制,降到3-4就好了;二是后端Tomcat线程池打满,增加了Spring MVC老项目JSP解析请求的干扰;三是Wi-Fi环境不稳定,单个分片传一半信号抖动,TCP重传超时。我最后的处理是前端每个分片重试3次,并且失败后先把并发数动态降为1再重试,这样弱网环境下也能慢慢传完。
4.2 上传完成后文件打不开,或者内容不对
文件损坏,九成是合并顺序错乱。前面说过 DirectoryStream 不保证顺序,必须显式对 chunkIndex 排序。还有一种场景是用户上传过程中刷新了页面,前一个上传会话的临时分片目录被定时清理,新会话又开始上传同一批文件,旧分片和新分片混在一起。我建议文件标识里加上“会话ID+时间戳”唯一属性,避免不同批次的上传互相干扰。
4.3 服务器限制上传大小,分片也救不了
有读者问,我已经分片了,为什么服务器还是拒绝?这时候要检查是不是有反向代理或防火墙在中间层拦截。我遇到过内网部署的nginx默认 client_max_body_size 1m,直接把所有POST请求挡在外面,报413 Request Entity Too Large。解决方式是改nginx配置:
nginx复制client_max_body_size 100m;
注意不仅要配置nginx,如果前面还有负载均衡、WAF,每一层都要检查。云环境的安全组策略有时候也会限制请求体大小,排查起来确实费劲,但总比用户报错要强。
4.4 上传到一半提示“当前页面有未保存的内容,确定离开吗”
这是个很讨人厌的体验问题。一些老JSP页面在 beforeunload 事件里加了全局提示,本来是为了防止表单内容丢失,结果一上传文件就弹“确定离开吗”,用户一脸懵。我的处理方式是在上传开始时临时移除这个监听器,上传结束或成功后重新挂回去。如果你控制不了全局代码,也可以在上传期间给 window.onbeforeunload 赋空函数覆盖掉。
4.5 合并时权限不足,提示“你需要来自system的权限才能对此文件夹进行更改”
这在内网Windows服务器上很常见。Tomcat服务以系统服务方式启动时,其工作账户通常是Local System,但 /data/upload_final 目录的写权限可能只给了某个人。解决办法就是给Tomcat服务账户添加目标目录的“修改”权限,或者直接把上传目录的写权限开放给Everyone。这里要注意别为了省事把整个磁盘权限放开,只针对上传根目录操作就好。
4.6 兼容性坑:某些浏览器选了文件夹就报错
webkitdirectory 在Chrome、Edge(Chromium内核)、Firefox、Opera上都正常,但老版本Safari可能有兼容问题,IE就完全没戏。如果项目必须支持IE,建议做功能降级:检测到不支持 input.webkitdirectory 时,提示用户用WinRAR打包成ZIP再上传,服务端加一个解压接口兜底。这虽然是土办法,但在政务内网、老企业办公环境里非常实用。
安全方面再提醒一句,上传接口一定要做文件类型检测和病毒扫描,后缀过滤只是最基础的,生产环境建议在合并完成之后接入杀毒引擎扫描或者至少限制可执行文件上传,防止用户传个带社工包或Webshell的东西到你的服务器上。
5. 扩展与优化方向
如果你觉得这套原生实现还不够顺手,有几个方向可以继续演进。
一是引入Web Worker。文件夹包含大量小文件时,前端的切片操作会阻塞UI线程,页面看起来像卡死。把切片逻辑放到Worker里,利用转移对象(Transferable Object)把ArrayBuffer直接交给主线程,页面可以保持流畅。前端上传也会更稳。
二是对接云存储。这套本地磁盘的方案在单机部署时够用,但如果文件要分发到多台服务器或CDN,就要考虑把分片直接POST到对象存储平台,走云厂商的multipart upload接口。遇到云存储的 AccessDenied: put public object acl is not allowed 这种报错,通常是Bucket权限策略限制,和代码无关,去控制台调IAM权限就好。
三是服务端合并改成“边传边拼”。目前是全部传完再合并,遇到超大批量文件,合并阶段耗时会比较长。进阶做法是前端按文件级别串行上传,传完一个文件就合并一个,不要等所有分片全部完成再统一合并。这样用户看到的是“已传完12个文件/总共37个”,体感上更实时,也降低了合并阶段的瞬时磁盘消耗。
四是在JSP页面里增加“已传列表”和“断点续传”的UI。页面刷新后通过localStorage记录 fileId,重新选择同一文件夹时,跳过已成功分片。这套逻辑我第一次做的时候踩了不少坑,核心在于 fileId 要稳定,并且合并完成后必须清理localStorage记录。开发时可以先在控制台打印 completedChunks 辅助调试,稳定后再隐藏日志。
个人来说,我做了这么多上传需求之后最大的体会是:大文件上传,方案原理就那些,真正决定成败的是边界情况和细节——服务端磁盘够不够、清理任务有没有跑、并发数合不合理、文件路径有没有被篡改。设计架构的时候多在这些环节花时间,上线之后就会少很多半夜被叫醒处理问题的机会。
如果你正在改造一个老JSP项目,建议先拿这篇里的分片并发方案落地,跑通之后再逐步叠加断点续传、云存储和Worker优化。代码量不大、没有额外依赖,属于性价比比较高的演进路径。后续如果你在落地过程中遇到具体报错,欢迎回来对照这篇文章里的排查清单。
