Rust in Everything
基于 reqwest 0.13.5

请求体与 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(要开 stream feature)把一个 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。
❗IMPORTANT
动脑筋: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 密钥)和一个计数器,每次把计数器加一、哈希一下。它只要求边界不和内容撞上,不是密码学随机数。

📝NOTE
冷知识:边界曾经是 UUID,后来又换过一次随机数

最早的边界是 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() 在同样情况下保留用户的值、不加第二行(请求构建)。

📝NOTE
没有蠢问题

问: 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。

💬 关联讨论

发起讨论
加载中...