资讯详情

Java 发送 multipart/form-data 文件上传 POST 请求实战与避坑

📅 2026/10/26 19:43:21 | 华诺云谱 👁 阅读
Java 发送 multipart/form-data 文件上传 POST 请求实战与避坑
对接第三方接口时碰到文件上传十有八九会绕回multipart/form-data这个格式。我做过不少「Java 发送 POST 请求把文件数据推到对方接口」的活儿从早期手拼字符串到后来用 HttpClient 的MultipartEntityBuilder踩过的坑基本能凑成一本小册子boundary少两个横线服务端直接 400中文文件名到对面变成问号几百兆的包一次性读进内存直接把堆撑爆接口偶发超时但重试又怕传重复。这些问题的共同点是——报错信息都很含糊真正的原因全在请求体那几十个字节的格式细节里。这篇东西就围绕「用 Java 发一个带文件的 multipart POST 请求」展开把报文结构、实现方案选型、手写与用库两条路、长度计算、编码处理、超时重试、大文件内存控制、以及一堆现场排查经验讲透。适合刚接手接口联调的同学也适合写过几次但总在细节上翻车的朋友。看完你至少能做到拿到一个上传接口文档能判断对方要的到底是哪种编码方式能自己把请求体拼出来也能在出问题时三分钟内定位到是头不对还是流没写对。1. multipart/form-data 到底长什么样先把它拆开看1.1 一次上传请求的原始报文长这样很多人用惯了封装好的 API从没见过请求体的真面目结果一出问题就抓瞎。multipart/form-data的本质非常朴素它就是一个用固定分隔符切开的多段字节流每一段代表一个表单字段段与段之间靠boundary这个字符串隔开。它跟 JSON 最大的不同在于——它可以同时携带二进制文件和普通文本而且不需要 base64 编码膨胀体积所以文件上传场景几乎都用它。假设我要往接口推一个 Excel 文件和两个业务参数真实发出去的请求体大概是这样------FormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; namebizType Content-Type: text/plain; charsetUTF-8 monthly_report ------FormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; nametoken Content-Type: text/plain; charsetUTF-8 abc123 ------FormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; namefile; filename月度报表.xlsx Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 这里是文件的原始字节 ------FormBoundary7MA4YWxkTrZu0gW--对应的请求头只有一个关键点Content-Type: multipart/form-data; boundary----FormBoundary7MA4YWxkTrZu0gW Content-Length: 1234567看明白这段报文后面的所有问题基本都能自己推。比如服务端报「找不到 file 字段」你就去看namefile有没有写错、--加够了没有报「解析失败」你就去看换行是不是严格\r\n。这类需求在实际工作中的分布其实很集中对接第三方文件网关、往对象存储的代理接口推文件、企业内部系统的批量导入、图片/证件照上传、日志包回传。它们的共同特征是「文本参数 二进制内容混在一起」所以multipart/form-data是默认答案几乎没有替代品。1.2 boundary 的三条硬性规则错一条就报 400boundary是整个格式的骨架我用血泪总结出三条第一请求头里声明的 boundary 和报文里实际用的必须完全一致包括前面的--。头里写的是boundaryabc123报文里每段开头就是--abc123注意这里是两个横线加上 boundary 本体不是三个也不是一个。很多人手写时在头里写了--abc123报文里也写--abc123结果服务端按「头里的值就是 abc123」解析拼出来变成----abc123直接对不上。规范里头的值是不带前缀横线的报文行才加。第二结尾必须有一个闭合段也就是-- boundary --后面跟一个 CRLF。少了这个闭合符有些解析器会一直等下一段最终超时或报「unexpected end of stream」。第三boundary 本身要足够随机且不能出现在内容里。如果文件二进制里恰好出现了和 boundary 一模一样的字节序列解析器会提前切断文件就废了。实际做法是用 UUID 加前缀长度控制在 30 到 60 字符之间规范建议不超过 70 字符。有些人图省事用固定字符串跑测试没问题上线后偶发文件损坏查一周都查不出来。提示boundary只允许由字母、数字和()_,-./:?以及空格组成但结尾不能是空格。用 UUID 去掉横线是最省心的做法。1.3 为什么不能拿 urlencoded 传文件有人会问application/x-www-form-urlencoded也是表单格式为什么不能用。原因是这个格式只处理「键值」的文本对二进制数据必须做百分号编码一个字节可能膨胀成三个字符一个 10MB 的文件编码完变成 30MB而且服务端还得反向解码性能和稳定性都不划算。更关键的是多数服务端框架的 urlencoded 解析器对超长单值有长度限制直接给你截断。multipart/form-data的设计目标就是「把二进制原样搬过去」文件段只加几十字节的头体积几乎无损耗。所以只要涉及文件别犹豫就是这个格式。唯一需要注意的是文本字段如果内容里有中文务必在段头里声明charsetUTF-8否则服务端可能按 ISO-8859-1 解出来就是乱码。2. 三种主流实现方案怎么选2.1 HttpURLConnection、HttpClient、OkHttp 横向对比Java 里发 multipart 请求主流就三条路我先把结论摆出来再说理由。方案依赖multipart 支持上手难度适合场景HttpURLConnectionJDK 自带零依赖无封装需手拼报文中细节多老项目、不能加依赖、请求简单Apache HttpClient 4.5.xhttpclient httpmimeMultipartEntityBuilder开箱即用低绝大多数服务端项目Apache HttpClient 5.xhttpclient5 httpcore5同类 API包名和类名变了中新项目、需要 HTTP/2OkHttp 3/4okhttpMultipartBody.Builder链式调用低Android、追求简洁 APIHttpURLConnection的短板不在功能而在「什么都要自己干」拼接、长度计算、流控、异常处理全得写。它的优势是零依赖很多对依赖有严格审计的环境只能用这个。HttpClient 的优势在于成熟连接池、超时、重试、拦截器、代理配置都是现成的MultipartEntityBuilder把报文拼装这件事封装得很干净还能自动算Content-Length。OkHttp 的 API 最舒服MultipartBody.Builder().addFormDataPart(file, a.txt, RequestBody.create(file, mediaType))一行搞定但多一个依赖而且部分企业内网对第三方库的来源有要求。我自己的默认选择是 HttpClient 4.5.x因为它几乎在所有 Java 服务端项目里都已经间接存在了很多框架的传递依赖里就有加个httpmime的成本最低。2.2 依赖版本带来的 API 分裂这里有个特别容易踩的坑HttpClient 4.x 和 5.x 的包名、类名几乎全变了但网上搜到的代码经常混着给。你在 5.x 的工程里贴一段 4.x 的代码编译器报「找不到符号」然后你开始怀疑人生。区别记这几条就够了4.x 的包是org.apache.http.*5.x 是org.apache.hc.client5.http.*和org.apache.hc.core5.http.*。4.x 的核心类是CloseableHttpClient5.x 叫CloseableHttpClient但走的构建器是HttpClients.custom()里的同名方法实际执行入口换成execute的重载异常体系也换成了HttpException。4.x 的 multipart 构建器在org.apache.http.entity.mime.MultipartEntityBuilder5.x 在org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder。ContentType在 4.x 是org.apache.http.entity.ContentType5.x 是org.apache.hc.core5.http.ContentType前者有withCharset返回新实例后者用ContentType.create(type, charset)。依赖坐标方面4.5.x 需要两个包httpclient加httpmime后者才是提供MultipartEntityBuilder的地方很多人只加了前者然后满世界找类。5.x 一般引httpclient5就够了httpcore5会被传递带进来。注意如果项目里同时存在 4.x 和 5.x比如某个中间件强制带进来一个类路径会冲突表现为NoSuchMethodError或ClassNotFoundException。用mvn dependency:tree搜一下httpclient把多余的版本排掉比改代码快得多。2.3 我的默认选型和例外情况正常服务端项目我选 HttpClient 4.5.x 加httpmime理由是生态稳定、示例多、连接池配置成熟。如果项目已经在用 Spring其实也可以用RestTemplate配MultiValueMap加ByteArrayResource的方式代码更短但底层还是 HttpClient 或 JDK 的实现遇到诡异问题一样要往下挖所以我更倾向直接控制底层。如果环境完全不允许加依赖那就手写报文配HttpURLConnection这就是第 3 章的内容。别觉得手写很原始理解了手写过程你用任何库出问题都能快速定位因为你知道它背后到底发了什么字节。还有一种例外如果上传的文件在 100MB 以上或者需要断点续传那重点就不在「怎么发」而在「怎么分」这时候要跟服务端确认是否有分片接口单次 multipart 请求本身不适合承载超大文件。3. 从零手写 multipart 报文把请求体当字节拼3.1 boundary 生成与整体长度预估手写的第一步是生成 boundary并算出整个请求体的字节长度。为什么非要算长度因为HttpURLConnection如果不知道长度会把整个请求体缓冲到内存里再发大文件必炸。设置固定长度后它会边写边发内存占用恒定。生成方式String boundary ----FormBoundary UUID.randomUUID().toString().replace(-, );长度计算有个通用公式记住它比记代码有用总长度 各段段头字节数 段体字节数 2 字节的 CRLF之和 闭合段字节数其中段头是那段Content-Disposition文本按 UTF-8 转成的字节段体对文本字段是value.getBytes(UTF_8).length对文件就是file.length()。闭合段是-- boundary -- CRLF 的字节数。举个具体例子boundary 是----FormBoundary加 32 位 UUID共 48 字符。一个文本段头--{boundary}\r\nContent-Disposition: form-data; namebizType\r\nContent-Type: text/plain; charsetUTF-8\r\n\r\n大约是 130 字节值monthly_report是 14 字节再加末尾 CRLF 2 字节这一段 146 字节。你自己算的时候不用手数写个方法把段头字符串getBytes(UTF_8).length打出来累加就行比心算靠谱。提示中文文件名按 UTF-8 算字节一个汉字 3 字节length()拿到的是字符数不是字节数这里必须用getBytes(UTF_8).length否则长度算少请求会被截断服务端报「Malformed multipart body」。3.2 文本字段和文件字段的拼装代码我把段头生成抽成两个方法逻辑就清楚了。先看文本段private static final String CRLF \r\n; private static final Charset UTF8 StandardCharsets.UTF_8; private static byte[] textHeader(String boundary, String name) { String head -- boundary CRLF Content-Disposition: form-data; name\ name \ CRLF Content-Type: text/plain; charsetUTF-8 CRLF CRLF; return head.getBytes(UTF8); } private static byte[] fileHeader(String boundary, String name, String filename, String contentType) { String safeName filename.replace(\, %22) .replace(\r, %0D) .replace(\n, %0A); String head -- boundary CRLF Content-Disposition: form-data; name\ name \; filename\ safeName \ CRLF Content-Type: contentType CRLF CRLF; return head.getBytes(UTF8); }这里有两个细节。文件名里的双引号和换行必须转义RFC 7578 给出的处理方式就是百分号编码否则一个带引号的文件名能直接把整个报文结构打乱。另一个是每个段结束后都要补一个 CRLF它就相当于「这一行结束了」的标记少了它下一个--boundary会粘在文件最后一个字节后面解析器就找不到了。写入时严格按顺序段头字节 → 段体字节 → CRLF循环往复最后写闭合段。文件内容用FileInputStream配transferTo或 8KB 缓冲区拷贝绝对不要Files.readAllBytes。private static void writeFile(OutputStream out, File file) throws IOException { try (InputStream in new FileInputStream(file)) { byte[] buf new byte[8192]; int n; while ((n in.read(buf)) ! -1) { out.write(buf, 0, n); } } }3.3 流式写入与 Content-Length 设置拿到总长度之后事情就好办了HttpURLConnection conn (HttpURLConnection) new URL(url).openConnection(); conn.setRequestMethod(POST); conn.setDoOutput(true); conn.setDoInput(true); conn.setUseCaches(false); conn.setRequestProperty(Content-Type, multipart/form-data; boundary boundary); conn.setRequestProperty(Connection, keep-alive); conn.setFixedLengthStreamingMode(totalLength); // 关键流式写入不缓冲 try (OutputStream out conn.getOutputStream()) { // 依次写各段 }setFixedLengthStreamingMode(long)在 Java 7 之后支持 long 参数这个调用是「大文件不炸内存」的关键。如果不设置JDK 会默认把请求体会缓冲在内存里几 MB 看不出来几百 MB 直接OutOfMemoryError。如果实在算不出长度比如文件来自一个长度未知的网络流那就用setChunkedStreamingMode(8192)它会以分块传输编码发送。代价是有部分网关或老服务端不支持 chunked会报 400 或直接断开。我的经验是能算长度就一定算chunked 只当兜底。写完之后记得读响应。HttpURLConnection有个经典陷阱如果不读响应体连接不会释放用久了连接池耗尽表现为请求卡住。正确做法是拿到getInputStream()2xx或getErrorStream()非 2xx读干净再disconnect()。3.4 中文文件名的三种处理方式中文文件名乱码是这类需求的「保留节目」根因是客户端写进去的字节编码和服务器解出来的编码不一致。三种处理方式按推荐顺序排第一种直接 UTF-8 写 filename。现代框架基本都按 UTF-8 解析比如主流的 Servlet 容器配 Spring 的 multipart 解析器默认就是 UTF-8。这种方式最干净缺点是碰到老服务端比如基于老版 commons-fileupload 且没配编码的会乱码。第二种同时给filename和filename*。这是 RFC 5987 的兼容写法filename里放 ASCII 兜底值filename*里放UTF-8加百分号编码的真实名字。支持新标准的服务端会优先用filename*老的用filename两边都不空。Content-Disposition: form-data; namefile; filenamereport.xlsx; filename*UTF-8%E6%9C%88%E5%BA%A6%E6%8A%A5%E8%A1%A8.xlsx第三种按对方约定的编码转。有些接口文档会直接写「文件名需 URLEncode 后传输」或「文件名使用 GBK 编码」这种就别自作主张照着来。判断依据永远是对方的文档和联调结果不是哪个更「标准」。排查时最有效的手段是抓包看实际发出的字节。你把请求体按字节 dump 出来看filename后面那串十六进制一眼就知道是 UTF-8中文三字节E6 9C 88开头还是 GBK两字节D4 C2开头。这一步比改八遍代码有用得多。4. HttpClient 落地MultipartEntityBuilder 完整用法4.1 依赖与最小可运行骨架引依赖4.5.x 记两个包dependency groupIdorg.apache.httpcomponents/groupId artifactIdhttpclient/artifactId version4.5.14/version /dependency dependency groupIdorg.apache.httpcomponents/groupId artifactIdhttpmime/artifactId version4.5.14/version /dependency最小骨架长这样先跑通再谈优化public static String upload(String url, File file, String filename) throws IOException { try (CloseableHttpClient client HttpClients.createDefault()) { HttpPost post new HttpPost(url); HttpEntity entity MultipartEntityBuilder.create() .setMode(HttpMultipartMode.RFC6532) .setCharset(StandardCharsets.UTF_8) .addTextBody(bizType, report, ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8)) .addBinaryBody(file, file, ContentType.APPLICATION_OCTET_STREAM, filename) .build(); post.setEntity(entity); try (CloseableHttpResponse resp client.execute(post)) { int code resp.getStatusLine().getStatusCode(); String body EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); if (code ! 200) { throw new IOException(上传失败, code code , body body); } return body; } } }这里有三处容易忽略的配置。setCharset(UTF_8)决定文本字段的实际编码和段头里的 charset 参数不设的话默认是 ISO-8859-1中文必乱。setMode(RFC6532)决定文件名的写法STRICT模式下中文会被替换成问号BROWSER_COMPATIBLE会输出filename*形式RFC6532直接 UTF-8 原样写。三种模式对应三类服务端口味联调时逐个试是最快的办法。还有一点不要手动post.setHeader(Content-Type, multipart/form-data)。MultipartEntityBuilder生成的实体自带正确的 Content-Type 和 boundary你手动覆盖反而会把 boundary 参数弄丢服务端解析时找不到分隔符直接 400。这个错误我在好几个项目里见过改起来就一行找起来能耗半天。4.2 文本字段、文件字段、内存流字段MultipartEntityBuilder的 add 系列方法对应三种数据来源addTextBody(name, value, contentType)普通文本适合业务参数。addBinaryBody(name, file, contentType, filename)本地文件内部用FileBody流式读不会一次性载入内存。addBinaryBody(name, inputStream, contentType, filename)内存或网络流适合文件内容已经在内存里或者从别处转发过来的场景。注意这个流要自己负责关闭别在build()之后马上关要等请求执行完。addBinaryBody(name, byte[], contentType, filename)字节数组适合小文件几百 KB 以内还行再大就别用了。传InputStream的时候有个隐藏问题getTotalLength()算不出长度实体返回 -1HttpClient 会退化成 chunked 传输。如果对方网关不认 chunked就会失败。解决办法是把流先落到临时文件再用File方式传代价是一次磁盘 IO换来稳定。多个文件也很简单同一个字段名addBinaryBody多次就行服务端按数组收。字段名不同就分别加。业务参数最多的情况我见过一个接口二十多个文本字段这时候建议写个循环从 Map 里批量 add比一行行写可维护。4.3 超时、连接池与重试默认HttpClients.createDefault()的超时是不设的这意味着一个卡住的连接能把线程挂死。生产环境必须显式配置RequestConfig config RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(60000) .setConnectionRequestTimeout(3000) .build(); PoolingHttpClientConnectionManager cm new PoolingHttpClientConnectionManager(); cm.setMaxTotal(50); cm.setDefaultMaxPerRoute(20); CloseableHttpClient client HttpClients.custom() .setConnectionManager(cm) .setDefaultRequestConfig(config) .evictExpiredConnections() .evictIdleConnections(30, TimeUnit.SECONDS) .build();三个超时的分工要搞清楚这是排查「请求卡住」类问题的前提。connectTimeout是 TCP 握手时间设 5 秒够了。socketTimeout是两次数据包之间的最大间隔也就是「服务端多久没吐一个字就放弃」上传大文件时这个值要给足因为服务端可能先收完再处理。connectionRequestTimeout是从连接池借连接的最长等待池子满了会卡在这里报ConnectionPoolTimeoutException。重试要特别小心。POST 上传默认不该自动重试因为服务端可能已经收到文件只是响应丢了再发一次就是两份。如果确实要重试只在「连接建立失败」这种请求还没发出去的情况下重试用自定义的HttpRequestRetryHandler判断异常类型别用默认策略。注意evictIdleConnections不是必须的但长跑的服务建议加上否则服务端的 keep-alive 超时和客户端认知不一致时会偶发「连接被重置」。4.4 响应处理与资源释放响应处理有两个必做动作读完实体、关闭响应。try (CloseableHttpResponse resp client.execute(post)) { HttpEntity respEntity resp.getEntity(); String body respEntity null ? : EntityUtils.toString(respEntity, StandardCharsets.UTF_8); // 处理业务 }EntityUtils.toString会自动消费实体并归还连接。如果只想拿状态码不看内容也必须显式EntityUtils.consume(resp.getEntity())否则连接一直被占用池子慢慢就满了表现出来就是「跑一段时间后所有请求超时」。另外提醒一句很多接口在上传成功时返回的不是 200 而是 201 或者自定义的{code:0}。判断成功与否要把状态码和业务码都覆盖别只看code 200不然明明成功了却抛异常。5. 常见问题与排查实录5.1 问题速查表现象大概率原因排查动作400 Bad Requestboundary 头与实际不一致、缺闭合段、CRLF 用了\ndump 请求体检查每行结尾是不是0D 0A服务端收不到 file 字段name拼错、段头缺 Content-Disposition对照接口文档字段名检查段头首行中文文件名变问号编码不一致抓包看 filename 字节判断是 UTF-8 还是被替换成?文件内容损坏boundary 与文件内容撞车、长度算少被截断换随机 boundary核对 Content-Length415 Unsupported Media TypeContent-Type 缺 boundary 参数检查是否手动覆盖了 Content-Type413 Payload Too Large网关或服务端大小限制查 Nginx client_max_body_size 和框架配置大文件 OOM请求体全缓冲进内存用 setFixedLengthStreamingMode 或 FileBody请求一直不返回未设 socketTimeout、服务端等流结束但客户端没 flush补 flush、补超时偶发 Connection reset连接池中的连接已被服务端关闭开启空闲连接清理重试后出现重复文件POST 被自动重试关闭自动重试或加重试幂等标识5.2 三次印象深刻的排查过程第一次是服务端一直报「Malformed multipart body」。我一开始怀疑是 boundary 写错检查了十几遍都对。后来把请求体按字节打到日志里发现每行结尾只有0A没有0D。原因是我用了一个文本编辑器手改代码把\r\n保存成了实际换行Java 字符串里的\r变成了字面的反斜杠加 r。改成显式的\r\n常量后立刻通了。这个教训让我此后所有 CRLF 都用常量引用绝不手打。第二次是中文文件名乱码。服务端收到的文件名是??.xlsx。现象很像编码问题但我把setCharset(UTF_8)加上了还是问号。最后发现是HttpMultipartMode.STRICT模式导致的这个模式按 ISO-8859-1 输出文件名中文直接被替换。改成RFC6532之后正常。这说明「设置了 charset」和「文件名的编码模式」是两个独立的开关不能混为一谈。第三次是跑批量任务时内存曲线一路往上。定位到是某个同事用了addBinaryBody(file, Files.readAllBytes(path), ...)20 个 50MB 的文件并发上传内存直接见底。换成addBinaryBody(file, new File(path), ...)之后堆占用平得像一条直线。后来我把这条写进了团队的上传规范超过 5MB 一律用 File 或流禁止 byte 数组。5.3 我自己踩出来的避坑清单请求头里的Content-Type只写一次交给实体去生成别手动拼。文本字段一定显式指定 UTF-8 的 ContentType别依赖默认值。文件名里的引号、换行、分号都要转义RFC 7578 有明确处理方式。上传前先算体积超过对方限制就提前拦截比等 413 报错友好。大文件用流式或 File禁止readAllBytes。POST 上传默认别开自动重试。所有请求配 connect 和 socket 超时socket 超时按最大文件体积估。响应实体必须消费否则连接泄漏。联调阶段打开 HttpClient 的 wire 日志能看到实际发出的每一行报文System.setProperty(org.apache.commons.logging.Log, org.apache.commons.logging.impl.SimpleLog); System.setProperty(org.apache.commons.logging.simplelog.showdatetime, true); System.setProperty(org.apache.commons.logging.simplelog.log.org.apache.http.wire, DEBUG);这段配置能救命尤其是面对「对方说没收到文件」这种查无可查的情况直接看日志里开头的那几行就知道发了什么。6. 大文件上传的性能与稳定性6.1 内存占用到底来自哪里一个上传请求占用的内存有三块请求体缓冲、响应体缓冲、连接池里的 Socket 缓冲区。最容易被忽视的是第一块。HttpURLConnection在没设置 streaming 模式时会用ByteArrayOutputStream缓存整个请求体文件多大就占多大。HttpClient 用FileBody时不会缓冲内容但会计算长度内存开销可以忽略。判断你的代码有没有踩坑看两点一是有没有把文件内容读成byte[]或String二是用的库有没有流式支持。改完之后可以在-Xmx256m的堆里传一个 500MB 的文件验证如果能稳定跑完就说明是流式的。6.2 进度监控怎么做MultipartEntityBuilder本身不提供进度回调要自己包一层。思路是自定义一个HttpEntity包装原实体在writeTo时把OutputStream替换成一个计数流每写 n 字节回调一次。这算是比较标准的做法比外部起个线程定时查文件句柄靠谱。如果只是想知道「有没有卡住」更简单的办法是给 socketTimeout 设成 30 秒然后观察异常的时间点。上传过程中如果 30 秒没数据交互客户端就会超时抛异常说明链路不通或服务端不读数据。这个成本最低先做这个。6.3 分片和重试的边界单次请求传几百 MB 不是不能做但风险高任何一次网络抖动都要从头再来而且中间经过的每一层网关都有体积上限。真要做大文件跟服务端确认有没有分片接口通常是「先调初始化拿一个 uploadId再按固定大小切片上传最后调合并」。这个模式下每次请求都是小体积失败只重传一片。分片还有一个好处是并发多片并行上传能明显提速。代价是复杂度上去了要处理片序号、重传、超时和最终一致性。我的判断标准是小于 50MB 走单次 multipart超过就走分片中间那一段视服务端能力决定。注意如果走分片每一片仍然是multipart/form-data格式只是文件内容变成了一个切片流。别以为分片了就不用管格式细节。最后分享一个小技巧我在联调阶段会先在本地起一个极简的接收端把收到的请求头、字段名、文件名、文件大小全打出来。Java 里用HttpServer加几十行代码就能做或者用命令行工具临时收一下。这么做的好处是把「对方的问题」和「自己的问题」彻底隔离开——如果本地接收端收得对那就不是客户端的问题理直气壮地让对面查服务端。这一招帮我省过很多来回拉扯的时间。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