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_filefuture。
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 秒长。只影响一条日志,不影响响应。
问: 我用 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。告警要打开tracingfeature 才有。