Rust in Everything
基于 reqwest 0.13.5

reqwest 解决什么问题

主线位置:请求主线的起点之前。本页不跟某一个请求,而是回答「为什么要有这个库」:README 承诺了什么,这些承诺落在哪几段代码上;再把同一批请求用 reqwest、直接用 hyper-util legacy 客户端、ureq 各写一遍,比能测的四样东西:客户端代码量、依赖数、二进制大小、本机吞吐。
flowchart TB
  app["应用代码"] --> rq["reqwest Client"]
  rq --> dec["解压 重定向"]
  dec --> ck["cookie 重试"]
  ck --> hu["hyper-util Client"]
  hu --> conn["Connector"]
  conn --> px["代理 系统代理"]
  conn --> tls["rustls 平台校验"]
  hu --> hyper["hyper h1 h2"]

定位

README 的第一句是「An ergonomic, batteries-included HTTP Client for Rust」,下面列了七条。逐条对到 tag 上的代码:

README 的条目代码里在哪
Async and blocking Clientsasync_impl::Client;blocking::Client 用一个后台线程包住它,见 源码地图
Plain bodies, JSON, urlencoded, multipartRequestBuilder::json、form、multipart;各要一个 feature
Customizable redirect policyredirect::Policy,默认最多 10 次;循环本身是 tower-http 的 FollowRedirect
HTTP ProxiesProxy,默认还读环境变量和系统设置
HTTPS via rustls (or optionally, system-native TLS)build 里的 TLS 分支和 connect.rs 的 Inner,见 TLS 配置
Cookie StoreCookieStore 和 CookieService 层,要 cookies feature 加 cookie_store(true)
WASMwasm/ 下另一套实现

「batteries-included」体现在默认值上:不调任何 builder 方法,一个 Client 就带着连接池(空闲 90 秒、每主机不限)、10 次重定向、系统代理、gzip 解压(开了 gzip feature 时)、Accept: */* 默认头、HTTP/2(ALPN 协商)、rustls 加系统信任库。它没有默认超时:timeout、connect_timeout、read_timeout 都是 None,见 超时。

reqwest 自己几乎不碰字节:解析 HTTP 的是 hyper,管连接池的是 hyper-util,重定向和解压是 tower-http,重试是 tower 的 Retry,TLS 是 rustls。它做的是选默认值、把这些部件接起来、给出一套顺手的 API。下面的对照就是在量「接起来」这件事值多少代码、花多少字节和时间。

同一批请求,三种写法

任务:建一个客户端,信任本机测试服务器的 CA(HTTPS 时),对一个 URL 反复发 GET,检查状态码,读完响应体。三个程序共用同一个计时外壳,只有标出的「客户端」部分不同。

reqwest(reqwest = "=0.13.5",默认 feature):

type Client = reqwest::Client;

fn make_client(ca_pem: Option<&[u8]>) -> Result<Client, Box<dyn std::error::Error>> {
  let mut b = reqwest::Client::builder().no_proxy();
  #[cfg(feature = "tls")]
  if let Some(pem) = ca_pem {
    b = b.add_root_certificate(reqwest::Certificate::from_pem(pem)?);
  }
  #[cfg(not(feature = "tls"))]
  let _ = ca_pem;
  Ok(b.build()?)
}

async fn fetch(client: &Client, url: &str) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
  let body = client.get(url).send().await?.error_for_status()?.bytes().await?;
  Ok(body.len())
}

直接用 hyper-util 0.1.21 的 legacy Client,为了和 reqwest 的默认值对齐,TLS 也用 aws-lc-rs 加 rustls-platform-verifier,经 hyper-rustls 0.27.10 接上,打开 HTTP/2:

use bytes::Bytes;
use http_body_util::{BodyExt, Empty};
use hyper_util::client::legacy::{connect::HttpConnector, Client as LegacyClient};
use hyper_util::rt::TokioExecutor;

#[cfg(feature = "tls")]
type Connector = hyper_rustls::HttpsConnector<HttpConnector>;
#[cfg(not(feature = "tls"))]
type Connector = HttpConnector;
type Client = LegacyClient<Connector, Empty<Bytes>>;

fn make_client(ca_pem: Option<&[u8]>) -> Result<Client, Box<dyn std::error::Error>> {
  let mut http = HttpConnector::new();
  http.set_nodelay(true);
  #[cfg(feature = "tls")]
  let connector = {
    use rustls::pki_types::{pem::PemObject, CertificateDer};
    use std::sync::Arc;
    http.enforce_http(false);
    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
    let extra: Vec<CertificateDer<'static>> = match ca_pem {
      Some(pem) => CertificateDer::pem_slice_iter(pem).collect::<Result<_, _>>()?,
      None => Vec::new(),
    };
    let verifier = rustls_platform_verifier::Verifier::new_with_extra_roots(extra, provider.clone())?;
    let tls = rustls::ClientConfig::builder_with_provider(provider)
      .with_safe_default_protocol_versions()?
      .dangerous()
      .with_custom_certificate_verifier(Arc::new(verifier))
      .with_no_client_auth();
    hyper_rustls::HttpsConnectorBuilder::new()
      .with_tls_config(tls)
      .https_or_http()
      .enable_http1()
      .enable_http2()
      .wrap_connector(http)
  };
  #[cfg(not(feature = "tls"))]
  let connector = {
    let _ = ca_pem;
    http
  };
  Ok(LegacyClient::builder(TokioExecutor::new()).build(connector))
}

async fn fetch(client: &Client, url: &str) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
  let req = hyper::Request::get(url)
    .header(hyper::header::ACCEPT, "*/*")
    .body(Empty::new())?;
  let resp = client.request(req).await?;
  if !resp.status().is_success() {
    return Err(format!("status {}", resp.status()).into());
  }
  let body = resp.into_body().collect().await?.to_bytes();
  Ok(body.len())
}

