Rust in Everything
基于 tower-http 0.7.1

ServeDir 的 Backend trait

主线位置:ServeDir 是叶子服务,不包内层,作用点主线上只有「call 时」一处:把 URI 变成路径,问 backend 要元数据、打开文件,造出响应。这一页只讲 0.7.0 加进来的那一层抽象。URI 到路径、目录与重定向、预压缩、Range、条件请求、try_call 的完整流程,axum 卷 ServeDir 与 ServeFile 已经对着 0.7.1 跑过,这里只链过去。

flowchart TB
  sd["ServeDir::try_call"] --> of["open_file:Box::pin 一次"]
  of --> isdir["backend.metadata(路径):是不是目录"]
  isdir --> head{"HEAD 吗"}
  head -->|是| meta["backend.metadata(路径)"]
  head -->|否| open["backend.open(路径)"]
  open --> fmeta["file.metadata():GAT future"]
  meta --> etag["mtime + 长度 算 ETag"]
  fmeta --> etag
  etag --> resp["条件请求、Range、造响应"]
  etag -.->|拿不到 mtime| warn["rate_limited! 告警"]

三个 trait

0.7.0 的 #684 之前,ServeDir 直接调 tokio::fs。之后,文件系统操作都经过 backend.rs 里的三个 trait:

pub trait Metadata: Send + 'static {
    fn is_dir(&self) -> bool;
    fn modified(&self) -> io::Result<SystemTime>;
    fn len(&self) -> u64;
    // is_empty 有默认实现
}

pub trait File: AsyncRead + AsyncSeek + Unpin + Send + Sync {
    type Metadata: Metadata;
    type MetadataFuture<'a>: Future<Output = io::Result<Self::Metadata>> + Send
    where
        Self: 'a;
    fn metadata(&self) -> Self::MetadataFuture<'_>;
}

pub trait Backend: Clone + Send + Sync + 'static {
    type File: File<Metadata = Self::Metadata>;
    type Metadata: Metadata;
    type OpenFuture: Future<Output = io::Result<Self::File>> + Send;
    type MetadataFuture: Future<Output = io::Result<Self::Metadata>> + Send;
    fn open(&self, path: PathBuf) -> Self::OpenFuture;
    fn metadata(&self, path: PathBuf) -> Self::MetadataFuture;
}
  • Metadata 只要三样:是不是目录、修改时间、长度。modified 返回 io::Result,拿不到可以报错,ServeDir 会退化成不发 ETag 和 Last-Modified。
  • File 要能异步读、能 seek(Range 请求要从中间开始读),文档注释直接建议内存实现用 std::io::Cursor。
  • Backend 按值收 PathBuf,两个 future 都是普通关联类型,不借用 self。它要 Clone:try_call 每个请求都 self.backend.clone() 一份,交给已经 Box::pin 过的 open_file future。

ServeDir 多了一个类型参数,ServeDir<F = DefaultServeDirFallback, B = TokioBackend>,默认值保证老代码不用改;fallback() 换掉 F 时保留 B。用自己的 backend 要走 ServeDir::with_backend(path, backend)。ServeFile 是 struct ServeFile(ServeDir),固定用默认的 TokioBackend,没有对应的构造函数。

默认的 TokioBackend 两个 future 都是 Pin<Box<dyn Future + Send>>,里面就是原来的 tokio::fs::File::open 和 tokio::fs::metadata。打开后的文件在 FileRequestExtent::Full 里又被装成 Box<dyn AsyncRead + Unpin + Send>,所以 B::File 这个类型在打开之后就被擦掉了,不会出现在响应 body 的类型里,ServeDir 的响应 body 仍然是那一个 ServeFileSystemResponseBody。

为什么 MSRV 变成 1.65

File::MetadataFuture<'a> 带一个生命周期参数,这是泛型关联类型(GAT)。TokioFile 的 metadata 返回的 future 要借着 &self 去调 tokio::fs::File::metadata:

impl File for TokioFile {
    type Metadata = std::fs::Metadata;
    type MetadataFuture<'a> =
        Pin<Box<dyn Future<Output = io::Result<std::fs::Metadata>> + Send + 'a>>;

    fn metadata(&self) -> Self::MetadataFuture<'_> {
        Box::pin(async move { self.0.metadata().await })
    }
}

没有 GAT,关联类型里写不出这个 'a,future 只能是 'static,就得先把文件句柄克隆一份再挪进 future。GAT 在 Rust 1.65 稳定。#684 的标题就是「Add configurable Backend trait for ServeDir, bump MSRV 1.65」,同一个提交把 Cargo.toml 的 rust-version 和 CI 的工具链从 1.64 改成 1.65,CHANGELOG 记为「MSRV bumped from 1.64 to 1.65 ([#684])」。提交信息没写原因;1.65 同时稳定的还有 let-else,tag 里没有用到它,而 GAT 在 crate 里只出现在这一处(加上测试里的一个内存 backend),所以说 MSRV 是被这个 GAT 推上去的,是从代码推断,不是提交里的原话。

