Rust in Everything
基于 axum 0.8.9

dioxus 生成的 Router

主线位置:装配主线。本站自己几乎不写 Router::new():dioxus-server 先把 server 函数、静态文件和 SSR 装进一个 Router,main.rs 拿到时它已经是 Router<()>;本页逐步看它是怎么装出来的,再看 main.rs 往上加了什么、加的顺序对请求主线意味着什么。

flowchart TB
  app["router(App)"] --> rsf["注册 server 函数"]
  rsf --> ssa["挂 public 文件"]
  ssa --> fb["SSR fallback"]
  fb --> ws["with_state"]
  ws --> hz["main.rs:/healthz"]
  hz --> mounts["四个 mount"]
  mounts --> layers["四次 layer"]
  layers --> serve["axum::serve"]

本页的站点源码链接都指向本站仓库的提交 89a61b1。那个提交的 Cargo.lock 锁的是 dioxus 0.7.9(dioxus-server、dioxus-fullstack、dioxus-fullstack-macro 同为 0.7.9)、axum 0.8.9、tower-http 0.6.11。dioxus 的源码按「crate + 版本 + 文件」引用。

从 router(App) 到 Router<()>

main.rs 第 369 行拿到的 Router 来自 dioxus::server::router(App)。dioxus-server 0.7.9 的 src/launch.rs:

pub fn router(app: fn() -> Element) -> Router {
    let cfg = ServeConfig::new();
    apply_base_path(
        Router::new().serve_dioxus_application(cfg.clone(), app),
        app,
        cfg,
        base_path().map(|s| s.to_string()),
    )
}

apply_base_path 只在配了 base path 时才把整个路由 nest 到前缀下;本站的 Dioxus.toml 没配,它原样返回。真正干活的是 src/server.rs 里的 serve_dioxus_application:

    fn serve_dioxus_application<M: 'static>(
        self,
        cfg: ServeConfig,
        app: impl ComponentFunction<(), M> + Send + Sync,
    ) -> Router<()> {
        self.register_server_functions()
            .serve_static_assets()
            .fallback(get(FullstackState::render_handler))
            .with_state(FullstackState::new(cfg, app))
    }

四步,前三步都在 Router<FullstackState> 上做,第四步 with_state 把它变成 Router<()>。FullstackState 里是 ServeConfig、构造 VirtualDom 的闭包、SSR 渲染器池,以及一个 tokio-util 的 LocalPoolHandle(线程数取 available_parallelism)。只有 dioxus 自己的 handler 用 State<FullstackState> 取它;with_state 之后,这个状态已经装进每个端点,main.rs 在 Router<()> 上再加的 handler 用不到它(「还欠着的状态」这个说法见 状态:State 与 FromRef)。下面分别看前三步。

server 函数:一条 POST 路由一个函数

register_server_functions:

    fn register_server_functions(mut self) -> Router<FullstackState> {
        use std::collections::HashSet;

        let mut seen = HashSet::new();

        for func in ServerFunction::collect() {
            if seen.insert(format!("{} {}", func.method(), func.path())) {
                tracing::info!("Registering: {} {}", func.method(), func.path());

                self = self.route(func.path(), func.method_router())
            }
        }

        self
    }

ServerFunction::collect() 是 inventory::iter::<ServerFunction>().collect()。每个 server 函数在编译时由宏展开出一段 inventory::submit!,把 (方法, 路径, 构造 MethodRouter 的函数) 登记进一张全局表;启动时这里把表读出来,逐个 Router::route。所以 main.rs 里看不到任何一个 server 函数的名字,它们都是「链接进来就注册」。

路径和方法由 dioxus-fullstack-macro 0.7.9 决定。本站源码里有 72 个 #[post("/api/...")] 和 4 个 #[server]:

写法方法路径
#[post("/api/topics/list")]POST原样,{x} 形式的参数直接是 axum 0.8 的路径语法
#[server](不写路径)POST/api + 函数名 + 一串十进制数字,比如 /api/list_blog_posts 后面紧跟数字

那串数字是 xxh64(concat!(env!("CARGO_MANIFEST_DIR"), ":", module_path!())),为的是不同模块里同名的函数不撞路径。代价是路径跟着构建目录走:客户端和服务端要从同一个目录编译(或者都设 SERVER_FN_OVERRIDE_KEY)才对得上。