ureq 3.5.0(本机 cargo 缓存里有,--offline 能编)是阻塞客户端,不依赖 tokio,只说 HTTP/1.1,默认 TLS 是 rustls 加 ring、根证书用编进去的 webpki-roots:

type Client = ureq::Agent;

fn make_client(ca_pem: Option<&[u8]>) -> Result<Client, Box<dyn std::error::Error>> {
  let mut b = ureq::Agent::config_builder().proxy(None);
  #[cfg(feature = "tls")]
  if let Some(pem) = ca_pem {
    use ureq::tls::{Certificate, RootCerts, TlsConfig};
    let roots = RootCerts::new_with_certs(&[Certificate::from_pem(pem)?]);
    b = b.tls_config(TlsConfig::builder().root_certs(roots).build());
  }
  #[cfg(not(feature = "tls"))]
  let _ = ca_pem;
  Ok(b.build().new_agent())
}

fn fetch(client: &Client, url: &str) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
  let body = client.get(url).call()?.body_mut().read_to_vec()?;
  Ok(body.len())
}

客户端部分去掉空行,reqwest 15 行、hyper-util 53 行、ureq 17 行(各含 3 到 5 行 cfg 切换)。ureq 不用调 error_for_status:它的 http_status_as_error 默认就是真,4xx / 5xx 直接成为 Err。

hyper-util 版多出来的 38 行全在 make_client 里:拼 rustls 配置、选校验器、把 TLS 接到连接器上。而且它只做到了「发出去、收回来」,reqwest 在同样 15 行里还免费带着这些 hyper-util 版没有的东西:

  • 重定向(tower-http FollowRedirect,最多 10 次,跨域去掉敏感头)和 gzip 解压(Decompression,自动补 Accept-Encoding);
  • 环境变量和系统代理、NO_PROXY 规则、CONNECT 隧道、SOCKS;
  • HTTP/2 的 GOAWAY / REFUSED_STREAM 自动重试;
  • Url 解析(含国际化域名),带 URL 的 Error,json / form / query、text 的字符集解码;
  • 总超时、读超时、连接超时三个开关,https_only,cookie。

依赖数与二进制大小

每个程序编两次:带 TLS(各自的默认 TLS 配置)和不带 TLS(reqwest 关掉默认 feature,hyper-util 只用 HttpConnector,ureq 关掉默认 feature)。依赖数是 cargo tree -e normal --prefix none 去重后的 crate 数(含过程宏,不含程序自己);大小是 release、strip = true 的可执行文件:

程序TLS 栈依赖(TLS / 无 TLS)二进制字节(TLS / 无 TLS)
reqwest 0.13.5rustls + aws-lc-rs + platform verifier,HTTP/298 / 675372704 / 1859936
hyper-util 0.1.21 + hyper-rustls同上55 / 304533392 / 1281024
ureq 3.5.0rustls + ring + webpki-roots,gzip27 / 102263904 / 880976

