0%

tornado.httputil 对 Http request message-body 的解析

HTTPServerRequest 解析 message-body

Tornado 对每次请求都会创建一个 tornado.httputil.HTTPServerRequest 实例。由之前的分析我们知道 Http Request 的 “Request-Line” 和头域解析工作已经在 tornado.http1connection.Http1Connection 中完成,所以在 tornado.httputil.HTTPServerRequest 中的主要工作就是对请求参数的解析,而其中对 message-body 的解析相比较 query 要复杂一些。

下面是 HTTPServerRequest 解析 message-body 的方法代码,其中解析结果存放在 body_argumentsfiles 字段中(注:字段 arguments 存放所有请求参数,包括 query 参数和 body 参数,对 query 的解析已经在 HTTPServerRequest 的构造函数中完成)。

1
2
3
4
5
6
7
8
def _parse_body(self):
parse_body_arguments(
self.headers.get("Content-Type", ""), self.body,
self.body_arguments, self.files,
self.headers)

for k, v in self.body_arguments.items():
self.arguments.setdefault(k, []).extend(v)

由代码可知,_parse_body 方法对 body 解析是委托模块函数 parse_body_arguments 完成的。

parse_body_arguments 函数

Http 协议是以ASCII码传输,建立在 TCP/IP 协议之上的应用层协议。协议把 Http 请求分成了三部分:请求行,请求头,消息体。协议没有规定消息体中数据的编码方式。在浏览器 Form 表单 POST 提交时,如没有指定 enctype 属性的值,默认使用 application/x-www-form-urlencoded 方式编码提交,该编码方式不能用于文件上传。通常使用表单上传文件时使用 multipart/form-data 方式编码提交,这是浏览器原生支持的,就目前而言原生 Form 表单也只支持这两种方式。另外还有 application/jsontext/xmltext/plaintext/html 等等编码方式,不过这些方式大多都用在 Response 上。实际上只要服务器支持,任何一种编码都可以在请求中使用,我觉得通过 application/json 编码请求数据在 RESTful 接口中非常有用。

tornado.httputil 模块中的 parse_body_arguments 函数默认支持解析 application/x-www-form-urlencodedmultipart/form-data 编码的 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
28
29
30
31
32
33
34
35
36
def parse_body_arguments(content_type, body, arguments, files, headers=None):
"""Parses a form request body.

Supports ``application/x-www-form-urlencoded`` and
``multipart/form-data``. The ``content_type`` parameter should be
a string and ``body`` should be a byte string. The ``arguments``
and ``files`` parameters are dictionaries that will be updated
with the parsed contents.
"""
# 只支持解码后(或无编码)的 body 数据。实际上在 Tornado 中有专门的
# `_GzipMessageDelegate` 类支持 `gzip` 解码,body 数据都通过其解码后再调用
# `parse_body_arguments` 函数。参见 `Http1Connection.read_response` 实现。
# `_GzipMessageDelegate` 处理请求后会删除其中的 'Content-Encoding' 头域,用
# 'X-Consumed-Content-Encoding' 头域代替。
if headers and 'Content-Encoding' in headers:
gen_log.warning("Unsupported Content-Encoding: %s",
headers['Content-Encoding'])
return
if content_type.startswith("application/x-www-form-urlencoded"):
try:
uri_arguments = parse_qs_bytes(native_str(body), keep_blank_values=True)
except Exception as e:
gen_log.warning('Invalid x-www-form-urlencoded body: %s', e)
uri_arguments = {}
for name, values in uri_arguments.items():
if values:
arguments.setdefault(name, []).extend(values)
elif content_type.startswith("multipart/form-data"):
fields = content_type.split(";")
for field in fields:
k, sep, v = field.strip().partition("=")
if k == "boundary" and v:
parse_multipart_form_data(utf8(v), body, arguments, files)
break
else:
gen_log.warning("Invalid multipart/form-data")

application/x-www-form-urlencoded

这是浏览器原生表单 POST 默认的编码方式,GET 默认的请求数据编码方式也是它,差别就在于 GET 时编码后的数据是放在 URL 中一起发送给服务器的,并且 URL 有一个 2048 字符长度的限制;POST 时编码后的数据是放在 Message-Body 中发送给服务器的,协议上并没有限制长度。

从网上找了一个 POST 提交的请求数据,删除无关的消息头域后,格式如下:

1
2
3
4
POST http://www.example.com HTTP/1.1
Content-Type: application/x-www-form-urlencoded;charset=utf-8

title=test&sub%5B%5D=1&sub%5B%5D=2&sub%5B%5D=3

