Rust in Everything
基于 reqwest 0.13.5

设计取舍

主线位置:三条主线的全局回看。请求主线上的 tower 栈怎么来的、连接主线为什么交给 hyper-util、默认配置在三条线上各插了什么,以及它们在本机实测里花了多少。
flowchart TB
  api["reqwest API 与默认值"] --> layers["tower-http 与 tower 层"]
  layers --> legacy["hyper-util legacy Client"]
  legacy --> pool["池与 hyper h1 h2"]
  legacy --> conn["reqwest Connector"]
  conn --> dns["DNS 与代理"]
  conn --> tls["rustls 或 native-tls"]

reqwest 自己写的代码集中在图的两头:最上面的 API 和默认值,和中间偏下的 Connector。中间那几层都是别人的:重定向、解压是 tower-http,重试是 tower,池和 HTTP/1、HTTP/2 是 hyper-util 和 hyper,握手是 rustls。所以讨论 reqwest 的设计,主要是讨论它选了什么、默认开了什么、怎么拼。下面每一节给出取舍和它的代价,数字都来自本卷各页的实测(M2 Max、macOS 27、rustc 1.99.0,本机回环),链接指向原页。

默认值:带了什么,付什么

Cargo.toml 的 default 只有四项:default-tls、charset、http2、system-proxy。加上 ClientBuilder::new 里的配置默认值,一个 Client::new() 带着这些东西:

默认好处实测代价见
连接池(hyper-util,空闲 90 秒,每主机空闲数不限)复用时本机 HTTPS 每请求约 0.09 ms,每次新建 Client 约 2.5 ms不限制并发连接数;空闲连接最多留 90 秒一个 Client、连接池与 HTTP 版本
系统代理(system-proxy)公司网络、HTTPS_PROXY 不用配就生效每次 build 读一遍环境变量和 macOS 的 SCDynamicStore,80 到 140 µs(关掉后不到 1 µs);macOS 的例外列表不读,127.0.0.1 的请求也进代理一个 Client、代理
rustls + aws-lc-rs + 平台校验器(default-tls)不依赖 OpenSSL,用系统信任库进程里第一次用 aws-lc-rs 约 26 ms;每次完整握手的证书校验约 0.7 ms 交给 macOS;默认 feature 整体让剥离后的二进制从 1.86 MB 涨到 5.37 MB,大头是 TLS一次请求、一个 Client、reqwest 解决什么问题
HTTP/2,由 ALPN 决定(http2)对支持 h2 的服务器一条连接多路复用冷启动时 100 个并发请求做了 100 次 TCP 和 100 次 TLS 握手,最后只留 1 条连接连接池与 HTTP 版本
重定向,最多 10 次3xx 自动跟,跨域去敏感头多一层 FollowRedirect重定向
重试,只重试协议层面的拒绝HTTP/2 的 GOAWAY(NO_ERROR)和 REFUSED_STREAM 换连接重发,最多 2 次每个请求都先被克隆一份(见下文)重试
charsettext() 按 Content-Type 的 charset 解码encoding_rs、mime 两个依赖响应体

