Rust in Everything
基于 bytes 1.12.1

server 函数的请求体

主线位置:共享主线(Bytes)。server 函数的整个 JSON body 经 axum 的 Bytes 提取器合成一个 Bytes,过程和 上一页 相同:多块时 BytesMut 拷一次再 freeze。不同的是读完之后怎么用,以及读不完(超限)时怎么失败。

flowchart TB
  req["POST /api/upload"] --> pin["spawn_pinned 跑处理函数"]
  pin --> fr["Bytes::from_request"]
  fr -->|未超 2 MiB| coll["collect:拷成一个 Bytes"]
  coll --> json["serde_json::from_slice"]
  json --> body["server 函数本体"]
  fr -->|超了| rej["Err:LengthLimitError"]
  rej --> unwrap["unwrap:panic"]
  unwrap --> join["JoinError"]
  join --> r500["500"]

本页的站点源码链接都指向本站仓库的提交 89a61b1。那个提交的 Cargo.lock 锁的是 dioxus 0.7.9(dioxus-server、dioxus-fullstack 同为 0.7.9)、axum 0.8.9、axum-core 0.5.6、tokio-util 0.7.18。dioxus 的源码按「crate + 版本 + 文件」引用。server 函数怎么注册成 axum 路由、这处缺口是怎么被发现的,axum 卷的 dioxus 生成的 Router 已经讲过;这一页只看 body 这一个 Bytes。

dioxus 怎么读 body

参数都是普通 serde 类型的 server 函数,宏把参数打包成一个结构体(___Body_Serialize___),在服务端走 dioxus-fullstack 0.7.9 src/magic.rs 第 523 行的这个 ExtractRequest 实现。本站的图片上传 upload_image(name: String, data_base64: String) 就是这种:

    // One-arg case
    impl<In, Out, H> ExtractRequest<In, Out, H> for &&&&&&&&&&ServerFnEncoder<In, Out>
    where
        In: DeserializeOwned + 'static,
        Out: 'static,
        H: FromRequestParts<FullstackContext>,
    {
        fn extract_axum(
            &self,
            _state: FullstackContext,
            request: Request,
            map: fn(In) -> Out,
        ) -> impl Future<Output = Result<(Out, H), Response>> + 'static {
            async move {
                let (mut parts, body) = request.into_parts();
                let headers = H::from_request_parts(&mut parts, &_state)
                    .await
                    .map_err(|e| e.into_response())?;

                let request = Request::from_parts(parts, body);
                let bytes = Bytes::from_request(request, &()).await.unwrap();
                let as_str = String::from_utf8_lossy(&bytes);

                let bytes = if as_str.is_empty() {
                    "{}".as_bytes()
                } else {
                    &bytes
                };

                let out = serde_json::from_slice::<In>(bytes)
                    .map(map)
                    .map_err(|e| ServerFnError::from(e).into_response())?;

                Ok((out, headers))
            }
        }
    }

按 bytes 的视角逐行看:

  • Bytes::from_request(request, &()):axum-core 的 Bytes 提取器,Limited 加 collect 加 to_bytes,和支付回调用的是同一个。单块不拷贝,多块拷一次,见 上一页 的「这个 Bytes 是怎么来的」。图片上传的 body 有几 MB,在真实连接上一定是多块,所以这里一定有一次整块拷贝。
  • String::from_utf8_lossy(&bytes):只用来判断 body 是不是空的。body 是合法 UTF-8 时返回 Cow::Borrowed,扫一遍、不分配。
  • serde_json::from_slice::<In>(bytes):直接从字节解析,不先转 String。但 In 的字段是 String,serde_json 要为每个字符串字段分配一个新的 String,data_base64 是整个 body 的绝大部分,等于又拷一次。
  • bytes 的生命期:它是这个 async 块的局部变量,extract_axum 返回 (out, headers) 时就 drop 了,比 server 函数本体先结束。

upload_image 接下来用 check_upload_size 按 len * 3 / 4 估算解码后的大小,超过 UPLOAD_MAX_BYTES(5 MiB)就拒绝,否则 base64 解码成一个 Vec<u8>,这是第三块大缓冲。

超过 2 MiB:rejection 变成 panic

Bytes::from_request 套的 Limited 默认上限是 2_097_152 字节。Limited 每收到一块就从剩余额度里扣掉这块的 remaining(),某一块超出额度时返回 LengthLimitError;collect 把错误原样交出,to_bytes 不会执行。所以超限时什么都没拷:已经排进 BufList 的块只是一串指向 hyper 读缓冲的 Bytes,随 Collected 一起 drop。

