Python 发送 POST 请求之 Multipart/form-data

前言

在 HTTP/1.1 协议中,使用 POST 请求提交数据时常用的 Content-Type 有以下几种:

  • application/x-www-form-urlencoded 原生 Form 默认的提交方式, 最常用的一种,支持GET/POST等方法。主要把数据编码成键值对的方式, 并且把特殊字符转义成 utf-8 字符,如空格会被转义成 %20。
  • application/json 由于 JSON 格式所表示的结构化数据远比键值对复杂得多,所以使用 JSON 系列化之后的字符串进行数据交换的方式越来越受人们青睐。特别适合 RESTful 类型的接口。
  • text/xml 使用 XML-RPC(XML Remote Procedure Call) 协议进行数据传输,相比于 JSON 的方式更为臃肿。
  • multipart/form-data 使用 Form 提交小文件, 直接把文件内容放在 Body 中进行传输的方式。考虑到同时上传多个字段或文件,所以需要按照一定规则随机生成或手动指定一个 boundary 用于分割数据,然后按照一定格式、顺序进行排列构成完整的 Body 进行传输。(multipart/form-data 官方定义)

客户端发送 multipart/form-data 请求

假设现在有 ./file_1.txt./file_2.txt 两个文件,内容分别如下:

1
2
3
4
5
# cat ./file_1.txt
test file 1 content!

# cat ./file_2.txt
test file 2 content!

使用 Requests 实现

1
2
3
4
5
6
7
8
9
10
11
import requests

data = {'key_1': 'value_1', 'key_2': 'value_2'}

files = [
('file_1', open('./file_1.txt', 'rb')),
('file_2', open('./file_2.txt', 'rb')),
]

resp = requests.post('http://127.0.0.1:8000/upload', data=data, files=files)
print(resp.request.body.decode('utf-8'))

打印出来的 request body 内容是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
--bfa60c05b6631915da313e8fb696e7b2
Content-Disposition: form-data; name="key_1"

value_1
--bfa60c05b6631915da313e8fb696e7b2
Content-Disposition: form-data; name="key_2"

value_2
--bfa60c05b6631915da313e8fb696e7b2
Content-Disposition: form-data; name="file_1"; filename="file_1.txt"

test file 1 content!

--bfa60c05b6631915da313e8fb696e7b2
Content-Disposition: form-data; name="file_2"; filename="file_2.txt"

test file 2 content!

--bfa60c05b6631915da313e8fb696e7b2--

其中 bfa60c05b6631915da313e8fb696e7b2 就是上面所提到自动生成的 boundary。

值得注意的是 {'key_1': 'value_1', 'key_2': 'value_2'} 这两个本身是键值对的数据也被自动转成了 multipart/form-data 的编码方式。如果不传 files 字段时,将自动使用 application/x-www-form-urlencoded 的编码方式,所以 request body 内容应该是这样的

1
key_1=value_1&key_2=value_2

在 requests 中数据编码时,只有 data 参数为 None 时才会判断使用 json 参数,所以 datajson 两个参数同时存在时,只会编码 data 的数据;但 datafiles 是可以同时存在的,而且只要有 files 存在,其它键值对数据也会一起使用 multipart/form-data 的编码方式生成 body 数据。

1
2
3
4
5
# json 参数将会被忽略
resp = requests.post('http://127.0.0.1:8000/upload', data=data, json=xxxx)

# 这样是 OK 的
resp = requests.post('http://127.0.0.1:8000/upload', data=data, files=files)

使用 AIOHTTP 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import asyncio
import aiohttp

async def send_request():
async with aiohttp.ClientSession() as session:
data = aiohttp.FormData()
data.add_field('key_1', 'value_1')
data.add_field('key_2', 'value_2')
data.add_field('file_1', open('./file_1.txt', 'rb'), filename='file_1.txt',
content_type='multipart/form-data')
data.add_field('file_2', open('./file_2.txt', 'rb'), filename='file_2.txt',
content_type='multipart/form-data')

async with session.post('http://127.0.0.1:8000/upload', data=data) as resp:
print(await resp.text())

asyncio.run(send_request())

打印出来的 request body 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
--a63b12cbef044b039c5c788b25a71336
Content-Type: text/plain; charset=utf-8
Content-Disposition: form-data; name="key_1"
Content-Length: 7

value_1
--a63b12cbef044b039c5c788b25a71336
Content-Type: text/plain; charset=utf-8
Content-Disposition: form-data; name="key_2"
Content-Length: 7

value_2
--a63b12cbef044b039c5c788b25a71336
Content-Type: multipart/form-data
Content-Disposition: form-data; name="file_1"; filename="file_1.txt"; filename*=utf-8''file_1.txt
Content-Length: 21

test file 1 content!

--a63b12cbef044b039c5c788b25a71336
Content-Type: multipart/form-data
Content-Disposition: form-data; name="file_2"; filename="file_2.txt"; filename*=utf-8''file_2.txt
Content-Length: 21

test file 2 content!

--a63b12cbef044b039c5c788b25a71336--

可以看到 aiohttp 对键值对默认使用了 Content-Type: text/plain, 即纯文本的方式,这只是不同库的默认值和实现方式有些区别而已。

服务端解析 multipart/form-data 请求

这里服务端使用 Sanic 框架接收数据请求,Sanicpython3 中性能非常好异步无阻塞的 web 框架,特别是跟 uvloop 配合着使用,性能上可以发挥到极致。用法跟Flask非常类似。项目主页: https://github.com/huge-success/sanic

1
2
3
4
5
6
7
8
9
10
11
12
from sanic import Sanic, response

app = Sanic(__name__)

@app.post('/upload')
async def upload_handler(request):
print('request.files', request.files)
print('request.form', request.form)
return response.text('ok')

if __name__ == "__main__":
app.run(host='0.0.0.0', port=8000)

输出信息如下

1
2
request.files {'file_1': [File(type='multipart/form-data', body=b'test file 1 content!\n', name='file_1.txt')], 'file_2': [File(type='multipart/form-data', body=b'test file 2 content!\n', name='file_2.txt')]}
request.form {'key_1': ['value_1'], 'key_2': ['value_2']}