欢迎光临
我们一直在努力

multipart/form-data 到底长什么样:文件上传请求的边界与坑

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 的核心就是一个约定的分隔方式:用一个随机串把若干块内容分开,每块自带描述信息,末尾用特殊形式收尾。它没有多少玄妙的东西,麻烦之处在于细节——行尾、空格、结尾标记、中文文件名,任何一处漏了都可能换来一个含糊的失败。

调试这类问题最有效的办法是把请求体原样打出来比对一眼。构造一次、抓一次包、把两边的边界符号逐字符对照,比反复改参数猜错在哪快得多。

本文讨论的是自己实现和调试接口时的格式问题,涉及第三方服务请遵守对方的服务条款与调用规范。

赞(0)
未经允许不得转载:171主机测评 » multipart/form-data 到底长什么样:文件上传请求的边界与坑
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址