搞上传功能最怕遇到这种灵异事件:客户端用的是Apache HttpClient 4.5里的MultipartEntityBuilder,服务端是常见的Web框架,代码看着没有任何问题,控制台明明打印出HTTP 200,服务端日志却明明白白写着文件参数为null。不是偶发,是每次都这样,两边开发一碰头就开始互相怀疑。
这个问题的坑不止一个,我前前后后绕了不少弯路才把整条链路理清楚。这篇文章不是只给最终答案,我想把我排查“MultipartEntityBuilder上传图片接收不到数据”的完整思路和代码结论放出来。如果你正在被同样的问题折磨,下文的内容基本都是可以照着操作的。
1. 症状全景:控制台明明打印200,服务端却给了一个null
先说一个典型失败现场。客户端大体是这样写的:
CloseableHttpClient client = HttpClients.createDefault(); HttpPost post = new HttpPost("http://localhost:8080/upload"); MultipartEntityBuilder builder = MultipartEntityBuilder.create(); builder.addBinaryBody("file", new File("C:/tmp/a.jpg"), ContentType.create("image/jpeg"), "a.jpg"); builder.addTextBody("name", "test", ContentType.TEXT_PLAIN); post.setEntity(builder.build()); CloseableHttpResponse resp = client.execute(post); System.out.println(resp.getStatusLine().getStatusCode());服务端是一个普通的Spring Boot接口:
@PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file) { System.out.println(file == null ? "null" : file.getOriginalFilename()); return Result.success(); }结果就是最让人上头的那种组合:客户端打印200,服务端控制台输出null。有的项目运气差点,服务端直接给你抛一个“Required request part 'file' is not present”,但因为你前端有个全局异常处理器,最后浏览器收到的还是200。
这里必须明确一个教训:HTTP状态码根本不是判断业务是否成功的依据。请求可以正常打到服务端,也可以正常返回响应,但服务端内部解析multipart失败这件事,Spring MVC有时候会当成普通400返回,有时候会被你自己的异常处理代码转换成“看起来成功”的JSON。所以我看到“200”的第一反应不是庆祝,而是先怀疑:这个200是谁给的,是框架给的还是我自己处理异常时给的。
这一阶段把现象记录完整很重要。我建议你把下面几个问题写下来再继续排查:
- 服务端日志里到底有没有MVC层之外的异常(比如MaxUploadSizeExceededException)。
- 服务端打印的Content-Type是什么样的。
- 请求是否真的到了业务方法,还是卡在参数解析阶段。
带着这些记录往下看,比瞎改代码靠谱得多。
2. MultipartEntityBuilder的构建原理:数据不是“传”出去的,是“写”进请求体里的
很多人对MultipartEntityBuilder的理解停留在“调用它就能上传”的层面,忽略了一个关键事实:它本质上是在内存或临时文件里拼出一个符合RFC 2388格式的请求体,然后把这个请求体交给HttpClient发送。
可以把它类比成打包发货。MultipartEntityBuilder负责把文件、普通文本字段装进一个贴着“内容类型”标签的包裹里,并自动生成一个用于分隔不同字段的boundary字符串,这串字符串既是包裹标签的一部分,也是包裹内容的分隔线。HttpClient拿到这个entity之后,要做的只是把整个包裹原封不动地写到TCP连接里。
所以“接收不到数据”这个描述不够准确,更准确的说法是:请求体根本没按服务端期望的格式到达,或者到达了但格式已经坏了。
结合我踩过的坑,下面几种情况最容易造成“服务端没收到”:
第一种,有人拿EntityUtils.toString(entity)把MultipartFormEntity打印了一遍。这一步会直接消费掉entity内部的输入流。你说巧不巧,打印完还挺开心,觉得都看到内容了,然后又把同一个entity设置到Post请求里发送。服务端那边收到的请求体是空的,自然一个part都拿不到。正确做法是:调试时如果需要看entity内容,打印之后必须重新builder.build(),不要复用已经消费过的entity。
第二种,把一个entity同时复用到两次请求里。如果addBinaryBody里塞的是File或byte[],entity本身是“可重复读”的,第二次执行还能勉强工作。但如果addBinaryBody里塞的是InputStream,这个entity是非repeatable的,第一个请求用完流就没了,第二个请求发出一个空body,排查起来特别难发现。
第三种,请求发出前调用了post.setHeader("Content-Type", "multipart/form-data")。这一个操作是重灾区,我单独放在下一节详细说,因为它牵涉到boundary的丢失问题。
所以这一章的排查重点就一句话:确认你设置到HttpPost上的entity,就是那个没有被消费、没有被错误改动过的原始entity。最简单的方法是在post.setEntity(builder.build())这里打断点,看entity内部的ContentType和isRepeatable,以及发送前后流指针位置。
3. 手动设置Content-Type导致boundary丢失:把包裹标签撕了重贴
这个坑出现频率极高,而且特别隐蔽,因为代码看起来非常“合理”:
HttpPost post = new HttpPost(uploadUrl); post.setHeader("Content-Type", "multipart/form-data"); post.setEntity(builder.build());你觉得你是好心,提前告诉服务端这是一个multipart请求。但恰恰是这个手动设置的Header,把MultipartEntityBuilder辛辛苦苦生成的boundary参数给覆盖了。
MultipartEntityBuilder生成的entity自带Content-Type,准确的写法长这样:
Content-Type: multipart/form-data; boundary=gc0p4Jq0M2Yt08jU534c0p这个boundary是请求体的分隔线,服务端必须靠它才能区分哪个part叫file、哪个part叫name。手动设置的multipart/form-data里面没有boundary信息,服务端拿到请求后只知道自己收到的是multipart类型,却不知道用什么字符串切分请求体。Tomcat的PartHandler遇到这种情况通常直接放弃解析,表现就是request.getParts()返回空列表,或者框架层面直接判定参数缺失。
更骚的操作是,有些开发不仅手动设置了Content-Type,还顺手自己编了一个boundary:
post.setHeader("Content-Type", "multipart/form-data; boundary=123456");但entity内部实际用的boundary是另外一串随机字符串,相当于包裹外面贴的分隔标记和包裹里面的分隔线根本对不上。服务端按你写的123456去找part,自然什么都找不到。
这个问题的正确解决办法是极其反直觉的:什么都不要做。把entity设置到Post上,HttpClient在执行时会读取entity自带的Content-Type并写入请求头。
可以留意一个小细节:如果你项目里引入了某些代理层或自定义HttpRequestInterceptor,里面又对请求头做了一次“标准化处理”,也可能把boundary剥掉。我有一次就是被内部网关动态修改了Content-Type,客户端本地抓包完全正常,线上就是收不到,后来直接请求后端IP绕开网关才定位到是中间层搞的鬼。
4. 先别急着怪客户端:服务端也有几处容易“接不住”的地方
当客户端确认没问题了,服务端同样有一堆能导致“接收不到数据”的地方。这里不是为客户端推卸责任,联调中很多问题其实两边各占一半,我把常见的情况列在下面。
4.1 字段名不一致:file还是image,必须一字不差
这是最蠢但最常见的错误。客户端addBinaryBody的第一个参数是part名字,服务端@RequestParam或@RequestPart里的名称必须和它完全一致。
// 客户端 builder.addBinaryBody("file", bytes, ContentType.APPLICATION_OCTET_STREAM, "a.jpg"); // 服务端,两边名字都是file @PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file) { }如果客户端叫“image”,服务端写“file”,那结果一定是没有这个参数。这个没什么原理,就是对齐字段名。
4.2 @RequestParam和@RequestPart的选择
对于文件字段,@RequestParam和@RequestPart都能接收MultipartFile,区别在于@RequestPart更贴合“multipart/form-data里一个独立part”的语义,失败报错也更明确。普通文本字段用@RequestParam更顺手。
不过要提醒一点:如果一个接口同时有文件字段和普通文本字段,客户端用addTextBody添加文本时,文本part的name也要和服务端参数名一致。如果发现文件收到了但name收到null,再tm检查一下addTextBody的字段名。
4.3 Spring Boot的大小限制
Spring Boot有个默认配置,很多人根本没注意到:
spring.servlet.multipart.max-file-size=1MB spring.servlet.multipart.max-request-size=10MB当你上传的图片超过1MB,框架会在参数解析阶段直接抛出MaxUploadSizeExceededException。接口方法体都进不去,更别说打印什么日志了。如果你的全局异常处理器写得“太包容”,把这个异常捕获后返回一个业务码为成功、HTTP状态为200的JSON,那前端看起来就是典型的“返回200但接收不到数据”。
排查时别光盯着MultipartFile,翻一翻Spring的错误日志,看看有没有关于multipart大小限制的异常。有的话调大配置再试:
spring.servlet.multipart.max-file-size=20MB spring.servlet.multipart.max-request-size=30MB spring.servlet.multipart.max-in-memory-size=2MB4.4 网关和反向代理的请求体限制
如果服务端前面还挂了Nginx或其他网关,还要留意这些组件对请求体大小的限制。Nginx默认client_max_body_size是1MB,上面Spring Boot允许20MB但Nginx在1MB就把请求挡下了。客户端拿到的可能是413或者504,但如果网关层做了异常转换,也可能会伪装成200回来。
排查方法很简单:绕开网关直连服务端,再发一次请求。如果通了,问题就在网关配置。
5. 从curl到抓包的排查链路:每一步都有一份明确证据
很多人在这个问题上卡几天,是因为一上来就改代码,改完不行再改回,纯靠猜。我自己比较推崇一条有证据链的快速定位路线,分享出来你可以直接照着走。
第一层,用curl做中间对照。这是判断“客户端问题还是服务端问题”最快的一招:
curl -v -F "file=@a.jpg" -F "name=test" http://localhost:8080/upload如果curl能成功,说明服务端接口本身没问题,问题出在你写的Java客户端。如果curl也失败,就要集中精力看服务端。如果curl失败但Java客户端成功,这个组合也很有价值,说明是Java客户端特有的header、超时或代理配置问题。
第二层,抓客户端实际发出的请求头。给HttpClient开wire日志:
<logger name="org.apache.http" level="DEBUG"/> <logger name="org.apache.http.wire" level="DEBUG"/>开了之后控制台能看到类似这样的内容:
>> PUT /upload HTTP/1.1 >> Content-Type: multipart/form-data; boundary=gc0p4Jq0M2Yt08jU534c0p重点就是Content-Type那一行。如果边界里没有boundary,说明我们前面说的手动设置Header坑命中;如果边界里带boundary但请求体为空,则是entity被提前消费的问题。
第三层,抓TCP包或直接看服务端收到的原始请求。如果请求经过本机回环测试,可以用tcpdump:
tcpdump -i lo -A -s 0 port 8080 | grep -i -A 2 "multipart"看到请求体和Header,一切伪装都能撕开。有没有boundary、有没有定义各part的Content-Disposition、body是空还是完整,全部一目了然。
第四层,服务端打印三大件。如果请求真的进来了,在Controller方法里加一行最原始的日志:
log.info("contentType={}, parts={}", request.getContentType(), request.getParts().size());- 如果ContentType不带boundary,请求头问题。
- 如果getParts抛异常,多半是框架解析multipart失败。
- 如果parts有内容,说明业务方法拿的是空壳,再检查参数名和类型。
第五层,查中间层。再次强调,Nginx网关、客户端代理、公司内部的API网关都可能改请求头或限制body大小。这一步排查很枯燥,但往往是最容易忽略的幕后黑手。
6. 可以直接复制的稳定写法:把坑填平,再谈优化
最后给一份我目前一直在用的最小可靠模板。它不一定是最精简的,但在这个问题上踩过坑以后,我觉得“稳定”比“短”重要。
6.1 客户端建议写法
byte[] fileBytes = ...; // 或者用FileInputStream读取后转byte[] String filename = "a.jpg"; String name = "test"; try (CloseableHttpClient client = HttpClients.custom() .disableAutomaticRetries() .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(30000) .setConnectionRequestTimeout(5000) .build()) .build()) { HttpPost post = new HttpPost(uploadUrl); MultipartEntityBuilder builder = MultipartEntityBuilder.create(); builder.setMode(HttpMultipartMode.RFC6532); builder.addBinaryBody("file", fileBytes, ContentType.APPLICATION_OCTET_STREAM, filename); builder.addTextBody("name", name, ContentType.create("text/plain", StandardCharsets.UTF_8)); // 核心:绝对不要手动setHeader Content-Type post.setEntity(builder.build()); try (CloseableHttpResponse response = client.execute(post)) { int code = response.getStatusLine().getStatusCode(); String respBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); // code只代表HTTP层,业务是否成功还要解析respBody } }几个关键点我再强调一遍:
- 不要手动setHeader。
- 如果要从InputStream上传,先读成byte[]或者ByteArrayInputStream,避免上传失败重试时流不可重读。
- 上传文件较大时,byte[]会占内存,要合理评估,不要为了省事把几百MB文件直接读进内存。
- 如果使用连接池,每次请求响应后一定要通过
EntityUtils.toString或EntityUtils.consume释放连接,否则连接池里的连接会被占用,后续请求会卡在“获取连接超时”。
6.2 服务端建议写法
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public Result upload(@RequestPart("file") MultipartFile file, @RequestParam(value = "name", required = false) String name) { log.info("filename={}, size={}, name={}", file.getOriginalFilename(), file.getSize(), name); // 业务逻辑 return Result.success(); }如果发现服务端还是收不到,先别急着怀疑客户端,把上一步的curl和wire日志证据拿出来,再看request.getContentType()和request.getParts()。这一套组合拳打完,基本能把问题定位到某一个具体环节。
最后再分享一个个人习惯:每次做上传联调,我永远把“curl先测一次”作为第一步,而不是先看代码。代码里一切看起来都没问题的时候,反而是问题最难找的时候,这时候原始证据比你的眼睛诚实得多。