Rust in Everything
基于 axum 0.8.9

MethodRouter

主线位置:两条主线都有。装配主线上,get(handler)、.post(...)、on(filter, ...) 把端点放进对应方法的槽,同时把方法名追加进一个预先拼好的 Allow 头;同一方法重复注册立即 panic。请求主线上,路径匹配之后,MethodRouter::call_with_state 按请求方法挑槽,挑不到就交给 fallback,默认是 405。

flowchart TB
  req["请求:路径已匹配"] --> hd{"方法是 HEAD"}
  hd -->|是| hs{"head 槽有端点"}
  hs -->|有| callh["调 head 端点"]
  hs -->|没有| gs{"get 槽有端点"}
  gs -->|有| callg["调 get 端点"]
  hd -->|否| other{"对应方法的槽"}
  other -->|有| callm["调该端点"]
  gs -->|没有| fbk["fallback"]
  other -->|"没有或未知方法"| fbk
  fbk --> r405["默认:405"]
  fbk --> allow["补 Allow 头"]

Router::route(path, method_router) 的第二个参数就是它。路径怎么匹配见 matchit 与路径语法;匹配到的端点怎么被调用、HEAD 的响应体怎么被去掉,见 Route 与类型擦除。

九个槽、一个 fallback、一个 Allow

MethodRouter 没有用表或 HashMap,每个方法一个字段:

pub struct MethodRouter<S = (), E = Infallible> {
    get: MethodEndpoint<S, E>,
    head: MethodEndpoint<S, E>,
    delete: MethodEndpoint<S, E>,
    options: MethodEndpoint<S, E>,
    patch: MethodEndpoint<S, E>,
    post: MethodEndpoint<S, E>,
    put: MethodEndpoint<S, E>,
    trace: MethodEndpoint<S, E>,
    connect: MethodEndpoint<S, E>,
    fallback: Fallback<S, E>,
    allow_header: AllowHeader,
}
  • 每个槽是 MethodEndpoint:None(没注册)、Route(已经是装箱的服务)、BoxedHandler(还欠着状态的 handler,with_state 时才转成 Route,见 状态:State 与 FromRef)。
  • fallback 是 Fallback 枚举,MethodRouter::new 放进去的默认值是一个只回 StatusCode::METHOD_NOT_ALLOWED 的 service_fn。
  • AllowHeader 有三态:None(还没拼任何方法)、Skip(不要 Allow 头,any 用)、Bytes(BytesMut)(拼好的值)。

九个固定字段意味着只认这九个标准方法。PROPFIND 之类的扩展方法没有槽,永远落到 fallback。MethodFilter 也只是九个位的 u16,MethodFilter::try_from(Method) 遇到其他方法返回 NoMatchingMethodFilter。

注册:填槽、拼 Allow