func.method_router() 返回的是 ServerFunction::make_handler 造出的 MethodRouter<FullstackState>(dioxus-server 0.7.9 src/serverfn.rs):

        axum::routing::method_routing::on(
            method
                .try_into()
                .expect("MethodFilter only supports standard HTTP methods"),
            move |state: State<FullstackState>, request: Request| async move {
                // ...
                let result = state.rt.spawn_pinned(move || async move {
                    // ...
                    let (parts, body) = request.into_parts();
                    let server_context = FullstackContext::new(parts.clone());
                    let request = axum::extract::Request::from_parts(parts, body);
                    // ...
                }).await;

                match result {
                    Ok(response) => response,
                    Err(err) => Response::builder()
                        .status(StatusCode::INTERNAL_SERVER_ERROR)
                        .body(Body::new(if cfg!(debug_assertions) {
                            format!("Server function panicked: {}", err)
                        } else {
                            "Internal Server Error".to_string()
                        }))
                        .unwrap(),
                }
            },
        )

站在 axum 这边看,每个 server 函数只是一个两参数的 handler:State<FullstackState>(FromRequestParts)加 Request(FromRequest,拿走整个请求)。参数的解码、返回值的编码都不归 axum 管,而是在 spawn_pinned 起的任务里由宏生成的代码做:

  • 用 LocalPoolHandle::spawn_pinned 跑,是为了允许 server 函数的 future 不是 Send。代价是 handler 里的 panic 不会冒到 axum,而是变成 JoinError,再被上面的 match 变成 500。
  • 请求体按 JSON 解:dioxus-fullstack 0.7.9 src/magic.rs 里,参数走 body 的那个 extract_axum 先 Bytes::from_request(request, &()).await.unwrap(),空 body 当成 {},再 serde_json::from_slice。
  • 成功的返回值 serde_json::to_string 后配 Content-Type: application/json,状态码 200;错误按错误类型编成 JSON 的 ErrorPayload。
  • 请求头里 Accept 含 text/html(普通表单提交)、响应成功又没有 Location 时,改成 302 跳回 Referer。

Router::route 碰到已有的路径会把两个 MethodRouter 合并,同一路径同一方法则 panic。dioxus 先用 HashSet 按「方法 路径」去重,所以两个 crate 里写了同一个 #[post] 路径时不会 panic,而是只注册 inventory 先给出的那一个,另一个静默丢掉。本站 76 个路径没有重复。

📝NOTE
冷知识:这句 panic 里的路径是 0.6 才加的

axum 自己遇到重复会 panic,信息是 Overlapping method route. Handler for `POST /api/topics/list` already exists(下面实验的最后一行)。在 #1306「Show path in panic message when merging overlapping MethodRouters」之前,合并两个 MethodRouter 时 panic 的只有一句不带路径的「Cannot merge two method routes that both define GET」。PR 描述:「The panic message will now contain the path (/foo/bar) so the error is hopefully easier to track down.」它在 2022-08-23 合并,首次发布于 axum-v0.6.0-rc.1,正式版 0.6.0。今天的代码里两种措辞都还在:merge_for_path 拿得到路径时用带路径的那句,拿不到路径时(直接调 MethodRouter::merge)退回旧的那句。dioxus 的去重恰好让这句 panic 不会出现,换来的是静默丢掉一个。

public 目录:每个文件一组路由

第二步 serve_static_assets 找到 public/ 目录(环境变量 DIOXUS_PUBLIC_PATH,没设就是可执行文件旁边的 public),交给 serve_dir_cached。它遍历目录,跳过 index.html(那一页由 SSR 生成),其余每一项:

        if path.is_dir() {
            // ...
            #[cfg(debug_assertions)]
            {
                router = router.nest_service(&route, ServeDir::new(&path));
            }

            #[cfg(not(debug_assertions))]
            {
                router = serve_dir_cached(router, public_path, &path);
            }
        } else {
            let serve_file = ServeFile::new(&path).precompressed_br();
            // ...
            if file_name_looks_immutable(&route) {
                router = router.nest_service(&route, cache_response_forever(serve_file))
            } else {
                router = router.nest_service(&route, serve_file)
            }
        }
  • release 构建递归展开,每个文件一次 nest_service,服务是 ServeFile;debug 构建对子目录直接挂 ServeDir,源码注释说是为了让新生成的文件(比如 wasm 热补丁模块)不用重建路由就能访问。
  • 文件名里有 -dxh 加十六进制哈希的(main-dxh1a2b.js 这种),用 tower 的 ServiceExt::map_response 加上 Cache-Control: public, max-age=31536000, immutable:内容变了文件名就变,可以永久缓存。
  • 都配了 precompressed_br():目录里有 .br 版本、客户端接受 br 时直接给它。
  • nest_service 每调用一次往路由树里插三条:{prefix}、{prefix}/ 和 {prefix}/{*..}(nest、merge 与 fallback)。release 构建里,public/ 有多少个文件,路由树里就有三倍的条目。

