multipart/form-data 到底长什么样:文件上传请求的边界与坑
表单上传是 Web 里最常见的操作之一,但多数人只用过封装好的方法,没见过这种请求长什么样。等到要在命令行里复现一个上传、要自己构造一个接口调用、或者调试对方说"你发的格式不对"的时候,就只能靠猜了。
这篇把它的结构拆开讲一遍,看完之后你应该能手写出一个正确的 multipart 请求,也能读懂抓包看到的那个奇怪字符串。
一、为什么需要 multipart
先说清楚它和另外两种常见请求体的区别。
最简单的表单,如果不带文件,浏览器默认会用 application/x-www-form-urlencoded。这种格式把字段拼成 name=tom&age=20 的形式,特殊字符做百分号转义。它紧凑,但天生不适合塞二进制内容:转义之后体积膨胀,而且没法区分"这段是文件内容"和"这段是字段值"。
传文件时就需要一种能把文本字段和二进制内容装在一起的容器,multipart/form-data 就是干这个的。它用一段几乎不可能在文件内容里出现的随机字符串当分隔符,把各个部分隔开,每一部分自带一小段描述信息,说明自己是什么、叫什么名字。
二、结构长什么样
一个最简单的上传请求体是下面这个样子。注意每一行结尾是回车换行,不是单独的换行符。
–AaB03x
Content-Disposition: form-data; name="avatar"; filename="head.png"
Content-Type: image/png
<这里是 png 的二进制内容>
–AaB03x
Content-Disposition: form-data; name="remark"
帮我换掉旧头像
–AaB03x–
几个细节值得留意。
开头的 –AaB03x 是分隔行,中间内容是文件部分。结尾的 –AaB03x– 前后各两个连字符,表示整个请求体结束,这个收尾标记不能少。
每一部分都由头部区域和内容区域组成,中间隔一个空行。头部至少包含 Content-Disposition,其中 name 对应表单字段名。如果是文件部分,还会带上 filename;可选地再写一行 Content-Type,不写的话默认是 text/plain。
分隔字符串是自己生成的随机内容,唯一的硬要求是它不能出现在任何一部分的实际内容里。这也是为什么各种实现都会生成一串足够长的随机值。
三、请求头怎么配
请求头里必须声明内容类型,并且把分隔字符串告诉对方:
Content-Type: multipart/form-data; boundary=AaB03x
这里的 boundary 值要和请求体里实际使用的一致,注意它比体里的分隔行少了前面那两个连字符。少分割横线、多空格、大小写写错这类小问题,都会让服务端解析失败,而且报错通常语焉不详。
请求体长度用 Content-Length 明确给出即可,也可以在双方都支持的前提下改用分块传输,两种写清楚一种就行。
四、常见的坑
第一个是把 Content-Type 手动写死,却没带上 boundary。这是最高频的错误,服务端不知道用什么切分,自然只能报错。
第二个是行尾细节。规范里每一部分之间要用回车换行分隔,实际开发中常见的写法只用了 \\n。多数服务端能容错,但并不是所有实现都这么宽容,遇到解析异常时值得检查这一点。
第三个是中文文件名。filename 字段历史上是字节流,非 ASCII 字符需要按 RFC 2231 的方式编码,也就是写成 filename*=UTF-8''%E5%A4%B4%E5%83%8F.png 这种形式。各种库的支持程度不一致,对接之前最好先试一个带中文文件名的样本。
第四个是结尾标记。最后那个 –boundary– 缺失时,有些解析器会一直等下去直到超时,表现为"请求很慢然后失败",很容易误判成网络问题。
五、用代码构造一份看看
如果只是想在脚本里复现一次上传,手写构造并不难。以下面的写法为例:
import uuid
import requests
BASE = "https://example.com"
boundary = "—-" + uuid.uuid4().hex
file_path = "head.png"
parts = []
parts.append(f"–{boundary}\\r\\n")
parts.append('Content-Disposition: form-data; name="avatar"; filename="head.png"\\r\\n')
parts.append("Content-Type: image/png\\r\\n\\r\\n")
body = "".join(parts).encode() + open(file_path, "rb").read()
tail = f"\\r\\n–{boundary}\\r\\n".encode()
tail += 'Content-Disposition: form-data; name="remark"\\r\\n\\r\\n'.encode()
tail += "帮我换掉旧头像\\r\\n".encode()
tail += f"–{boundary}–\\r\\n".encode()
resp = requests.post(
BASE + "/upload",
data=body + tail,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
timeout=15,
)
print(resp.status_code)
日常使用当然还是直接调库更省事,上面这份代码的价值在于让你看清结构。理解了每一段的职责,再去看各种库的参数名,会发现它们只是在给这些字段填值而已。
写在最后
multipart 的核心就是一个约定的分隔方式:用一个随机串把若干块内容分开,每块自带描述信息,末尾用特殊形式收尾。它没有多少玄妙的东西,麻烦之处在于细节——行尾、空格、结尾标记、中文文件名,任何一处漏了都可能换来一个含糊的失败。
调试这类问题最有效的办法是把请求体原样打出来比对一眼。构造一次、抓一次包、把两边的边界符号逐字符对照,比反复改参数猜错在哪快得多。
本文讨论的是自己实现和调试接口时的格式问题,涉及第三方服务请遵守对方的服务条款与调用规范。
![[鼎捷 E10] 财务相关单据操作时异常问题解决(一)-171主机测评](https://www.171host.com/wp-content/uploads/2026/10/20261030154444-6ae4bb6c483bc-220x150.png)
![【PBAP】规范精讲[9]: 蓝牙PBAP的互联互通法则——GOEP互操作性要求深度解析-171主机测评](https://www.171host.com/wp-content/uploads/2026/10/20261030152436-6ae4b6b4a87e0-220x150.png)