顶层的 get、post 等由 top_level_handler_fn! 生成,都是 on(MethodFilter::$method, handler);链式的 .get(...) 同理。最后都进 on_endpoint,它对九个槽各调一次内部函数 set_endpoint:

        fn set_endpoint<S, E>(
            method_name: &str,
            out: &mut MethodEndpoint<S, E>,
            endpoint: &MethodEndpoint<S, E>,
            endpoint_filter: MethodFilter,
            filter: MethodFilter,
            allow_header: &mut AllowHeader,
            methods: &[&'static str],
        ) where
            MethodEndpoint<S, E>: Clone,
            S: Clone,
        {
            if endpoint_filter.contains(filter) {
                if out.is_some() {
                    panic!(
                        "Overlapping method route. Cannot add two method routes that both handle \
                         `{method_name}`",
                    )
                }
                *out = endpoint.clone();
                for method in methods {
                    append_allow_header(allow_header, method);
                }
            }
        }

        set_endpoint(
            "GET",
            &mut self.get,
            &endpoint,
            filter,
            MethodFilter::GET,
            &mut self.allow_header,
            &["GET", "HEAD"],
        );
        // ... HEAD、TRACE、PUT、POST、PATCH、OPTIONS、DELETE、CONNECT 同样各一次

三件事发生在这里:

  1. 一个过滤器可以填多个槽。 on(MethodFilter::PUT.or(MethodFilter::PATCH), h) 让 put 和 patch 两个槽都指向同一个端点(各 clone 一份)。
  2. GET 槽带上 HEAD。 填 get 槽时追加的是 &["GET", "HEAD"],Allow 里自动有 HEAD;MethodFilter::GET 的位并不包含 HEAD,所以 head 槽仍然空着,可以另外注册。
  3. Allow 在启动时拼好。 append_allow_header 往 BytesMut 里追加 ,方法名,已经包含就跳过;Skip 状态什么都不做。顺序就是 set_endpoint 的调用顺序加上注册的先后,不排序。

any(handler) 不填任何槽:它是 MethodRouter::new().fallback(handler).skip_allow_header(),把 handler 放在 fallback 位置,并把 Allow 设成 Skip。

分发:HEAD 先找 head,再找 get

call_with_state 用一个局部宏 call! 逐个比较方法:

        call!(req, HEAD, head);
        call!(req, HEAD, get);
        call!(req, GET, get);
        call!(req, POST, post);
        call!(req, OPTIONS, options);
        call!(req, PATCH, patch);
        call!(req, PUT, put);
        call!(req, DELETE, delete);
        call!(req, TRACE, trace);
        call!(req, CONNECT, connect);

        let future = fallback.clone().call_with_state(req, state);

        match allow_header {
            AllowHeader::None => future.allow_header(Bytes::new()),
            AllowHeader::Skip => future,
            AllowHeader::Bytes(allow_header) => future.allow_header(allow_header.clone().freeze()),
        }

call! 在方法相等且槽不为 None 时直接 return 端点的 future。HEAD 请求先看 head 槽,空的话再看 get 槽:这就是「get 自动处理 HEAD」的全部实现,与注册先后无关。显式注册的 head 端点总是优先。

所有槽都没接住,才走 fallback,并把启动时拼好的 Allow 交给返回的 RouteFuture。RouteFuture::poll 在响应出来后调用 set_allow_header:响应里还没有 Allow 时才插入。它不看状态码,只要走了 fallback 就补;AllowHeader::None(一个方法都没注册的 MethodRouter)补的是空值。

用一组路由把这几条都跑一遍:

axum = "=0.8.9"
tokio = { version = "=1.54.1", features = ["full"] }
tower = { version = "=0.5.3", features = ["util"] }
http-body-util = "0.1"
use axum::{
  Router,
  body::Body,
  http::{Method, Request, StatusCode},
  routing::{MethodFilter, any, get, on},
};
use http_body_util::BodyExt;
use tower::ServiceExt;

async fn send(app: &Router, method: &str, uri: &str) {
  let req = Request::builder()
    .method(Method::from_bytes(method.as_bytes()).unwrap())
    .uri(uri)
    .body(Body::empty())
    .unwrap();
  let res = app.clone().oneshot(req).await.unwrap();
  let status = res.status().as_u16();
  let allow = res.headers().get("allow").map(|v| v.to_str().unwrap().to_owned());
  let len = res.headers().get("content-length").map(|v| v.to_str().unwrap().to_owned());
  let body = res.into_body().collect().await.unwrap().to_bytes();
  println!(
    "{method:<8} {uri:<6} {status} allow={allow:?} content-length={len:?} body={:?}",
    String::from_utf8_lossy(&body)
  );
}

#[tokio::main]
async fn main() {
  let app = Router::new()
    .route(
      "/a",
      get(|| async {
        println!("  (get handler runs)");
        "hello from GET"
      })
      .post(|| async { "post" })
      .delete(|| async { "delete" }),
    )
    .route(
      "/b",
      get(|| async { "hello from GET" }).head(|| async {
        println!("  (head handler runs)");
        (StatusCode::OK, [("x-head", "1")], "head body")
      }),
    )
    .route("/c", on(MethodFilter::PUT.or(MethodFilter::PATCH), || async { "put or patch" }))
    .route("/c", get(|| async { "get" }))
    .route("/d", any(|| async { "any" }))
    .route(
      "/e",
      get(|| async { "get" }).fallback(|m: Method| async move { format!("custom fallback for {m}") }),
    );

  send(&app, "GET", "/a").await;
  send(&app, "HEAD", "/a").await;
  send(&app, "PUT", "/a").await;
  send(&app, "PROPFIND", "/a").await;
  send(&app, "HEAD", "/b").await;
  send(&app, "GET", "/b").await;
  send(&app, "DELETE", "/c").await;
  send(&app, "DELETE", "/d").await;
  send(&app, "POST", "/e").await;
}

M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同。这里先看前 8 行,/c 那一行留给本页的动脑筋,完整输出在最后:

  (get handler runs)
GET      /a     200 allow=None content-length=Some("14") body="hello from GET"
  (get handler runs)
HEAD     /a     200 allow=None content-length=Some("14") body=""
PUT      /a     405 allow=Some("GET,HEAD,POST,DELETE") content-length=Some("0") body=""
PROPFIND /a     405 allow=Some("GET,HEAD,POST,DELETE") content-length=Some("0") body=""
  (head handler runs)
HEAD     /b     200 allow=None content-length=Some("9") body=""
GET      /b     200 allow=None content-length=Some("14") body="hello from GET"
  • HEAD /a:没有 head 槽,get 的 handler 真的跑了一遍,响应带着 GET 那份 body 的长度 14,body 被丢掉。去 body、补 Content-Length 发生在 RouteFuture 里,见 Route 与类型擦除。
  • PUT /a 和扩展方法 PROPFIND /a 一样,都是 405,Allow 是 GET,HEAD,POST,DELETE:get 带来 GET,HEAD,之后按链式调用的顺序追加。
  • HEAD /b:显式注册的 head handler 接住了,Content-Length 是它自己那份 body 的长度 9;GET 的 handler 没有运行。
📝NOTE
没有蠢问题

问: HEAD 请求不要 body,get 的 handler 却从头跑到尾,查库、渲染模板都做了一遍,这不是白干吗?

答: 是白干,axum 选择了正确性而不是省事。HEAD 的响应头应该和 GET 一致,最可靠的办法就是真的执行 GET,再把 body 换成空的;Content-Length 在丢 body 之前按 size_hint 算好,所以客户端看到的长度和 GET 一样。如果某个 GET 很贵、而 HEAD 请求又多(比如被监控系统频繁探测),就单独注册一个 head handler:分发时 head 槽先于 get 槽被检查,上面 /b 的输出就是这样。

重复注册:启动时 panic

同一方法有两个端点,axum 不猜该用哪个,直接 panic。有三个入口,报错措辞不同:

axum = "=0.8.9"
use std::panic::{self, AssertUnwindSafe};

use axum::{
  Router,
  routing::{MethodFilter, MethodRouter, get, head, post},
};

fn try_build<T>(label: &str, f: impl FnOnce() -> T) {
  match panic::catch_unwind(AssertUnwindSafe(f)) {
    Ok(_) => println!("{label}\n  ok"),
    Err(e) => {
      let msg = e
        .downcast_ref::<String>()
        .cloned()
        .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string()))
        .unwrap_or_default();
      println!("{label}\n  panic: {msg}");
    }
  }
}

