如果你写过多端联调,大概率遇到过这样一种场景:客户端用 MultipartEntityBuilder 上传图片,代码从网上抄了好几版,看着完全没问题,结果服务端拿到的 MultipartFile 死活是 null,或者请求压根进不了接口就报 400。这个坑我在五六个项目里都见过,从刚入行的朋友到带我的老同事,都绕着它走过弯路。今天这篇就把“通过 MultipartEntityBuilder 上传图片但接收不到数据”这个问题的原理、踩坑点和排查方法一次说透,适合正在被前后端文件上传折磨的开发者。
1. 问题现象与根因分析
1.1 典型场景还原
先还原一下现场。客户端用的是 Apache HttpClient 4.5.x,上传图片的代码大概是这样的简化版本:
MultipartEntityBuilder builder = MultipartEntityBuilder.create(); builder.addBinaryBody("file", new File("/tmp/a.png")); builder.addTextBody("name", "a"); HttpPost post = new HttpPost("http://测试环境地址/upload"); post.setEntity(builder.build()); post.setHeader("Content-Type", "multipart/form-data"); CloseableHttpResponse resp = client.execute(post);服务端是 Spring Boot 写的接口,也特别普通:
@PostMapping("/upload") public String upload(@RequestParam("file") MultipartFile file, @RequestParam("name") String name) { return "ok:" + file.getSize(); }现象通常有三种,很多人对不上号:
- 服务端直接返回 400,提示
file参数缺失; - 服务端返回 200,但
file为 null,一取就空指针; - 在老框架或者自定义 Servlet 的场景下,服务端收到一个空的请求体,
getInputStream()读出来是 0 字节。
这三种现象背后,未必是同一个原因。这也是“接收不到数据”这句话最迷惑人的地方——同一个标题下,藏着好几个不同层次的故障。要排查,就得先把 multipart 请求的结构搞清楚。
1.2 multipart/form-data 请求体到底长什么样
很多朋友写代码时只知道 MultipartEntityBuilder 能帮我们拼请求体,却不清楚它到底拼出了什么。一个标准的 multipart 请求,在 HTTP 层面长这样:
POST /upload HTTP/1.1 Host: localhost:8080 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="name" a ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="file"; filename="a.png" Content-Type: image/png (这里放二进制图片内容) ------WebKitFormBoundary7MA4YWxkTrZu0gW--整个请求体被一个随机字符串boundary分成多个 part。每个 part 之间用--boundary分隔,最后一个 part 结束用--boundary--收尾。每个 part 里有一部分描述信息(Content-Disposition,标明字段名 name、文件名 filename,二进制 part 还会带上Content-Type),然后是空行,接着才是真正要传的内容。
服务端解析器拿到请求后,必须先从请求头的Content-Type里读出boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW,再把整个 body 按照这个边界字符切开,才能还原出每个字段和每个文件。
这么说吧:boundary 就是服务端拆分身体的“手术刀”,请求头里没有它,或者它和 body 里的实际边界对不上,服务端就只能看到一整段没法识别的二进制流,自然拿不到任何字段。
1.3 一根 header 引发的“接收不到数据”
问题往往出在博主们最容易忽略的一行代码上:
post.setHeader("Content-Type", "multipart/form-data");这行代码单独看,意图是好的:上传请求确实需要 multipart/form-data。但 MultipartEntityBuilder 构建出的HttpEntity,本身会提供一份完整的Content-Type,里面带着它自己的 boundary,比如:
Content-Type: multipart/form-data; boundary=----e62f0d7d-3a2a-4a56-8b8c-6cef314a2f24当你执行post.setHeader("Content-Type", "multipart/form-data")时,这个操作会把 Entity 自带的、带 boundary 的请求头覆盖掉。服务端最终收到的请求头变成了没有 boundary 的multipart/form-data,自然切不开请求体,数据也就“接收不到”了。
这个原因有多常见?我见过不下十次。大家在复制粘贴代码时,往往把两种写法混搭在一起:一种是手动setHeader拼请求,另一种是直接用 Entity 自带头。结果就是把自己坑了。
注意:
HttpPost.setHeader(String, String)是覆盖式设置,不是追加。如果你不确定 Entity 内部是怎么生成 Content-Type 的,千万别手贱去覆盖它。
2. MultipartEntityBuilder 标准写法和三个致命细节
2.1 可以直接复用的客户端代码模板
先把正确的、能稳定工作的客户端写法放出来。下面这段代码在我的项目里已经跑过很多次,可以直接参考:
import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ContentType; import org.apache.http.entity.mime.HttpMultipartMode; import org.apache.http.entity.mime.MultipartEntityBuilder; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import java.io.File; import java.nio.charset.StandardCharsets; public class UploadClient { public static void main(String[] args) throws Exception { CloseableHttpClient client = HttpClients.createDefault(); HttpPost post = new HttpPost("http://localhost:8080/upload"); MultipartEntityBuilder builder = MultipartEntityBuilder.create(); builder.setMode(HttpMultipartMode.BROWSER_COMPATIBLE); builder.setCharset(StandardCharsets.UTF_8); // 二进制文件部分:字段名,文件字节/File对象,ContentType,文件名 builder.addBinaryBody( "file", new File("/tmp/a.png"), ContentType.create("image/png"), "a.png" ); // 普通文本字段 builder.addTextBody("name", "a", ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8)); // 构建 Entity 并设置到请求上,这一步自带正确的 Content-Type 和 boundary post.setEntity(builder.build()); CloseableHttpResponse response = client.execute(post); System.out.println(EntityUtils.toString(response.getEntity())); response.close(); client.close(); } }这段代码最关键的地方在于:构建完 Entity 后,直接post.setEntity(builder.build()),然后什么都不用做,直接execute。HttpClient 发送请求时,会使用 Entity 自带的Content-Type,其中包含了boundary和完整的媒体类型信息。
2.2 致命细节一:Content-Type 被手动覆盖
第一条已经展开说过了,这里再补充一个容易误伤的场景:有人会这样写:
post.setHeader("Content-Type", "multipart/form-data; boundary=" + "----自定义字符串");看起来是手工指定了 boundary,以为这样没问题。但是 MultipartEntityBuilder 内部的 boundary 是随机生成的,你写在 header 里的边界,和 Entity body 里实际用的边界并不是同一个字符串。服务端按照 header 里的 boundary 去切 body,切出来的结果完全错乱,最终还是收不到数据。
正确做法是让 Entity 自己管理 boundary。如果需要自定义 boundary,MultipartEntityBuilder 其实也有setBoundary(String)方法,你可以显式设置同一个 boundary,而不是自己往 header 里写:
builder.setBoundary("----MyCustomBoundary");这样 builder 生成的 body 内部也会用同一个边界,才能保证前后一致。
2.3 致命细节二:文件名、字段名没对齐
第二个高频坑是字段名不匹配。addBinaryBody的第一个参数是表单字段名,很多新手以为这个名字随便写,其实它必须和服务端@RequestParam("file")、@RequestPart("file")里的名字完全一致。你写addBinaryBody("img", ...),服务端声明却是@RequestParam("file"),那服务端解析器会认为请求里没有file这个 part,直接报参数缺失。
服务端如果不指定@RequestParam("file"),而是用MultipartHttpServletRequest来拿文件,那也要确保 request 里有对应的 part 名。
另外还有一个隐蔽的小点:addBinaryBody(String name, byte[] bytes, ContentType contentType, String filename)中如果不传文件名,或者传空字符串,部分服务端解析器会认为这是一个字段而不是文件,甚至直接把该 part 当作FormBodyPart处理,文件也就读不出来了。所以文件 part 一般都要带上文件名。
2.4 致命细节三:边界条件与字符集
再往深一点说,还有一个大家容易忽略的地方——HttpMultipartMode和字符集。
STRICT模式:严格按照 RFC 规范生成,部分老旧的服务器解析器反而接受不了;BROWSER_COMPATIBLE模式:模拟浏览器行为,对中文文件名、特殊字符兼容性更好,绝大多数 Java 项目都用这个模式;RFC6532模式:用于支持非 ASCII 字符集的 header,正常业务中用得少。
文本字段的编码问题也很常见。如果服务端或者中间层对编码敏感,你addTextBody("name", "张三")不带ContentType,可能会按默认 ISO-8859-1 编码发送,服务端收到的就是乱码,看起来就像“数据没传对”。建议文本 part 都显式指定 UTF-8:
builder.addTextBody("name", "张三", ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8));文件字节数组也是同理,不要依赖默认编码,明确传递ContentType.create("image/png")或ContentType.APPLICATION_OCTET_STREAM。
3. 服务端解析限制与排查
3.1 Spring Boot 接收 MultipartFile 的前置条件
很多时候客户端代码没问题,数据也确实发到了服务器,但服务端那边有自己的限制。Spring Boot 2.x 默认开了 multipart 解析,但有几个默认参数很容易坑人:
spring: servlet: multipart: enabled: true max-file-size: 1MB max-request-size: 10MBmax-file-size默认是 1MB,max-request-size默认是 10MB。如果你上传的图片超过 1MB,Spring 会在解析阶段直接拒绝,抛MaxUploadSizeExceededException,客户端看到的可能是“请求失败”或者干脆超时,而不是清晰的业务提示。如果项目是传统 Spring MVC,没有用 Spring Boot,还需要确认是否配置了MultipartResolver,比如:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="maxUploadSize" value="20971520"/> </bean>没配置这个 Bean 的话,@RequestParam MultipartFile根本不会绑定。
3.2 服务端“接收不到数据”的三种真实含义
“接收不到数据”这个描述过于笼统,真正排查时要先定位到底是哪一种:
| 现象 | 真实含义 | 常见原因 |
|---|---|---|
| 接口直接返回 400,提示缺少参数 | MultipartHttpServletRequest 没解析出对应 part | 字段名不一致、请求头没有 multipart/form-data |
| 接口正常返回 200,但 file 为 null | 参数绑定成功但值没塞进去 | 服务端泛型参数不匹配、文件名缺失 |
| Controller 没被调用,或者请求体读出来为空 | 请求在更早的 Filter/Interceptor 被拦或消费 | 又拍了一层自定义 filter、网关限制 |
举个例子,我有一次帮朋友排查,他报“接口接收不到图片”,最后发现是他自己的项目中加了一个全局过滤器,把所有请求的getInputStream()提前读了一遍,用来做日志记录,但是没把流 reset。等到 Spring 的 multipart 解析器再读同一段流时,流已经到尾部了,自然解析不到任何 part。这种问题光看客户端代码是永远查不出来的。
还有一种是网关或反向代理的 body 大小限制。比如 Nginx 默认client_max_body_size 1m,图片超过 1MB 时,Nginx 直接在代理层就把请求挡了,服务端连请求都不会收到。表现也是“接收不到数据”,但问题根本不在应用代码。
3.3 用 curl 做基准验证,把锅分给客户端还是服务端
遇到这种问题,我的第一反应不是改代码,而是先做一次标准验证:用 curl 模拟同样的 multipart 请求,看服务端是否正常接收。
curl -X POST http://localhost:8080/upload \ -F "file=@/tmp/a.png" \ -F "name=a"如果 curl 能正常返回,说明服务端接收能力和接口逻辑基本没有大问题,锅大概率在客户端写法上。如果 curl 也能复现同样的问题,那就要重点看服务端配置、拦截器和代理层。这一步能帮你把排查范围缩小一半,是性价比最高的动作。
需要注意的是,curl 的-F参数本身就带了正确的 multipart 编码,而且会自动生成 boundary,它验证的是“服务端能不能收”,不能代替你验证“你的 Java 客户端写法对不对”。所以接下来还需要抓包看客户端实际发出的请求长什么样。
4. 实操一条龙:从复现到抓包到修复
4.1 准备一个最小复现工程
为了方便演示,我整理了一个极简复现场景。服务端用一个 Spring Boot 2.x 项目,只留一个上传接口:
@RestController public class UploadController { @PostMapping("/upload") public Map<String, Object> upload(@RequestParam("file") MultipartFile file, @RequestParam("name") String name) { Map<String, Object> result = new HashMap<>(); result.put("fileName", file.getOriginalFilename()); result.put("fileSize", file.getSize()); result.put("name", name); return result; } }然后用 2.1 节里的正确客户端代码去调用,正常会返回:
{ "fileName": "a.png", "fileSize": 4096, "name": "a" }如果刻意把客户端写成带post.setHeader("Content-Type", "multipart/form-data")的版本,再调用一次,服务端很可能直接返回 400,或者报Required request part 'file' is not present。这个小实验已经够说明问题了。
4.2 抓包看请求体
接下来用抓包工具观察实际请求。Windows 上推荐 Fiddler,macOS / Linux 也可以用 Wireshark。如果你在本地调试 Java 进程,Fiddler 默认监听 8866 端口,需要在客户端代码里设置系统代理,或者在发起请求前指定:
System.setProperty("http.proxyHost", "127.0.0.1"); System.setProperty("http.proxyPort", "8866");抓到请求后,重点看两个地方:
第一是请求头Content-Type。坏请求长这样:
Content-Type: multipart/form-data好请求长这样:
Content-Type: multipart/form-data; boundary=----e62f0d7d-3a2a-4a56-8b8c-6cef314a2f24第二是请求体。抓包工具里通常能直接看到 raw body 的文本形式。确认 body 里的边界字符串和请求头里的 boundary 是否一致,确认每个 part 的Content-Disposition中的 name 是不是file和name。
这一眼往往就能定位七八成问题。
4.3 修复前后的请求体对比
我用一段真实的抓包输出对比修复前后区别。
修复前,手动覆盖了 Content-Type 的客户端发出的部分请求头:
POST /upload HTTP/1.1 Content-Type: multipart/form-data Content-Length: 6512 Host: localhost:8080 Connection: Keep-Alive User-Agent: Apache-HttpClient/4.5.13 ------e62f0d7d-3a2a-4a56-8b8c-6cef314a2f24 Content-Disposition: form-data; name="name" a ------e62f0d7d-3a2a-4a56-8b8c-6cef314a2f24 Content-Disposition: form-data; name="file"; filename="a.png" Content-Type: image/png (二进制内容) ------e62f0d7d-3a2a-4a56-8b8c-6cef314a2f24--注意:body 内部的 boundary 明明存在,但请求头里的Content-Type没有把它带出来。服务端凭空拿到一个没有 boundary 的 multipart 请求,等于没有拆分地图,最终解析出一个空映射表。
修复后,客户端不覆盖 Content-Type,发出的请求头变成:
POST /upload HTTP/1.1 Content-Type: multipart/form-data; boundary=----e62f0d7d-3a2a-4a56-8b8c-6cef314a2f24 Content-Length: 6512 Host: localhost:8080 Connection: Keep-Alive User-Agent: Apache-HttpClient/4.5.13 ------e62f0d7d-3a2a-4a56-8b8c-6cef314a2f24 Content-Disposition: form-data; name="name" a ------e62f0d7d-3a2a-4a56-8b8c-6cef314a2f24 Content-Disposition: form-data; name="file"; filename="a.png" Content-Type: image/png (二进制内容) ------e62f0d7d-3a2a-4a56-8b8c-6cef314a2f24--两边一对比,问题一目了然:请求头Content-Type必须携带boundary,且 boundary 值必须和 body 使用的完全一致。修复就是删掉那一行多余的setHeader,让 Entity 自己决定 Content-Type。
5. 高频问题速查表与我的排查习惯
5.1 高频问题速查表
我在多个项目里把同类问题整理成了一个速查表,遇到“接收不到数据”可以直接对照看:
| 现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| 报 400:Required request part 'file' is not present | 表单字段名不匹配 | 核对addBinaryBody的第一个参数与@RequestParam值 |
| 报 400:the request was rejected because its size exceeds | 服务端文件大小限制 | 调整spring.servlet.multipart.max-file-size |
| Controller 没进,或者 Filter 用完流 | 业务代码提前消费 inputStream | 检查拦截器、日志过滤器是否读取过请求体 |
返回 200,但file.getSize()为 0 | 上传的二进制字节为空 | 检查文件读取逻辑、路径是否正确 |
| 图片上传后文件名乱码 | 字符集和模式配置不对 | 使用BROWSER_COMPATIBLE+ UTF-8 |
| 客户端报 UnsupportedOperationException | MultipartEntityBuilder 版本不匹配问题 | 检查 Apache HttpClient 相关 jar 版本一致性 |
| 内网环境收不到,本地正常 | 反向代理或网关限制 body 大小 | 检查 Nginxclient_max_body_size或网关配置 |
还有一个不太常见但值得单独记录的:如果你用的是老的 HttpClient 3.x 项目,别人给的示例是 4.x 的MultipartEntityBuilder,类都导不出来,那就不是数据收不到的问题,而是依赖体系对不上。此时要么升级 HttpClient 到 4.x,要么改用MultipartRequestEntity,别硬混。
5.2 我现在的排查习惯
被这个问题折腾过几轮后,我给自己定了一套固定排查顺序,效率高很多:
- 先用 curl 验证服务端:确认服务端能力是否正常,排除代理层限制;
- 抓包看客户端实际请求:重点看
Content-Type是否带boundary,boundary 是否与 body 一致; - 核对字段名和服务端入参名是否完全一致;
- 检查服务端 multipart 配置,尤其是文件大小限制;
- 如果上面全都没问题,再怀疑业务代码层面的流消耗、拦截器、日志过滤器。
这套顺序的核心思路就是:先确认数据到底有没有到服务端,再确认到的是不是完整的数据。很多人一上来就改客户端代码,改完再改服务端,反复试,其实效率很低。抓一次包,定位速度立刻提升一个级别。
另外,如果你在维护旧系统,服务端不是 Spring Boot 而是原生 Servlet,那么接口里应该用request.getPart("file")而不是request.getParameter("file")来取文件。getParameter只能拿到文本表单字段,拿不到文件 part。这也算“接收不到数据”的常见低级失误之一。
再补充一个真实经验:有一次我排查了半天,最后发现是服务端 bean 里@RequestParam("file")写成了@RequestParam("files"),而客户端发的字段名是file。这种名字类的 bug,用 curl 验证时会因为 curl 里也写了同样的字段而掩盖掉,真正要做的就是抓包后睁大眼睛看字段名。这是我踩过的坑,希望大家避免。
最后分享一个小习惯
经历过这一系列问题后,我现在写上传相关代码时,都会在请求发出去之前打印一行日志:
HttpEntity entity = builder.build(); System.out.println(entity.getContentType().getValue()); post.setEntity(entity);确认输出的 Content-Type 里带有boundary=...,再继续执行。这个小动作成本极低,但能挡掉一大半“上传图片接收不到数据”的低级问题,也是我如今在团队里给大家立下的规矩之一。如果你正在被这个问题卡住,别急着改服务端,先把你客户端发出的请求头打印出来看一眼,多半答案就在你眼前。