axum-core 把这个错误归成 FailedToBufferBody::LengthLimitError。普通的 axum handler 里它会被渲染成 413 和「Failed to buffer the request body: length limit exceeded」,这句话怎么拼出来的见 axum 卷的 Failed to buffer the request body: length limit exceeded。dioxus 在这里 unwrap(),于是 panic。

panic 没有直接把连接断掉,而是变成一个 500,是因为 dioxus-server 0.7.9 的 make_handler(src/serverfn.rs 第 64 行)把整个处理过程放进了 tokio-util 的 LocalPoolHandle::spawn_pinned(第 76 行,为了允许 !Send 的 future),panic 被那个任务吞掉,变成 JoinError。第 135 行起的 match:

                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(),
                }

生产是 release 构建,客户端看到的是 500 和 Internal Server Error,日志里是 panic 信息。

上限按 JSON 算。base64 把图片放大到 4/3,再加 {"name":"a.png","data_base64":"..."} 的几十个字节,2 MiB 的 JSON 装得下的图片约 1.57 MB。本站在修复说明里记的是「in practice uploads failed above about 1.5 MB」,和这个数一致。

实验:照 dioxus 的写法读 body

不依赖 dioxus,照上面两段代码写一个处理函数:spawn_pinned 里 Bytes::from_request(..).await.unwrap(),serde_json::from_slice,再做本站 upload_image 的大小估算和 base64 解码。另一条 /plain 是普通的 Bytes 提取器,用来对照。body 按 32 KiB 一块流式发出,模拟 hyper 交上来的多块。计数分配器只记 1 MiB 以上的分配。「修之后」那一段套上本站的修法(下一节):

bytes = "=1.12.1"
tokio = { version = "=1.54.1", features = ["full"] }
axum = "=0.8.9"
tower = { version = "=0.5.3", features = ["util"] }
tokio-util = { version = "=0.7.18", features = ["rt"] }
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "=0.22.1"
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};

use axum::{
  Router,
  body::{Body, Bytes, to_bytes},
  extract::{DefaultBodyLimit, FromRequest, Request, State},
  http::StatusCode,
  middleware::{self, Next},
  response::{IntoResponse, Response},
  routing::post,
};
use base64::Engine as _;
use serde::Deserialize;
use tokio_util::task::LocalPoolHandle;
use tower::{Layer, ServiceExt};

const MIB: usize = 1024 * 1024;

// 只记 1 MiB 以上的分配
struct Counting;
static BIG_N: AtomicUsize = AtomicUsize::new(0);
static BIG_SIZES: [AtomicUsize; 16] = [const { AtomicUsize::new(0) }; 16];

unsafe impl GlobalAlloc for Counting {
  unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
    if layout.size() >= MIB {
      let i = BIG_N.fetch_add(1, Relaxed);
      if i < 16 {
        BIG_SIZES[i].store(layout.size(), Relaxed);
      }
    }
    unsafe { System.alloc(layout) }
  }
  unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
    unsafe { System.dealloc(ptr, layout) }
  }
}

#[global_allocator]
static GLOBAL: Counting = Counting;

fn big_allocs() -> Vec<usize> {
  let n = BIG_N.swap(0, Relaxed).min(16);
  (0..n).map(|i| BIG_SIZES[i].load(Relaxed)).collect()
}

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

// 普通的 axum handler:Bytes 提取器失败时由 axum 回 rejection
async fn plain(body: Bytes) -> String {
  format!("got {} bytes", body.len())
}

// 照 dioxus 0.7.9 的做法:dioxus-server 的 make_handler 用 spawn_pinned 跑,
// 里面是 dioxus-fullstack magic.rs 的 Bytes::from_request(..).await.unwrap() + serde_json::from_slice
async fn server_fn(State(pool): State<LocalPoolHandle>, req: Request) -> Response {
  let result = pool
    .spawn_pinned(move || async move {
      let bytes = Bytes::from_request(req, &()).await.unwrap();
      let args = match serde_json::from_slice::<UploadArgs>(&bytes) {
        Ok(args) => args,
        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
      };
      // 本站 upload_image 接着做的事:先按 len * 3 / 4 估算大小(check_upload_size),再 base64 解码。
      // ServerFnError::new 在 dioxus 里是 500
      if args.data_base64.len() * 3 / 4 > 5 * MIB {
        return (StatusCode::INTERNAL_SERVER_ERROR, "文件过大(限制 5 MB)").into_response();
      }
      let data = base64::engine::general_purpose::STANDARD.decode(&args.data_base64).unwrap();
      format!("ok {} {} bytes", args.name, data.len()).into_response()
    })
    .await;
  match result {
    Ok(res) => res,
    // dioxus-server 在 debug 构建里回这句,release 构建里回 "Internal Server Error"
    Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Server function panicked: {err}")).into_response(),
  }
}