fn main() {
  panic::set_hook(Box::new(|_| {}));
  let h = || async {};

  try_build("get(h).get(h)", || -> MethodRouter { get(h).get(h) });
  try_build("get(h).on(GET.or(POST), h)", || -> MethodRouter {
    get(h).on(MethodFilter::GET.or(MethodFilter::POST), h)
  });
  try_build("get(h).merge(get(h))", || -> MethodRouter { get(h).merge(get(h)) });
  try_build("route(\"/x\", get(h)).route(\"/x\", get(h))", || -> Router {
    Router::new().route("/x", get(h)).route("/x", get(h))
  });
  try_build("route(\"/x\", get(h)).route(\"/x\", post(h).get(h))", || -> Router {
    Router::new().route("/x", get(h)).route("/x", post(h).get(h))
  });
  try_build("get(h).fallback(h).merge(post(h).fallback(h))", || -> MethodRouter {
    get(h).fallback(h).merge(post(h).fallback(h))
  });
  try_build("get(h).head(h)", || -> MethodRouter { get(h).head(h) });
  try_build("head(h).get(h)", || -> MethodRouter { head(h).get(h) });
}

连跑 3 次,输出相同:

get(h).get(h)
  panic: Overlapping method route. Cannot add two method routes that both handle `GET`
get(h).on(GET.or(POST), h)
  panic: Overlapping method route. Cannot add two method routes that both handle `GET`
get(h).merge(get(h))
  panic: Overlapping method route. Cannot merge two method routes that both define `GET`
route("/x", get(h)).route("/x", get(h))
  panic: Overlapping method route. Handler for `GET /x` already exists
route("/x", get(h)).route("/x", post(h).get(h))
  panic: Overlapping method route. Handler for `GET /x` already exists
get(h).fallback(h).merge(post(h).fallback(h))
  panic: Cannot merge two `MethodRouter`s that both have a fallback
get(h).head(h)
  ok
head(h).get(h)
  ok
  • 链式追加走 set_endpoint,报「Cannot add two method routes that both handle」。
  • MethodRouter::merge 和 Router::route 走同一个 merge_for_path,区别在 path 参数:merge 传 None,报「Cannot merge two method routes」;Router::route 传路径,报「Handler for GET /x already exists」。
  • Router::route 对已注册的路径不会再插一次 matchit:PathRouter::route 先用 path_to_route_id 查到旧的 MethodRouter,和新的合并(源码注释:「This makes .route("/", get(_)).route("/", post(_)) work」)。所以 .route("/x", get(a)).route("/x", post(b)) 是合法的,Allow 也会合并(AllowHeader::merge 用逗号拼接两边)。
  • 两边都有自定义 fallback 也不能合并。
  • get 和 head 不冲突,顺序也无所谓:它们是两个槽。
