请求体与 multipart
主线位置:请求主线上,
RequestBuilder填进去的 body 一路跟着请求穿过 tower 栈(重定向、重试要复制它),最后由 hyper 的 HTTP/1 或 HTTP/2 编码器写到连接上。这一页讲 body 的两种形态、它们怎么决定线上的分帧,以及multipart::Form怎么拼成一个 body。
flowchart TB
src["body 来源"] --> reuse["Reusable Bytes"]
src --> strm["Streaming BoxBody"]
reuse --> exact["size_hint 精确"]
strm --> hint{"size_hint 精确"}
hint -->|是| exact
hint -->|否| unk["长度未知"]
exact --> cl["content-length"]
unk --> m{"GET HEAD CONNECT"}
m -->|否| chunk["chunked"]
m -->|是| drop["不发 body"]
form["multipart Form"] --> strm
form --> len["compute_length"]
len -->|算得出| hdr["手设 content-length"]
hdr --> cl
文件是 async_impl/body.rs(486 行,后半是响应 body 的超时包装,见 超时)和 async_impl/multipart.rs(754 行,测试从第 602 行开始)。分帧的规则在 hyper 里,这一页只给结果,规则本身见 hyper 卷的 解析与编码。
实验在 exp-reqwest/req-lab 的 body 和 h2。HTTP/1.1 用的是自己写的服务器:直接读 TCP 字节,打印请求头原文,再按 content-length 或 chunked 读 body,记下每个块的大小,所以服务端看到的就是线上的样子。HTTP/2 用 hyper 的服务端加 http2_prior_knowledge。客户端都调了 .no_proxy()。
两种形态
Body 里面只有一个枚举:
pub struct Body {
inner: Inner,
}
enum Inner {
Reusable(Bytes),
Streaming(BoxBody<Bytes, Box<dyn std::error::Error + Send + Sync>>),
}
Reusable:一块完整的Bytes。From<&'static str>、From<String>、From<Vec<u8>>、From<Bytes>、From<&'static [u8]>都到这里,json()、form()生成的 body 也是。名字说明了它的用途:克隆只是给Bytes加一次引用计数,重定向、重试可以随便复制。Streaming:一个装箱的http_body::Body。wrap_stream(要开streamfeature)把一个TryStream转成StreamBody,wrap接受任意http_body::Body,From<tokio::fs::File>用ReaderStream走wrap_stream,multipart::Form也落在这里。
try_clone 和 as_bytes 都只认 Reusable:
pub(crate) fn try_clone(&self) -> Option<Body> {
match self.inner {
Inner::Reusable(ref chunk) => Some(Body::reusable(chunk.clone())),
Inner::Streaming { .. } => None,
}
}
Body 自己实现了 http_body::Body,hyper 只通过三个方法看它:
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context,
) -> Poll<Option<Result<hyper::body::Frame<Self::Data>, Self::Error>>> {
match self.inner {
Inner::Reusable(ref mut bytes) => {
let out = bytes.split_off(0);
if out.is_empty() {
Poll::Ready(None)
} else {
Poll::Ready(Some(Ok(hyper::body::Frame::data(out))))
}
}
// ...
fn size_hint(&self) -> http_body::SizeHint {
match self.inner {
Inner::Reusable(ref bytes) => http_body::SizeHint::with_exact(bytes.len() as u64),
Inner::Streaming(ref body) => body.size_hint(),
}
}
Reusable 一次交出全部字节(split_off(0) 把内容搬走、留下空的 Bytes),下一次就是结束;size_hint 精确等于长度。Streaming 两样都转给里面的 body:StreamBody 的 size_hint 是默认值(不知道长度),String 这类 http_body::Body 实现会给出精确长度。
body 实验先打印三种构造方式:
from(&str) as_bytes=Some(5) size_hint.exact=Some(5)
wrap_stream as_bytes=None size_hint.exact=None
wrap(String) as_bytes=None size_hint.exact=Some(5)
Body::wrap(String::from("hello")) 长度是知道的,但它是 Streaming:as_bytes() 返回 None,try_clone() 也是 None(请求构建 的 try_clone 一节)。手里已经是完整数据时,用 Body::from,别用 wrap。
线上:content-length 还是 chunked
reqwest 不写 Content-Length(multipart 是唯一的例外,下文),也不写 Transfer-Encoding。HTTP/1 下是 hyper 的 dispatcher 先问 body:is_end_stream() 为真就当没有 body;否则 size_hint().exact() 有值是「已知长度」,没有是「未知长度」。然后 Client::set_length 按方法和用户写的头决定分帧。
== POST Bytes
[ 17ms server] request head:
POST /bytes HTTP/1.1
accept: */*
accept-encoding: gzip
host: 127.0.0.1:61864
content-length: 5
-- body: content-length 5 (5 bytes)
== POST wrap_stream
[ 19ms server] request head:
POST /stream HTTP/1.1
accept: */*
accept-encoding: gzip
host: 127.0.0.1:61864
transfer-encoding: chunked
-- body: chunked, chunk sizes [6, 10, 5, 0] (21 bytes)
-- body text: "hello streaming world"
== POST wrap(String)
[ 19ms server] request head:
POST /wrap HTTP/1.1
accept: */*
accept-encoding: gzip
host: 127.0.0.1:61864
content-length: 5
-- body: content-length 5 (5 bytes)
== POST wrap_stream with explicit content-length
[ 20ms server] request head:
POST /stream-cl HTTP/1.1
content-length: 21
accept: */*
accept-encoding: gzip
host: 127.0.0.1:61864
-- body: content-length 21 (21 bytes)
- 流
["hello ", "streaming ", "world"]变成 3 个 chunk 加结尾的 0 块,每个Bytes一个 chunk,hyper 不合并。 wrap(String)虽然是Streaming,size_hint精确,线上仍然是content-length。分帧看的是size_hint,不是Reusable/Streaming。- 流式 body 也能用
content-length:自己设这个头,hyper 优先用它(set_length里「用户写了合法的Content-Length:用它」)。 - 不带 body 的
POST(client.post(u).send())线上没有content-length: 0。Body::empty()的is_end_stream()为真,hyper 当作没有 body,两个头都不写(wire实验的post with no body一项)。要求请求必须带长度的服务端会回 411 Length Required,那时要手设content-length: 0。
client.get(url).body(Body::wrap_stream(chunks())),同样 21 字节的流,HTTP/1.1 服务器收到多少字节的 body?换成 HTTP/2 呢?multipart:边界
Form 包着一个 FormParts<Part>:
pub(crate) struct FormParts<P> {
pub(crate) boundary: String,
pub(crate) computed_headers: Vec<Vec<u8>>,
pub(crate) fields: Vec<(Cow<'static, str>, P)>,
pub(crate) percent_encoding: PercentEncoding,
}
泛型参数 P 让阻塞版(blocking::multipart,Part 里是一个 Read)共用同一套边界、长度和头的编码。边界在 Form::new() 时由 gen_boundary 生成,四个 64 位随机数,十六进制,- 连接:
fn gen_boundary() -> String {
use crate::util::fast_random as random;
let a = random();
let b = random();
let c = random();
let d = random();
format!("{a:016x}-{b:016x}-{c:016x}-{d:016x}")
}
长度固定是 4×16+3 = 67 个字符。fast_random 每个线程一个 RandomState(随机的 SipHash 密钥)和一个计数器,每次把计数器加一、哈希一下。它只要求边界不和内容撞上,不是密码学随机数。
最早的边界是 Uuid::new_v4().to_simple(),为此依赖了 uuid(features = ["v4"])。#679(提交 8583dd2,「Replace uuid dependency with tiny internal RNG」,首次发布 v0.10.0)删掉这个依赖,在 multipart.rs 里写了现在的 gen_boundary 和一个 xorshift 生成器:线程局部状态用 RandomState 哈希出一个非零种子,每次 n ^= n >> 12; n ^= n << 25; n ^= n >> 27,再乘 0x2545_f491_4f6c_dd1d。后来这个函数挪到 util.rs 改名 fast_random(connection_verbose 的连接日志也用它生成连接 id)。提交 4023493(「refactor: change fast_random from xorshift to siphash a counter」,没有 PR 号,首次发布 v0.12.24,CHANGELOG 记作「Refactor internal random generator.」)把 xorshift 换成现在的「SipHash 一个计数器」,种子循环也不需要了。边界的格式从 #679 起没变过。
multipart:线上的样子和预先算出的长度
RequestBuilder::multipart 做三件事:
let mut builder = self.header(
CONTENT_TYPE,
format!("multipart/form-data; boundary={}", multipart.boundary()).as_str(),
);
builder = match multipart.compute_length() {
Some(length) => builder.header(CONTENT_LENGTH, length),
None => builder,
};
if let Ok(ref mut req) = builder.request {
*req.body_mut() = Some(multipart.stream())
}
body 是 Form::stream,非空表单一律是 Body::stream(..),也就是 Streaming,size_hint 未知。所以长度只能靠前面手设的 Content-Length。流本身是 part_stream 一段段 chain 起来的:
// start with boundary
let boundary = stream::once(future::ready(Ok(
format!("--{}\r\n", self.boundary()).into()
)));
// append headers
let header = stream::once(future::ready(Ok({
let mut h = self
.inner
.percent_encoding
.encode_headers(&name.into(), &part.meta);
h.extend_from_slice(b"\r\n\r\n");
h.into()
})));
// then append form data followed by terminating CRLF
boundary
.chain(header)
.chain(part.value.into_data_stream())
.chain(stream::once(future::ready(Ok("\r\n".into()))))
所有字段之后再接一个 --{boundary}--\r\n。FormParts::compute_length 照着这个格式逐项加:
length += 2
+ self.boundary().len() as u64
+ 2
+ header_length as u64
+ 4
+ value_length
+ 2
注释自己也承认这里和 part_stream 的格式串是「not an obvious dependency」:改了一边忘了另一边,Content-Length 就错了。任何一个字段的 value_len 是 None,整个函数返回 None,不设 Content-Length。value_len 先看 Part 自带的 body_length(stream_with_length 和 Part::file 会填),再看 body 的 content_length():Reusable 是字节数,Streaming 是 size_hint().exact()。
compute_length 顺手把每个字段的头编码一遍、缓存在 computed_headers 里,注释说是为了发送时不用再编码。但异步版的 part_stream 不读这个缓存,又调了一次 encode_headers;读它的只有 blocking/multipart.rs。
三个表单的实测:
== multipart text only
boundary = 82e03220adf220af-ee4fff19025ee75e-a2f0b211c9bd42d7-84daa1bc2c5739a0 (67 chars)
[ 22ms server] request head:
POST /mp HTTP/1.1
content-type: multipart/form-data; boundary=82e03220adf220af-ee4fff19025ee75e-a2f0b211c9bd42d7-84daa1bc2c5739a0
content-length: 337
accept: */*
accept-encoding: gzip
host: 127.0.0.1:61864
-- body: content-length 337 (337 bytes)
-- body text: "--82e03220adf220af-ee4fff19025ee75e-a2f0b211c9bd42d7-84daa1bc2c5739a0\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nhi\r\n--82e03220adf220af-ee4fff19025ee75e-a2f0b211c9bd42d7-84daa1bc2c5739a0\r\nContent-Disposition: form-data; name*=utf-8''%E5%90%8D%E5%AD%97\r\n\r\nv\r\n--82e03220adf220af-ee4fff19025ee75e-a2f0b211c9bd42d7-84daa1bc2c5739a0--\r\n"
== multipart with an unknown-length part
[ 22ms server] request head:
POST /mps HTTP/1.1
content-type: multipart/form-data; boundary=7d56d1ea69846066-35533828f1d9feac-5fef5a25f41b7b64-be9d0f71e2232518
accept: */*
accept-encoding: gzip
host: 127.0.0.1:61864
transfer-encoding: chunked
-- body: chunked, chunk sizes [71, 48, 2, 2, 71, 65, 6, 10, 5, 2, 73, 0] (355 bytes)
...
== multipart file part (length from metadata)
[ 24ms server] request head:
POST /mpf HTTP/1.1
content-type: multipart/form-data; boundary=c3b3f6b4887e3b85-2f6096f04388cf2e-ce88c6349e422cf7-2dc2c28fc778251e
content-length: 257
...
-- body text: "--c3b3f6b4887e3b85-2f6096f04388cf2e-ce88c6349e422cf7-2dc2c28fc778251e\r\nContent-Disposition: form-data; name=\"f\"; filename=\"req-lab-upload.txt\"\r\nContent-Type: text/plain\r\n\r\nfile body\n\r\n--c3b3f6b4887e3b85-2f6096f04388cf2e-ce88c6349e422cf7-2dc2c28fc778251e--\r\n"
- 第一个表单的 337 可以手算:
title字段是 2+67+2 + 44(Content-Disposition: form-data; name="title")+4 + 2(hi)+2 = 123;名字字段的头是 63 字节,2+67+2+63+4+1+2 = 141;结尾 2+67+4 = 73;合计 337,和服务器读到的一致。 - 字段名
名字不是 ASCII,默认的PercentEncoding::PathSegment把它编成name*=utf-8''%E5%90%8D%E5%AD%97(RFC 8187 的写法);文件名不做百分号编码,只转义\、"、\r、\n(encode_headers的注释引了 issue #419:RFC 7578 不允许filename*=)。 - 第二个表单有一个
Part::stream(Body::wrap_stream(..)),长度未知,compute_length返回None,整个请求变成 chunked。块的大小就是part_stream那几段:71 是--{boundary}\r\n,48 是字段头加\r\n\r\n,2 是hi,2 是\r\n,流的三块 6、10、5 原样透传,最后 73 是结尾边界。 Part::file打开文件后用metadata().len()作长度,按扩展名猜 MIME(.txt是text/plain),所以文件字段也能算出Content-Length。
multipart() 的 Content-Type 用的是 header(),即 append。先手设一个 Content-Type 再调 multipart(),线上就有两行:
== content-type set before multipart
[ 25ms server] request head:
POST /ct HTTP/1.1
content-type: multipart/form-data
content-type: multipart/form-data; boundary=a3ba5d77d96cb961-bb0d9320a6a240ff-fa7906318c234b9c-e64011bdc6bb573e
第一行没有 boundary,服务端若取第一行就解析不了 body。form() 和 json() 在同样情况下保留用户的值、不加第二行(请求构建)。
问: Part::stream_with_length 的长度是我自己报的。报错了会怎样?
答: 两个方向结果不一样。compute_length 相信这个数,Content-Length 按它算;hyper 的编码器按 Content-Length 写。报小了(说 5、实际 21 字节),Encoder::encode 走 Holding more than is owed, so write only what is left 那一支,多出来的直接截掉,请求照样成功:
== stream_with_length lies (says 5, sends 21)
[ 25ms server] request head:
POST /lie HTTP/1.1
content-type: multipart/form-data; boundary=401b6b69b5ed7614-66bce5dabb43428b-d12b1069adfaa891-16cd438fc0f46d75
content-length: 195
...
-- body text: "--401b6b69b5ed7614-66bce5dabb43428b-d12b1069adfaa891-16cd438fc0f46d75\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\nhello streaming world\r\n--401b6b69b5ed7614-66bce5dabb43428b-d12b1069adfaa891-16cd"
client: ok 200 OK
服务端收到的表单少了结尾边界的后半截,客户端拿到 200 OK,没有任何报错。报大了(说 50),body 提前结束,hyper 报错:
== stream_with_length lies (says 50, sends 21)
client: error sending request for url (http://127.0.0.1:61908/short)
source: client error (SendRequest)
source: user body write aborted
source: early end, expected 29 more bytes
所以拿不准长度时用 Part::stream,宁可走 chunked。
动脑筋答案
HTTP/1.1:
== GET wrap_stream (body silently dropped?)
[ 21ms server] request head:
GET /get-stream HTTP/1.1
accept: */*
accept-encoding: gzip
host: 127.0.0.1:61864
-- body: no body (0 bytes)
== GET with Bytes body
[ 21ms server] request head:
GET /get-bytes HTTP/1.1
accept: */*
accept-encoding: gzip
host: 127.0.0.1:61864
content-length: 5
-- body: content-length 5 (5 bytes)
HTTP/2(h2 实验,同样四个请求):
server HTTP/2.0 POST /bytes content-length=Some("5") transfer-encoding=None data frames=1 bytes=5
server HTTP/2.0 POST /stream content-length=None transfer-encoding=None data frames=4 bytes=21
server HTTP/2.0 GET /get-stream content-length=None transfer-encoding=None data frames=4 bytes=21
server HTTP/2.0 POST /empty content-length=Some("0") transfer-encoding=None data frames=0 bytes=0
HTTP/1.1 下是 0 字节,客户端不报错,请求返回 200。长度未知、方法是 GET 时,hyper 的 Client::set_length 走注释为「GET, HEAD, and CONNECT almost never have bodies」的分支,Encoder::length(0):不写 chunked,流根本不会被读。同样的 GET 换成完整的 Bytes,长度已知,5 字节照常发出。
HTTP/2 下是 21 字节:HTTP/2 用 DATA 帧分界,没有这条规则,GET 的流式 body 照发(服务端读到 4 个数据帧、合计 21 字节,比流的 3 块多出一个空帧)。另外,没有 body 的 POST 在 HTTP/2 上带了 content-length: 0,在 HTTP/1.1 上什么也不带。同一段代码,换个协议,服务端看到的 body 不一样。
小结
Body只有Reusable(Bytes)和Streaming(BoxBody)两种;只有Reusable能try_clone/as_bytes。Body::wrap即使包的是String也是Streaming。- 分帧由 hyper 按
is_end_stream和size_hint().exact()决定:长度已知写content-length,未知写 chunked(每个Bytes一块),用户设的Content-Length优先;空 body 的POST在 HTTP/1.1 上不写content-length: 0。 - HTTP/1.1 下
GET/HEAD带长度未知的流式 body,body 被静默丢弃;HTTP/2 照发。 multipart::Form的边界是 4 个fast_random拼成的 67 字符;body 总是流,长度靠compute_length按格式预先算出,任何一个字段长度未知就退回 chunked。multipart()的Content-Type是追加的,先手设会出现两行;非 ASCII 字段名编成name*=utf-8''...。stream_with_length报小了,多出的字节被 hyper 截掉且请求成功;报大了,报early end, expected N more bytes。