这些 ServeFile 用的是 dioxus-server 自己依赖的 tower-http(本站锁的同一个 0.6.11);本站自己的静态目录在下一页。

SSR 是 fallback,而且只认 GET

第三步 .fallback(get(FullstackState::render_handler))。Router::fallback 收的是 Handler,而 MethodRouter 本身实现了 Handler<(), S>,所以可以把 get(...) 整个当 fallback。fallback 本身是第二棵路由树里的 / 和 /{*..}(见 nest、merge 与 fallback),没匹配上任何路由的请求都进这个 MethodRouter,再由它按方法分发:GET 和 HEAD 去 render_handler,其余方法落到 MethodRouter::new 默认的那个兜底,回 405(带 Allow,见 MethodRouter)。

render_handler 先 request.into_parts(),body 直接丢掉,用 Parts 跑一遍 SSR;成功时响应体是 Body::from_stream(rx),rx 是一个 futures_channel::mpsc::Receiver,后台任务边渲染边往里写 HTML 片段。

下面这个程序照着 dioxus 的做法搭了一个同形状的 Router:注册 server 函数(换成一个返回路径的 handler)、SSR fallback(换成返回 ssr 加路径)、with_state,略去静态文件,再按 main.rs 的顺序加 /healthz 和一层中间件;中间件只记一笔「我被调用过」。

axum = "=0.8.9"
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
use std::collections::HashSet;
use std::sync::Mutex;

use axum::{
  Router,
  body::{Body, to_bytes},
  extract::{Request, State},
  http::{Method, header},
  middleware::{Next, from_fn},
  response::Response,
  routing::{MethodFilter, get, on},
};
use tower::ServiceExt;

// 相当于 FullstackState:只有 dioxus 自己的 handler 能拿到它
#[derive(Clone)]
struct FullstackState;

static SEEN: Mutex<Vec<String>> = Mutex::new(Vec::new());

// 相当于 inventory 收集到的 ServerFunction:(方法, 路径),故意放一个重复项
const SERVER_FNS: &[(&str, &str)] = &[
  ("POST", "/api/topics/list"),
  ("POST", "/api/list_blog_posts1234"), // #[server] 不写路径:/api/ + 函数名 + 一串数字
  ("POST", "/api/topics/list"),         // 两个 crate 写了同一个路径
];

async fn server_fn(State(_): State<FullstackState>, req: Request) -> String {
  format!("server fn {}", req.uri().path())
}

async fn render_handler(State(_): State<FullstackState>, req: Request) -> String {
  format!("ssr {}", req.uri().path())
}

async fn outer_mw(req: Request, next: Next) -> Response {
  SEEN.lock().unwrap().push(format!("{} {}", req.method(), req.uri().path()));
  next.run(req).await
}

fn dioxus_router() -> Router {
  let mut router: Router<FullstackState> = Router::new();
  // register_server_functions:按「方法 路径」去重,再逐个 route
  let mut seen = HashSet::new();
  for (method, path) in SERVER_FNS {
    if seen.insert(format!("{method} {path}")) {
      let filter = MethodFilter::try_from(Method::from_bytes(method.as_bytes()).unwrap()).unwrap();
      router = router.route(path, on(filter, server_fn));
    }
  }
  // SSR 作为 fallback,然后立刻 with_state
  router.fallback(get(render_handler)).with_state(FullstackState)
}

async fn send(app: &Router, method: Method, path: &str) {
  SEEN.lock().unwrap().clear();
  let req = Request::builder().method(method.clone()).uri(path).body(Body::empty()).unwrap();
  let res = app.clone().oneshot(req).await.unwrap();
  let status = res.status().as_u16();
  let allow = res.headers().get(header::ALLOW).map(|v| v.to_str().unwrap().to_owned());
  let body = to_bytes(res.into_body(), 1024).await.unwrap();
  let seen = !SEEN.lock().unwrap().is_empty();
  println!(
    "{:<5}{:<26} {status} allow={:<10} mw={seen:<5} body={:?}",
    method.as_str(),
    path,
    allow.unwrap_or("-".into()),
    String::from_utf8_lossy(&body)
  );
}