📝NOTE
冷知识:405 带 Allow 是 0.4.6 才补上的

RFC 9110 要求 405 响应带 Allow 头,列出这个资源支持的方法。axum 早期的 MethodRouter 只回 405。#733(2022-01-30 合并,CHANGELOG 记在 0.4.6 下:「Set Allow header when responding with 405 Method Not Allowed」;合并提交首次进入 axum-v0.5.0,0.4 分支上是反向移植的同名提交,首次发布于 axum-v0.4.6)补上了它。PR 描述交代了今天代码里的两个细节:「The header value is built up as a BytesMut to avoid allocations when creating the response」,即注册时就拼好、每次 405 只 clone().freeze();「The header is also set when the fallback is hit, unless the response generated by the fallback already contains the header」,即 set_allow_header 里那句 !headers.contains_key(header::ALLOW),让自定义 fallback 可以自己决定 Allow。

MethodRouter::fallback 与 any

405 只是默认的 fallback。MethodRouter::fallback 把它换成任意 handler,上面程序里的 /e 就是这样。输出的最后一行(见本页最后)是:

POST     /e     200 allow=Some("GET,HEAD") content-length=Some("24") body="custom fallback for POST"

自定义 fallback 回了 200,Allow 照样被补上,因为 set_allow_header 不看状态码。不想要这个头,就在 fallback 的响应里自己设 Allow(哪怕是你认为正确的值),axum 不会覆盖。

any(handler) 也是用 fallback 实现的:DELETE /d 回 200 且没有 Allow,因为 AllowHeader::Skip。合并时只要有一边是 Skip,结果就是 Skip。

Router 上还有一个 method_not_allowed_fallback,给已经注册的每个 MethodRouter 调一次内部的 default_fallback:只有 fallback 还是默认值时才替换,自定义过的不动。它只作用于调用那一刻已经存在的路由,和 nest 一起用时顺序有讲究,见 nest、merge 与 fallback。

❗IMPORTANT
动脑筋:/c 先注册了 on(MethodFilter::PUT.or(MethodFilter::PATCH), ...),又用第二个 .route("/c", get(...)) 加了 GET。对 /c 发 DELETE,Allow 头的值是什么,方法按什么顺序排列?代码就是上面那个程序,M2 Max、rustc 1.99.0。答案在本页最后。

动脑筋答案

分发程序的完整输出,连跑 3 次相同:

  (get handler runs)
GET      /a     200 allow=None content-length=Some("14") body="hello from GET"
  (get handler runs)
HEAD     /a     200 allow=None content-length=Some("14") body=""
PUT      /a     405 allow=Some("GET,HEAD,POST,DELETE") content-length=Some("0") body=""
PROPFIND /a     405 allow=Some("GET,HEAD,POST,DELETE") content-length=Some("0") body=""
  (head handler runs)
HEAD     /b     200 allow=None content-length=Some("9") body=""
GET      /b     200 allow=None content-length=Some("14") body="hello from GET"
DELETE   /c     405 allow=Some("PUT,PATCH,GET,HEAD") content-length=Some("0") body=""
DELETE   /d     200 allow=None content-length=Some("3") body="any"
POST     /e     200 allow=Some("GET,HEAD") content-length=Some("24") body="custom fallback for POST"

答案是 PUT,PATCH,GET,HEAD:

  • on(PUT | PATCH, ...) 进 on_endpoint,九次 set_endpoint 的固定顺序是 GET、HEAD、TRACE、PUT、POST、PATCH、OPTIONS、DELETE、CONNECT,其中 PUT 和 PATCH 命中,所以先是 PUT,PATCH,不是按 or 的书写顺序,也不是按字母。
  • 第二次 .route("/c", get(...)) 不插 matchit,而是 merge_for_path,AllowHeader::merge 把新的 GET,HEAD 用逗号接在后面。
  • 整个过程都在启动时完成,405 时只是把这串字节原样放进响应。

小结

  • MethodRouter 是九个固定的方法槽加一个 fallback 和一个预先拼好的 Allow;扩展方法没有槽,总是落到 fallback。
  • get 填 GET 槽并往 Allow 里加 GET,HEAD;HEAD 请求先找 head 槽,再找 get 槽,GET 的 handler 会完整执行,body 随后被丢弃。
  • 没有对应方法时调用 fallback,默认 405;只要走了 fallback,响应里没有 Allow 就补上,不看状态码。
  • 同一方法注册两次在启动时 panic,链式、merge、Router::route 三种入口措辞不同;同一路径多次 route 不同方法是合法的,会合并。
  • any 是「fallback + 不要 Allow」;MethodRouter::fallback 自定义 405;Router::method_not_allowed_fallback 只替换还是默认值的 fallback。

💬 关联讨论

发起讨论
加载中...