其中 Content-Type 被指定为 application/x-www-form-urlencoded;charset=utf-8, 消息体部分是 URL 编码后的数据且为 utf-8 编码(注:Tornado 默认支持的是 utf-8,若要使用其他的编码例如 GBK,则需要自己覆写一下 RequestHandler.decode_argument 方法。

在 Tornado 中 application/x-www-form-urlencoded 编码数据的解析是由 parse_qs_bytes 函数支持的。

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
try:
from urllib.parse import parse_qs as _parse_qs # py3
except ImportError:
from urlparse import parse_qs as _parse_qs # Python 2.6+
……

if sys.version_info[0] < 3:
parse_qs_bytes = _parse_qs
else
def parse_qs_bytes(qs, keep_blank_values=False, strict_parsing=False):
"""Parses a query string like urlparse.parse_qs, but returns the
values as byte strings.

Keys still become type str (interpreted as latin1 in python3!)
because it's too painful to keep them as byte strings in
python3 and in practice they're nearly always ascii anyway.
"""
# This is gross, but python3 doesn't give us another way.
# Latin1 is the universal donor of character encodings.
result = _parse_qs(qs, keep_blank_values, strict_parsing,
encoding='latin1', errors='strict')
encoded = {}
for k, v in result.items():
encoded[k] = [i.encode('latin1') for i in v]
return encoded

注意一下,在 python3 时 parse_qs_bytes 函数返回字典 key 是 unicode str,value 是 byte strings。在 python2 中 key/value 都是 str(对应 python3 的 byte strings)。这个应该是由于在 python3 中字符串默认使用的 unicode str,使用 byte strings 作为字典的 key,读取参数时操作不便。

multipart/form-data

multipart/form-data 编码的 body 包含一系列由 ”boundary“ 分割的字段数据。”boundary“ 是一段字符串,为了与避免与正文内容重复,一般这段字符串都很长。消息体里按照字段分为不同的部分,每部分以 ”–boundary“ 开始,紧接着内容描述信息,然后是两个回车换行,最后是字段具体内容(文本或二进制)。如果传输的是文件,还要包含文件名和文件类型信息。消息体最后以 ”–boundary–“ 结束。multipart/form-data 的具体定义请前往 rfc7578 查看。下面是一段从网上 copy 的示例数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST http://www.example.com HTTP/1.1
Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryrGKCBY7qhFd3TrwA

------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="text"


title
------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="file"; filename="chrome.png"
Content-Type: image/png


PNG ... content of chrome.png ...
------WebKitFormBoundaryrGKCBY7qhFd3TrwA--

其中Content-Type 被指定为 multipart/form-data; boundary=----WebKitFormBoundaryrGKCBY7qhFd3TrwAboundary 为 ”—-WebKitFormBoundaryrGKCBY7qhFd3TrwA“,body 部分包含两个字段,一个是名为 ”text“ 的文本,一个是图片文件 ”chrome.png“。对照这个示例数据,理解下面 parse_multipart_form_data 函数就很容易。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def parse_multipart_form_data(boundary, data, arguments, files):
"""Parses a ``multipart/form-data`` body.

The ``boundary`` and ``data`` parameters are both byte strings.
The dictionaries given in the arguments and files parameters
will be updated with the contents of the body.
"""
# The standard allows for the boundary to be quoted in the header,
# although it's rare (it happens at least for google app engine
# xmpp). I think we're also supposed to handle backslash-escapes
# here but I'll save that until we see a client that uses them
# in the wild.
#
# 兼容以引号包围的 ”boundary“ 字符串,示例中是没有引号的。
if boundary.startswith(b'"') and boundary.endswith(b'"'):
boundary = boundary[1:-1]

# 消息体结束符 ”--boundary--“
final_boundary_index = data.rfind(b"--" + boundary + b"--")
if final_boundary_index == -1:
gen_log.warning("Invalid multipart/form-data: no final boundary")
return

# 获取以 ”--boundary“ 分割的各部分列表
parts = data[:final_boundary_index].split(b"--" + boundary + b"\r\n")
for part in parts:
if not part:
continue
eoh = part.find(b"\r\n\r\n")
if eoh == -1:
gen_log.warning("multipart/form-data missing headers")
continue
headers = HTTPHeaders.parse(part[:eoh].decode("utf-8"))
disp_header = headers.get("Content-Disposition", "")
disposition, disp_params = _parse_header(disp_header)
if disposition != "form-data" or not part.endswith(b"\r\n"):
gen_log.warning("Invalid multipart/form-data")
continue

# b"\r\n\r\n" 的长度为 4,b"\r\n" 的长度为 2。
value = part[eoh + 4:-2]
if not disp_params.get("name"):
gen_log.warning("multipart/form-data value missing name")
continue
name = disp_params["name"]

# 上传的若是文件,则 ”Content-Disposition“ 中包含 ”filename“ 字段,且包含 ”Content-Type“ 头域。
if disp_params.get("filename"):
ctype = headers.get("Content-Type", "application/unknown")
files.setdefault(name, []).append(HTTPFile(
filename=disp_params["filename"], body=value,
content_type=ctype))
else:
arguments.setdefault(name, []).append(value)