文件上传下载这个功能,放在整个JavaEE知识体系里看,属于那种"看起来简单、真动手做起来处处是坑"的典型代表。很多人学Servlet的时候,前面处理表单、玩转发重定向、搞Cookie和Session都挺顺,一到文件上传下载这块就卡住了——要么是getParameter拿不到值,要么是文件存到了服务器上但不知道存哪去了,要么是下载的时候浏览器直接把文件内容显示在页面上而不是弹出保存框。这些问题我在带新人的过程中反复见到,几乎每一批人都会在同样的地方栽跟头。
这篇内容面向的是已经掌握了Servlet基础、正在啃文件上传下载这块硬骨头的Java学习者,也适合那些工作中需要快速实现一个上传下载功能但不想引入重型框架的开发者。我会把Servlet原生方案(也就是Part接口和@MultipartConfig注解这套)的完整实现路径拆开来讲,包括前端表单怎么配、后端代码怎么写、文件存哪里、下载怎么控制、以及那些文档里不会写但实际开发中一定会遇到的坑。核心关键词围绕Servlet文件上传、Part接口、MultipartConfig、文件下载响应头、上传路径管理这几个点展开,每一个都会落到能直接抄的代码和能直接避的坑上。
1. 先搞清楚Servlet 3.0之后文件上传的底层逻辑变了什么
1.1 从commons-fileupload到Part接口的演进
如果你翻过一些老教程,会发现文件上传部分大量使用commons-fileupload和commons-io这两个第三方库,代码里充斥着DiskFileItemFactory、ServletFileUpload、FileItem这些类。这套方案在Servlet 3.0之前是唯一的选择,因为Servlet规范本身没有提供文件上传的解析能力,请求体里的multipart数据需要开发者自己或者借助第三方库来解析。
Servlet 3.0(对应JavaEE 6)引入了一个关键变化:规范层面直接支持了multipart/form-data请求的解析。具体表现为两个东西——@MultipartConfig注解和HttpServletRequest接口上的getPart()/getParts()方法。这意味着容器(比如Tomcat)在收到multipart请求时,会帮你把请求体解析好,你直接通过Part对象就能拿到文件流和表单字段,不需要再引入任何第三方依赖。
这个变化带来的直接好处是依赖精简和代码简化。但很多教程没有讲清楚的是:@MultipartConfig注解不加上,getPart()直接抛异常。这个异常是IllegalStateException,信息大概是"Request doesn't contain a multipart/form-data or multipart/mixed stream"。新手看到这个报错往往一头雾水,因为前端表单明明写了enctype="multipart/form-data",问题出在后端Servlet没有声明自己准备接收multipart请求。
1.2 容器到底帮你做了什么
理解容器的工作机制,对排查问题非常有帮助。当Tomcat收到一个Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXXXX的请求时,它做的事情大致是这样的:
- 识别出这是multipart请求,根据boundary把请求体切分成多个part
- 每个part有自己的头部(包含
name、filename等信息)和体部(实际数据) - 如果Servlet上标注了
@MultipartConfig,容器会把解析结果封装成Part对象集合 - 普通表单字段也会被封装成Part,通过
request.getParameter()依然可以获取(容器做了兼容处理)
这里有个细节值得注意:普通字段和文件字段在Part层面是统一的。也就是说,你通过request.getPart("username")也能拿到普通文本字段的值,通过request.getParameter("username")也能拿到。但两者的行为有微妙差异——getParameter()在multipart请求下的可靠性依赖于容器的实现,某些情况下(比如文件很大、解析配置不当)可能拿不到值。所以我的习惯是:multipart请求里,普通字段统一用getPart().getInputStream()或者更简单地用request.getParameter()但确保配置正确,文件字段用getPart()。
1.3 为什么你的getParameter返回了null
这是最高频的问题之一。前端表单明明有<input type="text" name="desc">,后端request.getParameter("desc")却返回null。原因通常有三个:
第一,Servlet没有加@MultipartConfig。没有这个注解,容器不会解析multipart请求体,getParameter()自然拿不到任何东西,连普通字段都拿不到。
第二,表单的enctype写错了。必须是multipart/form-data,写成application/x-www-form-urlencoded(默认值)的话,文件根本传不上去,而且普通字段虽然能拿到,但文件字段会变成文件名的字符串。
第三,@MultipartConfig的maxRequestSize设置得太小,请求被容器直接拒绝,解析都没完成。这个参数默认值是-1(不限制),但有些开发者会随手设一个值,结果大文件上传时整个请求被拒,连普通字段都读不到。
排查顺序建议:先确认注解加了没,再确认前端enctype对不对,最后看容器日志有没有请求体过大的警告。
2. 前端表单的写法决定了后端能不能拿到文件
2.1 enctype、method和input name三件套
文件上传的表单有三个硬性要求,缺一个都不行:
<form action="/upload" method="post" enctype="multipart/form-data"> <input type="text" name="description" /> <input type="file" name="file" /> <button type="submit">上传</button> </form>method必须是post,因为GET请求没有请求体,文件数据没地方放。enctype必须是multipart/form-data,这是唯一能承载二进制文件数据的编码方式。input type="file"的name属性必须有值,后端就是靠这个name来定位Part的。
很多人会忽略的一点是:<input type="file">的name如果为空,浏览器根本不会提交这个文件。我见过有人的代码里name写成了空字符串或者干脆忘了写,后端getPart("file")返回null,然后对着代码找半天。这种问题没有报错,只能靠检查前端HTML发现。
2.2 多文件上传的name策略
如果要支持一次选多个文件,有两种写法。第一种是多个input用同一个name:
<input type="file" name="files" multiple />注意multiple属性让用户可以在文件选择框里多选,提交时同一个name会对应多个Part。后端用request.getParts()遍历,或者request.getParts("files")拿到该name下的所有Part。
第二种是多个input用不同name,后端逐个getPart("file1")、getPart("file2")。这种方式适合文件类型或用途不同的场景,比如一个头像一个附件。
我个人的建议是:能用multiple就用multiple,后端遍历处理逻辑统一,扩展性也好。但要注意,multiple属性在老版本浏览器上的兼容性,虽然现在基本不是问题了,但如果你的用户环境特殊,还是老老实实多个input。
2.3 表单里混用普通字段的注意事项
multipart表单里混普通文本字段是完全合法的,后端用getParameter()就能拿到。但有一个坑:如果Servlet没有正确配置@MultipartConfig,getParameter()在multipart请求下可能返回null,而不会报错。这跟普通表单请求的行为不一样,普通请求下getParameter()拿不到就是真的没有,multipart下可能是解析没生效。
所以我的做法是:在multipart场景下,普通字段也通过getPart()来读,用new String(part.getInputStream().readAllBytes(), StandardCharsets.UTF_8)转成字符串。这样虽然代码多两行,但行为确定,不依赖容器的兼容处理。当然,如果你确认@MultipartConfig配置正确,getParameter()也是可靠的,这个看个人习惯。
3. 后端接收:@MultipartConfig的配置细节与Part操作
3.1 注解参数的实际含义
@MultipartConfig有四个参数,每个都有实际影响:
| 参数 | 默认值 | 实际作用 | 踩坑点 |
|---|---|---|---|
fileSizeThreshold | 0 | 超过此大小的文件写入磁盘临时文件,否则放内存 | 设太小导致频繁磁盘IO,设太大吃内存 |
maxFileSize | -1(不限) | 单个文件最大字节数 | 超限抛IllegalStateException |
maxRequestSize | -1(不限) | 整个请求最大字节数 | 超限同样抛异常,且普通字段也读不到 |
location | 空(容器临时目录) | 文件临时存储目录 | 不设的话临时文件位置不可控 |
fileSizeThreshold这个参数容易被忽视。它的作用是:小于阈值的文件Part会保存在内存中,大于阈值的写到磁盘临时文件。默认0意味着所有文件都写磁盘。对于小文件居多的场景,适当调大(比如1MB)可以减少磁盘IO,但要注意内存占用。反过来,如果上传的都是大文件,保持0或者设小一点更稳妥。
maxFileSize和maxRequestSize的区别要分清:前者限制单个文件,后者限制整个请求(所有文件加普通字段的总和)。两个都设上比较安全,比如单文件限10MB、总请求限50MB。超限时容器抛IllegalStateException,你需要在代码里catch住给用户友好提示,否则用户看到的是500错误页。
3.2 一个能跑的最小后端实现
@WebServlet("/upload") @MultipartConfig( fileSizeThreshold = 1024 * 1024, maxFileSize = 10 * 1024 * 1024, maxRequestSize = 50 * 1024 * 1024 ) public class UploadServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String description = req.getParameter("description"); Part filePart = req.getPart("file"); if (filePart == null || filePart.getSize() == 0) { resp.getWriter().write("no file uploaded"); return; } String submittedName = filePart.getSubmittedFileName(); String safeName = UUID.randomUUID() + "_" + submittedName.replaceAll("[^a-zA-Z0-9._-]", "_"); String uploadDir = getServletContext().getRealPath("/WEB-INF/uploads"); File dir = new File(uploadDir); if (!dir.exists()) dir.mkdirs(); File target = new File(dir, safeName); try (InputStream in = filePart.getInputStream(); OutputStream out = new FileOutputStream(target)) { in.transferTo(out); } resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().write("uploaded: " + safeName); } }这段代码有几个设计决策值得说明。第一,文件名做了清洗和加UUID前缀,原因后面单独讲。第二,存储目录用了/WEB-INF/uploads,放在WEB-INF下浏览器无法直接访问,必须走下载Servlet,安全性更好。第三,用transferTo而不是手动循环读写,代码简洁且性能不差(底层有优化)。
3.3 getSubmittedFileName的编码陷阱
Part.getSubmittedFileName()返回的是客户端提交的原始文件名。这里有个经典问题:中文文件名乱码。不同浏览器、不同容器版本下,这个方法的编码处理不一致。有的返回正常中文,有的返回乱码,有的返回ISO-8859-1编码的字节串。
稳妥的处理方式是对返回值做一次编码修正:
String rawName = filePart.getSubmittedFileName(); String fileName = new String(rawName.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);但这段代码在已经正确解码的环境下会把中文搞坏。所以更稳的做法是先判断:如果字符串里包含乱码特征字符(比如Ã、å这类),再做转换。或者干脆不依赖原始文件名,用UUID加扩展名的方式重命名,原始文件名只存数据库做展示用。我倾向于后者,简单粗暴且不会出错。
4. 文件存哪里:路径选择直接决定了安全性和可维护性
4.1 getRealPath的局限性与替代方案
getServletContext().getRealPath("/")拿到的是Web应用在服务器文件系统上的真实路径。把上传文件存到这个路径下(比如/uploads)是最直观的做法,但有几个问题:
第一,应用重新部署时文件可能丢失。很多容器在重新部署war包时会清空整个应用目录,上传的文件跟着没了。第二,集群环境下不可用,多台服务器各自存各自的,用户下次请求打到另一台就找不到文件了。第三,getRealPath可能返回null,当应用以war包形式部署且容器没有解压时,这个方法返回null,你的代码直接NPE。
所以生产环境更推荐把文件存到应用目录之外的固定路径,比如/data/uploads(Linux)或D:/uploads(Windows),路径通过配置文件或环境变量注入。这样应用部署、重启都不影响文件,也方便做备份和迁移。
String uploadDir = System.getProperty("upload.dir", "/data/uploads");用系统属性做默认值加外部覆盖,本地开发和生产环境可以灵活切换。
4.2 目录分片:单目录文件过多的性能问题
所有文件堆在一个目录里,文件数量到几万级别时,文件系统的目录查找性能会明显下降(尤其是ext3这类用线性查找的文件系统)。解决方案是按日期或哈希做二级目录分片:
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); File dir = new File(uploadDir, datePath);这样文件按上传日期分散到不同目录,单目录文件数可控。取文件时根据存储时记录的相对路径拼接即可。这个设计在文件量大的系统里几乎是标配,提前做好比后期迁移省事得多。
4.3 文件名安全:路径穿越与覆盖攻击
直接用用户提交的文件名存盘是极其危险的。攻击者可以提交../../etc/passwd这样的文件名(路径穿越),或者提交一个和已有文件同名的文件(覆盖)。虽然Part.getSubmittedFileName()在多数容器里已经做了基本过滤,但不同容器行为不一致,不能依赖。
我的处理原则是:存储文件名完全由服务端生成,用户提交的名字只做展示。生成规则用UUID加原始扩展名:
String ext = ""; int dot = submittedName.lastIndexOf('.'); if (dot > 0) ext = submittedName.substring(dot).toLowerCase(); String storedName = UUID.randomUUID().toString().replace("-", "") + ext;扩展名也要做白名单校验,只允许业务需要的类型(比如图片只允许jpg/png/gif),防止上传可执行文件。白名单比黑名单可靠,黑名单永远列不全。
5. 文件下载:响应头设置错一个字节,浏览器行为就完全不同
5.1 Content-Disposition的inline与attachment
下载功能的核心是设置Content-Disposition响应头。这个头有两个关键值:
inline:浏览器尝试直接在页面内展示(图片、PDF会直接打开)attachment:浏览器弹出保存对话框,强制下载
resp.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");很多人下载图片时发现浏览器直接打开了图片而不是下载,就是因为用了inline或者没设这个头。要强制下载就用attachment。
5.2 中文文件名的RFC 5987编码
filename参数直接放中文会乱码,因为HTTP头默认是ISO-8859-1编码。RFC 5987定义了filename*参数来支持非ASCII字符:
String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8) .replace("+", "%20"); resp.setHeader("Content-Disposition", "attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded);同时提供filename和filename*是为了兼容不同浏览器。现代浏览器优先用filename*,老浏览器回退到filename。注意URLEncoder会把空格编码成+,在HTTP头里+不是空格,要替换成%20。
5.3 流式传输与大文件的内存控制
下载的实现是把文件读出来写到response的输出流:
resp.setContentType(getServletContext().getMimeType(fileName)); resp.setContentLengthLong(file.length()); try (InputStream in = new FileInputStream(file); OutputStream out = resp.getOutputStream()) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } }关键点:不要用Files.readAllBytes一次性读入内存,大文件会OOM。用固定大小的buffer循环读写,8KB是常见选择,和磁盘块大小匹配。setContentLengthLong告诉浏览器文件大小,浏览器才能显示下载进度条,不设的话进度条就是不确定状态。
还有一个细节:下载完成后不要关闭response的输出流(容器会管),但输入流必须关闭(try-with-resources保证了)。如果中途出错,已经写出的部分无法撤回,浏览器会得到一个损坏的文件,这个只能靠前端校验文件完整性来兜底。
6. 那些文档里不写但一定会遇到的坑
6.1 临时文件清理问题
@MultipartConfig的location指定的临时目录,容器在请求处理完后会自动清理Part对应的临时文件。但有一个前提:你必须调用了part.delete()或者请求正常结束。如果代码里把Part对象存到了Session或者某个静态集合里,临时文件就不会被清理,时间长了磁盘被撑满。
我见过一个案例:有人为了"稍后处理",把Part放进了ConcurrentHashMap,结果临时目录几天涨了几十GB。正确做法是:请求内就把文件流读完写到最终位置,不要持有Part引用。
6.2 上传大小超限的异常处理
前面提到超限抛IllegalStateException,但这个异常可能在getPart()调用时抛出,也可能在请求解析阶段就被容器抛出(取决于容器实现)。所以catch的范围要覆盖整个doPost逻辑:
try { Part part = req.getPart("file"); // ... } catch (IllegalStateException e) { resp.setStatus(413); resp.getWriter().write("file too large"); }413是Payload Too Large状态码,语义正确。给用户的提示要明确说"文件超过XX MB",而不是笼统的"上传失败"。
6.3 断点续传与Range请求
标准Servlet下载不支持断点续传,因为没处理Range请求头。如果业务需要(比如大文件下载、视频播放),要手动解析Range: bytes=start-end,返回206状态码和Content-Range头。这块逻辑不复杂但容易写错边界条件,尤其是start越界、end超过文件大小的情况。如果项目对断点续传有硬需求,建议评估是否直接用现成的静态资源服务方案,而不是在Servlet里手写。
6.4 上传进度的实现思路
原生Servlet没有内置上传进度回调。要实现进度条,常见方案是前端用XMLHttpRequest的upload.onprogress事件(这是前端能力,和后端无关),后端只需要正常接收即可。如果非要在后端做进度统计,需要自己包装InputStream计数并配合Session共享进度,复杂度较高,一般不建议。
7. 从能跑到好用:几个提升健壮性的改造点
7.1 文件类型校验不能只看扩展名
扩展名校验可以被绕过(改个后缀就行)。更可靠的是读文件头部的魔数(magic number)。比如JPEG文件开头是FF D8 FF,PNG是89 50 4E 47。读前几个字节判断真实类型:
byte[] header = new byte[8]; try (InputStream in = part.getInputStream()) { in.read(header); } // 根据header判断类型这个校验放在保存之前做,不通过直接拒绝。虽然多几行代码,但能挡住大部分恶意上传。
7.2 存储与元数据分离
文件本身存文件系统,文件名、原始名、大小、类型、上传时间、上传者这些元数据存数据库。这样列表展示、检索、权限控制都方便,文件系统只负责存二进制。这个设计几乎是所有文件管理功能的标配,一开始就分开比后期拆省事。
7.3 下载的权限校验
下载Servlet不能只根据文件名直接返回文件,必须先校验当前用户有没有权限下载这个文件。常见做法是文件ID走数据库查元数据,校验通过后再读文件系统。直接暴露文件路径的下载接口是安全漏洞。
我在实际项目里踩过最深的坑是临时文件清理和中文文件名这两块。前者是隐蔽的磁盘泄漏,不监控根本发现不了;后者是用户投诉"下载的文件名是乱码",排查半天发现是响应头编码问题。这两个点建议在实现阶段就处理好,别等上线后被动修。另外,上传目录的磁盘配额和监控也要提前规划,文件上传功能对磁盘的消耗往往超出预期,尤其是没做大小限制的时候。