设计取舍
主线位置:三条主线的全局回看。请求主线上的 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 次 | 每个请求都先被克隆一份(见下文) | 重试 |
charset | text() 按 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。
问: 既然栈是具体类型、插不进中间,想给所有请求加一层(比如打日志、限流)该怎么办?
答: 套在 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 两个目标都编过了)。代价是「同名」掩盖了很大的差别:
| async | blocking | wasm | |
|---|---|---|---|
| 实现 | tower 栈 + hyper-util | 后台线程跑 async | 浏览器 fetch |
ClientBuilder 的 pub fn | 90 | 87(少 read_timeout、tls_early_data、windows_named_pipe) | 4 |
| 默认总超时 | 无 | 30 秒,按段计 | 无 |
每个 Client 的线程 | 0 | 1 | 0 |
| 重定向、代理、TLS、池 | reqwest 配置 | 同 async | 浏览器决定,不可配 |
| 只在这里有的方法 | upgrade、chunk 等 | Response 实现 std::io::Read | 12 个 fetch_* |
(pub fn 按 tag 上 impl ClientBuilder 块里的方法名计,含各 feature 和平台下的。)
同一段代码在三个地方编译通过,不代表行为相同。最容易踩的两处:异步代码里误用 blocking,debug 下 panic、release 下静静地占住一个 worker(阻塞客户端);以为 timeout 两边一样,其实阻塞版的默认 30 秒每段重新计时。
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 仍优先,默认不带后量子密钥交换。