上周接了一个老 JSP 项目的改造,需求列表里有一条看着很简单:上传整个文件夹到服务器。我一开始没当回事,直到测试拿着一份几十层的设备图纸目录,拖到页面上才发现,普通 file input 根本不支持选文件夹。那一刻我才意识到,JSP 里的文件夹上传,和普通文件上传完全是两码事:前端要拿到目录结构,后端要按目录结构落盘,中间还要处理中文路径、重名覆盖、大目录超时这类问题。这篇文章就梳理一下我实际调研和测试过的开源文件夹上传组件,以及最后真正跑通的方案。
1. 先搞清楚“文件夹上传”到底难在哪
1.1 普通文件上传组件为什么处理不了文件夹
HTML 里最标准的 <input type="file"> 虽然有 multiple 属性,但它的语义是“多个文件”,不是“一个文件夹”。用户点击选择文件时,浏览器弹出的系统对话框并没有提供目录树选择模式,你只能按 Ctrl 键一个个点选文件。很多开源上传组件本质上还是在操作这个 input 标签,顶多做了拖拽、队列、进度条这些外围功能,并没有真正解决“目录结构”的问题。
文件夹上传的核心难点在于:浏览器默认不会告诉你文件在哪个目录。服务器收到一堆文件流之后,根本不知道它们原本的层级关系;就算能把名字对上,也没有办法还原多层目录。所以“开源文件夹上传组件”这个概念,拆开来看其实是两部分:前端负责拿到目录结构和文件列表,后端负责根据结构把文件落盘。单靠一个组件很难包办。
1.2 浏览器给的上传能力边界:webkitdirectory 是核心
后来我做了一轮技术验证,发现浏览器其实提供了原生能力,只是很多项目没用过。给 input 加上 webkitdirectory 和 multiple,Chrome、Edge、Firefox 会弹出目录选择器,选中整个文件夹之后,input.files 里会返回该目录下所有文件。关键属性是每个 File 对象上的 webkitRelativePath,它会返回类似 设备图纸/0305批次/结构图/001.dwg 这样的相对路径,这正是服务端重建目录结构的关键信息。
这个原生能力已经存在很多年了,比很多开源组件都稳定。但问题在于,它只解决了“选择文件夹”这一步,后面的队列管理、路径拼接、安全校验、大目录分批上传,都需要自己写。所以我们可以用原生 API 做地基,再配上开源的服务端库,形成一套完整方案。
1.3 选型之前先画一条边界
我在动手之前给自己画了一条边界:前端负责把“相对路径”传给后端,后端负责安全地按相对路径创建目录和写文件,组件只负责解决“选择目录”和“上传流程”。带着这个边界再去调研开源组件,就会非常清楚哪些能用、哪些需要改造。
选型时我会问三个问题:这个组件支持不支持读取 webkitRelativePath?社区还在维护吗?和当前 JSP 项目里的 jQuery、Bootstrap 老一套能不能兼容?这三个问题卡下来,很多组件其实已经被淘汰了。接下来我把实际调研到的几款开源组件列出来,说清楚它们的优缺点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 亲身用过和调研过的开源组件横评
2.1 百度 WebUploader:最契合但已停止维护
提到文件夹上传,很多做 Java Web 的老工程师第一反应就是百度 FEX 团队开源出来的 WebUploader。它在 GitHub 上的仓库是 fex-team/webuploader,MIT 协议,支持文件夹选择、拖拽上传、分片上传、并发控制和上传进度,底层同时封装了 HTML5 和 Flash 两种模式,当年就是为了兼容 IE8+ 设计的。它对文件夹的支持逻辑就是基于 webkitRelativePath 的,拿到相对路径之后,可以在 beforeUpload 回调里把它放到上传请求里,后端再按这个路径存文件。
我在一个老 ERP 项目里确实用过 WebUploader,当时的感觉是功能很全,配置项多到可以满足大部分需求。但后来再看,这个项目基本已经处于停更状态,仓库常年没有新提交,官方文档里推荐你下载的 JS 文件也是很多年前编译的。它依赖 jQuery 1.x,在现代浏览器里偶尔会碰到按钮点击无响应、上传队列闪烁这类问题。如果你手里的 JSP 项目刚好是 Bootstrap 3 + jQuery 1.12 + IE 这种古董组合,WebUploader 仍然值得考虑;但如果用户已经切换到 Chrome 或者 Edge,我更建议别再往里面引了。
2.2 Blueimp jQuery-File-Upload:文件上传神器,文件夹能力弱
Blueimp 的 jQuery-File-Upload 在 GitHub 上 Star 数量很多,也是 MIT 协议,jQuery 时代文件上传库里的“事实标准”。它支持多文件选择、拖拽上传、进度条、缩略图、分块上传,服务端还提供了 Java 示例,集成很方便。
但它是文件上传组件,不是文件夹上传组件。你把 webkitdirectory 加到它的 input 上,它也能把文件夹下的所有文件塞进队列,但每个文件不会自动携带目录路径。你必须自己读 file.webkitRelativePath,在 add 回调里把路径塞到 formData 里,后端再单独取出来用。等于说,你只是借了它的队列、重试、进度 UI,核心的文件夹逻辑还是自己写。如果你的 JSP 项目已经在用 jQuery,并且不想引入太多新库,Blueimp 是一个不错的底子,但不要指望它开箱即用。
2.3 Plupload / Dropzone.js / Fine Uploader:各有侧重,但都需要二次开发
Plupload 是老牌开源上传组件,授权是 GPLv2 或者商业授权。它支持 HTML5、Flash、Silverlight 等多种运行时,早年兼容性很强。我实际用过之后的感觉是,它的队列交互很成熟,分片上传也稳定,但文件夹支持同样是短板,目录树的信息不会自动带过来。
Dropzone.js 是一个轻量库,MIT 协议,界面简洁好看,适合快速搭一个拖拽上传区。它的文件上传交互做得非常舒服,但默认也只是多文件上传,文件夹支持需要通过自定义事件读取 webkitRelativePath,能力属于“可以改”的级别。Fine Uploader 有开源版,协议是 GPLv3,原生支持部分文件夹上传,但 GPLv3 对商业项目有协议传染风险,在对接 JSP 商业系统之前一定要先和法务确认许可证。
这几款开源组件的共同问题,都不是“能不能用”,而是“帮你做了一半就让你自己去填剩下的一半”。与其这样,不如直接用原生方案,代码完全可控,排查问题也更容易。
2.4 横向对比表格
| 组件名称 | 开源协议 | 文件夹上传支持 | 维护状态 | 适合场景 |
|---|---|---|---|---|
| 百度 WebUploader | MIT | 原生支持 | 基本停更 | 兼容 IE8+ 的旧 JSP 系统 |
| Blueimp jQuery-File-Upload | MIT | 需二次开发 | 较活跃 | jQuery 技术栈的多文件上传 |
| Plupload | GPLv2/商业 | 仅多文件 | 已归档 | 老浏览器兼容场景 |
| Dropzone.js | MIT | 需二次开发 | 活跃 | 轻量界面,快速集成 |
| Fine Uploader | GPLv3/商业 | 原生支持,协议需评估 | 维护中 | 对协议合规有把握的项目 |
| 原生 webkitdirectory + 开源后端库 | 无额外许可 | 最直接 | 依赖浏览器 | Chrome/Edge/Firefox 用户 |
调研完之后我发现,单纯问“有哪些开源的文件夹上传组件”,答案并不重要,重要的是选型思路。下面说说我最后选定的方案,以及为什么这么做。
3. 我最终推荐的方案:原生目录选择 + Apache Commons FileUpload
3.1 为什么放弃 WebUploader 而用原生方案
我在真实项目里最终选了“原生 webkitdirectory 选目录 + Apache Commons FileUpload 收文件”的组合,没有用 WebUploader。原因有三。
第一,项目不需要兼容 IE,只需要保证 Chrome、Edge、Firefox 能用。既然浏览器原生支持目录选择,何必再引几百 KB 的 JS 库。
第二,WebUploader 的依赖链比较重,它和旧版 jQuery 深度绑定,而我们的新 JSP 页面采用的是原生 JavaScript 和少量 jQuery,两套东西混在一起很别扭。
第三,文件夹上传的问题重心在后端,也就是如何安全地还原目录结构。前端不管选哪个组件,最后都要传 webkitRelativePath 给后端,用原生方案反而更容易控制路径字段的传递,排错也直观。
Apache Commons FileUpload 是 Apache 开源的老牌 multipart 解析库,配合 Servlet API 能完成文件接收。它本身没有文件夹的概念,但可以帮你拿到表单字段和文件流。我们只需要把前端传来的相对路径放在表单字段里,保存时拼上去,就能把目录结构还原出来。
3.2 前端如何拿到目录结构和文件列表
先看最核心的 HTML 和 JS 代码。目录选择框长这样:
html复制<input type="file" id="folderPicker" webkitdirectory multiple />
当用户选择完目录后,遍历 input.files 就能拿到所有文件和路径:
javascript复制const input = document.getElementById('folderPicker');
input.addEventListener('change', function (e) {
const files = Array.from(e.target.files);
files.forEach(file => {
// file.webkitRelativePath 形如 "根目录/子目录/文件名"
console.log(file.webkitRelativePath, file.size, file.name);
});
});
这段代码已经能拿到完整的目录层级信息了。真正的上传需要把这些文件包成 FormData,一并 POST 给后端。为了让后端准确知道每个文件对应的目录路径,我给每个文件增加了一个 path_索引 字段:
javascript复制function uploadFolder() {
const formData = new FormData();
pendingFiles.forEach((file, index) => {
formData.append('file_' + index, file, file.name);
formData.append('path_' + index, file.webkitRelativePath || file.name);
});
$.ajax({
url: 'folderUpload',
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function (res) {
alert(res);
}
});
}
这里有一个产品层面的限制要提前说清楚:目录选择器只会返回文件列表,空目录是不会出现在结果里的。也就是说,如果你有一个空文件夹需要保留占位,这种方案无法实现。遇到这种需求,要么额外加一个“创建空目录”的接口,要么接受这个限制。
3.3 后端如何还原目录:不要在 getSubmittedFileName 上做文章
很多 JSP 项目里,传统文件上传的后端代码都是获取文件名,然后直接保存到指定目录:
java复制String fileName = item.getName();
File target = new File(uploadRoot, fileName);
item.write(target);
这种方式在普通文件上传里没问题,但在文件夹上传里会丢掉目录结构,同名子目录下的文件还会互相覆盖。正确做法是额外读取每个文件携带的 path 字段,对路径做清洗和校验,然后先创建父目录,再写文件。
后面我会给出完整代码,这里先讲一下思路:
java复制String relativePath = getFieldValue(item, "path");
String safePath = sanitizeRelativePath(relativePath);
File target = new File(uploadRoot, safePath);
File parent = target.getParentFile();
if (parent != null && !parent.exists()) {
parent.mkdirs();
}
item.write(target);
这里的 sanitizeRelativePath 就是安全核心,必须处理路径穿越问题,这部分我在踩坑章节详细说。
4. 一个可落地的 JSP 文件夹上传 Demo
4.1 依赖环境和项目结构
我用的是 JDK 8、Tomcat 8.5、Servlet 3.1,后端用 Apache Commons FileUpload 1.5 解析 multipart 请求。注意 Commons FileUpload 的旧版本存在安全问题,要么用它提供的安全版本,要么至少保证版本是新的稳定版。
项目结构保持 Maven 标准结构:
text复制src/main/java/com/example/FolderUploadServlet.java
src/main/webapp/upload.jsp
pom.xml
pom.xml 里需要加上依赖:
xml复制<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.5</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
4.2 upload.jsp 页面与 JS 核心代码
upload.jsp 页面不需要太花哨,核心是目录选择按钮、上传按钮和文件列表展示区。我这里为了保持和老 JSP 页面风格一致,用了少量 jQuery 发 Ajax,但上传逻辑本身都是原生 API 实现的。
html复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>文件夹上传Demo</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<h3>选择文件夹后点击上传</h3>
<input type="file" id="folderPicker" webkitdirectory multiple />
<button id="uploadBtn">上传文件夹</button>
<ul id="fileList"></ul>
<script>
let pendingFiles = [];
const folderPicker = document.getElementById('folderPicker');
const uploadBtn = document.getElementById('uploadBtn');
const fileList = document.getElementById('fileList');
folderPicker.addEventListener('change', function (e) {
pendingFiles = Array.from(e.target.files);
fileList.innerHTML = '';
pendingFiles.forEach(file => {
const li = document.createElement('li');
li.textContent = file.webkitRelativePath + ' (' + file.size + ' bytes)';
fileList.appendChild(li);
});
});
uploadBtn.addEventListener('click', function () {
if (pendingFiles.length === 0) {
alert('请先选择文件夹');
return;
}
const formData = new FormData();
pendingFiles.forEach((file, index) => {
formData.append('file_' + index, file, file.name);
formData.append('path_' + index, file.webkitRelativePath || file.name);
});
$.ajax({
url: 'folderUpload',
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function (res) {
alert('上传成功:' + res);
},
error: function (xhr) {
alert('上传失败:' + xhr.responseText);
}
});
});
</script>
</body>
</html>
这里使用 file_0、path_0、file_1、path_1 这样的字段名,是为了后端能按索引一一匹配。如果直接拼 FormData,多个文件字段和路径字段混在一起,解析时顺序依赖太强,容易出隐藏 bug。
4.3 后端 FolderUploadServlet 完整写法
后端 Servlet 用 Commons FileUpload 解析。因为前端是 multipart/form-data,但字段名是自定义的 file_N 和 path_N,所以代码里要做索引匹配。
java复制package com.example;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@WebServlet("/folderUpload")
public class FolderUploadServlet extends HttpServlet {
private static final String UPLOAD_ROOT = System.getenv().getOrDefault("UPLOAD_ROOT", "/data/upload");
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
resp.setContentType("text/plain;charset=UTF-8");
if (!ServletFileUpload.isMultipartContent(req)) {
resp.getWriter().write("not multipart");
return;
}
DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setSizeThreshold(1024 * 1024);
factory.setRepository(new File(System.getProperty("java.io.tmpdir")));
ServletFileUpload upload = new ServletFileUpload(factory);
upload.setHeaderEncoding("UTF-8");
upload.setFileSizeMax(1024L * 1024L * 1024L);
upload.setSizeMax(1024L * 1024L * 1024L);
Map<Integer, FileItem> files = new HashMap<>();
Map<Integer, String> paths = new HashMap<>();
try {
List<FileItem> items = upload.parseRequest(req);
for (FileItem item : items) {
String fieldName = item.getFieldName();
if (item.isFormField()) {
if (fieldName.startsWith("path_")) {
int idx = Integer.parseInt(fieldName.substring(5));
paths.put(idx, item.getString("UTF-8"));
}
} else {
if (fieldName.startsWith("file_")) {
int idx = Integer.parseInt(fieldName.substring(5));
files.put(idx, item);
}
}
}
File root = new File(UPLOAD_ROOT);
if (!root.exists()) {
root.mkdirs();
}
int count = 0;
for (Map.Entry<Integer, FileItem> entry : files.entrySet()) {
FileItem item = entry.getValue();
String relativePath = paths.get(entry.getKey());
if (relativePath == null || relativePath.trim().isEmpty()) {
relativePath = item.getName();
}
relativePath = sanitizeRelativePath(relativePath);
File target = new File(root, relativePath);
File parent = target.getParentFile();
if (parent != null && !parent.exists()) {
parent.mkdirs();
}
item.write(target);
count++;
}
resp.getWriter().write("ok:" + count);
} catch (IllegalArgumentException e) {
resp.setStatus(400);
resp.getWriter().write(e.getMessage());
} catch (Exception e) {
resp.setStatus(500);
resp.getWriter().write("error:" + e.getMessage());
}
}
private String sanitizeRelativePath(String path) {
if (path == null || path.trim().isEmpty()) {
return System.currentTimeMillis() + ".tmp";
}
String normalized = path.replace('\\', '/');
File f = new File(normalized);
if (f.isAbsolute() || normalized.startsWith("/") || normalized.contains("..") || normalized.contains(":")) {
throw new IllegalArgumentException("非法路径: " + path);
}
return normalized;
}
}
这段代码有几个关键点。第一,sanitizeRelativePath 里先替换反斜杠,避免 Windows 风格的 ..\.. 绕过检查。第二,f.isAbsolute() 能拦截 C:/xxx 这种绝对路径,normalized.contains(":") 是为了更保险地拦截盘符。第三,检查到非法路径直接抛异常,由外层捕获后返回 400,而不是继续往下写。
4.4 部署时容易忽略的参数调整
代码写完之后,还有两个部署层面的参数经常被忽略。
Tomcat 的 maxPostSize 默认是 2MB,这个值限制的是 POST 表单提交的 body 大小。如果文件夹里的文件稍微多一点,请求体很快就能超过 2MB,然后 Tomcat 直接返回 413。解决办法是在 server.xml 的 Connector 上配置:
xml复制<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443"
maxPostSize="0" />
把 maxPostSize 设为 0,表示不限制 POST body 大小,或者根据实际场景设一个大一点的数值。
另外还要注意 JVM 内存和临时目录。DiskFileItemFactory 的 sizeThreshold 设成 1MB,意思是超过 1MB 的文件会先写入临时目录,不会常驻内存。但如果用户一次性上传几千个文件,临时目录里的文件会非常多,建议定期清理系统的 java.io.tmpdir,或者把临时目录配置到独立路径,避免撑爆系统盘。
5. 上线后最容易踩的六个坑
5.1 中文编码乱码
目录和文件名里带中文,在 JSP 上传场景里几乎百分百遇到。前端把文件路径放进 FormData,浏览器的 multipart 格式通常带 UTF-8 编码,但 Commons FileUpload 默认按 ISO-8859-1 读表单字段,所以必须显式设置上传组件的编码和字段读取编码。
我在上面 Servlet 代码里写了两处:
java复制upload.setHeaderEncoding("UTF-8");
item.getString("UTF-8");
这两处缺一不可。第一处影响解析 multipart 头里的 filename,第二处影响读取 path 表单字段的值。另外,JSP 页面本身也要在开头声明 UTF-8 编码。如果你后端改成使用 Servlet 3.1 的 Part API,同样要在 request.setCharacterEncoding("UTF-8") 之后再去 getParameter,否则中文路径依然会乱。
5.2 路径穿越攻击:必须做路径归一化
前端传来的 webkitRelativePath 看起来是安全的,但攻击者可以绕过前端,直接构造一个恶意 HTTP 请求,把 path_0 字段改成 ../../../../etc/crontab,如果后端直接 new File(root, relativePath),就能把文件写到上传目录之外,甚至覆盖系统文件。
我见过不少项目在实现文件上传时完全忽略这个问题。文件夹上传等于把一个可以任意指定路径的接口直接暴露了出去,风险比普通文件上传更高。因此后端必须在保存前做严格校验,至少要做到三层:
第一,拒绝 .. 这个路径片段。第二,拒绝绝对路径。第三,保存前用 getCanonicalPath() 判断最终路径是否仍然在根目录下。
第三层可以这样实现:
java复制String canonicalRoot = root.getCanonicalPath();
String canonicalTarget = target.getCanonicalPath();
if (!canonicalTarget.startsWith(canonicalRoot + File.separator)) {
throw new IllegalArgumentException("路径越界: " + target.getPath());
}
这样即使前面有遗漏,最后一道防线也能把问题拦住。
5.3 大目录一次提交内存溢出
一个文件夹里放了 5000 个文件,全部塞进一个 FormData 一次提交,后端虽然会把文件流写临时目录,但请求解析和遍历 items 的过程仍然会在内存里维护较大的列表,而且 Tomcat 默认的工作线程有限,一个超大请求会长时间占着一个线程,并发一上来就可能把服务器拖垮。
我建议在前端做分批上传。比如每次最多 200 个文件,如果用户选择了 5000 个文件,就循环发起 25 次上传请求,每次请求之间保留一点间隙。这样单个请求的 body 不会太大,后端也能更快响应。如果业务允许,还可以考虑在服务端支持分目录压缩后上传,比如把整个文件夹打成 ZIP,由后端解压还原目录结构。这个方案对超大目录的稳定性会好很多,但需要额外引入解压逻辑,并且要防止 ZIP 炸弹攻击。
5.4 IE 和旧版 Chrome 不支持 webkitdirectory
虽然现代浏览器已经普及,但企业级 JSP 项目里偶尔还是会有 IE 或者很老的 Chrome 访问。IE11 对 webkitdirectory 的支持不完整,老版本 Safari 也有兼容问题。如果项目不能要求用户换浏览器,就要在前端做特性检测:
javascript复制const input = document.createElement('input');
if (!('webkitdirectory' in input)) {
alert('当前浏览器不支持文件夹上传,请使用 Edge、Chrome 或 Firefox');
}
同时要提供一个降级方案,比如“压缩成 ZIP 再上传”。这种功能在文件管理类系统里很常见,我认为既然要支持老浏览器,就必须把 ZIP 上传通道同步开发出来,否则功能等于没做完。
5.5 同名子目录文件覆盖
上传两个不同的顶层目录,但它们内部有相同的子目录结构和文件名,比如 A/2025/report.pdf 和 B/2025/report.pdf,如果后端只按相对路径存,第二个文件会覆盖掉第一个。因为两个相对路径完全相同,都是 2025/report.pdf。
解决思路有两个。最简单的办法是在服务端保存时加上一层顶层会话目录,比如按时间戳生成一个上传会话 ID,作为根目录下的第一层目录,所有文件都放在这个会话目录里,后续再让业务系统去遍历。另一个更符合业务场景的办法是,要求前端在拼接路径时把顶层根目录名称也带进来,这样两个目录上传后至少顶层目录是不同的。
5.6 上传中断污染临时目录
用户选择了一个大目录,点击上传,传到一半网络断了。Commons FileUpload 会把超过内存阈值的文件先写入临时目录,如果请求异常中断,临时文件不会被自动清理,时间长了会把系统盘塞满。这种问题在开发环境不常见,上线后处理大文件时会特别明显。
我的建议是定时清理任务。如果项目中没有现成的任务调度框架,可以在系统临时目录外面包一层专属目录,比如 UPLOAD_TMP,然后每天凌晨写一个脚本清理超过 24 小时没有修改的临时文件。DiskFileItemFactory 的 repository 属性可以指定到这个目录,这样就不会污染系统级临时目录,清理时也更安全。
最后再说一点个人体会。文件夹上传这件事,真没有哪个开源组件能做到让人完全省心,关键在于想清楚“目录结构信息从哪来、到哪去”。我踩过几次坑之后,现在反而更倾向于用原生 webkitdirectory 加一个精简的后端接收层,而不是一开始就引入大而全的上传框架。这样代码可控,出了问题也容易定位。如果团队里的同事还在为选哪个组件纠结,可以让他们先把这个原生方案跑通,再往上叠加交互和功能,你会发现很多原本以为需要组件解决的问题,其实几十行代码就够了。
