连接器
主线位置:连接主线的入口和出口。hyper-util 的连接池里没有可用连接时,用 pool key 拼出一个只有 scheme 和 authority 的
Uri,调 reqwest 的Connector;Connector走完 DNS、TCP、代理、TLS,交回一个Conn,池子从它的connected()里读出这条连接是不是 HTTP/2、是不是明文代理。
flowchart TB
pool["hyper-util 连接池"] --> conn["Connector::call"]
conn -- Simple --> svc["ConnectorService::call"]
conn -- WithLayers --> layers["用户的 connector_layer"]
layers --> unname["Unnameable 转回 Uri"]
unname --> svc
svc --> uds{"unix_socket?"}
uds -- 是 --> local["connect_local_transport"]
svc --> px{"代理拦截?"}
px -- 是 --> via["connect_via_proxy"]
px -- 否 --> direct["connect_with_maybe_proxy"]
direct --> https["HttpsConnector"]
https --> http["HttpConnector:DNS 和 TCP"]
https --> tls["TLS 握手"]
tls --> out["Conn"]
via --> out
local --> out
out --> pool
源码在 connect.rs,2139 行。真正做事的不到 500 行:ConnectorBuilder、ConnectorService 的三个连接函数、Conn。剩下大半是同一段 impl 按「TCP / Unix socket / Windows 命名管道」乘「native-tls / rustls」重复的 TlsInfoFactory 和 Connection,再加 socks 和 verbose 两个小模块。DNS 见 DNS,TLS 配置见 TLS 配置,代理隧道见 代理,连接拿回去以后怎么复用见 连接池与 HTTP 版本。
实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,feature 用卷里的模板(默认 feature 加 json、stream、blocking、cookies、gzip、multipart、hickory-dns、socks、query、form),release 构建。这台机器开着系统代理(scutil --proxy 显示 HTTP/HTTPS 代理 127.0.0.1:7890,例外列表里没有 127.0.0.1),所以每个客户端都调了 .no_proxy()。服务器都是同一进程里起在 127.0.0.1 上的 hyper 服务器,HTTPS 用 rcgen 现签的证书。
三个类型
ClientBuilder::build 里先建 resolver(见 DNS),再建一个 hyper-util 的 HttpConnector:
let mut http = HttpConnector::new_with_resolver(resolver.clone());
http.set_connect_timeout(config.connect_timeout);
然后按 config.tls 的值把它交给 ConnectorBuilder::new_rustls_tls、new_native_tls 或 from_built_native_tls(分支怎么选见 TLS 配置),得到一个 ConnectorBuilder。接着设 connect_timeout、connection_verbose、TCP keepalive,最后一句把它变成 Connector 交给 hyper-util 的 Client:
let hyper_client = builder.build(connector_builder.build(config.connector_layers));
三个类型的分工:
ConnectorBuilder:构造期的可变配置。set_keepalive这类方法要伸进Inner里的HttpConnector去改,所以每个都是一个三分支的match。ConnectorService:真正连接的Service<Uri>。字段和 builder 差不多:inner(TLS 分支)、proxies、verbose、simple_timeout、nodelay、tls_info、user_agent(给 CONNECT 请求用),开了socks还有一个 resolver。Connector:交给 hyper-util 的类型,一个两分支的枚举:
#[derive(Clone)]
pub(crate) enum Connector {
// base service, with or without an embedded timeout
Simple(ConnectorService),
// at least one custom layer along with maybe an outer timeout layer
// from `builder.connect_timeout()`
WithLayers(BoxCloneSyncService<Unnameable, Conn, BoxError>),
}
hyper-util 对连接器的要求是「一个 Service<Uri>,future 是 Unpin + Send,返回的 IO 实现 Read + Write + Connection」(见 hyper 卷的 HttpConnector)。Connector 的 Future 是 Connecting,即 Pin<Box<dyn Future<Output = Result<Conn, BoxError>> + Send>>,装箱后天然 Unpin。每建一条新连接,hyper-util 克隆一份 Connector、调一次 call;连接池命中时连接器根本不会被调用。
build:没有层就不装箱
ConnectorBuilder::build 先把字段搬进 ConnectorService,然后分两种情况:
if layers.is_empty() {
// we have no user-provided layers, only use concrete types
base_service.simple_timeout = self.timeout;
return Connector::Simple(base_service);
}
// otherwise we have user provided layers
// so we need type erasure all the way through
// as well as mapping the unnameable type of the layers back to Uri for the inner service
let unnameable_service = ServiceBuilder::new()
.layer(MapRequestLayer::new(|request: Unnameable| request.0))
.service(base_service);
let mut service = BoxCloneSyncService::new(unnameable_service);
for layer in layers {
service = ServiceBuilder::new().layer(layer).service(service);
}
// now we handle the concrete stuff - any `connect_timeout`,
// plus a final map_err layer we can use to cast default tower layer
// errors to internal errors
match self.timeout {
Some(timeout) => {
let service = ServiceBuilder::new()
.layer(TimeoutLayer::new(timeout))
.service(service);
let service = ServiceBuilder::new()
.map_err(|error: BoxError| cast_to_internal_error(error))
.service(service);
let service = BoxCloneSyncService::new(service);
Connector::WithLayers(service)
}
// ...
}
- 没有用户层(绝大多数情况):
Connector::Simple,connect_timeout存进simple_timeout,在ConnectorService::call里用tokio::time::timeout直接包住连接 future。字段注释说这样省掉一层Box::pin,因为tokio::time::Timeout是Unpin。 - 有用户层:最里面一层
MapRequestLayer把Unnameable拆回Uri,再装进BoxCloneSyncService(类型擦除,见 tower 卷的 Box 与类型擦除)。用户的层按添加顺序一层层往外套,后加的在外面;connect_timeout变成一个 towerTimeoutLayer套在最外面;最外面再加一个map_err,用cast_to_internal_error把 tower 的Elapsed换成 reqwest 自己的TimedOut,这样Error::is_timeout()认得出来。
connector_layer 是 0.12.10 才有的在它之前,Connector 是一个 struct,用户只能通过 builder 上的开关影响它。#2496(「allow pluggable tower layers in connector service stack」,首次发布在 v0.12.10)加了 connector_layer,同时把 Connector 改成现在的 Simple / WithLayers 两个分支:ConnectorService 新增的 simple_timeout 字段,注释写明「When there is a single timeout layer and no other layers, we embed it directly inside our base Service::call()」,就是为了不给不用这个功能的人加一层装箱。同一个 PR 加的例子 examples/connect_via_lower_priority_tokio_runtime.rs 说明了动机:把含 TLS 握手的连接调用转到另一个低优先级的 tokio 运行时,「long-running futures during handshake crypto operations don't block other I/O futures」。同一个 PR 还在 error.rs 加了 cast_to_internal_error。
connector_layer:层看到的是 Unnameable
connector_layer 的签名:
pub fn connector_layer<L>(mut self, layer: L) -> ClientBuilder
where
L: Layer<BoxedConnectorService> + Clone + Send + Sync + 'static,
L::Service:
Service<Unnameable, Response = Conn, Error = BoxError> + Clone + Send + Sync + 'static,
<L::Service as Service<Unnameable>>::Future: Send + 'static,
请求类型不是 Uri,是 Unnameable;响应是 Conn。两者都定义在私有的 sealed 模块里,用户写不出它们的名字,只能写对请求和响应类型泛型的层(TimeoutLayer、ConcurrencyLimitLayer、自己的计数层)。这是有意的:reqwest 以后改连接器的内部类型,不会破坏用户代码。代价是层里拿不到目标地址做路由,只能 Debug 打印它。
一个只计数和打印的层:
#[derive(Clone)]
struct Count<S> {
inner: S,
n: Arc<AtomicUsize>,
}
impl<S, R> Service<R> for Count<S>
where
S: Service<R>,
S::Future: Send + 'static,
R: std::fmt::Debug,
{
type Response = S::Response;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<S::Response, S::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: R) -> Self::Future {
let k = self.n.fetch_add(1, Ordering::SeqCst) + 1;
println!(" layer: call #{k} with {req:?}");
Box::pin(self.inner.call(req))
}
}
// ...
let client = Client::builder()
.no_proxy()
.connector_layer(layer_fn(move |inner| Count { inner, n: n2.clone() }))
.build()
.unwrap();
for _ in 0..3 {
let r = client.get(format!("http://127.0.0.1:{}/", http.port())).send().await.unwrap();
let _ = r.text().await.unwrap();
}
println!(" connector called {} time(s)", n.load(Ordering::SeqCst));
Client 对同一个 HTTP/1.1 服务器顺序发 3 个请求,每个都把响应体读完。计数层会被调用几次?打印出来的请求长什么样?ConnectorService::call:三条路
call:
fn call(&mut self, dst: Uri) -> Self::Future {
log::debug!("starting new connection '{:?}'", dst.host());
let timeout = self.simple_timeout;
// Local transports (UDS, Windows Named Pipes) skip proxies
#[cfg(any(unix, target_os = "windows"))]
if self.should_use_local_transport() {
return Box::pin(with_timeout(
self.clone().connect_local_transport(dst),
timeout,
));
}
for prox in self.proxies.iter() {
if let Some(intercepted) = prox.intercept(&dst) {
return Box::pin(with_timeout(
self.clone().connect_via_proxy(dst, intercepted),
timeout,
));
}
}
Box::pin(with_timeout(
self.clone().connect_with_maybe_proxy(dst, false),
timeout,
))
}
- 设了
unix_socket(path)(Windows 上是windows_named_pipe):不管 URL 里写的是哪个主机,都连这个本地套接字,代理全部忽略(build里还会清空proxies,打一条 trace 日志)。URL 是https时照样在套接字上做 TLS。 - 代理列表里第一个拦截这个
Uri的代理:走connect_via_proxy。HTTPS 目标先发 CONNECT 建隧道再做 TLS,HTTP 目标直接连代理、以绝对形式写请求行;SOCKS 走另一条。细节见 代理。系统代理在build时作为ProxyMatcher::system()追加在列表末尾,所以.no_proxy()之后这个循环是空的。 - 其余:
connect_with_maybe_proxy(dst, false)。
三条路都包在 with_timeout 里,timeout 为 None 时直接 await。
Inner:TLS 分支在构造时就定了
#[derive(Clone)]
enum Inner {
#[cfg(not(feature = "__tls"))]
Http(HttpConnector),
#[cfg(feature = "__native-tls")]
NativeTls(HttpConnector, TlsConnector),
#[cfg(any(feature = "__rustls"))]
RustlsTls {
http: HttpConnector,
tls: Arc<rustls::ClientConfig>,
tls_proxy: Arc<rustls::ClientConfig>,
},
}
Inner 选哪个变体由 ClientBuilder::build 决定,一个 Client 一辈子只用一种 TLS 实现。两个细节:
- 编进任何 TLS 实现后,
Http变体就不存在了。 明文http://请求也走NativeTls或RustlsTls分支:hyper-tls / hyper-rustls 的HttpsConnector对httpscheme 原样透传 TCP,返回MaybeHttpsStream::Http。为此三个构造函数都对HttpConnector调了enforce_http(false)(hyper-util 默认拒绝非http的 scheme,见 hyper 卷的 HttpConnector)。 - rustls 分支存两份配置。
new_rustls_tls在有代理时克隆一份ClientConfig,清空 ALPN,作为tls_proxy:
let (tls, tls_proxy) = if proxies.is_empty() {
let tls = Arc::new(tls);
(tls.clone(), tls)
} else {
let mut tls_proxy = tls.clone();
tls_proxy.alpn_protocols.clear();
(Arc::new(tls), Arc::new(tls_proxy))
};
tls_proxy 用于和 HTTPS 代理本身握手:隧道里的 CONNECT 请求按 HTTP/1.1 写,不能让代理在 ALPN 里选成 h2。和目标服务器握手仍用带 ALPN 的 tls。这份拷贝来自 #466(「fix proxy missing alpn」,提交里的一条是「disable alpn for proxy connection」)。
connect_with_maybe_proxy 的 rustls 分支:
Inner::RustlsTls { http, tls, .. } => {
let mut http = http;
// Disable Nagle's algorithm for TLS handshake
//
// https://www.openssl.org/docs/man1.1.1/man3/SSL_connect.html#NOTES
if !self.nodelay && (dst.scheme() == Some(&Scheme::HTTPS)) {
http.set_nodelay(true);
}
let mut http = hyper_rustls::HttpsConnector::from((http, tls));
let io = http.call(dst).await?;
if let hyper_rustls::MaybeHttpsStream::Https(stream) = io {
if !self.nodelay {
let (io, _) = stream.inner().get_ref();
io.inner().inner().set_nodelay(false)?;
}
Ok(Conn {
inner: self.verbose.wrap(RustlsTlsConn { inner: stream }),
is_proxy,
tls_info: self.tls_info,
})
} else {
Ok(Conn {
inner: self.verbose.wrap(io),
is_proxy,
tls_info: false,
})
}
}
DNS 和 TCP 都在 HttpsConnector 内部调用的 HttpConnector 里完成;reqwest 自己只做两件事。一是 Nagle:tcp_nodelay 默认就是 true,这段只在用户关掉它时起作用:握手期间临时打开 TCP_NODELAY,握手完再关回去(注释指向 OpenSSL 文档里 SSL_connect 的说明,这段来自 2019 年的提交 f5e7e88「disable nagle algorithm for TLS handshake」)。二是把结果包成 Conn:HTTPS 包一层 RustlsTlsConn,明文直接用。native-tls 分支结构相同,换成 hyper_tls::HttpsConnector 和 NativeTlsConn。
Conn:一个装箱的连接,加两个标记
pin_project! {
/// Note: the `is_proxy` member means *is plain text HTTP proxy*.
/// This tells hyper whether the URI should be written in
/// * origin-form (`GET /just/a/path HTTP/1.1`), when `is_proxy == false`, or
/// * absolute-form (`GET http://foo.bar/and/a/path HTTP/1.1`), otherwise.
#[allow(missing_debug_implementations)]
pub struct Conn {
#[pin]
pub(super)inner: BoxConn,
pub(super) is_proxy: bool,
// Only needed for __tls, but #[cfg()] on fields breaks pin_project!
pub(super) tls_info: bool,
}
}
Conn 的 inner 是 Box<dyn AsyncConnWithInfo>:TCP、TLS over TCP、TLS over 隧道、Unix socket 都擦成同一个类型,hyper-util 的池子里只有一种连接。Read / Write 全部转发给 inner。
is_proxy 只在「明文 HTTP 经代理」时为 true(connect_via_proxy 对 HTTP 目标调 connect_with_maybe_proxy(proxy_dst, true));走 CONNECT 隧道的 HTTPS 是 false,因为隧道里就是直连。
hyper-util 拿到连接后调 Connection::connected:
impl Connection for Conn {
fn connected(&self) -> Connected {
let connected = self.inner.connected().proxy(self.is_proxy);
#[cfg(feature = "__tls")]
if self.tls_info {
if let Some(tls_info) = self.inner.tls_info() {
connected.extra(tls_info)
} else {
connected
}
} else {
connected
}
#[cfg(not(feature = "__tls"))]
connected
}
}
inner.connected() 一路转到最里层:TCP 的实现放进 HttpInfo(本地和对端地址,Response::remote_addr() 读的就是它),RustlsTlsConn 在 ALPN 协商出 h2 时再调 negotiated_h2():
impl Connection for RustlsTlsConn<TokioIo<MaybeHttpsStream<TokioIo<TcpStream>>>> {
fn connected(&self) -> Connected {
if self.inner.inner().get_ref().1.alpn_protocol() == Some(b"h2") {
self.inner
.inner()
.get_ref()
.0
.inner()
.connected()
.negotiated_h2()
} else {
self.inner.inner().get_ref().0.inner().connected()
}
}
}
hyper-util 据此决定在这条连接上做 HTTP/1 还是 HTTP/2 握手(ALPN 列表怎么来的见 TLS 配置,池子怎么处理 HTTP/2 连接见 连接池与 HTTP 版本)。Connected 的 extra 由 legacy Client 插进每个响应的 extensions,同一条连接上的所有响应都带着它。
verbose 是另一层包装:connection_verbose(true) 且 log 开了 TRACE 级别时,Wrapper::wrap 把连接包进 Verbose,每次读写打一行带随机连接 id 的 trace 日志,内容是转义后的原始字节(TLS 连接上是解密后的明文)。两个条件缺一个就不包,没有额外开销。
TlsInfo
tls_info(true) 打开上面那个 if self.tls_info。TlsInfo 两个字段:对端叶子证书的 DER、协商出的 TLS 版本。rustls 的 TlsInfoFactory 从 peer_certificates() 和 protocol_version() 取;native-tls 拿不到版本,version 恒为 None。版本字段是 0.13.5 才加的(#3067,「expose the negotiated TLS version via TlsInfo」)。
取法是 resp.extensions().get::<reqwest::tls::TlsInfo>()。同一个 rustls 服务器,分别用默认 rustls 后端和 native-tls 后端(另一个同时打开 native-tls feature 的 crate,见 TLS 配置):
client: 200 OK HTTP/2.0, tls version Some(Version(Tls1_3)), peer cert 343 bytes
client: 200 OK HTTP/2.0, tls version None, peer cert 343 bytes
经 CONNECT 隧道的 HTTPS 连接拿不到 TlsInfo。connect_via_proxy 的两个 TLS 分支在构造 Conn 时把 tls_info 写死成 false:
return Ok(Conn {
inner: self.verbose.wrap(RustlsTlsConn {
inner: TokioIo::new(io),
}),
is_proxy: false,
tls_info: false,
});
SOCKS 的 rustls 分支也一样(native-tls 的 SOCKS 分支却用了 self.tls_info)。用一个三十行的 CONNECT 代理验证(它对任何 CONNECT 都回 200,再把字节转发到本地 HTTPS 服务器):
via_proxy=false: 200 OK HTTP/2.0, TlsInfo present: true
proxy: CONNECT api.test:57561 HTTP/1.1
via_proxy=true: 200 OK HTTP/2.0, TlsInfo present: false
连接本身没问题,HTTP/2 也照常协商出来了,只是扩展里没有 TlsInfo。要靠 TlsInfo 做证书钉扎(pinning)的代码,一旦环境里配了代理(包括系统代理),就会拿到 None。
connect_timeout 管两处
connect_timeout 的值在 build 里用了两次:
http.set_connect_timeout(config.connect_timeout):hyper-util 的HttpConnector只拿它约束 TCP 建连,并且按解析出的地址数平分(见 hyper 卷 HttpConnector 的「connect_timeout:按地址数平分」,在 reqwest 里的效果见 DNS)。connector_builder.set_timeout(config.connect_timeout):ConnectorService外面的with_timeout或最外层的TimeoutLayer,约束整个连接器调用:DNS、TCP、代理握手、TLS 握手,加上用户的connector_layer。
第二处是关键。一个接受 TCP、读掉 ClientHello、永远不回的服务器,三种超时各设 300ms:
timed("connect_timeout(300ms)", Client::builder().connect_timeout(ms(300)), https_silent.clone()).await;
timed("timeout(300ms)", Client::builder().timeout(ms(300)), https_silent.clone()).await;
timed(
"connector_layer(TimeoutLayer 300ms)",
Client::builder().connector_layer(tower::timeout::TimeoutLayer::new(ms(300))),
https_silent.clone(),
)
.await;
连跑 3 次,毫秒数差 4ms 以内:
== TLS server that never answers
connect_timeout(300ms): after 306ms is_connect=true is_timeout=true is_dns=false
error sending request for url (https://127.0.0.1:64697/) <- client error (Connect) <- operation timed out
timeout(300ms): after 302ms is_connect=false is_timeout=true is_dns=false
error sending request for url (https://127.0.0.1:64697/) <- operation timed out
connector_layer(TimeoutLayer 300ms): after 302ms is_connect=true is_timeout=true is_dns=false
error sending request for url (https://127.0.0.1:64697/) <- client error (Connect) <- operation timed out
connect_timeout在 TCP 早已连上之后的 TLS 握手阶段触发,错误链里有 hyper-util 的client error (Connect),所以is_connect()为真。- 总超时
timeout也在 300ms 触发,但它在连接器外面(请求的 future 上),错误链里没有Connect,is_connect()为假。各个超时的完整分工见 超时。 - 自己套的
TimeoutLayer被cast_to_internal_error换成了同一个TimedOut,和connect_timeout的错误看不出区别。
三个都不设时,这个请求永远不会结束:同一个服务器,外面套 5 秒的 tokio::time::timeout,输出 still pending after 5s: true。Client::new() 的 connect_timeout、timeout、read_timeout 默认都是 None。
动脑筋答案
== a counting connector_layer, HTTP/1.1 server, 3 sequential requests
layer: call #1 with Unnameable(http://127.0.0.1:64705/)
connector called 1 time(s)
- 1 次。 第一个请求时池子是空的,hyper-util 调连接器;响应体读完后连接回到池里,后两个请求直接复用,连接器不再被调用。
connector_layer是「每条新连接一次」,不是「每个请求一次」,拿它做请求计数、限流会数错。 - 层收到的是
Unnameable(http://127.0.0.1:64705/):hyper-util 用 pool key 拼出的Uri,只有 scheme 和 authority,路径固定是/,请求的真实路径在这一层看不到。
小结
ClientBuilder::build建HttpConnector,按 TLS 后端选ConnectorBuilder的构造函数,build(layers)得到Connector交给 hyper-util;连接器只在池子里没有可用连接时被调用。- 没有
connector_layer时是Connector::Simple,不装箱;有层时类型擦除,请求类型是不可命名的Unnameable,后加的层在外,connect_timeout在最外。 ConnectorService::call三条路:本地套接字、代理、直连;TLS 实现由Inner在构造时定死,编进 TLS 后明文请求也走HttpsConnector透传。Conn把各种连接擦成一个Box<dyn ...>,connected()带回明文代理标记、negotiated_h2和可选的TlsInfo;经 CONNECT 隧道或 rustls 的 SOCKS 时TlsInfo恒缺。connect_timeout一份值用两处:hyper-util 里按地址平分的 TCP 超时,和包住整个连接器(DNS、TCP、代理、TLS)的外层超时;三种超时都不设,卡在握手的请求永远不结束。