// 本站 2026-10-23 的修法:只给 /api/upload 套 DefaultBodyLimit::max(8 MiB)
async fn upload_body_limit(req: Request, next: Next) -> Response {
  if req.uri().path() != "/api/upload" {
    return next.run(req).await;
  }
  match DefaultBodyLimit::max(8 * MIB).layer(next).oneshot(req).await {
    Ok(res) => res,
    Err(never) => match never {},
  }
}

async fn send(app: &Router, path: &str, image_bytes: usize) {
  let b64 = base64::engine::general_purpose::STANDARD.encode(vec![0u8; image_bytes]);
  let json = format!(r#"{{"name":"a.png","data_base64":"{b64}"}}"#);
  let json_len = json.len();
  // 像 hyper 一样分块交上来:每块 32 KiB
  let chunks: Vec<Bytes> = json.into_bytes().chunks(32 * 1024).map(Bytes::copy_from_slice).collect();
  let stream = futures_util::stream::iter(chunks.into_iter().map(Ok::<_, std::io::Error>));
  let req = Request::post(path).body(Body::from_stream(stream)).unwrap();
  println!("{path:<12} 图片 {image_bytes} B,JSON {json_len} B");

  big_allocs();
  let res = app.clone().oneshot(req).await.unwrap();
  let big = big_allocs();
  let status = res.status().as_u16();
  let text = to_bytes(res.into_body(), MIB).await.unwrap();
  println!("  {status} {}", String::from_utf8_lossy(&text));
  println!("  1 MiB 以上的分配: {big:?}");
}

#[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 pool = LocalPoolHandle::new(1);
  let routes = Router::new()
    .route("/plain", post(plain))
    .route("/api/upload", post(server_fn))
    .with_state(pool);

  println!("== 修之前");
  send(&routes, "/plain", 1_600_000).await;
  send(&routes, "/api/upload", 1_500_000).await;
  send(&routes, "/api/upload", 1_600_000).await;

  println!("== 修之后");
  let fixed = routes.layer(middleware::from_fn(upload_body_limit));
  send(&fixed, "/plain", 1_600_000).await;
  send(&fixed, "/api/upload", 5_000_000).await;
  send(&fixed, "/api/upload", 6_000_000).await;
  send(&fixed, "/api/upload", 6_500_000).await;
}

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

== 修之前
/plain       图片 1600000 B,JSON 2133369 B
  413 Failed to buffer the request body: length limit exceeded
  1 MiB 以上的分配: []
/api/upload  图片 1500000 B,JSON 2000033 B
  200 ok a.png 1500000 bytes
  1 MiB 以上的分配: [2000033, 2000000, 1500000]
/api/upload  图片 1600000 B,JSON 2133369 B
  [panic] called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))
  500 Server function panicked: task 15 panicked with message "called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))"
  1 MiB 以上的分配: []
== 修之后
/plain       图片 1600000 B,JSON 2133369 B
  413 Failed to buffer the request body: length limit exceeded
  1 MiB 以上的分配: []
/api/upload  图片 5000000 B,JSON 6666701 B
  200 ok a.png 5000000 bytes
  1 MiB 以上的分配: [6666701, 6666668, 5000001]
/api/upload  图片 6000000 B,JSON 8000033 B
  500 文件过大(限制 5 MB)
  1 MiB 以上的分配: [8000033, 8000000]
/api/upload  图片 6500000 B,JSON 8666701 B
  [panic] called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))
  500 Server function panicked: task 21 panicked with message "called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))"
  1 MiB 以上的分配: []

同一个错误,两种结局。 2 133 369 字节的 JSON 超过了 2 097 152。/plain 得到 axum 渲染的 413;/api/upload 里 unwrap 打印的是这个错误的 Debug:FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError }))),外两层是 axum-core 的 rejection,里面是包着 http-body-util LengthLimitError 的 axum_core::Error。JoinError 的 Display 是「task N panicked with message ...」,N 是 tokio 的任务编号;release 构建的 dioxus 不带这一句,只回 Internal Server Error。

超限时没有大分配。 两个超限的请求都是 []:Limited 在第 65 块(累计超过 2 MiB 的那一块)报错,之前的 64 块只是排在 BufList 里的 Bytes,没有被拷到一起。

没超限时三块大缓冲。 1.5 MB 图片那一行,三次分配依次是:

