Rust in Everything
基于 axum 0.8.9

不起服务器测路由

主线位置:请求主线的使用端。测试代码扮演 axum::serve 的角色,直接调用 Router 的 Service::call,请求从 call_with_state 开始走完路由、中间件、提取器、handler,只是前面没有 TCP 和 hyper。

flowchart TB
  t["测试函数"] --> q{"怎么调用"}
  q -->|"oneshot(req)"| o["拿走一个 Router 克隆"]
  q -->|"as_service"| a["借 &mut,ready 后 call"]
  q -->|"TestClient"| srv["127.0.0.1:0 上的真实服务"]
  o --> callws["Router::call_with_state"]
  a --> callws
  srv --> hy["TCP、hyper、axum::serve"]
  hy --> callws
  callws --> resp["Response,读 body"]

tower 卷的 不起服务器的测试 已经讲过本站怎样用 oneshot 测路由、router.ready() 为什么推断不出类型、没调用 with_state 的 Router 每个请求重套 layer。这一页补上 axum 这一侧:Router 的 Service 实现、as_service 与 into_service、读 body、ConnectInfo 的替身,以及 axum 自己是怎么测的。

Router 作为 Service

impl Service<Request<B>> for Router<()>:

impl<B> Service<Request<B>> for Router<()>
where
    B: HttpBody<Data = bytes::Bytes> + Send + 'static,
    B::Error: Into<axum_core::BoxError>,
{
    type Response = Response;
    type Error = Infallible;
    type Future = RouteFuture<Infallible>;

    #[inline]
    fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))
    }

    #[inline]
    fn call(&mut self, req: Request<B>) -> Self::Future {
        let req = req.map(Body::new);
        self.call_with_state(req, ())
    }
}

三点和测试有关:

  • 只有 Router<()> 实现了它。还欠着状态的 Router<AppState> 要先 with_state,测试里通常写一个返回 Router 的 app() 函数,线上和测试共用(状态:State 与 FromRef)。
  • 请求体类型 B 是泛型,call 里 Body::new 统一转成 axum 的 Body。所以测试可以直接用 Request<String>、Request<Full<Bytes>>,不必先转成 Body。
  • poll_ready 永远就绪,Error 是 Infallible:oneshot(...).await.unwrap() 的 unwrap 不会失败,状态码要自己检查。

三种调用方式

oneshot:tower::ServiceExt::oneshot(self, req) 拿走服务,先等 poll_ready 再 call(ServiceExt 与 Oneshot)。拿走 Router 不心疼:它里面是一个 Arc<RouterInner>,app.clone() 只加引用计数。

as_service / into_service:想对同一个值发多个请求、用 ready() + call(),直接写 router.ready() 会因为 Router 有两个 Service 实现(另一个收 IncomingStream,给 axum::serve 用)而推断不出类型,报错见 tower 卷那一页。Router::as_service 返回一个借着 &mut Router、请求体类型固定为 B 的包装,它的 Service 实现 只是转发:

impl<B> Service<Request<B>> for RouterAsService<'_, B, ()>
where
    B: HttpBody<Data = bytes::Bytes> + Send + 'static,
    B::Error: Into<axum_core::BoxError>,
{
    type Response = Response;
    type Error = Infallible;
    type Future = RouteFuture<Infallible>;

    #[inline]
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        <Router as Service<Request<B>>>::poll_ready(self.router, cx)
    }

    #[inline]
    fn call(&mut self, req: Request<B>) -> Self::Future {
        self.router.call(req)
    }
}