同样重要的是默认没有的东西:

  • 没有超时。 异步客户端的总超时、读超时、连接超时默认都是 None,卡在握手或响应体上的请求永远不结束(连接器、超时)。阻塞客户端反而有一个 30 秒默认值,而且是按段计的(阻塞客户端)。同一个库里两种默认相反。
  • 没有 User-Agent。 默认头只有 accept: */*(执行与 Pending)。
  • 没有解压和 cookie。 gzip、brotli、zstd、deflate、cookies 都是要自己开的 feature。本卷的实验模板开了 gzip 和 cookies,所以前面几页看到的栈是四层;只用默认 feature 时,Decompression 和 CookieService 两层在类型里就不存在,栈只剩 FollowRedirect 和 Retry(下一节)。

这些默认值的共同点是:它们让第一次请求「能用」,代价要么在每次 build(系统代理),要么在每条新连接(握手、平台校验),要么在进程第一次(aws-lc-rs 初始化)。所以 reqwest 的文档和本卷各页反复说同一件事:建一个 Client,克隆着用。每个请求一个 Client,这些代价每次都付一遍。

建在 hyper-util 的 legacy Client 上

reqwest 从来不自己管连接:0.11 用的是 hyper 0.14 的 hyper::Client,0.12 换到 hyper 1 以后,用的是 hyper-util 里从 0.14 原样搬过来的 legacy Client。build 的最后一步是 hyper-util 的 Client::builder(...).build(connector),reqwest 只把池的两个参数、HTTP/1 和 HTTP/2 的选项传过去。这个 Client 在 hyper-util 里的定位写在它的模块注释里:

//! The legacy HTTP Client from 0.14.x
//!
//! This `Client` will eventually be deconstructed into more composable parts.
//! For now, to enable people to use hyper 1.0 quicker, this `Client` exists
//! in much the same way it did in hyper 0.14.

好处是 reqwest 免费拿到一个成熟的池和 HTTP/1、HTTP/2 客户端,这些在 hyper 卷的 legacy Client 和连接池里讲过(hyper 卷锁的也是 hyper 1.12.0、hyper-util 0.1.21)。代价是这个池的行为原样变成了 reqwest 的行为,reqwest 只能在外面配,不能改:

  • 池只限制空闲连接数,没有并发连接上限;poll_ready 永远就绪,想限流得在外面限请求数。
  • 默认 Ver::Auto 时,建连接之前不知道会不会是 h2,并发的冷请求各建各的,ALPN 选出 h2 后多余的连接被丢掉:上表里那 99 次白做的握手就出在这里。
  • retry_canceled_requests、set_host 这些 hyper-util 选项 reqwest 没有转出来。

HTTP/3 是唯一的例外:QUIC 用不上 hyper-util 的池,reqwest 在 async_impl/h3_client/ 里自己写了一个(连接池与 HTTP 版本)。tag 之后仓库里有一个未合并的 pool-ng 分支,说明上游也在准备换掉 legacy 的池;0.13.5 里没有它。

tower 层:编译期拼好的具体类型

build 里的栈是一行一行包出来的,类型也写死在 LayeredService 里:

type LayeredService<T> = MaybeDecompression<
    FollowRedirect<
        MaybeCookieService<tower::retry::Retry<crate::retry::Policy, T>>,
        TowerRedirectPolicy,
    >,
>;

MaybeDecompression<T> 和 MaybeCookieService<T> 在对应 feature 关掉时就是 T 本身。这种做法的取舍:

  • 好处:没有装箱、没有动态分发,关掉的功能在类型里消失,不是运行时跳过。每个请求的 future 类型也是具体的。
  • 代价一:顺序固定。 从外到里永远是解压、重定向、cookie、重试。顺序的后果在执行与 Pending里实测过:302 上的 Set-Cookie 能跟到下一跳,而被重试的 503 上的 Set-Cookie 丢了,因为 Retry 在 CookieService 里面。
  • 代价二:用户插不进中间。 请求这一侧没有类似 layer() 的入口。连接器那一侧有 connector_layer,但它让连接器装箱、类型擦除(连接器)。
  • 代价三:每层都有每请求的固定开销。 比如 tower 的 Retry::call 第一行就是 self.policy.clone_request(&request),reqwest 的 clone_request 在没重试过时会复制 body(Bytes 是引用计数)、整张 HeaderMap 和 extensions,哪怕这个请求永远不会被重试。reqwest 解决什么问题 测出回环上 reqwest 每个请求比裸 hyper-util 多 2 到 5 µs,这类开销是其中的一部分。重试策略本身见 tower 卷的 Policy 与 future。
📝NOTE
没有蠢问题

问: 既然栈是具体类型、插不进中间,想给所有请求加一层(比如打日志、限流)该怎么办?

答: 套在 Client 外面。Client 和 &Client 都实现了 tower 的 Service<reqwest::Request>(impl Service<Request> for Client,poll_ready 永远就绪),可以放进任何 ServiceBuilder。限制是这一层看到的是重定向之前的原始请求,一次 send 只经过它一次;想看到每一跳、每一次重试,只能在连接器那一侧用 connector_layer,而那里看到的只是「建一条连接」,不是请求。

错误延迟到 send

RequestBuilder 里存的是 crate::Result<Request>:URL 解析失败、非法头、JSON 序列化失败都先存起来,后面的链式调用全部跳过,到 send() 返回的 Pending 第一次被 poll 时才交出(请求构建)。这换来的是 client.get(url).header(..).json(..).send().await? 这样一条链只需要一个 ?。代价是错误离出错的地方很远:非法头的错误 Display 只有 builder error,不带头名也不带 URL;要早点拿到它,得自己调 build()。ClientBuilder 是同样的做法:比如 user_agent 传了非法的头值,错误存进 Config.error,到 build() 第一步才返回。

三套实现,一组名字

reqwest::Client 在非 WASM 目标上是 async_impl::Client,在 wasm32-unknown-unknown 上是 wasm::Client;reqwest::blocking::Client 是第三个。三者共用名字和大部分方法签名,好处是常见的代码(建 Client、get、header、json、send、text)可以不改一行在原生和浏览器上编译(WASM 一页的 bw-wasm 两个目标都编过了)。代价是「同名」掩盖了很大的差别:

asyncblockingwasm
实现tower 栈 + hyper-util后台线程跑 async浏览器 fetch
ClientBuilder 的 pub fn9087(少 read_timeout、tls_early_data、windows_named_pipe)4
默认总超时无30 秒,按段计无
每个 Client 的线程010
重定向、代理、TLS、池reqwest 配置同 async浏览器决定,不可配
只在这里有的方法upgrade、chunk 等Response 实现 std::io::Read12 个 fetch_*

(pub fn 按 tag 上 impl ClientBuilder 块里的方法名计,含各 feature 和平台下的。)

同一段代码在三个地方编译通过,不代表行为相同。最容易踩的两处:异步代码里误用 blocking,debug 下 panic、release 下静静地占住一个 worker(阻塞客户端);以为 timeout 两边一样,其实阻塞版的默认 30 秒每段重新计时。

📝NOTE
冷知识:reqwest::Client 这个名字在 0.10 换过主人

#626「Make the async Client default」(87a0932,2019-09-09,首次发布在 v0.10.0-alpha.1)。提交说明:「The previously default Client is moved to reqwest::blocking, while the async client becomes the main API.」0.9 里 reqwest::Client 是同步客户端,异步的在 reqwest::async 下;这个提交把 src/client.rs、src/wait.rs 等原样搬进 src/blocking/,把 async_impl 的类型提到顶层。搬过去的同步客户端在 0.9 里就已经是今天的形状:0.9 的 src/client.rs 里已经有 ClientHandle、InnerClientHandle 和一个 thread::JoinHandle,即后台线程包着异步客户端。所以「阻塞客户端是异步客户端的包装」不是 0.10 的新设计,0.10 改的只是哪一个拿到短名字。

0.13:默认 TLS 换成 rustls 加平台校验器

0.12 的 default-tls 是 native-tls(macOS 上 Security.framework,Linux 上 OpenSSL);0.13.0 起是 rustls:aws-lc-rs 做 provider,rustls-platform-verifier 把证书链交给操作系统校验,webpki-roots 那一族 feature 删掉了,rustls-tls 改名 rustls。CHANGELOG 把它列在 0.13.0 的「Breaking changes」第一条。细节在 TLS 配置,这里只列取舍:

  • 换来的:默认不再依赖系统的 OpenSSL;默认就是 TLS 1.3、ALPN 可用;证书仍然交给操作系统的信任库校验,这一点和 0.12 的 native-tls 一致,和以前 rustls-tls 默认带的 webpki-roots(编进二进制的一份根证书列表)不同。
  • 付出的:aws-lc-rs 进程首次初始化约 26 ms;平台校验器每次完整握手约 0.7 ms,比只信一张 CA 的 webpki 慢(一个 Client);默认 feature 的二进制 5.37 MB,关掉默认 feature 是 1.86 MB,多出来的 3.5 MB 里大头是 aws-lc-rs 和 rustls(这一档还含 h2、系统代理、charset,见 reqwest 解决什么问题);#2752 还为 Windows 的 CI 加了预编译 NASM 的步骤,aws-lc 的构建比纯 Rust 依赖重。
  • 没换的:只要依赖树里任何一处打开了 native-tls,TlsBackend::default() 仍然是 native-tls,这是 #2752 说明里写明保留的;reqwest 依赖 rustls 时关了它的默认 feature,所以默认 ClientHello 只带 X25519 的密钥共享,X25519MLKEM768 排在 supported_groups 末尾,默认不做后量子密钥交换(TLS 配置实测 263 字节的 ClientHello)。

本站锁在 0.12.28,默认走 native-tls;升级到 0.13 要改哪些 feature、行为会怎么变,见 在本站中。

小结

  • 默认四个 feature:default-tls(rustls + aws-lc-rs + 平台校验器)、charset、http2、system-proxy;加上池、10 次重定向和协议层重试。代价分别落在每次 build(系统代理 80 到 140 µs)、每条新连接(平台校验约 0.7 ms)、进程第一次(aws-lc-rs 约 26 ms)和冷启动的并发突发(100 次握手留 1 条)。
  • 默认没有超时(阻塞版例外,30 秒按段计)、没有 User-Agent、没有解压和 cookie。
  • 池和 HTTP 实现是 hyper-util 的 legacy Client,它的限制原样成为 reqwest 的限制;HTTP/3 的池是 reqwest 自己的。
  • tower 栈是编译期拼好的具体类型:零装箱,feature 关掉就消失;顺序固定,中间插不进层,Retry 每个请求都先克隆一份。
  • 错误延迟到 send,换来一条链一个 ?,代价是错误信息离现场远。
  • 三套实现共用名字:async 90 个 builder 方法、blocking 87、wasm 4;名字相同,超时默认值和线程模型不同。
  • 0.13 默认 TLS 换成 rustls + 平台校验器,但编进 native-tls 时 native-tls 仍优先,默认不带后量子密钥交换。

💬 关联讨论

发起讨论
加载中...