hyper 解决什么问题
主线位置:两条主线的外面。本页回答 hyper 在一个 HTTP 程序里只管哪一段,两条主线的起点(接受连接、连接池)为什么在 1.0 被交给了 hyper-util 和使用者。
flowchart TB axum["axum:路由、提取器"] --> serve["axum::serve:accept 循环"] serve --> autob["hyper-util auto::Builder"] autob --> sconn["hyper server::conn"] reqwest["reqwest"] --> legacy["hyper-util legacy::Client"] legacy --> pool["连接池、HttpConnector"] pool --> cconn["hyper client::conn"] sconn --> io["字节流:rt::Read + Write"] cconn --> io io --> tio["TokioIo 包住 TcpStream"]
「低层」:管什么,不管什么
lib.rs 第 25 行的 crate 文档给自己的定位是:
hyper is a lower-level HTTP library, meant to be a building block
for libraries and applications.
If looking for just a convenient HTTP client, consider the
reqwest crate.
「building block」落到代码上,就是 server::conn 和 client::conn 两个模块:给它一条已经建立好的连接(任何实现了 rt::Read + rt::Write 的东西),它负责这条连接上的 HTTP。连接从哪里来、断了以后怎么办,它不管。两个模块的文档各有一句划界的话:
server::conn:「Accepting a connection and binding it with a service are not handled at this level.」client::conn:「Connecting to a host, pooling connections, and the like are not handled at this level.」
| 事情 | 谁来做 |
|---|---|
| 解析和编码 HTTP/1 报文,keep-alive,body 的长度和分块,协议升级 | hyper(proto/h1) |
| HTTP/2 的流和连接管理 | hyper 套在 h2 上(proto/h2) |
accept 循环,每条连接 spawn 一个任务 | 你自己,或者 axum 的 serve |
| 一个端口同时接 HTTP/1 和 HTTP/2 | hyper-util 的 server::conn::auto |
| 连接池、DNS、TCP 连接 | hyper-util 的 client::legacy |
| TLS | hyper 里没有任何 TLS 代码;用别的 crate 把 TLS 流包成 IO 交给 hyper |
| 路由、提取器、中间件 | axum、tower |
| 运行时:IO 就绪、spawn、计时 | 通过 hyper::rt 的 trait 由使用者提供,tokio 的实现在 hyper-util |
hyper 的 docs/VISION.md 写了为什么要这么低:它的主要用户本身就是库。「Low-Level Client Library (curl, reqwest, aws-sdk)」一节说这些客户端库「are expressing their own opinion on how a more-featured HTTP client should act. This includes opinions on connection establishment, management, pooling, HTTP version options, and even runtimes.」;「Web Server Frameworks (deno, axum)」一节说 hyper 要「flexible enough for them to build a server framework」。连接怎么建、池怎么管、用哪个运行时,这些都是上层库的「opinion」,hyper 不替它们决定。第 89 行把这一点概括成一句:「In all cases, a user brings their own runtime and IO to work with hyper.」
和 reqwest、axum 的关系
最常见的两个用法都不直接碰 hyper 的类型,中间隔着 hyper-util:
- reqwest 0.13.5:内部的
HyperClient就是hyper_util::client::legacy::Client<Connector, Body>,用Client::builder(hyper_util::rt::TokioExecutor::new())构造;连接器包着hyper_util::client::legacy::connect::HttpConnector,再在外面加 TLS 和代理。也就是说,用 reqwest 发请求时走的就是本卷的客户端主线。 - axum 0.8.9:
axum::serve自己写 accept 循环,每条连接用hyper_util::server::conn::auto::Builder的serve_connection_with_upgrades,把Router用TowerToHyperService包一层交进去。这一段在 tower 卷的 Steer 与 MakeService 里已经拆开讲过。也就是说,axum 的每个请求走的是本卷的服务端主线。
所以读懂 hyper 的两条主线,reqwest 和 axum 底下发生的事也就清楚了。
1.0 为什么把 Server 和 Client 拿掉
hyper 0.14 有高层的 hyper::Server(自带 Accept trait 和 make_service_fn)和带连接池的 hyper::Client。1.0 把它们都删了。理由写在 1.0 的路线图 docs/ROADMAP-1.0.md 的「Higher-level Client and Server problems」一节,开头一句是「Both the higher-level Client and Server types have stability concerns.」,然后分别列问题。
hyper::Server 的问题:
- 「The
Accepttrait is complex, and too easy to get wrong. If used with TLS, a slow TLS handshake can affect all other new connections waiting for it to finish.」 - 「The
MakeService<&IO>is confusing. The bounds are an assault on the eyes.」 - 「The
MakeServiceAPI doesn't allow to easily annotate the HTTP connection withtracing.」 - 「Graceful shutdown doesn't give enough control.」
接着一句:「It's more common for people to simply use hyper::server::conn at this point, than to bother with the hyper::Server.」
hyper::Client 的问题:「The whole Connect design isn't stable.」(ALPN 和代理会带来额外的连接配置,有的连接器想看到路径而不只是 scheme、host、port),以及连接池「could be made more general or composable」,但更多定制该怎么暴露「it's not clear」。
结论在 「Focus on the Connection level」:
As mentioned in the *Known Issues*, the higher-level `Client` and `Server` have
stability and complexity problems. Therefore, for hyper 1.0, the main API will
focus on the "lower-level" connection types. The `Client` and `Server` helpers
will be moved to `hyper-util`.
照这个结论合并的是 #2932(删掉 hyper::Server、server::Builder、server::accept、make_service_fn,「New utilities for managing servers will exist in hyper-util.」)和 #2941(删掉 Client),都最早出现在 v1.0.0-rc.1。CHANGELOG 给 #2941 写的迁移说明只有一句:「A pooling client is in the hyper-util crate.」
同一份路线图的 「Split per HTTP version」还解释了为什么 1.x 的连接类型按版本拆成 http1 和 http2 两个模块:原来的 Connection 靠「generic AsyncRead + AsyncWrite transport」抽象掉版本,「But as we figure out HTTP/3, that needs to change.」HTTP/3 的流由 QUIC 创建,不能再假设底下是一条读写字节流。把两个版本合起来的「auto」类型,路线图说「It's likely that this "auto" type will start in hyper-util.」,也就是今天的 server::conn::auto。
0.14 的东西在 1.x 里去了哪:
| hyper 0.14 | hyper 1.x |
|---|---|
hyper::Server、Accept | 自己写 accept 循环 + server::conn::http1 / http2,或 hyper-util 的 auto、graceful |
make_service_fn | 删除;每条连接给 serve_connection 传一个 service 即可 |
hyper::Client、client::connect | hyper-util 的 client::legacy::Client 和 client::legacy::connect |
hyper::Body | 收到的 body 是 hyper::body::Incoming;自己构造的用 http-body-util 的 Full、Empty 等 |
runtime、tcp feature | hyper-util 的 TokioIo、TokioExecutor、TokioTimer |
tower 的 Service | hyper 自己的 Service,见下一节 |
问: hyper-util 还是 0.1,生产代码能依赖它吗?我写的库能在公开 API 里暴露它的类型吗?
答: 能依赖,但最好别公开暴露。路线图的 FAQ「Should I publicly depend on hyper-util?」说:「As with any pre-1.0 crate, you can publicly depend on it, but it is explicitly less stable.」以及「In most cases, it's recommended to not publicly expose your dependency on hyper-util.」如果你的库用到了 hyper-util 的 trait,建议定义自己的抽象,内部再做一层适配。
Service 为什么没有 poll_ready
hyper 0.14 直接用 tower 的 Service,有 poll_ready(tower 为什么需要它,见 tower 卷的 Service trait)。1.0 的 hyper::service::Service 只剩一个方法:
pub trait Service<Request> {
/// Responses given by the service.
type Response;
/// Errors produced by the service.
// ...
type Error;
/// The future response value.
type Future: Future<Output = Result<Self::Response, Self::Error>>;
/// Process the request and return the response asynchronously.
/// `call` takes `&self` instead of `mut &self` because:
/// - It prepares the way for async fn,
/// since then the future only borrows `&self`, and thus a Service can concurrently handle
/// multiple outstanding requests at once.
/// - It's clearer that Services can likely be cloned.
/// - To share state across clones, you generally need `Arc<Mutex<_>>`
/// That means you're not really using the `&mut self` and could do with a `&self`.
/// The discussion on this is here: <https://github.com/hyperium/hyper/issues/3040>
fn call(&self, req: Request) -> Self::Future;
}
改动分两步,每一步 hyper 都留了原话:
- 去掉
poll_ready。路线图在「You call hyper, or hyper calls you?」里问:「Doespoll_readypull its complexity weight for servers?」;在 未决问题「Should there be a simplifiedhyper::Servicetrait, or should hyper depend ontower-service?」下面写:「It's not clear that the backpressure is something needed at theServerboundary, thus meaning we should removepoll_readyfrom hyper.」同一处还提到 tower 自己当时也没定下来的事:「if it should be changed toasync fn call, and ifpoll_readyis the best way to handle backpressure.」issue #2853 定下做法:「a simplifiedServicetrait that is only thecallmethod」。#2920 实现,最早出现在 v1.0.0-rc.1,CHANGELOG 的迁移说明是「Change any manualimpl tower::Serviceto implementhyper::service::Serviceinstead. Thepoll_readymethod has been removed.」 call改取&self。issue #3040 在 1.0 正式版之前把&self和&mut self的利弊摆出来讨论,#3223 选了&self,最早出现在 v1.0.0-rc.4。三条理由就是上面代码注释里的三条;反方的理由也写在 #3040 里:「If aServicehas state (usually per-connection), it's cheaper to mutate it on each connection. Not being&mutwould require internal synchronization.」
代价是 hyper 的 Service 和 tower 的 Service 不再是同一个 trait。tower 的服务要经过 hyper-util 的 TowerToHyperService 才能交给 hyper,它每个请求克隆一次服务、再走一遍 poll_ready + call。细节见 Service 与 Body。
问: 没有了 poll_ready,服务处理得慢时,hyper 会不会一直从连接上读新请求、在内存里越堆越多?
答: 同一条 HTTP/1 连接上不会。Conn 读完一个请求头之后,读状态就离开了 Init,要等这个请求的响应连同 body 全部写完、刷出去,读写状态才一起回到 Init;在那之前 Dispatcher::poll_read_head 不会去解析下一个请求头。hyper 内部的 Dispatch trait 另有一个 poll_ready,服务端实现 dispatch::Server::poll_ready 在 in_flight(正在等的响应 future)还在时返回 Pending,是同一件事的另一道保险。所以一条连接同一时刻只有一个请求在处理。跨连接的总量则由你的 accept 循环决定:hyper 不 accept,自然也不替你限制连接数。细节见 Dispatcher。
drop 一个请求 future 会怎样
hyper 返回的 future 都可以随时 drop,这是官方支持的取消方式。但取消在线路上意味着什么,取决于协议。lib.rs 第 33 行的「Cancel safety」一节:
- **HTTP/1** has no in-protocol way to abort a single request without
affecting the shared connection, so dropping an in-flight request future
closes the underlying TCP connection. Any subsequent call on the same
`SendRequest` returns a `canceled` error; the connection cannot be
reused.
- **HTTP/2** resets the single stream with `RST_STREAM` (`CANCEL` error
code) and notifies the peer immediately rather than continuing to
deliver a response body that would be discarded. The shared connection
stays usable for other in-flight and future requests.
HTTP/1 的报文没有「请求 ID」,响应只能按顺序和请求对上。请求已经写出去了,响应还没读完,这时调用方不要了:连接上剩下的字节属于一个没人要的响应,要么把它读完扔掉,要么关连接。hyper 选择关连接。下面的程序验证这一点:服务端每个请求睡 200ms,客户端只等 50ms 就放弃,然后在同一个 SendRequest 上再试一次。
hyper = { version = "=1.12.0", features = ["client", "server", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
http-body-util = "=0.1.5"
bytes = "1"
tokio = { version = "=1.54.1", features = ["macros", "rt", "net", "time"] }
use std::convert::Infallible;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::Empty;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use tokio::net::{TcpListener, TcpStream};
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
// 服务端:每个请求睡 200ms 再回
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let svc = hyper::service::service_fn(|_req| async {
tokio::time::sleep(Duration::from_millis(200)).await;
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
});
hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), svc)
.await
});
let stream = TcpStream::connect(addr).await?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream)).await?;
let client_conn = tokio::spawn(conn);
let req = || Request::get("/").header("host", "x").body(Empty::<Bytes>::new()).unwrap();
// 第一个请求只等 50ms,超时后请求 future 被 drop
let first = tokio::time::timeout(Duration::from_millis(50), sender.send_request(req())).await;
println!("first: {}", if first.is_err() { "timed out, future dropped" } else { "finished" });
// 同一个 SendRequest:先等它就绪,再发一个
match sender.ready().await {
Ok(()) => println!("ready: Ok"),
Err(e) => println!("ready: Err({e:?})"),
}
match sender.send_request(req()).await {
Ok(resp) => println!("second: {}", resp.status()),
Err(e) => println!("second: {e:?}"),
}
println!("client conn task: {:?}", client_conn.await?);
println!("server conn task: {:?}", server.await?);
Ok(())
}
ready() 返回什么?服务端的连接任务以什么结果结束?平台 M2 Max、rustc 1.99.0。第一个请求已经写到了服务端,服务端正在 sleep 里;客户端放弃后,sender.ready() 是 Ok 还是错误?服务端那边的 serve_connection 是 Ok(()) 还是错误,如果是错误,是哪一种?
HTTP/2 的情况不同:每个请求是一个独立的流,单独发 RST_STREAM 就能取消,连接继续用。这一点放在 05 HTTP/2 讲。
取消行为在 v1.10.0 之前没有写进 hyper 的文档。issue #4054(「Document cancel safety」)问:「Would you accept a documentation patch describing Hyper's cancellation behavior?」,列出的要点就是「dropping an HTTP/1 request causes the connection to be closed」「dropping an HTTP/2 request leaves the shared connection in a good state and cancels the unwanted response (using RST_STREAM)」。
#4070 先在 HTTP/1、HTTP/2 两个 send_request 的文档里各加了一节「# Cancel safety」(HTTP/1 的在第 217 行),crate 文档只加了一句指路,随 v1.10.0 发布;PR 说明里写着服务端的行为留待以后,因为 issue 作者「hadn't verified it」。#4156 再把 crate 文档的那一句扩成上面引用的两段,理由是读者落到 crate 首页时真正想问的是「what happens on the wire when I drop this?」,随 v1.11.1 发布。本站锁的 1.10.1 只有前一半。
动脑筋答案
first: timed out, future dropped
ready: Err(hyper::Error(ChannelClosed))
second: hyper::Error(Canceled, "connection was not ready")
client conn task: Ok(())
server conn task: Err(hyper::Error(IncompleteMessage))
跑了 3 次,输出相同。逐行看:
ready: Err(ChannelClosed):请求 future 被 drop,它手里那个等响应的oneshot接收端也跟着 drop。连接任务里的dispatch::Client::poll_ready发现回调已被取消,返回Err,Dispatcher随即关闭连接,连接任务结束(第四行的Ok(()))。SendRequest再问「能发吗」,通道的另一头已经没了,得到ChannelClosed。second: Canceled, "connection was not ready":就是文档说的「returns acancelederror」。错误在send_request里当场产生:请求根本没放进通道。server conn task: Err(IncompleteMessage):服务端读完了请求,正在等 service 的响应,这时读到 EOF。连接处在一个消息的中间,hyper 把它当作错误,Display是「connection closed before message completed」。这条报文在常见坑里还会出现。
客户端这边一次 drop,换来的是整条连接作废:如果这是连接池里的连接,下一个请求要重新建连。
小结
- hyper 是「building block」:只管已经建立好的一条连接上的 HTTP;accept、连接池、TLS、路由、运行时都在它外面。
- reqwest 走 hyper-util 的
legacy::Client,axum 走 hyper-util 的auto::Builder;两条主线就是它们底下的路径。 - 1.0 删掉
Server和Client,路线图的原话是二者「have stability concerns」,主 API 改为聚焦连接级别,helpers 移到 hyper-util(#2932、#2941)。 Service去掉poll_ready(#2920,理由:服务端边界上是否需要背压「not clear」),call改取&self(#3223);代价是与 tower 的Service不兼容。- 同一条 HTTP/1 连接上,hyper 用内部的
Dispatch::poll_ready保证只有一个请求在 service 里。 - drop 一个 HTTP/1 请求 future 会关掉整条连接,之后的
send_request返回canceled;HTTP/2 只重置那一个流。