into_service 是拿走所有权的版本(RouterIntoService,可以 Clone)。两者都是 0.7.0 加的,CHANGELOG 的说法是「to workaround type inference issues when calling ServiceExt methods on a Router」,起因是同一版本去掉了 Router 的请求体类型参数 B(#1751)。axum 仓库的 examples/testing 里,multiple_request 测试就是 app().into_service() 加两次 ServiceExt::<Request<Body>>::ready(&mut app)。

下面的程序把这几种方式和读 body、ConnectInfo 放在一起:

axum = "=0.8.9"
tower = { version = "=0.5.3", features = ["util"] }
http-body-util = "=0.1.5"
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
use std::net::SocketAddr;

use axum::body::{to_bytes, Body};
use axum::extract::connect_info::{ConnectInfo, MockConnectInfo};
use axum::extract::Request;
use axum::routing::{get, post};
use axum::Router;
use http_body_util::BodyExt;
use tower::{Service, ServiceExt};

async fn echo(body: String) -> String {
  format!("echo: {body}")
}

async fn peer(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> String {
  format!("peer = {addr}")
}

fn app() -> Router {
  Router::new()
    .route("/echo", post(echo))
    .route("/peer", get(peer))
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  // 1. oneshot: consumes a clone of the Router
  let res = app()
    .oneshot(Request::post("/echo").body(Body::from("hi")).unwrap())
    .await
    .unwrap();
  let status = res.status();
  let bytes = res.into_body().collect().await.unwrap().to_bytes();
  println!("oneshot       {status} {bytes:?}");

  // 2. body type is generic: a Request<String> works too
  let res = app().oneshot(Request::post("/echo").body(String::from("from String")).unwrap()).await.unwrap();
  println!("Request<String> {}", String::from_utf8_lossy(&to_bytes(res.into_body(), 1024).await.unwrap()));

  // 3. to_bytes with a limit
  let res = app().oneshot(Request::post("/echo").body(Body::from("0123456789")).unwrap()).await.unwrap();
  match to_bytes(res.into_body(), 8).await {
    Ok(b) => println!("to_bytes(8)   {b:?}"),
    Err(e) => println!("to_bytes(8)   error: {e}"),
  }

  // 4. ready() + call() on &mut, several requests on one value
  let mut router = app();
  let mut svc = router.as_service::<Body>();
  for n in 1..=2 {
    let req = Request::post("/echo").body(Body::from(format!("#{n}"))).unwrap();
    let res = svc.ready().await.unwrap().call(req).await.unwrap();
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
    println!("as_service    {bytes:?}");
  }

  // 5. ConnectInfo without and with MockConnectInfo
  let res = app().oneshot(Request::get("/peer").body(Body::empty()).unwrap()).await.unwrap();
  println!("no mock       {} {}", res.status().as_u16(), String::from_utf8_lossy(&to_bytes(res.into_body(), 1024).await.unwrap()));
  let mocked = app().layer(MockConnectInfo(SocketAddr::from(([10, 0, 0, 1], 4242))));
  let res = mocked.oneshot(Request::get("/peer").body(Body::empty()).unwrap()).await.unwrap();
  println!("mock          {} {}", res.status().as_u16(), String::from_utf8_lossy(&to_bytes(res.into_body(), 1024).await.unwrap()));
}

M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:

oneshot       200 OK b"echo: hi"
Request<String> echo: from String
to_bytes(8)   error: length limit exceeded
as_service    b"echo: #1"
as_service    b"echo: #2"
no mock       500 Missing request extension: Extension of type `axum::extract::connect_info::ConnectInfo<core::net::socket_addr::SocketAddr>` was not found. Perhaps you forgot to add it? See `axum::Extension`.
mock          200 peer = 10.0.0.1:4242

as_service 和 oneshot 一样不会替你调用 with_state。with_state 之前的 handler 每个请求都要现做 into_route、重套 layer;测试里有带状态的 layer(限流、并发上限)时,先 .with_state(()),和 axum::serve 的行为保持一致,原因和实验在 tower 卷那一页。

读响应 body

响应的 body 是 axum 的 Body,它是一个 http_body::Body,有两种常用读法:

  • http_body_util::BodyExt::collect():读到结束,得到 Collected,再 .to_bytes()。没有长度上限。
  • axum::body::to_bytes(body, limit):不需要额外依赖 http-body-util,多了一个上限。实现就是在外面套一层 Limited:
pub async fn to_bytes(body: Body, limit: usize) -> Result<Bytes, axum_core::Error> {
    Limited::new(body, limit)
        .collect()
        .await
        .map(|col| col.to_bytes())
        .map_err(axum_core::Error::new)
}

上限按字节算,body 超过就返回错误(输出里 "echo: 0123456789" 有 16 字节,上限 8,得到 length limit exceeded),不会截断返回前 8 个字节。Limited 和请求侧 DefaultBodyLimit 用的是同一个东西,见 body 提取器。测试里只想要全部内容时写 usize::MAX。

SSE 这类不会自己结束的响应,collect 会一直等下去;要一块一块地读,用 Body::into_data_stream 转成 Stream,取够了就停。

ConnectInfo:MockConnectInfo

对端地址来自 into_make_service_with_connect_info 的每条连接的 make service(Path、Query 与请求元数据)。oneshot 没有连接,所以上面 no mock 那一行是 500。ConnectInfo 的提取 留了一个后门:

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        match Extension::<Self>::from_request_parts(parts, state).await {
            Ok(Extension(connect_info)) => Ok(connect_info),
            Err(err) => match parts.extensions.get::<MockConnectInfo<T>>() {
                Some(MockConnectInfo(connect_info)) => Ok(Self(connect_info.clone())),
                None => Err(err),
            },
        }
    }

先找真实的 ConnectInfo<T>,找不到再找 MockConnectInfo<T>。MockConnectInfo 自己是一个 Layer,实现 只有一行 Extension(self.clone()).layer(inner):每个请求往 extensions 里插一个 MockConnectInfo<T>。它是普通的 Router::layer,要在路由都加好之后再挂。

📝NOTE
冷知识:MockConnectInfo 之前,测试要自己伪造一个 extension

MockConnectInfo 是 #1767 加的,首次发布于 axum 0.6.7。PR 描述:「Using the ConnectInfo extractor during tests was a little tricky. You can't just call Router::into_make_service_with_connect_info and then send it requests, because that produces a MakeService and not a Service<Request>. The solution was to set ConnectInfo in an extension yourself however that relies on implementation details which feels bad.」所以它没有改变 ConnectInfo 存放的位置,只是在提取的 Err 分支里多查一种类型,把「往 extensions 里插什么」藏进了 axum 自己。

上面程序里 mock 那一行就是从这个 Err 分支拿到的 10.0.0.1:4242。提取的顺序决定了两者同时存在时谁赢。

❗IMPORTANT
动脑筋:测试用的 MockConnectInfo 层忘了删,应用又用 into_make_service_with_connect_info 跑在真实端口上,handler 看到的是哪个地址?
axum = "=0.8.9"
http-body-util = "=0.1.5"
hyper = { version = "=1.12.0", features = ["client", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
tokio = { version = "=1.54.1", features = ["macros", "rt", "net"] }
use std::net::SocketAddr;

use axum::body::Body;
use axum::extract::connect_info::{ConnectInfo, MockConnectInfo};
use axum::extract::Request;
use axum::routing::get;
use axum::Router;
use http_body_util::BodyExt;
use hyper_util::rt::TokioIo;

async fn peer(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> String {
  format!("peer = {}", addr.ip())
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  // test layer left in by mistake, and the real connect-info make service
  let app = Router::new()
    .route("/peer", get(peer))
    .layer(MockConnectInfo(SocketAddr::from(([10, 0, 0, 1], 4242))));

  let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
  let addr = listener.local_addr().unwrap();
  tokio::spawn(async move {
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap()
  });

  let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
  let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream)).await.unwrap();
  tokio::spawn(conn);
  let req = Request::get("/peer").header("host", "x").body(Body::empty()).unwrap();
  let res = sender.send_request(req).await.unwrap();
  let body = res.into_body().collect().await.unwrap().to_bytes();
  println!("{}", String::from_utf8_lossy(&body));
}

客户端是 hyper 自己的 client::conn,不读系统代理。M2 Max、rustc 1.99.0,答案在本页最后。

axum 自己怎么测:TestClient

axum 仓库里的测试大多不用 oneshot。axum/src 的 .rs 文件里,TestClient::new 出现了 162 次。它在 test_helpers/test_client.rs,核心是 spawn_service:

pub(crate) fn spawn_service<S>(svc: S) -> SocketAddr
where
    S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + 'static,
    S::Future: Send,
{
    let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
    std_listener.set_nonblocking(true).unwrap();
    let listener = TcpListener::from_std(std_listener).unwrap();

    let addr = listener.local_addr().unwrap();
    println!("Listening on {addr}");

    tokio::spawn(async move {
        serve(listener, Shared::new(svc))
            .await
            .expect("server error")
    });

    addr
}
  • 绑 127.0.0.1:0,系统分配端口,测试可以并行跑。先用 std 的 TcpListener 同步绑定再转成 tokio 的,所以 TestClient::new 本身不是 async fn。
  • 服务用 tower 的 Shared 包成 make service 交给 axum::serve:每条连接克隆一份。所以它不只收 Router,任何 Service<Request, Response = Response, Error = Infallible> + Clone 都行,handler 的测试直接传 handler.into_service() 之类的服务。
  • TestClient::new 建一个关掉重定向跟随的 reqwest 客户端(redirect::Policy::none()),测 Redirect 时才能看到 3xx。reqwest 是 axum 的 dev-dependency,default-features = false。
  • get、post 等返回自己的 RequestBuilder,它实现了 IntoFuture,所以测试写 client.get("/").await,不用 .send();返回的 TestResponse 通过 Deref 暴露 reqwest::Response,text()、json() 这些方法内部直接 unwrap。

这样测,请求真的经过 TCP、hyper 和 axum::serve,和生产路径一致。有些行为只有这样才测得到:比如 WebSocketUpgrade 要从请求的 extensions 里取出 hyper 放进去的 hyper::upgrade::OnUpgrade,oneshot 发的请求里没有它(WebSocketUpgrade)。代价是每个测试一个端口、一个后台任务。

两个配套的细节:

  • test_helpers 模块是 cfg(any(test, feature = "__private"))。__private feature 打开 tokio、http1 和 reqwest,axum-extra 的 dev-dependencies 开了它,所以 axum-extra 的测试也用同一个 TestClient。名字带 __private 就是说别在自己的项目里依赖它。
  • axum 的测试函数上写的不是 #[tokio::test] 而是 #[crate::test],即 axum-macros 的 __private_axum_test。它把函数原样保留并加上 #[tokio::test],再生成一个副本,把里面两个参数的 .nest(...) 调用全换成 .nest_service(...),函数名加 _with_nest_service 后缀。文档注释的说法是「This makes it easy to that nest and nest_service behaves in the same way, without having to manually write identical tests for both methods.」

自己的项目里两种都可以用:大部分路由逻辑用 oneshot 测,快、不占端口;涉及连接层的行为(ConnectInfo 的真实值、升级、超时、优雅停机)起一个 127.0.0.1:0 的真实服务器,客户端用 hyper 的 client::conn 或者关掉代理的 reqwest。

动脑筋答案

连跑 3 次输出相同:

peer = 127.0.0.1

是真实地址,不是 10.0.0.1。into_make_service_with_connect_info 给这条连接上的每个请求插入真正的 ConnectInfo<SocketAddr>,ConnectInfo 的提取先查它,查到就返回,根本走不到 MockConnectInfo 那个分支。MockConnectInfo 的文档也这么写:「If you're accidentally using MockConnectInfo and Router::into_make_service_with_connect_info at the same time then Router::into_make_service_with_connect_info takes precedence.」所以把它留在线上代码里不会伪造地址,只是每个请求白插一次 extension。

小结

  • Router<()> 的 Service 实现对请求体类型泛型、永远就绪、错误 Infallible;测试里 oneshot(req).await.unwrap() 只会拿到响应,状态码自己断言。
  • 想在同一个值上发多个请求,用 as_service::<Body>()(借用)或 into_service()(拥有),它们把请求体类型钉住,ready().call() 才推断得出来。它们和 oneshot 一样不调用 with_state。
  • 读 body:BodyExt::collect().to_bytes() 不限长度;axum::body::to_bytes(body, limit) 超限返回 length limit exceeded;不会结束的流用 into_data_stream。
  • 测试里的 ConnectInfo 用 .layer(MockConnectInfo(addr));真实的 ConnectInfo 优先。
  • axum 自己用 TestClient:127.0.0.1:0 上起 axum::serve(listener, Shared::new(svc)),用不跟随重定向的 reqwest 访问;#[crate::test] 让每个用到 nest 的测试再跑一遍 nest_service 版本。

💬 关联讨论

发起讨论
加载中...