搞Web开发的人早晚都会碰到一个有点尴尬的需求:页面上放一个上传按钮,用户要传的不是单个文件,而是整个文件夹,里面还嵌套着好几层子目录。单文件上传用input file就完事了,文件夹上传却没那么简单——浏览器默认压根不让你拿文件夹路径,HTTP协议本身也没有“目录上传”这种概念,更别说JSP这种服务端页面技术还得负责把请求接住、把目录结构还原出来。
这篇东西就是来聊清楚这件事的:前端怎么让用户选整个文件夹,怎么把文件夹里的文件连同相对路径一起塞进HTTP请求,JSP后端怎么接收、怎么按原结构落盘,以及大文件夹上传会遇到哪些坑、怎么排掉。适合正在做Java Web项目、被文件夹上传需求卡住的开发者,或者对multipart协议和文件上传原理想深入了解的朋友。下面全是实操过的方案和代码,可以直接照抄改改用。
1. 文件夹上传的难点与整体方案设计
1.1 为什么“文件夹上传”在Web端是个老大难
先说清楚问题根源。HTTP协议传输文件用的是multipart/form-data格式,它本质上是一段一段的二进制流,每一段对应一个文件字段。协议层面既没有“文件夹”这种数据类型,也没有“目录树”的表达方式。也就是说,后端能收到的只有一堆文件,至于这些文件原来在哪个目录、目录层级长什么样,HTTP协议不管。
早期Java Web项目处理上传,清一色用commons-fileupload这类组件,它按Part来解析请求体,每个Part就是一个文件。你把整个文件夹拖进页面,浏览器默认只会上传文件夹里能被file input识别到的文件,而且目录结构全丢。这里有两个问题必须解决:
- 前端必须拿到每个文件相对于所选根目录的路径,比如
src/main/java/UserController.java,而不是只有文件名UserController.java。 - 后端拿到这些路径后,要在服务器磁盘上逐层创建目录,把文件放回对应的位置。
第一个问题的突破口是webkitdirectory属性,第二个问题则是后端处理逻辑的设计。所以整套方案不是某个单一技术就能搞定的,必须前端采集路径、HTTP传输文件流、后端重建目录三步配合。
1.2 整体技术选型:前端采集 + HTTP分批传输 + 后端落盘
我最终落地的方案分三层:
- 前端:用
<input type="file" webkitdirectory>让用户选择文件夹,JS遍历FileList,读取每个File对象的webkitRelativePath属性拿到相对路径,再用FormData批量拼装,通过XMLHttpRequest或fetch发送。 - 传输层:依然是标准的multipart/form-data请求,每个文件作为一个Part,额外附带一个自定义字段(比如相对路径)来标识这个文件该落到哪个位置。
- 后端:JSP页面负责展示上传界面和结果,真正接收请求的是Servlet,通过Servlet 3.0提供的
getParts()方法逐个处理Part,按相对路径逐层mkdir后写入文件。
这套方案的优势在于:不需要引入额外的复杂依赖,浏览器原生能力加Servlet原生API就能跑通;传输格式是标准HTTP,方便调试和扩展;目录层级信息通过自定义字段传递,不依赖任何私有协议。缺点也很明显:没有断点续传和并发控制,上传大文件夹时容易超时。这些后面再单独说优化方案。
1.3 方案对比:为什么不用压缩包上传
很多人第一反应是:让用户把文件夹打成zip再传不就行了?这类方案确实能绕开目录结构的问题,但在实际业务场景里非常不推荐。
一是用户体验差,要求非技术用户去找压缩软件、选中文件夹、压缩、再上传,每一步都是流失点。二是在部分企业内网系统里,出于安全考虑会禁用压缩软件或限制zip文件传输。三是服务端解压需要处理zip解压漏洞(比如zip slip路径穿越攻击)、中文文件名编码问题、压缩包大小限制,复杂度一点不比直接传文件夹低。四是如果文件夹里有大量大文件,用户本地压缩耗时极长,体验雪上加霜。
所以我更倾向于直接传文件夹。前端拿相对路径,后端重建目录,用户只操作一次,点击选择、确定、等待进度条走完。这个方案在真实项目中我已经跑通过多次,稳定性和体验都明显优于zip中转。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前端实现:让浏览器把整个文件夹交出来
2.1 webkitdirectory属性与文件夹选择
HTML里给input加上webkitdirectory属性后,点击选择框就会变成选择文件夹的模式,选中后input.files会返回该文件夹下的所有文件(默认是递归的,包含所有子目录里的文件)。
html复制<input type="file" id="folderPicker" webkitdirectory multiple />
注意multiple也要加上,虽然webkitdirectory本身就包含多文件语义,但某些浏览器版本对兼容写法有要求。加了这个属性之后,用户在Chrome、Edge里看到的就是文件夹选择对话框,选完以后页面拿到的FileList是一个扁平的数组,每个元素是一个File对象,但每个File对象上会多一个属性:webkitRelativePath。
比如用户选择了/Users/me/project这个文件夹,里面有一个文件位于project/src/main/java/Hello.java,那么遍历时这个File对象的webkitRelativePath就是src/main/java/Hello.java——已经自动去掉了根文件夹那层。
这个属性几乎不需要额外处理就能用,但有几个值得注意的点:
webkitRelativePath在不同浏览器里分隔符都是/,所以后端按/拆路径是安全的。- 老版本Firefox对
webkitdirectory支持不好,需要做降级,至少给出提示。 - 用户选择文件夹后,
input.files里不包含空目录——这是浏览器行为限制,空目录无法通过这种方式上传,只能后端额外提供“新建空目录”的逻辑补救。
2.2 递归遍历File对象构建相对路径
理论上直接遍历input.files就够了,因为FileList已经把所有层级的文件都列出来了。但如果你想在页面上展示目录树结构,或者需要对文件做分组、过滤、统计大小,最好自己再处理一层。
javascript复制function collectFiles(fileList) {
const grouped = {};
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
const relPath = file.webkitRelativePath || file.name;
grouped[relPath] = file;
}
return grouped;
}
用relPath做key可以把文件按路径集合起来,方便后续上传时逐条发送。这里有一个非常容易踩的坑:如果你直接用file.name而不是webkitRelativePath,那么不同目录下的同名文件(比如两个子目录里都有config.xml)会被覆盖,上传结果直接丢文件。所以一定要用相对路径作为唯一标识。
另外,页面上最好先做一个文件列表展示,把每个文件的相对路径、大小列出来,让用户确认要传的内容。这个展示过程顺带过滤掉不需要的文件类型,比如node_modules整个目录,在业务上可以配置排除规则。
2.3 用FormData拼装multipart请求
收集好文件集合后,就要把文件通过HTTP请求发出去。最简单的方式是FormData:
javascript复制function uploadFolder(fileList, targetUrl) {
const formData = new FormData();
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
const relPath = file.webkitRelativePath || file.name;
const blob = file.slice(0, file.size, file.type);
formData.append('files', blob, relPath);
}
return fetch(targetUrl, {
method: 'POST',
body: formData
});
}
这里有两个关键设计:
第一,append('files', blob, relPath)里的第三个参数就是filename。我用相对路径当文件名传给后端,后端拿到Part的提交文件名,就能直接还原目录结构。这使得后端不需要再额外解析自定义字段,只要从Part的文件名里拆路径即可。
第二,一次把所有文件都塞进一个FormData,意味着一个HTTP请求要带完整个文件夹的内容。如果你要传的文件夹不大(比如几十MB以内),这样最简单。但如果文件夹里动辄几百个文件甚至几个GB,这个方案就不行了。后面我会讲怎么拆成多个请求并发上传。
发请求之前,最好给用户一个进度反馈。fetch的大文件上传进度拿不到,这时候可以换XMLHttpRequest:
javascript复制function uploadFolderWithProgress(fileList, targetUrl) {
const formData = new FormData();
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
formData.append('files', file, file.webkitRelativePath || file.name);
}
const xhr = new XMLHttpRequest();
xhr.open('POST', targetUrl);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
console.log(`上传进度:${percent}%`);
}
});
xhr.send(formData);
return xhr;
}
需要注意:一次性把所有文件append到一个FormData,浏览器是先把整个请求体缓存好再发送的,所以内存占用会随文件夹总大小增长。文件数量特别多时,建议分组分批发送,不要贪图省事一把梭。
3. JSP后端接收与落盘实现
3.1 Servlet 3.0 multipart解析入门
讲后端之前先明确一个原则:JSP在这套方案里只负责页面展示,真正的上传处理逻辑必须写在Servlet或者独立的处理类里。不要在JSP的<% %>里写一堆上传解析代码,那样既难维护又容易出安全问题。
Servlet 3.0之后,处理multipart请求变得非常简单,不再需要手动解析输入流。只需三步:
- 在Servlet类上加上
@MultipartConfig注解,或者对应Servlet注册时配置multipart-config。 - 在doPost里调用
request.getParts()拿到所有Part。 - 遍历Part,
part.getSubmittedFileName()拿文件名,part.getInputStream()拿文件流,然后写入目标目录。
java复制@WebServlet("/upload/folder")
@MultipartConfig(
maxFileSize = 1024 * 1024 * 500, // 单个文件最大500MB
maxRequestSize = 1024 * 1024 * 1024 * 2, // 整个请求最大2GB
fileSizeThreshold = 1024 * 1024 // 超过1MB写入临时文件
)
public class FolderUploadServlet extends HttpServlet {
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
Collection<Part> parts = request.getParts();
String baseDir = "/data/uploads/" + System.currentTimeMillis();
for (Part part : parts) {
String submittedFileName = part.getSubmittedFileName();
if (submittedFileName == null || submittedFileName.isEmpty()) {
continue;
}
// submittedFileName形如:src/main/java/Hello.java
File targetFile = new File(baseDir, submittedFileName);
File parentDir = targetFile.getParentFile();
if (!parentDir.exists()) {
parentDir.mkdirs();
}
try (InputStream in = part.getInputStream();
FileOutputStream out = new FileOutputStream(targetFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
}
response.getWriter().write("upload success");
}
}
这段代码的核心逻辑就一句话:拿到文件名,把文件写到“基础目录+相对路径”的位置,目录不存在就递归创建。看代码之前,先看三个必须提前搞清楚的配置项。
3.2 三个必须提前搞清楚的配置项
@MultipartConfig的每个参数都有讲究,我逐一说。
maxFileSize:单个文件的大小上限。如果某个文件超出这个值,Servlet会直接抛IllegalStateException,你需要在doPost里捕获并返回友好提示,否则用户看到的只是500错误页。maxRequestSize:整个请求体的大小上限。文件夹总大小超了也会报错。这个值取决于你的服务器磁盘和业务场景,我一般在测试环境设1GB,生产环境根据业务需要设10GB以上(注意操作系统和文件系统对单文件大小的限制)。fileSizeThreshold:超过这个阈值,上传的数据会先写入服务器临时目录,否则缓存在内存。太小会导致频繁磁盘IO,太大容易内存溢出。1MB是比较合理的默认值。
另外一个容易忽略的点是request.setCharacterEncoding("UTF-8")必须放在读取Part之前,否则中文文件名和路径很容易乱码。提交时浏览器使用UTF-8编码multipart头部,容器默认可能用ISO-8859-1来解码文件名,必须在解析前指定UTF-8。
3.3 路径穿越防护:别让文件名变成安全隐患
这是很多初学方案里几乎没有覆盖但是必须处理的问题。上面代码里new File(baseDir, submittedFileName)看起来简单,但如果有恶意用户构造文件名../../etc/passwd,文件就会写到目标目录之外去,这就是著名的路径穿越漏洞。
JSP项目里一定要做这层防护:
java复制private File safeResolve(String baseDir, String submittedFileName) throws IOException {
String canonicalBase = new File(baseDir).getCanonicalPath();
File target = new File(baseDir, submittedFileName).getCanonicalFile();
if (!target.getPath().startsWith(canonicalBase + File.separator)) {
throw new IOException("非法路径:" + submittedFileName);
}
return target;
}
防御思路是:先把目标目录的规范路径算出来,再把拼接后的文件路径规范化,检查最终路径是否以目标目录为前缀。遇到..、.,绝对路径这些特殊情况,getCanonicalPath()会自动处理好,所以这个检查是有效的。
我的习惯是后端不直接信任前端传的完整路径,而是让前端传一个自定义字段“根标识+相对路径”,后端拿相对路径做白名单校验(比如只允许字母数字斜杠点),再做一次前缀检查。双重保险,谁也没法绕过。
3.4 JSP页面展示上传界面与结果
前端和后端的主逻辑都有了,剩下就是JSP页面糊一个简易工具页,主要用于自测和给同事做演示。下面这张页面的设计思路是:包含文件夹选择控件、上传按钮、进度提示、服务端返回结果区域。
jsp复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>文件夹上传测试</title>
</head>
<body>
<h2>文件夹上传测试</h2>
<input type="file" id="folderPicker" webkitdirectory multiple />
<button onclick="doUpload()">开始上传</button>
<div id="progress"></div>
<script>
function doUpload() {
const input = document.getElementById('folderPicker');
if (!input.files || input.files.length === 0) {
alert('请先选择文件夹');
return;
}
const xhr = new XMLHttpRequest();
xhr.open('POST', '${pageContext.request.contextPath}/upload/folder');
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
document.getElementById('progress').textContent =
'进度:' + Math.round((e.loaded / e.total) * 100) + '%';
}
});
xhr.onload = function() {
document.getElementById('progress').textContent = '上传完成:' + xhr.responseText;
};
const formData = new FormData();
for (let i = 0; i < input.files.length; i++) {
formData.append('files', input.files[i], input.files[i].webkitRelativePath);
}
xhr.send(formData);
}
</script>
</body>
</html>
JSP里要注意两点:一是URL最好用${pageContext.request.contextPath}拼接项目上下文,避免部署路径变化后请求地址失效;二是上传响应页面也可以做在JSP里,但更推荐Servlet直接输出JSON,JSP页面通过fetch拿到结果后局部刷新,不要整页跳转,体验更好。
4. 大文件夹的断点续传与并发控制
4.1 一个请求全量上传的问题在哪
上面的方案能跑通,但只适合小文件夹。真实业务里用户传的可能是一个包含几百份文档的资料库,每份几MB,总量轻松超过1GB。如果一个请求把所有文件都塞进去,会面临三个问题:
- 服务器
maxRequestSize不能满足超大请求,Servlet容器对请求体大小有上限,超过直接拒绝。 - 没有进度恢复能力。用户传到一半网络断了,整个请求作废,要全部重来。
- 浏览器内存和服务器临时目录都扛不住大请求体。FormData一次性构造所有文件,内存占用直接和文件夹总大小成正比。
所以针对大文件夹场景,正确的做法是分文件、分片上传。
4.2 分文件并发上传:一点一点啃
最简单的改造思路是:把“一个请求传所有文件”改成“一个请求传一个文件”,文件数量多就并发传,每个文件请求之间互不影响。这样单请求大小可控,网络中断只需要重传当前文件,不需要全部重来。
前端按文件数量做并发控制:
javascript复制function uploadFilesConcurrently(files, maxConcurrent) {
let index = 0;
const total = files.length;
let activeCount = 0;
return new Promise((resolve, reject) => {
function next() {
if (index >= total) {
if (activeCount === 0) {
resolve();
}
return;
}
const file = files[index++];
activeCount++;
const formData = new FormData();
formData.append('file', file, file.webkitRelativePath || file.name);
fetch('/upload/folder/file', { method: 'POST', body: formData })
.then(() => {
activeCount--;
next();
})
.catch((err) => {
reject(err);
});
}
for (let i = 0; i < Math.min(maxConcurrent, total); i++) {
next();
}
});
}
并发数maxConcurrent一般设在3到5,既能把带宽吃满,又不会把服务器连接数打爆。如果服务器网络带宽有限,并发太高反而导致互相争抢带宽,每个请求都变慢。
后端对应改成单文件接口,接收单个Part,文件名为相对路径,逻辑跟之前一样。单文件接口的好处是错误定位很容易:哪个文件上传失败,重试哪个,不会影响其他文件。前端还可以做一个失败任务队列,把上传失败的文件保存下来,提供重试按钮。
4.3 进一步:MD5校验与秒传
传到一半断网、服务器磁盘满了、某个文件损坏——这些都会导致文件不完整。为了确认文件完整,可以在后端计算每个文件的MD5,前端上传前也计算一次,服务端校验不一致就报错重传。
前端用FileReader读取文件计算MD5:
javascript复制function computeMD5(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e) {
const buffer = e.target.result;
// 这里用crypto-js或SparkMD5库计算
const md5 = CryptoJS.MD5(CryptoJS.lib.WordArray.create(buffer));
resolve(md5.toString());
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
不过注意,计算大文件MD5会把整个文件读进内存,几十MB没问题,几百MB以上内存吃不消,要分块读取计算。后端同样用DigestInputStream包住输入流,在写文件的过程中顺便计算MD5。
如果文件在服务器上已经存在且MD5一致,就直接返回“秒传”结果,不用重新写文件。这个优化在多人上传相同文件素材的场景里能省很多磁盘空间和带宽。
4.4 请求过多时的服务端资源保护
并发上传会带来另一个问题:服务器同时处理多个上传请求,每个请求都要占线程和内存。如果用户开10个并发,每个都是大文件,服务器的临时目录和堆内存都可能告急。
这时候要做好两件事:
一是临时目录要监控。Servlet会把超过fileSizeThreshold的文件写入临时目录,默认是Tomcat的work目录。要确保这个目录空间足够,同时定期清理未完成的上传文件。
二是要给上传接口加上限流。按用户维度限制同时上传的任务数,超出直接返回“请等待当前任务完成”。简单做法是用一个全局计数器或Semaphore控并发。
java复制private static final Semaphore UPLOAD_SEMAPHORE = new Semaphore(5);
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
if (!UPLOAD_SEMAPHORE.tryAcquire()) {
response.sendError(429, "Too many uploads, please retry later");
return;
}
try {
// 处理上传
} finally {
UPLOAD_SEMAPHORE.release();
}
}
别小看这个信号量,没有它,多用户同时上传大文件夹时,Tomcat的默认线程池很容易被打满,连带其他业务接口全都无响应。
5. 常见问题与排查技巧实录
5.1 中文文件名和路径乱码
这是我做文件夹上传遇到最多的坑。前端传的webkitRelativePath里含中文,后端拿到的文件名变成???.java,或者干脆乱码,落盘后路径根本对不上。
排查思路:先确认前端发出来的数据是不是UTF-8编码的,再做后端解析。前端FormData默认使用UTF-8编码,问题一般出在后端。
后端需要做两件事:
request.setCharacterEncoding("UTF-8")在读取任何参数之前设置。- Tomcat的URI编码也保持UTF-8。8.5以上版本默认UTF-8,老版本需要改
server.xml里的URIEncoding。
如果是使用Spring MVC的项目(很多老旧JSP项目已经逐步迁移Spring),还需要检查@RequestParam等注解的编码,以及CharacterEncodingFilter的配置顺序。
5.2 上传大文件报错:IllegalStateException / maxRequestSize exceeded
这个报错很直接,就是@MultipartConfig里配的大小上限被超了。服务器端日志通常能看到java.lang.IllegalStateException: org.apache.tomcat.util.http.fileupload.FileUploadBase$SizeLimitExceededException。
处理方案有两个方向:
- 如果业务确实需要传大文件,调大
maxFileSize和maxRequestSize,同时要注意Tomcat的maxSwallowSize参数(默认2MB,超了会报connection reset)。如果上传超过2MB的请求被异常中断,很可能就是这个参数导致的,可以调大或设为-1。 - 如果不想调大单请求上限,就改用上面说的分文件上传方案,把大请求拆散成小请求。
在生产环境里,我推荐同时保留两条路:小文件夹走全量上传,大文件夹前端自动切换到分文件模式。判定阈值可以设在200MB左右。
5.3 上传后目录层级丢失,所有文件挤在同一个目录
这个问题的根源几乎都是前端没有用webkitRelativePath取相对路径,而是用了file.name。同名文件互相覆盖,不同目录的文件全混在一起,基本就废了。
排查方法:在前端把每个文件的实际提交文件名打出来看一眼。如果全是单个文件名没有路径分隔符,那一定是用错属性了。修复方式就是改成file.webkitRelativePath || file.name,并确保在FormData的append第三个参数位置传入的是该值。
也要注意:某些浏览器对webkitRelativePath的返回做了处理,根目录那层会被省略,这在还原目录时是正常的,因为服务器端基础目录本身就是那层“根”。
5.4 请求返回413 Request Entity Too Large
这个错误和Servlet层面的maxRequestSize不同,它是nginx或Apache这类反向代理服务器返回的,说明请求体超出了代理层的限制。
以nginx为例,默认client_max_body_size是1MB,你上传一个10MB的文件它直接拒绝。需要在nginx配置里加上:
nginx复制server {
client_max_body_size 2048m;
}
这里有个容易踩的点:改完nginx配置要重载才生效,而且最好在http、server、location三个级别都确认一遍,因为可能有覆盖。这类问题如果做了反向代理,反而比Servlet层面的配置更容易被忽略。
5.5 浏览器兼容性差异
webkitdirectory这个属性名字带webkit前缀,但实际Chrome、Edge、Firefox、Safari都支持。需要注意的其实是老版本浏览器的行为差异:
- Chrome/Edge新版本:支持,
webkitRelativePath返回相对于所选目录的路径。 - Firefox:支持,但有些版本不支持
multiple与webkitdirectory同时使用时,文件选择的交互会变成文件选择而非目录选择。需要兼容时,可以用特性检测:
javascript复制const input = document.getElementById('folderPicker');
if (typeof input.webkitdirectory === 'undefined') {
alert('当前浏览器不支持文件夹上传,请使用Chrome或Edge');
}
如果项目要兼容IE11,那基本无解,IE不支持webkitdirectory,只能降级到zip上传方案。但现在IE基本退出了历史舞台,这个兼容性问题影响越来越小。
5.6 上传过程页面卡死或浏览器崩溃
这个要分两种情况讨论。一种是文件数量特别多(比如上万个小文件),一次性遍历并append到FormData导致页面卡顿。解决方法是分批处理,不要一次性把所有文件都append进去,每批处理几十个,中间用setTimeout或requestAnimationFrame让出主线程。
另一种情况是文件总大小太大,FormData把所有数据缓存在内存里导致浏览器崩溃。这种情况只能走分文件上传方案,别想着单请求搞定一切。
6. 实战心得与扩展建议
最后聊一点我自己的体会。
文件夹上传这个需求,表面看是技术问题,本质上是“HTTP协议的文件传输模型和用户期望的目录操作模型”之间的映射问题。前端拿到webkitRelativePath,后端按路径重建目录,这套映射关系一旦建立,其他所有问题(并发、断点续传、校验、进度)都是在这个基础上的优化。
我在实际项目里最终形成的通用做法是:前端组件化封装一个FolderUploader,内部自动判断总大小,小文件夹走全量请求,大文件夹自动切分文件并发上传;后端统一提供/upload/folder/files和/upload/file两个接口,前者兼容小批量,后者支持大文件单传和失败重试。所有文件落盘前强制路径穿越校验,写完后计算MD5入库,磁盘里形成一套可追溯的文件资产。
如果你在做一个内容管理系统,文件上传只是其中一环,那么建文件夹上传功能时还要顺手考虑几个扩展点:同名文件的覆盖策略(是报错还是自动改名加时间戳)、文件类型黑名单过滤(防止上传可执行脚本)、OSS对象存储的对接(本地磁盘终究有上限),这些都属于后话了。
但是基础的东西就这么多——前端一个属性,后端一个Servlet,半小时能跑通demo。先把这一条链路摸熟,再逐层往上加东西,就会从容很多。
用上面的代码搭一次上传环境,上传一个带多级目录的文件夹试试。第一次看到目录结构在服务器上原样还原出来的那一刻,你会觉得这个需求没那么玄乎,就是一层窗户纸的事。
