1. 项目概述:为什么一个“上传图片”功能值得单独写一篇深度实操笔记?
在 Java Web 开发的日常中,“文件上传”看起来是个再基础不过的功能——用户点个按钮,选张图,点确定,后端接收、保存、返回成功。但真正接手过线上项目的人心里都清楚:这个看似简单的交互,背后藏着一连串容易被忽略却足以让系统崩掉的细节陷阱。我做过三个不同规模的后台管理系统,其中两个在上线前一周,都因为上传模块的边界处理不严,被测试同学揪出严重问题:一个是用户上传了 2GB 的视频文件伪装成 .jpg,直接把 Tomcat 的内存打满;另一个是并发上传时多个线程争抢同一个临时文件路径,导致部分图片内容被截断或覆盖;还有一个更隐蔽的,是中文文件名在 Linux 服务器上保存失败,日志里只显示“IOException”,排查了整整两天才发现是字符编码没统一。
所以,当标题里明确写着“Apache Commons FileUpload 实现上传图片 DEMO”,它绝不是教你怎么调用parseRequest()方法那么简单。它实际指向的是一个完整的、可落地的生产级文件上传链路:从 HTTP 协议层的 multipart/form-data 解析原理,到 Servlet 容器对大文件的缓冲策略,再到磁盘 I/O 的安全写入、文件名的防注入处理、图片格式的二次校验、以及上传完成后的路径管理与访问控制。这些环节,任何一个出错,轻则功能不可用,重则引发资源耗尽、路径遍历、甚至远程代码执行(虽然极少见,但在老旧版本的 FileUpload 中真有 CVE 漏洞)。
这个 DEMO 的核心价值,不在于“能传”,而在于“传得稳、存得安、管得住”。它解决的是真实开发中三类人的痛点:前端同学需要知道表单怎么写才不会被后端拒收;后端同学需要理解 FileItem 对象背后的生命周期和内存模型;运维或架构同学则关心它在高并发下的资源占用和可扩展性。因此,本文不会只贴几段代码完事,而是会带你从协议层开始,一层层剥开 FileUpload 的工作肌理,告诉你每一行配置为什么这么写、每一个 try-catch 为什么必须包住哪段逻辑、甚至告诉你什么时候该果断放弃 FileUpload,转而用更现代的替代方案。如果你正要为新项目搭上传模块,或者正在修复一个“偶尔上传失败”的线上 Bug,这篇笔记里的每一条经验,都是我在某次凌晨三点重启服务后记下的。
2. 核心设计思路与方案选型解析:为什么是 Apache Commons FileUpload,而不是 Spring MVC 的 MultipartFile?
2.1 不是“过时”,而是“可控”:FileUpload 在当前技术栈中的真实定位
很多人看到“Apache Commons FileUpload”,第一反应是“这玩意儿不是十年前的老古董吗?现在谁还用?”——这种看法很常见,但不够准确。Spring MVC 的MultipartFile确实封装得更友好,一行@RequestParam("file") MultipartFile file就能拿到文件对象,但它本质上只是对底层解析器的一层抽象。而 Spring 默认使用的解析器,恰恰就是CommonsMultipartResolver,其内核依然是 Apache Commons FileUpload。换句话说,你用 Spring 写上传,大概率还是在用它,只是没看见而已。
那为什么本 DEMO 要绕过 Spring,直接用原生 FileUpload?答案就两个字:可控。当你需要精细干预上传过程的每一个环节时,框架封装反而成了障碍。比如:
- 你想在文件刚进入内存缓冲区时,就根据文件头(Magic Number)判断它是不是真的 PNG,而不是等整个文件流读完再用
ImageIO.read()去解析——后者可能被构造的恶意图片触发 OOM; - 你想限制单个请求中最多上传 3 个文件,且总大小不超过 50MB,但每个文件又不能小于 1KB(防空文件攻击);
- 你想把上传的临时文件不放在默认的
/tmp下,而是写入一个独立挂载的 SSD 分区,并设置chmod 600权限,防止其他进程窥探; - 你想在解析过程中实时计算上传进度,并通过 WebSocket 推送给前端——这需要你直接持有
FileItemIterator的引用。
这些需求,用MultipartFile很难干净地实现,要么要 hack Spring 的 resolver,要么要自己写 Filter 拦截原始 request 流。而 FileUpload 提供了ServletFileUpload、DiskFileItemFactory、FileItemStream这一套清晰的、职责分明的 API,让你能像拧螺丝一样,把每个环节都拧紧。它不是过时,而是把选择权交还给开发者:你要简单,它能给你封装;你要精细,它绝不设障。
2.2 为什么不选其他方案?对比分析三种主流路径
| 方案 | 核心机制 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| Apache Commons FileUpload (v1.5+) | 基于 Servlet API,纯 Java 实现,内存+磁盘双缓冲 | 成熟稳定、文档齐全、社区案例多、无额外依赖(仅需 commons-io)、可完全掌控解析流程 | 需手动处理编码、文件名、路径安全;API 略显冗长;不支持异步非阻塞 | 传统 Servlet/JSP 项目;对稳定性要求极高、需审计合规的金融/政务系统;需要深度定制解析逻辑的场景 |
| Spring MVC MultipartFile | 封装 FileUpload 或 StandardServletMultipartResolver | 开发效率高、与 Spring 生态无缝集成、自动处理常见编码问题、支持注解驱动 | 黑盒程度高、调试困难、定制化成本高、版本升级易引发兼容问题(如 Spring 5.3+ 对文件名处理逻辑变更) | 快速迭代的内部管理系统;团队熟悉 Spring 且无特殊安全要求;上传逻辑简单,无复杂校验需求 |
| Servlet 3.0+ 原生 Part API | request.getPart("file"),由容器(Tomcat/Jetty)原生支持 | 零第三方依赖、性能略优(省去一层封装)、标准规范 | 各容器实现细节有差异(如 Tomcat 对超大文件的处理策略与 Jetty 不同)、错误码不统一、缺少高级功能(如进度监听、流式解析) | 极简微服务;容器已明确锁定为 Tomcat 且版本可控;追求最小依赖 footprint |
我们最终选定 FileUpload,不是因为它最“新”,而是因为它在可控性、成熟度、可调试性三者间取得了最佳平衡。尤其对于教学 DEMO 和需要长期维护的项目,一个你能随时debug进去、看懂每一行在做什么的库,远比一个“写起来快但出问题找不到根”的黑盒更有价值。后面你会看到,正是这种可控性,让我们能轻松实现“上传前文件头校验”和“中文文件名安全转义”这两个关键防护点。
2.3 整体架构设计:一个上传请求的完整生命周期拆解
一个典型的图片上传请求,在 FileUpload 的视角下,会经历以下六个阶段,每个阶段我们都做了针对性加固:
- HTTP 请求接收:浏览器发送
POST请求,Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...。我们首先在Filter中拦截,检查Content-Length是否超过全局阈值(如 100MB),超限直接413 Payload Too Large返回,避免无效请求进入业务逻辑。 - 请求解析初始化:创建
DiskFileItemFactory,设置内存阈值(sizeThreshold)和临时目录(repository)。这里我们不使用默认/tmp,而是指定为应用专属目录/opt/myapp/upload/tmp,并确保该目录权限为700。 - multipart 解析:
ServletFileUpload.parseRequest(request)扫描整个请求体,将每个 part(字段或文件)解析为FileItem。我们在此处注册自定义FileItemHeaders处理器,提取原始Content-Disposition中的filename*(RFC 5987 编码)字段,用于后续中文名还原。 - 文件内容校验:对每个
FileItem,先调用isFormField()区分普通字段和文件字段;对文件字段,不立即调用getInputStream(),而是先用get(),getSize()获取元数据,再用getInputStream()读取前 16 字节,比对 PNG (89 50 4E 47)、JPG (FF D8 FF)、GIF (47 49 46 38) 的 Magic Number,不匹配则抛出InvalidImageException。 - 安全存储:生成唯一文件名(UUID + 时间戳 + 原扩展名),校验扩展名白名单(
["jpg", "jpeg", "png", "gif"]),拼接目标路径。关键一步:使用Files.move()替代File.renameTo(),前者是原子操作,且能跨文件系统,避免因 NFS 挂载导致的移动失败。 - 响应组装与清理:返回 JSON 格式响应,包含
code、message、url(相对路径,由 Nginx 反向代理映射为可访问 URL)。最后,显式调用item.delete()清理临时文件,即使try-with-resources已关闭流,也要双重保险。
这个流程不是凭空设计的,而是我们在线上踩过坑后总结出的“最小安全闭环”。接下来,我会把每个环节的代码、参数、原理,掰开揉碎讲清楚。
3. 核心细节解析与实操要点:从依赖配置到文件名安全转义的硬核细节
3.1 依赖引入与版本选择:为什么必须用 1.5 以上,且要排除旧版 commons-io
FileUpload 的依赖看似简单,但版本组合不当,会直接导致运行时异常。以下是 Mavenpom.xml中必须精确配置的部分:
<dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.5</version> <exclusions> <!-- 强制排除旧版 commons-io,避免与项目中其他组件冲突 --> <exclusion> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> </exclusion> </exclusions> </dependency> <!-- 显式引入新版 commons-io,确保 API 兼容 --> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.11.0</version> </dependency>为什么是1.5?因为这是第一个正式支持 RFC 5987 文件名编码标准的版本。在旧版(如 1.3.3)中,当浏览器发送Content-Disposition: form-data; name="file"; filename*=UTF-8''%E6%96%87%E4%BB%B6.jpg这种带filename*的请求头时,FileUpload 会直接忽略它,转而尝试解析filename="?.jpg"这个乱码字段,导致中文名丢失。而1.5版本在FileItemHeaders类中新增了getHeader("Content-Disposition")的解析逻辑,能正确提取filename*并进行 UTF-8 解码。
commons-io的排除与重引,则是为了规避一个经典冲突:很多项目里spring-core或mybatis已经间接引入了commons-io:2.4或2.6,而老版 FileUpload 1.3.x 依赖的commons-io:1.4会与之发生NoSuchMethodError(比如IOUtils.toString(InputStream, Charset)方法在 1.4 中不存在)。强制统一到2.11.0,既能保证FileUtils.copyInputStreamToFile()等工具方法可用,又能避免反射调用失败。
提示:不要迷信“最新版”。FileUpload 1.6 在 2023 年发布,但其内部对
Servlet 4.0+的PartAPI 做了实验性支持,反而在 Tomcat 8.5(Servlet 3.1)环境下出现ClassCastException。生产环境建议锁死1.5,它经过了数年大规模验证,稳定性和兼容性最佳。
3.2 DiskFileItemFactory 的三大核心参数:内存阈值、临时目录、文件大小限制
DiskFileItemFactory是 FileUpload 的“发动机”,它的三个参数直接决定了上传的性能与安全性:
DiskFileItemFactory factory = new DiskFileItemFactory(); // 1. 内存阈值:单个文件在内存中缓存的最大字节数 factory.setSizeThreshold(1024 * 1024); // 1MB // 2. 临时目录:当文件超过阈值,将写入此目录的临时文件 factory.setRepository(new File("/opt/myapp/upload/tmp")); // 3. 全局文件大小限制:单个文件最大允许大小 ServletFileUpload upload = new ServletFileUpload(factory); upload.setFileSizeMax(10 * 1024 * 1024); // 10MBsetSizeThreshold(1MB):这是最关键的性能调优点。如果设为0,所有文件都直接写磁盘,I/O 压力大;如果设得太大(如10MB),小文件(如 10KB 的头像)也会占满内存,导致频繁 GC。1MB是一个经验值:它能覆盖 95% 的图片上传(微信头像约 50KB,微博配图约 300KB),同时为大文件(如 5MB 的产品图)留出磁盘缓冲空间。你可以用jstat -gc <pid>观察 Young GC 频率来微调。setRepository(...):绝对不要用new File(System.getProperty("java.io.tmpdir"))!/tmp目录通常被所有用户共享,存在安全隐患。我们创建专用目录/opt/myapp/upload/tmp,并执行:mkdir -p /opt/myapp/upload/tmp chown appuser:appgroup /opt/myapp/upload/tmp chmod 700 /opt/myapp/upload/tmp这样,只有应用进程能读写该目录,其他用户无法窥探或删除临时文件。
setFileSizeMax(10MB):这是第一道防线。它会在parseRequest()过程中,当解析到某个FileItem的size超过此值时,立即抛出FileSizeLimitExceededException。注意,它只限制单个文件,不限制整个请求的总大小。总大小限制需要另外设置:upload.setSizeMax(50 * 1024 * 1024)(50MB)。
注意:
setSizeThreshold和setFileSizeMax的单位都是字节(bytes),不是 KB 或 MB。我见过太多人写成1024以为是 1KB,结果发现阈值只有 1 字节,所有上传都失败。务必用1024 * 1024这种显式乘法,避免歧义。
3.3 中文文件名的终极解决方案:从 RFC 2231 到 RFC 5987 的演进与代码实现
中文文件名问题,是 Java Web 上传领域最古老也最顽固的 Bug。根源在于 HTTP 协议本身不规定filename字段的编码方式,导致各浏览器“八仙过海”:
- Chrome/Firefox:使用
filename*=UTF-8''%E6%96%87%E4%BB%B6.jpg(RFC 5987) - IE/Edge(旧版):使用
filename="?????.jpg"(GBK 或 UTF-8 乱码) - Safari:有时混合两种格式
FileUpload 1.5 的解决方案是:优先解析filename*,失败则 fallback 到filename,并对filename做多编码尝试。我们的代码如下:
private String extractFileName(FileItem item) throws Exception { String fileName = null; // 步骤1:尝试获取 RFC 5987 格式的 filename* String disposition = item.getHeaders().getHeader("Content-Disposition"); if (disposition != null) { fileName = FilenameUtils.getName(disposition); // FilenameUtils 来自 commons-io if (fileName != null && fileName.contains("''")) { // 解析 filename*=UTF-8''%E6%96%87%E4%BB%B6.jpg fileName = URLDecoder.decode(fileName.split("''")[1], "UTF-8"); } } // 步骤2:如果步骤1失败,尝试从 headers 中直接提取 if (fileName == null || fileName.trim().isEmpty()) { fileName = item.getName(); } // 步骤3:对 fileName 进行多重编码检测与转换(核心!) if (fileName != null && !fileName.trim().isEmpty()) { // 先尝试 UTF-8 解码(Chrome/Firefox 的原始编码) try { fileName = new String(fileName.getBytes("ISO-8859-1"), "UTF-8"); } catch (Exception ignored) { // 如果 ISO-8859-1 转 UTF-8 失败,说明可能是 GBK 编码(IE) try { fileName = new String(fileName.getBytes("ISO-8859-1"), "GBK"); } catch (Exception e) { // 最终 fallback:移除所有非 ASCII 字符,保留英文和数字 fileName = fileName.replaceAll("[^a-zA-Z0-9._\\-]", "_"); } } } return fileName == null ? "unnamed" : fileName; }这段代码的精妙之处在于第三步的“双重解码”。为什么是ISO-8859-1?因为 Servlet 容器(Tomcat)在解析Content-Disposition时,会将整个 header 字符串按ISO-8859-1编码读入内存。一个 UTF-8 编码的中文字符串文.jpg,其字节序列是E6 96 87 2E 6A 70 67,当 Tomcat 用ISO-8859-1解释它时,会得到一个乱码字符串æ–‡.jpg。此时,我们再把这个乱码字符串按ISO-8859-1编码回字节,就得到了原始的E6 96 87 2E 6A 70 67,再用UTF-8解码,就还原了文.jpg。这就是经典的“ISO-8859-1 中转法”。
实操心得:永远不要相信
item.getName()返回的字符串可以直接用。我曾在一个政府项目中,因为没做这步转换,导致所有上传的“报告.docx”在 Linux 服务器上变成了“?????.docx”,最后只能靠数据库记录的原始Content-Disposition字段人工恢复。从此以后,我的上传代码里,extractFileName()是雷打不动的第一行。
3.4 图片文件头(Magic Number)校验:为什么ImageIO.read()不是第一道防线?
很多教程教大家用ImageIO.read(inputStream)来判断是否为有效图片,这在功能上没错,但在安全层面是重大失误。ImageIO.read()会尝试将整个图像数据解码为BufferedImage,这个过程极其消耗 CPU 和内存。攻击者可以构造一个“超大尺寸但内容极简”的恶意图片(如一个 10000x10000 像素的纯白 PNG),ImageIO.read()会为其分配10000 * 10000 * 4 = 400MB的内存,瞬间拖垮 JVM。
真正的第一道防线,是读取文件的前几个字节,比对“魔数”(Magic Number):
| 格式 | 前 2 字节(Hex) | 前 3 字节(Hex) | 前 4 字节(Hex) |
|---|---|---|---|
| JPG/JPEG | FF D8 | FF D8 FF | FF D8 FF E0或FF D8 FF E1 |
| PNG | 89 50 4E 47 | 89 50 4E | 89 50 4E 47 |
| GIF | 47 49 46 | 47 49 46 38 | 47 49 46 38 37 61或47 49 46 38 39 61 |
我们的校验代码非常轻量:
private boolean isValidImageMagicNumber(InputStream inputStream) throws IOException { byte[] magicBytes = new byte[8]; // 读取前8字节,足够覆盖所有常见格式 int bytesRead = inputStream.read(magicBytes); if (bytesRead < 2) return false; // JPG: FF D8 if (magicBytes[0] == (byte) 0xFF && magicBytes[1] == (byte) 0xD8) { return true; } // PNG: 89 50 4E 47 if (bytesRead >= 4 && magicBytes[0] == (byte) 0x89 && magicBytes[1] == (byte) 0x50 && magicBytes[2] == (byte) 0x4E && magicBytes[3] == (byte) 0x47) { return true; } // GIF: 47 49 46 38 if (bytesRead >= 4 && magicBytes[0] == (byte) 0x47 && magicBytes[1] == (byte) 0x49 && magicBytes[2] == (byte) 0x46 && magicBytes[3] == (byte) 0x38) { return true; } return false; }这段代码只读 8 字节,毫秒级完成,且不分配任何大对象。它应该在parseRequest()之后、item.getInputStream()之前调用。注意,inputStream是FileItem.getInputStream()返回的,它是一个DeferredFileOutputStream的包装流,读取它不会触发磁盘 I/O,只是从内存缓冲区或临时文件中读取开头字节。
提示:Magic Number 校验只能证明“它看起来像图片”,不能证明“它真的是安全图片”。后续仍需用
ImageIO.read()做二次校验(但要在try-catch中,并设置ImageIO.setUseCache(false)防止缓存滥用),并检查BufferedImage.getWidth()和getHeight()是否在合理范围(如< 10000),这才是完整的防御纵深。
4. 完整实操过程与核心环节实现:从 Servlet 编写到 Nginx 配置的全链路
4.1 UploadServlet 的完整代码:包含异常处理、日志埋点与资源清理
下面是一个生产可用的UploadServlet,它包含了所有前述要点的落地实现:
@WebServlet("/api/upload") public class UploadServlet extends HttpServlet { private static final Logger logger = LoggerFactory.getLogger(UploadServlet.class); private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB private static final long MAX_REQUEST_SIZE = 50 * 1024 * 1024; // 50MB private static final String UPLOAD_DIR = "/opt/myapp/upload/images"; @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 步骤1:预检 Content-Length long contentLength = request.getContentLength(); if (contentLength == -1 || contentLength > MAX_REQUEST_SIZE) { sendErrorResponse(response, 413, "Request entity too large"); return; } // 步骤2:初始化 FileUpload DiskFileItemFactory factory = new DiskFileItemFactory(); factory.setSizeThreshold(1024 * 1024); // 1MB 内存阈值 factory.setRepository(new File("/opt/myapp/upload/tmp")); ServletFileUpload upload = new ServletFileUpload(factory); upload.setFileSizeMax(MAX_FILE_SIZE); upload.setSizeMax(MAX_REQUEST_SIZE); // 步骤3:解析请求 List<FileItem> items; try { items = upload.parseRequest(request); } catch (FileUploadException e) { logger.error("File upload parse failed", e); sendErrorResponse(response, 400, "Invalid request format"); return; } // 步骤4:遍历所有 item,处理文件 Map<String, Object> result = new HashMap<>(); List<String> uploadedUrls = new ArrayList<>(); for (FileItem item : items) { if (item.isFormField()) { // 处理普通表单字段,如 description String fieldName = item.getFieldName(); String value = item.getString("UTF-8"); logger.debug("Form field: {} = {}", fieldName, value); continue; } // 步骤5:处理文件字段 try { String originalFileName = extractFileName(item); if (originalFileName == null || originalFileName.trim().isEmpty()) { throw new IllegalArgumentException("Empty filename"); } // 步骤6:Magic Number 校验 try (InputStream is = item.getInputStream()) { if (!isValidImageMagicNumber(is)) { throw new IllegalArgumentException("Invalid image file type"); } } // 步骤7:生成安全文件名 String extension = getFileExtension(originalFileName); if (!isAllowedExtension(extension)) { throw new IllegalArgumentException("Unsupported file extension: " + extension); } String safeFileName = generateSafeFileName(originalFileName, extension); // 步骤8:构建目标路径并写入 File uploadDir = new File(UPLOAD_DIR); if (!uploadDir.exists()) { uploadDir.mkdirs(); // 确保目录存在 } File targetFile = new File(uploadDir, safeFileName); // 使用 Files.move 确保原子性 item.write(targetFile); logger.info("File uploaded successfully: {}", targetFile.getAbsolutePath()); // 步骤9:生成可访问 URL(相对路径) String relativeUrl = "/images/" + safeFileName; uploadedUrls.add(relativeUrl); } catch (Exception e) { logger.error("Failed to process file item", e); sendErrorResponse(response, 400, "File processing error: " + e.getMessage()); return; // 一个文件失败,整个请求失败,避免部分成功 } finally { // 步骤10:强制清理临时文件 item.delete(); } } // 步骤11:组装成功响应 result.put("code", 0); result.put("message", "Upload success"); result.put("data", uploadedUrls); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(new ObjectMapper().writeValueAsString(result)); } // ... extractFileName(), isValidImageMagicNumber(), 等辅助方法(见前文)... private void sendErrorResponse(HttpServletResponse response, int statusCode, String message) throws IOException { response.setStatus(statusCode); response.setContentType("application/json;charset=UTF-8"); Map<String, Object> error = new HashMap<>(); error.put("code", statusCode); error.put("message", message); response.getWriter().write(new ObjectMapper().writeValueAsString(error)); } }这份代码的关键设计点:
sendErrorResponse()统一错误出口:确保所有异常路径都返回标准 JSON 格式,前端无需处理多种错误格式。item.delete()放在finally块:这是防止磁盘被临时文件占满的最后保险。即使item.write()抛出IOException,临时文件也必须被清理。item.write(targetFile)而非item.getInputStream().transferTo(...):item.write()是 FileUpload 封装的健壮方法,它内部会处理流关闭、异常回滚等细节,比手动transferTo更安全。generateSafeFileName()的实现:它会将原文件名_时间戳_UUID.扩展名,例如我的头像_20231015143022_550e8400-e29b-41d4-a716-446655440000.jpg,彻底杜绝文件名冲突和覆盖。
4.2 前端 HTML 表单与 JavaScript:如何让浏览器发出标准 multipart 请求
后端再严谨,前端表单写错了也是白搭。一个符合标准的上传表单,必须满足三个条件:
method="POST"enctype="multipart/form-data"input type="file"的name属性与后端FileItem的getFieldName()一致
<!DOCTYPE html> <html> <head> <title>图片上传 DEMO</title> </head> <body> <form id="uploadForm" enctype="multipart/form-data" method="post"> <label for="fileInput">选择图片:</label> <input type="file" id="fileInput" name="file" accept="image/*" multiple /> <br><br> <label for="desc">描述(可选):</label> <input type="text" id="desc" name="description" /> <br><br> <button type="submit">上传</button> </form> <div id="result"></div> <script> document.getElementById('uploadForm').addEventListener('submit', function(e) { e.preventDefault(); // 阻止默认提交 const formData = new FormData(); const fileInput = document.getElementById('fileInput'); const files = fileInput.files; // 关键:必须用 append 添加文件,不能用 set for (let i = 0; i < files.length; i++) { formData.append('file', files[i]); // name="file" 必须与后端一致 } formData.append('description', document.getElementById('desc').value); // 发送请求 fetch('/api/upload', { method: 'POST', body: formData // 自动设置正确的 Content-Type 和 boundary }) .then(response => response.json()) .then(data => { document.getElementById('result').innerHTML = '<p>上传成功!共 ' + data.data.length + ' 个文件</p>' + '<ul><li>' + data.data.join('</li><li>') + '</li></ul>'; }) .catch(error => { document.getElementById('result').innerHTML = '<p style="color:red;">上传失败:' + error.message + '</p>'; }); }); </script> </body> </html>注意点:
accept="image/*"是前端提示,不是安全措施,后端必须二次校验。formData.append('file', files[i])中的'file'必须与后端item.getFieldName()返回的字符串完全一致,大小写敏感。fetch会自动设置Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...,你不需要手动设置,也不应该手动设置。
4.3 Nginx 反向代理配置:解决大文件上传的超时与缓冲区问题
当你的 Java 应用部署在 Nginx 后面时,Nginx 会成为上传的第一个关卡。如果配置不当,用户会遇到504 Gateway Timeout或413 Request Entity Too Large,而你的 Java 日志里却什么都没有——因为请求根本没到达 Tomcat。
在nginx.conf的http或server块中,必须添加以下配置:
# 1. 允许上传大文件 client_max_body_size 100M; # 2. 增加超时时间,避免大文件上传被中断 client_header_timeout 300; client_body_timeout 300; send_timeout 300; # 3. 关键:禁用 proxy_buffering,防止 Nginx 缓冲整个请求体 # 这会导致 FileUpload 无法流式解析,必须等整个文件上传完才转发 proxy_buffering off; # 4. 如果使用了 proxy_pass,确保它指向正确的 upstream location /api/upload { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 5. 静态图片访问配置(让 /images/ 路径直接由 Nginx 服务) location /images/ { alias /opt/myapp/upload/images/; expires 1h; add_header Cache-Control "public, no-transform"; }client_max_body_size 100M:必须大于后端MAX_REQUEST_SIZE(50MB),留出余量。proxy_buffering off:这是最容易被忽略的点。默认on时,Nginx 会把整个multipart请求体缓存到内存或磁盘,再一次性转发给 Tomcat。这不仅浪费资源,还会让 FileUpload 的流式解析失效,失去实时校验的能力。off后,Nginx 会以流式方式转发,后端可以边收边处理。/images/的alias配置:让图片 URL 如/images/xxx.jpg直接由 Nginx 读取文件并返回,不经过 Java 应用,极大减轻后端压力。
实操心得:每次上线新上传功能,我必做的三件事:1) 用
curl -F "file=@large_file.zip" http://localhost:8080/api/upload在本地测试;2) 用浏览器开发者工具 Network 面板,确认请求的Content-Type和boundary正确;3) 查看 Nginx 的error.log,确认没有upstream timed out或client intended to send too large body的报错。这三步做完,上传功能才算真正“活”了。