#[tokio::main]
async fn main() {
  // main.rs 的顺序:先 route / mount,最后才 layer
  let app = dioxus_router()
    .route("/healthz", get(|| async { "ok" }))
    .layer(from_fn(outer_mw));

  send(&app, Method::GET, "/healthz").await;
  send(&app, Method::POST, "/api/topics/list").await;
  send(&app, Method::GET, "/api/topics/list").await;
  send(&app, Method::POST, "/api/list_blog_posts1234").await;
  send(&app, Method::GET, "/blog/hello").await;
  send(&app, Method::HEAD, "/blog/hello").await;
  send(&app, Method::POST, "/blog/hello").await;
  send(&app, Method::POST, "/api/nope").await;

  // 不去重会怎样:axum 自己对同一方法同一路径 panic
  std::panic::set_hook(Box::new(|_| {}));
  let r = std::panic::catch_unwind(|| {
    Router::<()>::new()
      .route("/api/topics/list", on(MethodFilter::POST, || async {}))
      .route("/api/topics/list", on(MethodFilter::POST, || async {}))
  });
  if let Err(e) = r {
    let msg = e.downcast_ref::<String>().cloned().unwrap_or_default();
    println!("without dedupe: panic: {msg}");
  }
}
❗IMPORTANT
动脑筋:POST /api/nope 回什么状态码?

/api/nope 不是任何 server 函数的路径,看起来像一个 API 地址。它是 404,还是别的?响应带不带 Allow,带的话是什么?

main.rs 加了什么,按什么顺序

拿到 Router<()> 以后,main.rs 是这样接下去的:

    let router = dioxus::server::router(App)
      // S3:健康检查(ok/degraded),供 LB / 监控 / 部署脚本判活。
      .route("/healthz", get(crate::server::health::healthz));
    #[cfg(feature = "accounts")]
    let router = crate::server::auth_routes::mount(router, cookie_is_secure);
    let router = crate::server::static_assets::mount(router, assets_root);
    let router = crate::server::pay_routes::mount(router);
    // ...
    let router = crate::server::seo::mount(router, &base_url);

    // S2(风险 R4):应用层 per-IP 限流,仅作用于 /api/*(auth / pay 更严)。
    // ...
    let router = router.layer(axum::middleware::from_fn(crate::server::rate_limit::rate_limit_mw));
    // ...(尾斜杠、压缩、安全头,共四次 layer)

    Ok(router)

四个 mount 函数加了什么、用了哪些提取器,见下一页 路由与静态资源;四层 layer 的顺序和理由,tower 卷 本站的中间件栈 已经讲过。这里只核对一件事:有没有路由加在 layer 之后。

Router::layer 只套已经存在的路由和 fallback,之后再 route 的不会被包(layer 与 route_layer)。按这个提交逐处看:

  • main.rs:/healthz 和四个 mount 都在第 382 行第一次 layer 之前,layer 之后只有 Ok(router)。
  • dioxus 在闭包返回之后也没再加路由。dioxus-server 0.7.9 的 serve_router 在 release 构建里直接 axum::serve(listener, serve_callback().await.unwrap());debug 构建(dx serve)走自己的 accept 循环,也只是 router.into_make_service(),热补丁时重新调一次闭包、重新 into_make_service。router() 的文档注释里写着「WebSocket handling for live reload and devtools」,但 0.7.9 的这条路径里并没有注册任何 WebSocket 路由。
  • mount 函数内部:/js 和 /courses 两个子 Router 先 fallback_service 再 layer,套的是已经存在的 fallback,没有漏。

所以没有漏网的路由:server 函数、SSR、静态文件、/healthz、OAuth、支付回调、sitemap,每个请求都穿过这四层。上面那个程序的输出(见文末动脑筋答案)每一行都是 mw=true,包括 405 和走 fallback 的那几行。这也意味着 server 函数都在限流范围内:它们的路径都以 /api/ 开头,正是 rate_limit_mw 管的前缀。