一个内嵌资源的 backend

下面把两个文件用 include_bytes! 编进二进制,index.html 给一个固定的修改时间,app.js 故意拿不到修改时间。每次 backend 被调用都打印路径:

tower-http = { version = "=0.7.1", features = ["fs", "tracing"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
http = "1"
http-body-util = "0.1"
bytes = "1"
tracing-subscriber = "0.3"
// assets/index.html 内容是 "<h1>embedded</h1>\n",assets/app.js 是 "console.log(\"hi\");\n"
const FILES: &[(&str, &[u8], Option<u64>)] = &[
  ("index.html", include_bytes!("../assets/index.html"), Some(1_767_225_600)),
  ("app.js", include_bytes!("../assets/app.js"), None),
];

#[derive(Clone)]
struct EmbedMeta { dir: bool, len: u64, mtime: Option<u64> }

impl Metadata for EmbedMeta {
  fn is_dir(&self) -> bool { self.dir }
  fn modified(&self) -> io::Result<SystemTime> {
    let secs = self.mtime.ok_or(io::ErrorKind::Unsupported)?;
    Ok(UNIX_EPOCH + Duration::from_secs(secs))
  }
  fn len(&self) -> u64 { self.len }
}

struct EmbedFile { cursor: Cursor<&'static [u8]>, meta: EmbedMeta }
// AsyncRead / AsyncSeek 原样转发给 cursor,略

impl File for EmbedFile {
  type Metadata = EmbedMeta;
  type MetadataFuture<'a> = Ready<io::Result<EmbedMeta>>; // 用不到 'a,也得这样写
  fn metadata(&self) -> Self::MetadataFuture<'_> { ready(Ok(self.meta.clone())) }
}

#[derive(Clone)]
struct Embedded;

impl Embedded {
  // ServeDir 交来的是 "./assets/xxx",先去掉前缀
  fn lookup(path: &Path) -> io::Result<(Option<&'static [u8]>, EmbedMeta)> {
    let rel = path.strip_prefix("./assets").map_err(|_| io::ErrorKind::NotFound)?;
    if rel.as_os_str().is_empty() {
      return Ok((None, EmbedMeta { dir: true, len: 0, mtime: None }));
    }
    let (_, bytes, mtime) = FILES.iter().find(|(p, ..)| Path::new(p) == rel).ok_or(io::ErrorKind::NotFound)?;
    Ok((Some(*bytes), EmbedMeta { dir: false, len: bytes.len() as u64, mtime: *mtime }))
  }
}

impl Backend for Embedded {
  type File = EmbedFile;
  type Metadata = EmbedMeta;
  type OpenFuture = Ready<io::Result<EmbedFile>>;
  type MetadataFuture = Ready<io::Result<EmbedMeta>>;

  fn open(&self, path: PathBuf) -> Self::OpenFuture {
    println!("    backend.open({path:?})");
    ready(Self::lookup(&path).and_then(|(bytes, meta)| match bytes {
      Some(bytes) => Ok(EmbedFile { cursor: Cursor::new(bytes), meta }),
      None => Err(io::ErrorKind::IsADirectory.into()),
    }))
  }

  fn metadata(&self, path: PathBuf) -> Self::MetadataFuture {
    println!("    backend.metadata({path:?})");
    ready(Self::lookup(&path).map(|(_, meta)| meta))
  }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  tracing_subscriber::fmt().without_time().with_target(false).with_ansi(false).init();
  let svc = ServeDir::with_backend("assets", Embedded);
  // 依次请求:GET /、GET /app.js、GET /app.js(Range: bytes=0-6)、HEAD /app.js、GET /missing.css
  // 打印状态码、Content-Type、ETag、Last-Modified 和 body,略
}

use 和打印的辅助函数省略了,AsyncRead / AsyncSeek 两个 impl 各三行,全是转发给 Cursor。输出(M2 Max、rustc 1.99.0,跑 3 次相同):

GET / 
    backend.metadata("./assets")
    backend.open("./assets/index.html")
  200 OK type=text/html etag="6955b900.00000000-12" last-modified=Thu, 01 Jan 2026 00:00:00 GMT body=b"<h1>embedded</h1>\n"
GET /app.js 
    backend.metadata("./assets/app.js")
    backend.open("./assets/app.js")
 WARN ETag generation failed (mtime unavailable or pre-epoch) path=./assets/app.js
  200 OK type=text/javascript etag=- last-modified=- body=b"console.log(\"hi\");\n"
GET /app.js (range: bytes=0-6)
    backend.metadata("./assets/app.js")
    backend.open("./assets/app.js")
  206 Partial Content type=text/javascript etag=- last-modified=- body=b"console"
HEAD /app.js 
    backend.metadata("./assets/app.js")
    backend.metadata("./assets/app.js")
 WARN ETag generation failed (mtime unavailable or pre-epoch) path=./assets/app.js
  200 OK type=text/javascript etag=- last-modified=- body=b""
GET /missing.css 
    backend.metadata("./assets/missing.css")
    backend.open("./assets/missing.css")
  404 Not Found type=- etag=- last-modified=- body=b""
  • 路径带 ./:with_backend 先 PathBuf::from(".") 再 push(path)(第 122 行),backend 收到的是 ./assets/app.js,不是 assets/app.js。按字符串查表的实现要先去掉这个前缀。
  • 每个请求先问一次「是不是目录」:is_dir 调 backend.metadata,NotFound 算「不存在」,接着照常去打开,再由打开时的 NotFound 变成 404。HEAD 不打开文件,所以是两次 metadata。GET / 的路径是 ./assets,backend 说是目录,于是加上 index.html 再打开。
  • Content-Type 不归 backend 管:ServeDir 照旧按扩展名用 mime_guess 猜。
  • ETag 来自 mtime 和长度:ETag::from_metadata 拼成 "秒的十六进制.纳秒-长度",0x6955b900 就是 1767225600,0x12 是 18 字节。modified() 报错时 ETag 和 Last-Modified 都不发,条件请求永远拿不到 304。内嵌资源想要缓存验证,给一个构建时间当 mtime 就够了。
  • Range 靠 seek:Cursor 实现了 AsyncSeek,206 照常工作。

rate_limited!:每 60 秒最多一次

拿不到 mtime 时的告警在 open_file.rs 第 149 行(HEAD 分支)和 第 198 行(GET 分支),都包在 #[cfg(feature = "tracing")] 里:0.6.9 起 fs 不再自动打开 tracing(源码地图),上面的依赖要自己写上 tracing 才看得到这两行 WARN。对内嵌 backend,每个没有 mtime 的文件、每个请求都会走到这里,所以用 rate_limited! 限频:

macro_rules! rate_limited {
    ($interval:expr, $call:expr) => {{
        use std::sync::atomic::{AtomicU64, Ordering};
        use std::time::{Duration, SystemTime, UNIX_EPOCH};

        static NEXT_CALL: AtomicU64 = AtomicU64::new(0);

        let interval: Duration = $interval;
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or(Duration::ZERO)
            .as_secs();
        let next = NEXT_CALL.load(Ordering::Relaxed);
        if now >= next {
            let new_next = now.saturating_add(interval.as_secs());
            if NEXT_CALL
                .compare_exchange(next, new_next, Ordering::Relaxed, Ordering::Relaxed)
                .is_ok()
            {
                $call;
            }
        }
    }};
}
  • static 写在宏展开出来的块里,所以每个调用点各有一个计数器。上面的输出里,第二次 GET /app.js 没有告警(GET 分支 60 秒内已经报过),紧接着的 HEAD 又报了一次(HEAD 分支是另一个调用点)。
  • 没有锁:读出下一次允许的时刻,到点了就用 compare_exchange 抢着往后推,多个线程同时到点只有一个抢到,只有它调用 $call。
  • 精度是秒,interval 小于一秒会被 as_secs() 截成 0。
  • 宏上方的注释说「Uses a monotonic clock」,代码用的却是 SystemTime,墙上时间,不单调;紧接着的 TODO 写着 MSRV 到 1.70 以后换成 OnceLock<Instant>。系统时间往回拨时,now < next 会一直成立,告警被压住的时间比 60 秒长。只影响一条日志,不影响响应。
📝NOTE
没有蠢问题

问: 我用 rust-embed 之类的库,能不能直接给 ServeFile 也换 backend?

答: 0.7.1 不能。ServeFile 是 ServeDir 的一个新类型包装,里面的 ServeDir 没写 backend 参数,用的就是默认的 TokioBackend。单个内嵌文件,可以用 ServeDir::with_backend 配一个只认那一个路径的 backend,或者直接写一个 handler 返回字节。

小结

  • 0.7.0(#684)把 ServeDir 的文件系统操作抽成 Metadata、File、Backend 三个 trait;默认 TokioBackend 用装箱 future 包着 tokio::fs,老代码不受影响。
  • ServeDir 多了类型参数 B,自定义 backend 走 with_backend;ServeFile 不支持换 backend。
  • File::MetadataFuture<'a> 是 GAT,让 future 可以借用打开的文件;#684 同时把 MSRV 从 1.64 提到 1.65,GAT 正是 1.65 稳定的(「因为 GAT」是从代码推断的)。
  • backend 收到的路径带 ./ 前缀;每个请求先用 metadata 判断目录;拿不到 mtime 就没有 ETag 和 Last-Modified。
  • rate_limited!:每个调用点一个原子时间戳,无锁,秒级精度;注释说单调时钟,代码用的是 SystemTime。告警要打开 tracing feature 才有。

💬 关联讨论

发起讨论
加载中...