HTTP 报文编码格式
因为最近在尝试写一个 HTTP 服务器,用来练练手。处理 HTTP 请求首先需要解析 HTTP 请求,其中 HTTP 的 body 部分格式相对较多,在此期间我对常见格式做了了解,先记录于此。
chunk 编码
首先谈谈 HTTP 报文的 body 部分在传输过程中的编码。而下一节,讨论内容的解析,即给定一个字符串,要从中解析出有用信息来。这两者都需要依靠某种规定好的格式。
在 HTTP 中有 Content-Length
这个头部,它指明 body 部分有多少字节。如果知道待发送的数据的总长度,Content-Length
的值自然可以获得。此时数据可以直接放在 body 部分传输,并设置好 Content-Length
即可。为什么 Content-Length
很重要,因为现在的 HTTP 请求通常都是流水线式的,即在一个 TCP 连接中一次性发送多个请求。接收方需要从多个请求中找到请求之间的间隔吧,这就需要知道每个请求的 body 部分有多长。
有时候,在没得到全部待发送数据之前,就需要先发送部分数据了,此时自然不知道 Content-Length
是多少。那么多个请求如何区分开呢?这种情况下可以使用 chunk 编码。chunk 即分块的意思。
这种方式把 body 分成了多个分块来发送,每个分块的长度都是已知的。每个分块前都以十六进制指明分块的长度,而后跟随分块的内容。这样每个分块有多长就知道了。最后一个分块的长度为 0,它作为最后一个 chunk 的标志。
下面借用 MDN上的一个例子,如下:
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked
7\r\n
Mozilla\r\n
9\r\n
Developer\r\n
7\r\n
Network\r\n
0\r\n
\r\n
头部 Transfer-Encoding: chunked
说明 body 采用 chunk 编码。body 中的第一行为 7,说明第一个分块的长度为 7。下一行起 7 个字符就是本 chunk 的内容。解析 chunk 编码很容易,读取第一行得到第一个 chunk 的长度,然后从接下来的一行起读取指定长度的内容。而后再读取下一个 chunk。
有了 chunk 编码方式,可以把数据分成多个部分发送,不必等到所有数据都收集完了之后才一次性发送。最后一个 chunk 的长度为 0,用于标志 body 结束了。
在接收端,如果发现请求中包含 body ,比如 POST 请求,此时如果有 Content-Length
头部,那就读取固定长度作为 body 即可。如果没有,那就必须要使用 chunk
编码了,否则服务器端是无法解析的。
body 常见格式
HTTP 协议中并没有规定 body 部分采用什么格式,比如 JSON 格式、XML 格式,你可以发送任何数据。但是发过去的内容总得被解析。尤其是发送结构化数据,使用一个双方都支持的格式,这对收发双方都很重要。
下面是几种常见的格式:
application/x-www-form-urlencoded
multipart/form-data
application/json
- 其他
application/x-www-form-urlencoded
这种格式传输的是键值对,就像 GET 请求的 query 部分一样,使用 a=b
来表示一个键值对,使用 &
连接多个键值对。比如:
foo=123&bar=234
在浏览器中可以这样发起请求:
let params = new URLSearchParams;
params.append("foo", "123");
params.append("bar", "234");
fetch("http://127.0.0.1:8001", {
method: "POST",
body: params
});
发送的内容是一个 URLSearchParams
对象,此时浏览器会自动在请求头部加入 Content-Type: application/x-www-form-urlencoded
。
如果不是在浏览器环境中,也可以手动完成 body 的编码,并指定正确的 Content-Type
:
// 或者手动编码
fetch("http://127.0.0.1:8001", {
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: "foo=123&bar=234"
})
multipart/form-data
在网页中使用 form 提交表单是就是采用这种格式。如今已经是 2020 年了,通常很少使用 form 来发起 POST 请求了,通常都是由 JavaScript 来发起 HTTP 请求。
使用 FormData
对象作为发送内容,就会这种格式,下面是个例子:
var form = new FormData;
form.append("foo", "123");
form.append("bar", "234");
fetch("http://127.0.0.1:8001", {
method: "POST",
body: form
}).then(res => res.text()).then(data => {
console.log(data);
});
浏览器会自动添加头部:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary0YIe7rP7SPMwpOvO
请求的 body 部分的内容如下:
------WebKitFormBoundary0YIe7rP7SPMwpOvO
Content-Disposition: form-data; name="foo"
123
------WebKitFormBoundary0YIe7rP7SPMwpOvO
Content-Disposition: form-data; name="bar"
234
------WebKitFormBoundary0YIe7rP7SPMwpOvO--
可见整个内容由 boundary
分为多个部分,这个 boundary
需要在 Content-Type
中指出。它可以是任意字符串,但为了防止与用户发送的内容冲突,通常随机生成一个较长的字符串。
multipart/form-data
格式如下:
--{boundary}\r\n
Content-Disposition: form-data; name="key"\r\n
\r\n
value\r\n
--{boundary}\r\n
Content-Disposition: form-data; name="key"\r\n
\r\n
value\r\n
--{boundary}--\r\n
两个 boundary 之间的是一个条目。它分为头部和 body,两者之间空行分隔。
其中 Content-Disposition
是一个 HTTP 头部,但是此处在 multipart/form-data
中复用了此头部,它的格式如下:
Content-Disposition: form-data; name="fieldName"
Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"
name
指明 form-data 中 key-value 对的 key,而 value 就是其后的内容了。如果发送的是文件,那么还会有 filename
这个字段,用来说明用户上传的文件的名称。比如我发送了一个文件,那么这部分的内容就大致为:
------WebKitFormBoundaryZMPZDvIKME9OoGKU
Content-Disposition: form-data; name="file"; filename="nlu.py"
Content-Type: text/plain
import pandas as pd
import re
...
用 Content-Type: text/plain
指明了文件的类型。每次浏览器都会随机生成一个 boundary
,所以这里的 boundary
不和上面的一样。
最后一个 boundary 后面紧跟 --
,这是结束的标志。
application/json
这种格式想必是目前最为流行的格式了,后端系统的请求和响应大多采用此格式。它没什么特别的,body 部分就是序列化后的 JSON 字符串,在 Content-Type
中指明 application/json
,对端反序列化 JSON 字符串即可。
其他格式
无论什么格式,内容都是在 body 中放着,只不过解析方法不同罢了。用户完全可以自己定义一种格式,然后前后端采用匹配的格式化和解析方法即可。前面三种用的比较普遍,因此单拉出来说了说,其他的格式就不再说了。