还有一个顺带的后果。release 构建把 Router 直接交给 axum::serve,每接受一条连接都会 self.clone().with_state(()),把所有端点复制一遍(Router 的内部结构)。dioxus 那部分已经 with_state 过,都是现成的 Route,复制只是克隆;main.rs 自己加的 /healthz、OAuth、支付、SEO 这些 handler 路由还没拿到状态,每条连接都要重新 into_route、重新套那四层。路由不多,这只是每条连接多做的一点工作;先 into_make_service() 再交给 serve 就能在启动时一次做完,debug 路径正是这么做的。

📝NOTE
没有蠢问题

问: main.rs 和几个 mount 函数里的 handler 全是闭包,靠 move 捕获 cookie_is_secure、base_url 这样的配置。为什么不用 State?

答: 用不了。dioxus::server::router(App) 返回时已经 with_state 过,类型是 Router<()>;在它上面 .route(path, get(handler)),handler 里的 State<T> 要求 T: FromRef<()>,只有 T = () 满足。想用 State,得另起一个 Router::new().route(...).with_state(cfg) 再 merge 进来;本站的配置都是启动时就定下、之后不变的几个值,闭包捕获更直接。FullstackState 的字段都不公开,拿到也用不上。

一处缺口:server 函数的 body 上限

上面说过,参数走 body 的 server 函数取 body 用的是 Bytes::from_request(request, &()).await.unwrap()。axum-core 的 Bytes::from_request 会先 with_limited_body:请求的 extensions 里没有 DefaultBodyLimit 时,上限是 DEFAULT_LIMIT = 2_097_152(2 MiB),超了就返回 rejection(机制见 body 提取器)。axum 自己的 handler 会把 rejection 变成 413;这里是 unwrap(),于是 panic。本站没有在任何地方设 DefaultBodyLimit,dioxus-server 和 dioxus-fullstack 0.7.9 也没有。

本站最大的 body 是评论区的图片上传。upload_image 是 #[post("/api/upload")],参数是 name: String, data_base64: String,限制写在 UPLOAD_MAX_BYTES = 5 MiB(解码后),超了回「文件过大(限制 5 MB)」。base64 让体积变成 4/3,5 MiB 的图片是 6.7 MB 的 JSON,过不了 2 MiB 那一关。下面的程序照抄这段取 body 的代码,像 make_handler 一样放进另一个任务里跑(这里用 tokio::spawn,panic 时同样得到 JoinError),发几个不同大小的上传:

axum = "=0.8.9"
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use axum::{
  Router,
  body::{Body, Bytes, to_bytes},
  extract::{FromRequest, Request},
  http::StatusCode,
  response::{IntoResponse, Response},
  routing::post,
};
use serde::Deserialize;
use tower::ServiceExt;

#[derive(Deserialize)]
struct UploadArgs {
  name: String,
  data_base64: String,
}

async fn upload_image(req: Request) -> Response {
  // make_handler 用的是 LocalPoolHandle::spawn_pinned,这里用 tokio::spawn,panic 时同样得到 JoinError
  let result = tokio::spawn(async move {
    let bytes = Bytes::from_request(req, &()).await.unwrap();
    match serde_json::from_slice::<UploadArgs>(&bytes) {
      Ok(args) => {
        // 站点自己的 5 MiB 检查(check_upload_size:按 len * 3 / 4 估算)
        let estimated = args.data_base64.len() * 3 / 4;
        if estimated > 5 * 1024 * 1024 {
          (StatusCode::INTERNAL_SERVER_ERROR, "文件过大(限制 5 MB)").into_response()
        } else {
          format!("ok {} ~{} bytes", args.name, estimated).into_response()
        }
      }
      Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
    }
  })
  .await;
  match result {
    Ok(res) => res,
    Err(_) => Response::builder()
      .status(StatusCode::INTERNAL_SERVER_ERROR)
      .body(Body::new("Internal Server Error".to_string()))
      .unwrap(),
  }
}

#[tokio::main]
async fn main() {
  std::panic::set_hook(Box::new(|info| {
    let msg = info.payload().downcast_ref::<String>().cloned().unwrap_or_default();
    println!("  [panic] {msg}");
  }));
  let app = Router::new().route("/api/upload", post(upload_image));
  for image_bytes in [1_000_000usize, 1_500_000, 1_600_000, 4_000_000, 6_000_000] {
    let b64_len = image_bytes.div_ceil(3) * 4;
    let json = format!(r#"{{"name":"a.png","data_base64":"{}"}}"#, "A".repeat(b64_len));
    println!("image {image_bytes} B -> json {} B", json.len());
    let req = Request::post("/api/upload").header("content-type", "application/json").body(Body::from(json)).unwrap();
    let res = app.clone().oneshot(req).await.unwrap();
    let status = res.status().as_u16();
    let body = to_bytes(res.into_body(), 1 << 20).await.unwrap();
    println!("  {status} {:?}", String::from_utf8_lossy(&body));
  }
}
image 1000000 B -> json 1333369 B
  200 "ok a.png ~1000002 bytes"
image 1500000 B -> json 2000033 B
  200 "ok a.png ~1500000 bytes"
image 1600000 B -> json 2133369 B
  [panic] called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))
  500 "Internal Server Error"
