Servlet文件上传详解:从multipart到getParts原生API

发布时间:2026/10/18 23:56:24
Servlet文件上传详解:从multipart到getParts原生API 处理文件上传大概是Java Web开发里最常被问到的需求之一。很多刚接触Servlet的开发者以为从HttpServletRequest里拿上传的文件就像拿表单参数一样简单——结果一调getParameter返回null或者getInputStream读到一堆看不懂的二进制再不然就是Connector上直接给你抛个IllegalStateException。这篇文章我就结合自己踩过的坑把“HttpServletRequest获取上传文件”这件事从头到尾讲透从multipart/form-data的底层格式到Servlet 3.0原生API的完整写法再到老项目里那套第三方上传组件的兼容方案以及各种让人头大的中文乱码、内存溢出、临时文件清理问题全部梳理一遍。不管你是刚入门的新手还是被老项目折磨过一阵子的开发者照着这篇文章的思路去调整自己的代码基本都能落地。1. 先搞清楚需求文件上传到底在解决什么问题1.1 从表单到请求上传文件发生的完整链路“把文件传给服务器”这件事放在HTTP的世界里其实没那么复杂。浏览器把文件内容塞进HTTP请求体服务器从请求体里读取字节流再想办法还原成文件。关键就在于“塞进去”这个动作的编码方式。平时我们用得最多的表单提交默认的编码方式是application/x-www-form-urlencoded。这种格式下浏览器会把所有的表单数据拼成一个类似URL参数的字符串比如usernameadminpassword123456。这种格式传纯文本没问题但是传文件就不行了因为文件内容是二进制里面可能包含各种特殊字符甚至包含用于分隔字段的和直接按字符串拼接必然会乱套。所以表单里一旦出现input typefile就必须把form的enctype属性设置为multipart/form-data。这时候浏览器会把整个请求体切成若干个“段”Part每个段用一段随机生成的boundary字符串隔开。这个boundary会同时出现在请求头的Content-Type里比如Content-Type: multipart/form-data; boundary----WebKitFormBoundaryABC123。请求体就变成了类似这样的结构------WebKitFormBoundaryABC123 Content-Disposition: form-data; nameusername admin ------WebKitFormBoundaryABC123 Content-Disposition: form-data; namefile; filenametest.txt Content-Type: text/plain 文件内容字节流 ------WebKitFormBoundaryABC123--服务器端要做的就是先根据Content-Type里的boundary把请求体切成一个个Part再从Part的Content-Disposition头里解析出字段名、文件名最后把Part里的字节流保存起来。这件事Servlet容器在收到请求后其实已经帮你做了大半你的代码要做的只是调用方法拿结果。1.2 两种主流实现方案怎么选在Java Web世界里拿到上传文件这件事有两条主流路线。一条是Servlet 3.0规范提供的原生API也就是通过HttpServletRequest的getPart()和getParts()方法直接拿到容器解析好的Part对象。只要你用的Servlet容器支持Servlet 3.0规范现在主流容器都支持比如Tomcat 7及以上的版本你的项目里不需要引入任何额外的JAR包一个注解加几行代码就能搞定。另一条路线是使用第三方上传组件最经典的就是Commons FileUpload。这个组件在Servlet 3.0规范出现之前就已经存在了老项目里用得非常多它通过自己的解析器把请求体解析成List 代码风格和原生API差异很大但功能同样完整。选择哪条路线主要看你项目的实际情况。新项目我强烈建议直接走Servlet 3.0原生API零依赖、代码简洁、跟容器配合得也最顺畅。老项目如果已经在用Commons FileUpload且跑得好好的没必要为了追求新而推翻重来后面章节我会详细讲两种方案各自的完整写法和注意点。2. 核心原理HttpServletRequest与multipart/form-data的关系2.1 Content-Type和boundary到底是怎么回事很多人写文件上传代码的时候会忽略一个非常基础的问题请求头里那两个关键参数是干嘛用的Content-Type里不仅声明了multipart/form-data还带了一个boundary参数。boundary就是各个Part之间的“分隔线”它的值是一段随机字符串浏览器保证这个字符串不会出现在文件内容里。服务器解析请求体的时候就是靠这段boundary把一长串字节流切成一个个段落。这里有个特别容易踩的坑如果你手动构建HTTP请求或者用某些HTTP客户端工具发送multipart请求boundary没配对服务器解析必然失败。但用浏览器表单上传时浏览器会自动生成boundary通常不会出问题。不过后端代码里你会发现你根本不需要关心boundary是什么因为Servlet容器早就替你完成了拆分工作你拿到的Part对象就是拆分之后的产物。2.2 Part是什么容器帮你拆好的数据块从开发者的视角来看Part就是multipart/form-data请求体中一个独立的段落它对应一个表单字段比如usernameadmin的普通输入框对应一个上传文件。Part对象身上带了几个特别关键的方法getName()返回这个Part对应的表单字段name属性值比如input namefile这里返回的就是file。getSubmittedFileName()返回上传文件的原始文件名注意这个方法在Servlet 3.1规范中才正式提供3.0版本下可能拿不到。getContentType()返回文件MIME类型比如image/png、text/plain。getSize()返回这个Part的字节数可以用来做文件大小的预判断。getInputStream()拿到Part内容的输入流你可以自己把字节流读到目标文件。write(String fileName)容器帮我把Part内容直接写到一个文件里省去手动读流的麻烦。需要特别注意的是Part对象里装的不仅仅是文件。如果你在同一个表单里还放了普通输入框比如姓名、备注之类的字段这些字段也会被封装成Part对象只是它们的getSubmittedFileName()返回null且Content-Disposition里没有filename字段。所以遍历getParts()的时候要先判断一下是不是文件Part。2.3 为什么getParameter在文件上传时拿不到值这是我在很多初学者项目里看到的高频问题。表单都设置enctypemultipart/form-data了代码里request.getParameter(username)居然返回null搜索引擎一搜发现别人也有同样的问题。原因在于一旦表单编码方式变成multipart/form-data整个请求体就不再是那种usernameadminpassword123456的格式了。Servlet容器在处理普通格式请求时会把请求体自动解析到parameterMap里getParameter()自然就能拿到值。但遇到multipart格式时容器就不再执行这个解析动作了它把请求体原样保留下来等你通过getPart()/getParts()去读取。所以getParameter()返回null是正常现象。正确的做法是在遍历Part的时候判断isFormField类型然后从Part的输入流中读取普通字段的值。这一点我会在下一章的完整示例代码里展示出来。3. 实操用Servlet 3.0原生API获取上传文件3.1 前置准备注解与配置一个都不能少Servlet 3.0原生API的使用前提是在你的Servlet类上标注MultipartConfig注解或者在web.xml里配置multipart-config节点。这个注解就是告诉Servlet容器这个Servlet会接收multipart/form-data格式的请求请你帮我解析Part。如果你忘了加这个注解会有什么后果getParts()方法会正常返回一个空集合或者直接抛IllegalStateException具体表现取决于容器版本和实现。我身边就有同事由于忘记加注解还到处找bug最后发现是配置缺失很容易浪费大量时间。MultipartConfig注解有四个属性可以配置它们决定了文件上传的边界与临时文件策略属性含义建议值fileSizeThreshold文件大小阈值超过该值的数据会从内存转写到临时文件建议设1MB左右maxFileSize单个文件允许的最大字节数根据业务场景设比如10MBmaxRequestSize整个multipart请求体允许的最大字节数建议比maxFileSize稍大比如50MBlocationPart.write()方法写入文件的默认目录建议配置绝对路径别忘了在doPost方法里先调用request.setCharacterEncoding(UTF-8)否则中文字段和文件名可能乱码。这句话对普通表单同样适用尤其在容器默认编码不是UTF-8的情况下。3.2 核心代码一步步完成文件接收与保存直接给一个能跑通的基础版本。假设前端表单里有一个文件输入框namefile和一个普通输入框nameremark后端Servlet的完整代码如下WebServlet(/upload) MultipartConfig( fileSizeThreshold 1024 * 1024, maxFileSize 10 * 1024 * 1024, maxRequestSize 50 * 1024 * 1024 ) public class UploadServlet extends HttpServlet { Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); String uploadDir /data/upload; File dir new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } String remark null; for (Part part : request.getParts()) { if (part.getSubmittedFileName() null) { // 普通表单字段从输入流中读取值 remark readValue(part); continue; } // 文件Part String originName part.getSubmittedFileName(); String ext getExtension(originName); String newName UUID.randomUUID().toString().replace(-, ) ext; part.write(uploadDir File.separator newName); } response.getWriter().write(upload success, remark remark); } private String readValue(Part part) throws IOException { try (BufferedReader reader new BufferedReader( new InputStreamReader(part.getInputStream(), UTF-8))) { return reader.readLine(); } } private String getExtension(String fileName) { int index fileName.lastIndexOf(.); return index 0 ? fileName.substring(index) : ; } }这段代码里有几个细节值得说。第一part.write()的参数既可以是绝对路径也可以是相对路径。如果传入相对路径容器会把它拼接到MultipartConfig的location属性指定的目录后面。很多初学者在location没设置的情况下调用part.write(abc.txt)结果文件不知道被写到哪去了。最稳妥的方式是直接传一个绝对路径别依赖location。第二文件名不要直接用前端传上来的原始名字。一个是防覆盖的问题不同用户上传同名文件会把彼此的文件覆盖掉另一个是安全检查恶意用户可能通过文件名构造路径穿越攻击。用UUID重新命名保留原始文件的扩展名是性价比最高的做法。第三普通字段从Part里读取时用readLine()就能拿到字符串值这是因为一个表单字段的Part里内容通常就一行。如果是textarea这种多行文本就需要循环读取所有的行再拼接。3.3 关键参数调优MultipartConfig三个属性怎么设关于fileSizeThreshold这个参数控制的是“内存缓冲区的上限”。当上传的文件大小小于这个阈值时容器会直接把数据存储在内存里读起来很快。一旦文件超过阈值容器就会把数据转写到磁盘的临时文件中防止大文件把内存撑爆。所以如果你的应用经常要接收大文件建议把阈值调小一些比如256KB让数据尽早走磁盘。如果只是收一些很小的图片阈值可以设置2MB减少磁盘IO。maxFileSize和maxRequestSize建议严格设置不要设成-1放任不管。我见过一个项目没设大小限制结果用户上传了一个10GB的视频文件直接把磁盘打满了。maxFileSize限制的是单个Part的大小maxRequestSize限制的是整个请求体的总大小包括所有文件和普通字段。如果请求内容超过限制容器会抛出异常最常见的表现就是FileUploadException或IllegalStateException。4. 实操兼容老项目的Commons FileUpload方案4.1 组件选型什么场景还要用老方案虽然Servlet 3.0原生API用起来很顺手但我在实际工作中确实遇到过不得不使用Commons FileUpload的场景。最常见的就是老的项目还跑在Servlet 2.5容器上或者项目里已经有一套基于FileItem的封装代码牵一发而动全身。这种情况下即便你心里再想用原生API也不得不先把老代码的运作方式吃透。Commons FileUpload的核心类就两个DiskFileItemFactory负责配置临时文件策略和生成FileItem实例ServletFileUpload负责解析请求体、返回List 。FileItem的方法跟Part高度类似isFormField()判断是不是普通字段getFieldName()拿字段名getName()拿文件名getInputStream()拿内容流write()保存文件。使用这个方案有一个非常关键的前提必须在调用getParameter()之前就执行解析因为解析过程会消费请求体的输入流。一旦你提前调用了getParameter()容器已经把输入流消耗掉了此时再解析multipart你会发现拿到的集合是空的。这个坑我在排查问题时遇到过好几次。4.2 核心代码DiskFileItemFactory ServletFileUpload下面是一段标准的Commons FileUpload解析代码兼顾了普通字段和文件的处理protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { String uploadDir /data/upload; if (!ServletFileUpload.isMultipartContent(request)) { response.getWriter().write(not multipart request); return; } DiskFileItemFactory factory new DiskFileItemFactory(); // 超过1MB的数据写入临时文件避免内存溢出 factory.setSizeThreshold(1024 * 1024); factory.setRepository(new File(/tmp/upload_tmp)); ServletFileUpload upload new ServletFileUpload(factory); upload.setFileSizeMax(10 * 1024 * 1024); upload.setSizeMax(50 * 1024 * 1024); try { ListFileItem items upload.parseRequest(request); for (FileItem item : items) { if (item.isFormField()) { String fieldName item.getFieldName(); String value item.getString(UTF-8); // 处理普通字段 } else { String fileName new File(item.getName()).getName(); String ext getExtension(fileName); String newName UUID.randomUUID().toString().replace(-, ) ext; File storeFile new File(uploadDir, newName); item.write(storeFile); } } } catch (Exception e) { // 建议根据异常类型分别处理比如大小超限抛FileUploadBase.SizeLimitExceededException throw new ServletException(e); } }重点看两个地方。第一item.getName()在IE浏览器老版本下可能返回完整的本地路径比如C:\fakepath\test.txt所以代码里用new File(item.getName()).getName()把纯文件名提取出来防止把路径也当成文件名存下来。第二item.getString(UTF-8)这一句指定了编码方式如果不传参数默认会按平台编码解码中文乱码的概率极高。还有一个容易被忽略的点工厂的setRepository设置了临时文件目录这个目录必须存在且可写。如果目录不存在文件超过阈值转写临时文件时会报异常而且这个异常信息不那么直观排查起来挺费劲。4.3 新旧方案对比一张表看清差异对比项Servlet 3.0原生APICommons FileUpload依赖JAR无需额外依赖commons-fileupload、commons-io容器要求Servlet 3.0及以上Servlet 2.3及以上核心对象PartFileItem获取普通字段从Part输入流读取item.getString()文件大小限制MultipartConfig注解ServletFileUpload的set方法代码简洁度更简洁略繁琐容器解析时机容器内部自动解析代码中主动parseRequest这张表不是想说哪个方案绝对更好而是方便你根据自己项目的技术栈做决策。新项目无脑选Servlet 3.0原生API老项目既然已经在用Commons FileUpload只要代码运行稳定也不建议仅仅为了“更先进”去重构。5. 常见问题与排查技巧实录5.1 现象对照表报错与解决思路我在实际项目里遇到过大量文件上传相关的问题很多问题表面上报错千奇百怪根源却就那么几类。我整理了一份速查表按“现象-原因-解法”梳理遇到问题可以先对着表格检查现象可能原因解决思路request.getPart()抛IllegalStateExceptionServlet未配置MultipartConfig补充注解或web.xml配置getParts()返回空集合表单未设置enctypemultipart/form-data或boundary异常检查表单enctype设置getParameter()拿不到表单字段值multipart格式下容器不会填充parameterMap从Part/FileItem中读取字段文件保存后只有文件名没有内容part.write()未指定绝对路径且location未配置使用绝对路径保存getSubmittedFileName()返回nullServlet容器版本较低或者Part是普通字段而非文件升级容器或者对Part做isFileField判断上传中文文件名出现乱码请求编码与解析编码不一致setCharacterEncoding(UTF-8)并统一编码大文件上传时内存溢出fileSizeThreshold配置过大或者未配置调低fileSizeThreshold启用磁盘临时文件上传报SizeLimitExceededExceptionmaxFileSize或maxRequestSize超限增大参数限制或检查是否误设过小值临时目录堆积大量tmp文件请求异常终止导致临时文件未清理容器通常会定期清理自己写流时确保finally中清理临时文件对照表格看自己的场景大部分问题都能快速定位。唯一要提醒的是getSubmittedFileName()这个方法虽然名字看起来很标准但它在Servlet 3.0规范里并未正式定义到Servlet 3.1才补齐。如果项目运行在Servlet 3.0容器上建议用Part.getHeader(Content-Disposition)自行解析文件名或者直接升级容器版本。5.2 文件名、中文乱码与安全过滤的实战处理文件名处理是所有文件上传功能里最需要花心思的环节。第一个问题是我上面反复提到的覆盖文件名。用户传上来的文件名五花八门两个人同时传一个a.jpg再常见不过。如果不重新命名后上传的文件就会覆盖前面那个造成数据丢失。更严重的是有人可能在文件名里塞入../这类路径穿越字符如果代码用原始文件名拼路径文件就可能被写到目录之外。虽然现代容器多少会做一些防护但自己这块的防御不能省。我的建议是三段式处理先从原始文件名提取扩展名然后生成UUID作为新名字最后拼上扩展名。扩展名最好再用一个白名单过滤一遍比如只允许.jpg、.png、.pdf、.txt等业务明确需要的类型。不要依赖Content-Type做类型校验因为Content-Type是客户端提供的完全可以伪造。真正更稳妥的做法是保存后对图片类文件重新读取并校验它的实际格式。第二个问题是中文文件名乱码。这个问题的根源在于浏览器先按照UTF-8编码把文件名写进请求头而Servlet容器在解析请求头时如果没按UTF-8解码就会出现乱码。在代码开头调用request.setCharacterEncoding(UTF-8)能解决一部分问题但注意这个方法只影响请求体的编码对请求头中Content-Disposition里的文件名解析不同容器行为有差异。因此我的兜底方案是在后端拿到文件名后尝试重新解码。比如发现文件名乱码就用ISO-8859-1还原字节再按UTF-8编码重新生成。这段代码虽然看起来有点hack但在兼容老容器时非常管用。第三个问题是文件内容的真实性与大小限制。在保存文件之前建议先用part.getSize()或item.getSize()判断一下文件大小再结合业务提前抛错。别等文件完全写入磁盘之后才做校验那样白占IO带宽。如果需要限制图片尺寸最好借助图像处理工具类重新压缩而不是让用户直接传原图。5.3 多文件上传与场景扩展的特殊处理多文件上传在原生API下实现起来出乎意料地简单。前端只要给文件输入框加上multiple属性后端遍历getParts()时所有getSubmittedFileName()不为null的Part都是文件循环保存即可。唯一要注意的是多个文件的name属性可能相同不要通过getName()去区分文件而是直接遍历所有Part。比如前端代码长这样form enctypemultipart/form-data methodpost action/upload input typefile namefiles multiple button typesubmit上传/button /form后端这段伪代码可以同时处理多个文件for (Part part : request.getParts()) { if (part.getSubmittedFileName() null) { continue; } // 逐个保存 part.write(uploadDir File.separator UUID.randomUUID().toString().replace(-, )); }这里有个细节如果一个文件Part在遍历过程中没做任何处理请求结束后容器会负责清理可能产生的临时文件。但如果你手动调用了part.getInputStream()读取了数据流一定要在finally块里关闭InputStream。它有可能会占用临时文件句柄导致文件锁残留。如果你在项目里使用了Spring MVC这类框架内部的MultipartResolver本质上也是在解析HttpServletRequest里的Part原理和我上面写的一致。遇到框架上传问题时不妨先从Servlet层面排查一遍很多时候框架报的错根源都在容器解析和请求参数这一层。6. 最后再分享两个排查小技巧写到这里该收尾了补两个我在实际排查中觉得特别有效的小技巧。第一个是抓原始请求体。当你怀疑文件上传数据根本没到后端时不要急着打日志先抓原始请求体看看。用工具发一个multipart请求或者在后端拦截器里把request.getInputStream()的内容打印出来观察body中boundary、filename、Content-Disposition是否齐全。我排查过好几次“明明传了文件后端却说没收到”的问题最后都发现是前端请求把body搞丢了或者是nginx层把大请求体拦截了。这个办法能直接定位是传输链路的问题还是后端解析的问题。第二个是给临时文件目录单独做监控。因为文件上传常常会生成临时文件如果代码里某条异常路径没有清理临时文件时间长了会堆积大量垃圾占用磁盘空间。我习惯在运维侧给临时目录加一个定期清理任务比如清理三天前、大小超过一定阈值的临时文件。很多人只关注上传本身成功与否忽略了临时文件清理这一环等到服务器磁盘告警才想起来。回到最简单的那句话用Servlet容器接收文件上传本质就是理解multipart格式然后调用合适的API。把这些细节一条条理清了从getParts()方法拿到文件就不再是玄学而是彻底可控的工程行为。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询