最近接到一个挺棘手的需求:在信创环境下的JSP项目里,要做“文件夹上传”。需求方说得轻巧——“就和一个网盘一样,用户选一个文件夹,整个传上来,目录结构别乱就行”。可真做起来才发现,这个需求踩的坑比预想中多得多。因为信创环境里不只是浏览器不一样,中间件不一样,连带着很多以前在Windows+Chrome上顺手就用的方案,到这儿都可能直接失效。
这篇文章就把我整个排查、选型、实现和踩坑的过程整理出来。核心目标是解决三个问题:怎么让用户能一次选中整个文件夹?怎么把文件连同相对路径传到后台?在信创的各种浏览器和中间件组合下,怎么保证这套逻辑稳定可用。无论你是刚接手信创项目的Java开发,还是被临时拉去给JSP老系统加功能的同学,这篇文章应该能帮你省掉至少两三天的试错时间。
1. 这个需求是怎么来的
1.1 信创环境下的真实场景
先说背景。这类需求通常在传统纸面办公系统迁移时出现,比如政府机关、企业内部的文档管理系统、档案归集平台。原来系统都是C/S架构或者基于IE的ActiveX插件来实现“选择本地文件夹、一键上传”。但信创改造之后,客户端浏览器基本都换成了奇安信浏览器、360企业版浏览器、红莲花浏览器之类,操作系统变成了统信UOS、麒麟、中科方德。IE彻底没了,ActiveX更是一点戏都没有,整套上传方案必须重写。
而且这类系统大多是老项目,后端可能是JSP+Servlet,JSP页面里还嵌着Java脚本片段,前端用jQuery。业务方不会允许因为一个上传功能就让你把整个前端架构推倒重来,所以必须找到一个能在现有JSP体系里平滑接入的方案。
1.2 文件夹上传和普通文件上传的根本区别
很多人一开始会想:多文件上传不是早就有吗?input标签加个multiple不就行了?但文件夹上传和多文件上传是两回事。
- input multiple 只能让用户在文件选择框里按住Ctrl或Shift多选文件,一旦文件分散在不同文件夹里,就得一个个打开找,而且选择之后文件的目录关系就丢失了。
- 文件夹上传的诉求是:用户选一个顶层目录,例如“2025年项目档案”,这个文件夹下有子目录、子子目录,里面混着Word、PDF、图片、表格,系统需要真正把这个树形结构原样保存到服务器对应路径下。
浏览器层面并没有给网页一个通用的“选择本地文件夹”接口,唯一的可用方案就是HTML5里那个webkitdirectory属性,给input加了这个属性之后,选择器就会变成目录选择模式。要注意的是,这个时候input.files不是“选中的目录”,而是“目录下所有文件的扁平列表”。每个文件对象上会多一个webkitRelativePath属性,里面保存着相对顶层目录的完整路径,例如 2025年项目档案/子目录/技术协议/合同扫描件.pdf。这是整个方案里最核心的一个字段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:别一上来就想着改前端框架
2.1 HTML5隐藏目录选择器,为什么它是主方案
我最终的方案选择了原生HTML5目录选择器,配合FormData和XMLHttpRequest做上传。
理由很简单:这是目前唯一一个不依赖任何第三方插件、不需要管理员权限、不需要浏览器扩展的纯Web方案。只要浏览器内核是Chromium 20以上,ECMAScript能正常执行,它就能跑。信创环境下的主流浏览器几乎都是基于Chromium内核或WebKit内核做的二次开发,对webkitdirectory的支持相对稳定。
具体页面上的做法是放一个隐藏的input标签:
html复制<input type="file" id="folderPicker" webkitdirectory multiple style="display:none;" />
用户点击页面上的按钮时,用JS触发click事件,展示系统原生目录选择框。选择完成之后,input的files集合就是所有文件的列表。这份列表是扁平的,但配合webkitRelativePath,我们可以重建出完整的目录结构。
这里有一个关键点很多人容易忽略:只加webkitdirectory不加multiple,在某些浏览器里也能工作,但为了让所有兼容的浏览器都表现一致,最好两个属性都加上。
2.2 第三方上传组件能不能用
我最初也考虑过直接引入WebUploader、Uploadify这类成熟组件。翻完源码和文档之后发现并不划算。原因有三个:
- 它们更擅长的是“多文件选择+进度条+并发上传”,但对“文件夹相对路径”的支持要么没有,要么是半成品,需要自己改源码维护。
- 信创环境下前端资源加载也有讲究,很多内网环境根本连不了外网CDN,组件库要手动下载放到本地,依赖关系一多,维护成本直线上升。
- JSP老项目里已经有一堆全局变量和旧版jQuery,第三方组件的初始化逻辑容易和原有代码冲突,尤其是碰到那种页面上同时有几个异步请求的场景。
所以我的结论是:如果业务方要求的文件夹上传是核心功能,不要用通用上传组件硬套,原生实现反而更干净。
2.3 ZIP解压上传方案,什么场景才需要
还有一种思路是用户在浏览器端先把整个文件夹压缩成ZIP,再上传压缩包,服务端用工具解压。在纯浏览器网页里实现“选中文件夹->自动压缩->上传”,需要用到复杂的File System Access API,在信创浏览器的兼容性远不如webkitdirectory。因此这个方案只适合一种场景:用户本地已经有一个ZIP压缩包,而且服务端能保证存储路径和包内结构一致。
如果你的需求是“让不懂技术的普通用户选个文件夹就完事”,请死心,别选这条方案。
3. 前端实现:从选文件夹到构造完整的相对路径
3.1 基础代码结构
在JSP页面中,我新建了一个js文件来处理整套上传逻辑。大致的步骤是:
- 绑定按钮click事件,触发隐藏input的click。
- input的change事件触发后,读取files集合。
- 遍历files,从webkitRelativePath中取出文件相对路径。
- 通过FormData追加文件,同时追加一个customPath字段表示文件存放的相对路径。
- 用XMLHttpRequest把FormData发送到后台Servlet。
第4步是核心。FormData的优势是无需手动设置Content-Type,浏览器会生成完整的multipart/form-data边界字符串,服务端用现成的文件上传解析API就能接收。
下面是精简版实现:
javascript复制var fileList = [];
document.getElementById('btnSelectFolder').addEventListener('click', function () {
document.getElementById('folderPicker').click();
});
document.getElementById('folderPicker').addEventListener('change', function (event) {
var files = event.target.files;
if (!files || files.length === 0) return;
fileList = [];
for (var i = 0; i < files.length; i++) {
var file = files[i];
var relativePath = file.webkitRelativePath || file.relativePath || file.name;
fileList.push({
file: file,
relativePath: relativePath
});
}
renderFileTree(fileList); // 页面展示
});
function uploadFiles() {
if (fileList.length === 0) return;
var formData = new FormData();
for (var i = 0; i < fileList.length; i++) {
// 这里不能直接formData.append('files', fileList[i].file)
// 因为后端的Servlet需要同时收到路径信息
formData.append('files', fileList[i].file);
formData.append('paths', fileList[i].relativePath);
}
var xhr = new XMLHttpRequest();
xhr.open('POST', contextPath + '/uploadFolderServlet', true);
xhr.onload = function () {
if (xhr.status === 200) {
// 处理响应
}
};
xhr.send(formData);
}
注意我同时用两个同名字段分别存放文件和路径,在后台可以按顺序接收。有人会问,为什么不直接用file.webkitRelativePath作为文件名?因为很多老Servlet在接收时会把文件名当成纯文件名处理,一遇到路径分隔符就出问题,分开传更稳。
3.2 关键知识点:webkitdirectory、webkitRelativePath
这两个是WebKit内核首先实现的API,名字里带个webkit前缀,但在Chromium和大多数国产双核浏览器里都是可用的。Mozilla Firefox也实现了同样的功能,只是API名称可能不带前缀,因此兼容性写法是:
javascript复制var relativePath = file.webkitRelativePath || file.relativePath || file.name;
不同浏览器对webkitRelativePath返回值的分隔符不同。绝大多数场景下返回的是标准/分隔符,但个别浏览器在Windows平台可能返回反斜杠\。在把路径传给服务端之前,最好做一次统一替换,把\全部转成/。
javascript复制relativePath = relativePath.replace(/\\/g, '/');
如果不统一替换,在服务端用File.separator拼接路径时,Windows机器上可能又拼出反斜杠,Linux机器上拼出正斜杠,同一个程序部署到不同平台,行为不一致,排查起来很痛苦。
3.3 构建并保存目录树,避免乱序
files集合是一个扁平数组,而且顺序不保证和用户在资源管理器里看到的一致。直接按顺序上传,在服务端虽然能通过判断路径中的目录名来创建目录,但风险是:如果先上传深层目录的文件,而上层目录还没创建,后端代码又没做mkdirs,就会直接失败。
所以稳妥做法是:在上传前,先把所有文件的相对路径按目录层级拆分,前端构建出一个目录树对象,顺序上保证“先上传顶层文件,再上传子目录文件”。不过说实话,在后端统一用mkdirs递归创建目录之后,这个排序问题就弱化了很多。前端构建目录树真正的作用是给用户一个清晰的“待上传文件预览列表”,让人知道到底选了多少个文件、占用多大空间。
javascript复制function buildFileTree(fileList) {
var root = { name: '/', children: {}, files: [] };
fileList.forEach(function (item) {
var parts = item.relativePath.split('/');
var currentNode = root;
for (var i = 0; i < parts.length - 1; i++) {
if (!currentNode.children[parts[i]]) {
currentNode.children[parts[i]] = { name: parts[i], children: {}, files: [] };
}
currentNode = currentNode.children[parts[i]];
}
currentNode.files.push(item.file.name);
});
return root;
}
这里有一个实际体验问题:如果文件夹里有几千个文件,一次性渲染整个树形列表会让页面卡顿。我的处理是只展示目录树结构,不展示每个文件的详细信息,文件级别的信息折叠在目录节点后面,比如“人事档案(152个文件)”。这样用户能看懂上传范围,又不会因为DOM节点太多导致页面无响应。
3.4 并发上传与控制策略
一次性把所有文件放进一个FormData里发送,通常能行,但遇到大目录,比如几千个文件、几百MB,这个方案就危险了。就算服务器不限制请求体大小,浏览器也可能因为请求发送时间过长而超时,而且一个大请求挂了就得重传,没有任何断点能力。
所以我建议把“整体上传”改成“分批上传”:把所有文件按照一定规则分批,每批最多20个文件,所有文件并发数量控制在3个请求以内。
核心逻辑:
javascript复制var concurrency = 3;
var index = 0;
var activeCount = 0;
function startUpload() {
while (activeCount < concurrency && index < fileList.length) {
var batch = [];
for (var i = 0; i < 20 && index < fileList.length; i++, index++) {
batch.push(fileList[index]);
}
uploadBatch(batch);
}
}
function uploadBatch(batch) {
activeCount++;
var formData = new FormData();
batch.forEach(function (item) {
formData.append('files', item.file);
formData.append('paths', item.relativePath);
});
var xhr = new XMLHttpRequest();
xhr.open('POST', contextPath + '/uploadFolderServlet', true);
xhr.onload = function () {
activeCount--;
if (xhr.status === 200) {
// 该批次成功,记录进度
} else {
// 该批次失败,把批次状态标记为失败
}
startUpload();
};
xhr.onerror = function () {
activeCount--;
// 网络错误,处理重试逻辑
startUpload();
};
xhr.send(formData);
}
注意,并发数不是越大越好。信创环境下的服务器和中间件配置通常不会太高,并发太高容易把Tomcat线程池打满,导致其他业务接口被拖垮。实测下来,3个并发、每批20个文件是比较平衡的值。
4. 后端JSP/Servlet接收:把文件写到正确的位置
4.1 Servlet接收和解析
后端如果用的是Servlet 3.0以上版本,可以直接用Part接口接收文件;如果是老项目且没有用Spring MVC,还是用Apache Commons FileUpload更省心。这里我以Commons FileUpload为例。
因为前端传了files和paths两个字段,接收时需要按顺序匹配。Commons FileUpload的getFileItems会按请求体中的字段顺序返回,所以只要前端先append一个file,再append一个path,后端的循环就能一一对应。
java复制ServletFileUpload upload = new ServletFileUpload(new DiskFileItemFactory());
List<FileItem> items = upload.parseRequest(request);
List<FileItem> fileItems = new ArrayList<>();
Map<String, List<String>> paths = new HashMap<>();
for (FileItem item : items) {
if (item.isFormField()) {
if ("paths".equals(item.getFieldName())) {
paths.computeIfAbsent("paths", k -> new ArrayList<>()).add(item.getString("UTF-8"));
}
} else {
fileItems.add(item);
}
}
for (int i = 0; i < fileItems.size(); i++) {
FileItem fileItem = fileItems.get(i);
String relativePath = paths.get("paths").get(i);
saveFile(fileItem, relativePath);
}
这里有一个细节:FormData里同名fields是否能按顺序匹配,在不同浏览器下表现基本一致,但为了保险,我给每个path字段再带一个序号也行,比如path_0、path_1。不过实测发现,现代浏览器对于同名FormData字段的顺序是稳定的,所以前端可以不改。
4.2 路径拼接与目录穿越防护
这段是安全重点。用户传入的relativePath如果直接被拼进文件路径,比如:
java复制String saveRoot = "/data/upload/";
String finalPath = saveRoot + relativePath;
一旦用户可以自由构造路径,就能通过../跳出根目录,把文件写到服务器的任意位置,这绝对是灾难。所以服务端必须做两层防护:
第一层,校验路径里不允许出现..,不允许绝对路径开头的/。
java复制if (relativePath.contains("..") || relativePath.startsWith("/") || relativePath.startsWith("\\")) {
throw new IllegalArgumentException("非法路径");
}
第二层,最终保存前,将根目录 + 相对路径转换成标准路径,再确认它以根目录开头:
java复制Path rootPath = Paths.get(saveRoot).toAbsolutePath().normalize();
Path targetPath = rootPath.resolve(relativePath).normalize();
if (!targetPath.startsWith(rootPath)) {
throw new IllegalArgumentException("非法路径");
}
在代码里我还会用new File生成父目录:
java复制File targetFile = targetPath.toFile();
if (!targetFile.getParentFile().exists()) {
targetFile.getParentFile().mkdirs();
}
fileItem.write(targetFile);
目录穿越问题在信创环境的检查中通常会被安全扫描工具(漏洞扫描、等保测评)列为一个风险点,处理不好会直接被打回整改,所以这一步千万别省。
4.3 中间件与JSP容器适配
信创环境下,项目不一定部署在Tomcat上,常见的还有东方通TongWeb、宝兰德BES、金蝶Apusic等国产中间件。这些中间件很多是兼容Servlet规范的,但具体实现细节可能和Tomcat不一样。
我这里踩过的坑主要有两个:
- 文件上传大小限制。有些中间件默认配置文件里对request的maxPostSize设置得很小,比如2MB,需要去中间件的部署描述符或控制台调大。
- getPart和Part接口的实现兼容性问题。部分中间件对Servlet 3.0的Part支持不够完善,用getPart读文件名的时候返回值不规则。这也是为什么我更推荐Commons FileUpload而不是Servlet原生Part接口,因为Commons FileUpload是纯Servlet API之上的实现,兼容性更稳定。
如果你必须在Servlet原生接口上处理,建议多写一个兼容分支:
java复制String submittedFileName = null;
if (part.getContentDisposition() != null) {
for (String content : part.getHeader("content-disposition").split(";")) {
if (content.trim().startsWith("filename")) {
submittedFileName = content.substring(content.indexOf('=') + 1).trim().replace("\"", "");
}
}
}
这段代码在Tomcat、TongWeb、BES上都能正常运行。
5. 信创环境下的兼容性检查清单
5.1 浏览器兼容性
信创环境里没有IE,但浏览器种类还是比较多,常见的有奇安信浏览器、360企业版、红莲花浏览器、以及一些基于Chromium内核的自研浏览器。绝大部分都支持webkitdirectory,但有一个现象要注意:部分浏览器在非https页面下会禁用一些高级API,而文件选择不属于被禁范围,所以基本没问题。
还有一个兼容细节:这些浏览器在打开目录选择器时,顶部地址栏、状态栏会对本地路径做脱敏,但你拿到的File对象里的webkitRelativePath仍然是完整的。不要试图通过document.getElementById('folderPicker').value去获取目录路径,那个值在浏览器安全策略下通常是假的,比如C:\fakepath\xxx,它代表的只是第一个文件,而不是选中的目录。
5.2 中间件和JDK版本
JSP老项目可能还跑在JDK 7或JDK 8上。如果你用了Commons FileUpload 1.3.x,在JDK 7下没问题;如果升级到1.4或更高版本,要求JDK 8以上,注意排查。另外,FileUpload依赖的commons-io版本也要匹配,版本不一致会出现NoSuchMethodError。
我自己建议的项目依赖组合是:
- commons-fileupload 1.3.3
- commons-io 2.6
- Servlet API 3.1
JDK 8 + 这个组合,在TongWeb和Tomcat 8上都跑得很稳。
5.3 替换IE ActiveX遗留模块
老系统里通常会有个IE Only的上传控件,通过ActiveX读取用户选择的文件夹。改造时,页面里需要把这类控件彻底移除,否则浏览器会直接出现插件加载失败的错误提示。
替换思路是:保留原有上传按钮和回显样式,只把内部逻辑从ActiveX调用换成webkitdirectory选择器。如果原来的ActiveX控件占位是一个object标签,做一个兼容性判断:
javascript复制function isActiveXValid() {
try {
return typeof iFolderUpload !== 'undefined' && iFolderUpload != null;
} catch (e) {
return false;
}
}
在信创浏览器里,这段判断会返回false,就自动走HTML5分支,不会影响页面展示。
6. 常见问题与排查经验
6.1 文件列表为空,或选择器不出现在信创浏览器中
表现:点击按钮没反应,或选择器弹出来但选完目录后files为空。
排查思路:
- 确认input元素不是动态创建的,或者动态创建之后被append到了DOM里才能触发click。部分信创浏览器对未挂载DOM的元素click事件有限制。
- 确认webkitdirectory拼写正确,网上很多例子少写一个t,写成webkdirectory,在Chrome里不报错但不起作用。
- 确认multiple也在input上。奇安信浏览器版本较老时,只加webkitdirectory不加multiple,目录选择器可能不会弹出。
6.2 大型目录上传时死循环
表现:选择文件夹后点击上传,页面崩溃或一直卡住。
原因通常不是上传代码,而是渲染文件列表时一次性创建了上万行DOM。我建议把列表渲染改成懒加载,只渲染前100条,滚动到底部再追加渲染下一批。
另外一个隐藏性能坑是:webkitdirectory会把所有文件一次性加载进内存,包括隐藏文件、临时文件,如果目录里有几十万个文件,浏览器会自己先卡一会儿。这个属于浏览器底层行为,前端能优化的空间不大,但可以提示用户拆分成多个小目录分批上传。
6.3 中文文件名乱码
表现:传到服务器后中文文件名变成问号或乱码。
处理步骤:
- 前端FormData的append文件名时不需要额外编码,浏览器会自动处理。
- 服务端解析时,确保request.setCharacterEncoding("UTF-8")在parseRequest之前执行。
- Commons FileUpload构造时,设置setHeaderEncoding("UTF-8")。
java复制ServletFileUpload upload = new ServletFileUpload(new DiskFileItemFactory());
upload.setHeaderEncoding("UTF-8");
这一步非常关键,因为文件名字段是作为Header的一部分传递的,默认编码可能是ISO-8859-1,不设置UTF-8就会乱码。
6.4 目录结构错乱
表现:上传后所有文件都堆在根目录下,没有子目录。
大部分情况是前端没有把webkitRelativePath正确传过来。排查时先在前端控制台打印file.webkitRelativePath,确认值是否含有路径分隔符。如果打印出来只有文件名,说明浏览器没提供相对路径。可以试试file.relativePath这个旧字段,或者换成最新版相关浏览器的兼容模式。
6.5 上传到一半失败、连接中断
表现:文件数量多,传了200个之后突然失败。
原因可能是服务端请求体大小限制,或者是服务器超时时间设置过短。上传是在改造成批量发送之后,单个请求体不会大到触发限制,但如果在极慢的内网环境,一个批次也可能超时。处理办法:
- 服务端连接超时时间调整到120秒以上。
- 前端做批次数轮询,失败批次自动重试3次。
- 重试仍然失败的,记录到页面一个失败列表中,允许用户单独点击重传。
7. 一点实践总结
我最终交付的版本没有引入任何重型前端框架,底层就是一段原生JS加上Commons FileUpload,JSP页面里只多了一个隐藏input和两个按钮,整个功能在一个老式JSP表单页面里无缝嵌进去了。用户操作路径和以前一样:点按钮、选文件夹、看到文件树、点上传,唯一的变化是底层从ActiveX换成了浏览器原生能力。
踩过这么多坑之后,我个人最大的体会是:信创环境下的技术选型,永远别挑战框架和组件的兼容性极限,能用原生API解决的事,就不要为了炫技去引第三方库。Web标准里已经提供了足够强的能力,加上一层薄薄的封装,往往比那些动不动就几百KB的组件库更稳、更可控。
最后再分享一个提高开发效率的小技巧:在本地调试时,如果不需要测试真实的信创浏览器,直接用Chrome的开发人员工具里的设备模式模拟目录选择器即可,验证webkitRelativePath和FormData传输都完全等价。真正上线前再拿国产浏览器和信创中间件做一轮冒烟测试,把时间花在刀刃上。