image 4000000 B -> json 5333369 B
  [panic] called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))
  500 "Internal Server Error"
image 6000000 B -> json 8000033 B
  [panic] called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))
  500 "Internal Server Error"

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

  • 真正的上限是 JSON 2 MiB,折合图片大约 1.5 MB;5 MiB 那道检查只对更小的图片起作用,而那些图片本来就不会超。
  • 超过的请求不是 413,而是 panic 之后的 500,body 是 Internal Server Error(debug 构建里是 Server function panicked: ...)。最后一行 6 MB 的图片本该得到「文件过大」,也一样是 500。
  • 前端的 handle_upload 只处理 if let Ok(url) = upload_image(...),失败时什么也不显示,所以用户看到的是「传了,没反应」。

这是写作时(提交 89a61b1)的状态。要让 5 MiB 生效,就得在这些路由上放宽 DefaultBodyLimit(它是一个设置请求扩展的 layer,套在路由外面即可);放宽到多少、只放 /api/upload 还是全部 server 函数,是本站自己的取舍。

动脑筋答案

上面第一个程序的输出,M2 Max、rustc 1.99.0、--release,连跑 3 次相同:

GET  /healthz                   200 allow=-          mw=true  body="ok"
POST /api/topics/list           200 allow=-          mw=true  body="server fn /api/topics/list"
GET  /api/topics/list           405 allow=POST       mw=true  body=""
POST /api/list_blog_posts1234   200 allow=-          mw=true  body="server fn /api/list_blog_posts1234"
GET  /blog/hello                200 allow=-          mw=true  body="ssr /blog/hello"
HEAD /blog/hello                200 allow=-          mw=true  body=""
POST /blog/hello                405 allow=GET,HEAD   mw=true  body=""
POST /api/nope                  405 allow=GET,HEAD   mw=true  body=""
without dedupe: panic: Overlapping method route. Handler for `POST /api/topics/list` already exists
  • POST /api/nope 是 405,Allow: GET,HEAD。 它没匹配上任何路由,进了 fallback;fallback 是 get(render_handler),只有 GET(和随之而来的 HEAD)槽,POST 落到 MethodRouter 默认的兜底。Allow 列的是 SSR 能接的方法,不是这个「API」能接的方法。本站里,所有拼错了路径的 POST 请求、以及任何非 GET 的未知地址,都是这个 405,而不是 404。
  • GET /api/topics/list 也是 405,但 Allow: POST:路径匹配上了 server 函数的路由,是那个 MethodRouter 自己的 405。
  • HEAD /blog/hello 走 SSR 的 GET 处理,body 被丢掉。
  • 重复的 /api/topics/list 只注册了一次,没有 panic;最后一行是不去重时 axum 的反应。
  • 每一行都是 mw=true:405、fallback、/healthz 都穿过了最后加的那层。

小结

  • dioxus::server::router(App) 依次注册 server 函数、静态文件、SSR fallback,然后 with_state(FullstackState),交给 main.rs 的已经是 Router<()>。
  • server 函数靠 inventory 在启动时收集,每个一条路由:#[post("/api/x")] 是 POST /api/x,#[server] 是 POST /api/函数名 加一串由构建目录算出的数字;在 axum 看来是 State 加 Request 两个提取器,编解码都在 dioxus 生成的代码里。重复路径被静默去重。
  • SSR fallback 是 get(...),未知地址上的非 GET 请求回 405 Allow: GET,HEAD,不是 404。
  • main.rs 的路由都加在四次 layer 之前,dioxus 之后也不再加路由,没有漏网的路由;server 函数都在 /api/ 下,受限流管。
  • server 函数取 body 时对 Bytes::from_request 直接 unwrap,默认 2 MiB 上限一超就 panic 成 500;本站上传的 5 MiB 限制因此实际是 JSON 2 MiB。

💬 关联讨论

发起讨论
加载中...