大小是什么
2 000 033collect 的 BytesMut::with_capacity(len),整个 JSON 拷一次
2 000 000serde_json 为 data_base64 分配的 String
1 500 000base64 解码出的 Vec<u8>

5 MB 图片是同样三块,换成 6.67 MB、6.67 MB、5 MB。在 dioxus 里 bytes 在 server 函数本体运行前就 drop 了,所以按代码,第一块和第三块不会同时存在;同时活着的最多是 Bytes 加 String(反序列化时),或 String 加解码结果(解码时)。实验里的 bytes 一直活到闭包结束,峰值比 dioxus 里高,分配次数和大小不受影响。

本站的修法

本站在 2026-10-23 修了这个问题,这个提交写作时还没有推送到公开仓库,89a61b1 里没有它,所以这里只描述、不链接。修法是新加一个 axum::middleware::from_fn 中间件,在 main.rs 里套到整个 Router 上:请求路径是 /api/upload 时,用 DefaultBodyLimit::max(8 MiB) 把下一层包起来再调用,其余路径原样放行。8 MiB 的来历写在常量的注释里:5 MB 图片经 base64(×4/3)约 6.7 MB,再加 JSON 外壳。DefaultBodyLimit 只是往请求的 extensions 里写一个值,Bytes::from_request 读 body 时才按它套 Limited(body 提取器),所以在路由外面的中间件里设置,对 dioxus 生成的那条路由同样有效。提交里的两个测试分别确认 /api/upload 收得下 3 MB、别的路径 3 MB 仍是 413。

上面实验的「修之后」一段就是这个中间件:

  • 5 MB 的图片(JSON 6 666 701 字节)过了上限,走到 upload_image 的逻辑,200。
  • 6 MB 的图片(JSON 8 000 033 字节)也过了上限,第一次走到本站自己的 check_upload_size,得到「文件过大(限制 5 MB)」。修之前这道检查永远轮不到:2 MiB 先把所有大到需要它的请求拦住了。
  • 6.5 MB 的图片(JSON 8 666 701 字节)超过 8 MiB,又是 panic 和 500。修法挪了门槛,unwrap 还在 dioxus 里。
  • 别的路径上的 2 MB JSON 仍然被 2 MiB 挡住;对 /plain 是 413,对别的 server 函数则同样是 500。

写这一章时又发现了后两种情况,本站在 2026-10-24 补了一个提交(同样还没推送):同一个中间件先读请求头里的 Content-Length,超过这条路径的上限(/api/upload 8 MiB,其余 2 MiB)就直接回 413,请求到不了 dioxus 的 unwrap。所以现在 6.5 MB 的图片和别的 server 函数上的 2 MB JSON 都得到 413;只有不带 Content-Length 的分块请求还会走到 Limited、再 panic 成 500。同一天还修了 check_upload_size 的估算:len * 3 / 4 没有减去末尾的 =,正好 5 MiB 的图片会被估成多 1 个字节而拒绝,上面的实验因此用的是 5 000 000 字节的图片。

📝NOTE
没有蠢问题

问: 既然 axum 自己会把这个错误变成 413,能不能让超过 8 MiB 的上传也得到 413,而不是 500?

答: 在 dioxus 这条路上不行:错误在 extract_axum 里就被 unwrap 掉了,到不了 axum 的 rejection 处理。要得到 413,只能在请求走到 dioxus 之前就拒绝它。tower-http 的 RequestBodyLimitLayer 在请求头里看到超限的 Content-Length 就直接回 413,一个 body 字节都不读(Failed to buffer the request body: length limit exceeded 的「修法」一节)。这只对带 Content-Length 的请求有效。

小结

  • dioxus-fullstack 0.7.9 对参数走 JSON 的 server 函数用 Bytes::from_request(..).await.unwrap() 读 body,和 axum 的 Bytes 提取器是同一条路:多块时 collect 把整个 body 拷进一块 BytesMut 再 freeze。
  • 一次图片上传在这条路上有三块大缓冲:collect 的拷贝、serde_json 的 String、base64 解码结果;dioxus 在 server 函数本体运行前就 drop 了第一块。
  • 超过 2 MiB 时 Limited 报错,什么都没拷;unwrap 把 rejection 变成 panic,spawn_pinned 把 panic 变成 JoinError,dioxus-server 回 500。本站的上传因此在约 1.5 MB 的图片处失败。
  • 本站的修法只给 /api/upload 放宽到 8 MiB:5 MB 的图片能传,6 MB 第一次得到本站自己的「文件过大」,更大的仍是 500;其余 server 函数仍是 2 MiB。

💬 关联讨论

发起讨论
加载中...