读法:

  • 带 TLS 时 reqwest 比 hyper-util 版多 43 个 crate、多 0.84 MB。hyper-util 版的依赖是 reqwest 的真子集,多出来的有:url 带来的 idna 和一串 icu_*、zerovec、yoke 等 Unicode 数据 crate(约 25 个),tower、tower-layer、tower-http,system-configuration(读 macOS 系统代理),encoding_rs 和 mime(charset),以及 reqwest 自己。
  • 关掉默认 feature 后差距仍有 37 个 crate、0.58 MB:url、tower、tower-http 不是可选依赖,没有 TLS 的 reqwest 也带着它们。
  • 二进制里的大头是 TLS:reqwest 从无 TLS 到有 TLS 多 3.5 MB,这一档 feature 带进来的是 aws-lc-rs、rustls、平台校验器、h2、系统代理和字符集支持。ureq 的 TLS 版不到一半大,它用 ring 而不是 aws-lc-rs,没有 HTTP/2,也没有 tokio;两个 provider 的大小差别见 rustls 卷的 两个 provider:二进制大小。
  • ureq 不需要 tokio。对一个只发几个请求的命令行工具,这一项比上面所有数字都重要。

本机吞吐

服务端是独立进程里的测试服务器(hyper 1 服务端,强制 HTTP/1.1,响应体约 40 字节),客户端用上面三个带 TLS 的程序,每个先发一个请求把连接建好再计时。「并发 32」对 reqwest 和 hyper-util 是 32 个 tokio 任务共享一个客户端,对 ureq 是 32 个线程共享一个 Agent。每格 5 次,取最好的一次和中位数(requests/s,release,uptime 负载 12.6 到 17.3,机器 12 核):

场景reqwesthyper-utilureq
HTTP,单连接顺序 10000 次26522 / 2516330869 / 3052428343 / 28176
HTTP,并发 32,共 32000 次69390 / 6916372038 / 6982168014 / 63608
HTTPS,单连接顺序 10000 次26517 / 2579228212 / 278135414 / 5287
HTTPS,并发 32,共 32000 次62863 / 6144962460 / 580037100 / 6480

读法:

  • 单连接顺序请求最能看出客户端自身的开销。 HTTP 下 reqwest 每个请求约 37.7 µs,hyper-util 约 32.4 µs,差约 5 µs;HTTPS 下差约 2 µs。这几微秒花在四层 tower、execute_request 里的头合并和一次 HeaderMap 克隆、Pending 和响应体包装、Url 和 Uri 的转换上。tag 之后的 8e1c806(#3116,「avoid unnecessary HeaderMap clones when dispatching requests」)就是在削这一块,还没有发版。
  • 并发 32 时三者差不多,都在每秒六七万左右:这时瓶颈是同一台机器上的服务端和调度,不是客户端。
  • ureq 走 HTTPS 时慢了约五倍,并发也上不去。服务端日志确认它复用了连接(200 次请求只握手 1 次),所以不是在反复握手。原因本卷没有追,它和 reqwest 无关;这里只说明「阻塞客户端一定更轻」不能直接推出「一定更快」,要量。
  • 这些数字都是回环上的。真实网络上一个往返是毫秒级,客户端的几微秒淹没在里面;能拉开差距的是连接复用和 TLS 握手次数,见 一个 Client。
📝NOTE
没有蠢问题

问: 既然 hyper-util 版快几微秒、少 43 个依赖,为什么不直接用 hyper-util?

答: 要看那 38 行之外还要补多少。上一节列的重定向、解压、代理、URL 解析、超时,每一样自己写都比 38 行长,而且容易写错:跨域重定向要去掉 Authorization,系统代理要认 NO_PROXY,解压要和 Accept-Encoding 对上。只调一个固定的内部服务、不需要这些的程序,直接用 hyper-util 是合理的;本卷 09 动手 就拼一个这样的小客户端,看看补齐一部分要多少代码。依赖数对编译时间和供应链审计有意义,对运行时性能几乎没有。

小结

  • reqwest 是把 hyper、hyper-util、tower、tower-http、rustls 接起来,再配上默认值和一套 API:连接池、10 次重定向、系统代理、HTTP/2、rustls 加系统信任库都是默认的;解压和 cookie 要开对应的 feature,超时也不是默认的。
  • 同一批请求:客户端代码 reqwest 15 行、hyper-util 53 行、ureq 17 行;hyper-util 版只做到发和收。
  • 带 TLS:依赖 98 / 55 / 27 个 crate,剥离后二进制 5.37 / 4.53 / 2.26 MB;reqwest 多出的是 url 的 Unicode 数据、tower / tower-http、系统代理和字符集支持。
  • 回环单连接:reqwest 每个请求比 hyper-util 多约 2 到 5 µs;并发时三者都被服务端限住;ureq 的 HTTPS 明显慢,原因未查。

💬 关联讨论

发起讨论
加载中...