☰
深入 Salvo:Rust 异步 Web 框架的路由、中间件与实战踩坑
2026/10/8 3:29:06 网站建设 项目流程

如果你最近在关注 Rust 的 Web 后端生态,大概率会刷到一个名字:Salvo。这个框架中文文档友好、上手门槛低,口碑在社区里已经传开了。我过去两年主力是 actix-web,偶尔碰一下 axum,本来觉得框架嘛,能写业务就行,没必要换来换去。可上个月项目需要一个轻量接口服务,我就借周末 24 小时空档认真试了试 Salvo,结果从打开文档的第一眼就有点懵,跑通一个 Hello World 居然花了快一个小时,但越写越顺,到第二天睡前我已经用它把一整套带鉴权的用户接口撸完了,甚至还有点上头。这篇就是我记录下这 24 小时里从"这玩意儿怎么用"到"有点真香"的完整过程,包含所有卡壳的地方和对应的解决思路,适合想入坑 Salvo 但还没动手的朋友参考。

1. 初见 Salvo:文档友好但入口太多,第一眼真的会懵

1.1 为什么放着 actix-web 不用,非得折腾新框架

说实话,我一开始对 Salvo 是有点偏见的。Rust 的 Web 框架卷得很,光路由写法就能分成"宏派"和"函数派",actix-web 靠宏和 App 结构体,axum 靠 tower 生态,rocket 靠属性宏加编译期校验。我不太想再学一套新东西。真正让我愿意试 Salvo 的原因是项目需求比较特殊:

  • 服务要打包成镜像,编译速度和二进制体积都要控制。
  • 接口数量大概二三十个,但请求路径嵌套很深,权限层级多,路由组织必须直观。
  • 不想引一堆中间件栈,能一个框架自带最好。

Salvo 在这个场景下的宣传点正好命中:编译字节数在同级框架里属于比较小的,路由直接写在 Router 上,自带 JWT、CORS、静态文件、Server-Sent Events、WebSocket 这些常见组件,不用为了一个小服务把生态翻个底朝天。

不过话说回来,宣传点归宣传点,实际体验怎么样还两说。我当时给自己定的标准是:天亮之前跑通一个带三个子路由和权限校验的 demo,就算合格。为了不再花时间纠结,我先横向列了一张简单的选型对照表:

维度actix-webaxumSalvo
中间件模型App Data + extensionsState + 中间件栈Depot + hoop
路由风格宏 + 路径格式嵌套路由树形 Router
中文资料较少较少较多
开箱功能一般依赖 tower 生态内置较全

这张表其实已经说明问题了:Salvo 最吸引我的不是它哪个单项最厉害,而是"开箱功能"这一行。Rust 框架里,能把 CORS、鉴权、静态文件都给你准备好的,真不多。

1.2 环境准备与第一个 Hello World

Salvo 的环境准备超级简单,一个 Rust 工具链就行。Cargo.toml 里加:

[dependencies] salvo = "0.7" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } tracing = "0.1" tracing-subscriber = "0.3"

具体版本号以你拉到的稳定版为准,我这边用的是 0.7.x。这一步没有任何卡点。真正的卡点在后面——当我打开官方文档找 Hello World 时,发现 Salvo 快速上手页面里同时出现了三条代码路径:经典 Handler 写法、宏 Handler 写法,还有 RouterBuilder 串联。每种写法都是能跑的,但新手根本不知道该选哪一个。

我先抄了看起来最顺眼的宏版本:

use salvo::prelude::*; #[handler] async fn hello() -> &'static str { "Hello World" } #[tokio::main] async fn main() { let router = Router::new().get(hello); let acceptor = TcpListener::new("127.0.0.1:5800").bind().await; Server::new(acceptor).serve(router).await; }

cargo run之后浏览器一开,Hello World确实出来了。但我老实说,按下回车之前我心里是发虚的:这个#[handler]宏背后帮我把参数都处理好了,可一旦我要拿Request、要写Depot,模板那个async fn hello() -> &'static str就完全不够用了。结果我去查怎么拿请求参数,发现文档里出现了req.param、req.parse_json、extract::JsonBody好几种不同风格的 API,那一刻是真的有点懵。

后来我才想明白,这种"多入口"其实是 Salvo 历史版本的痕迹——早期版本 API 和现在不一样,文档又一直保留着新旧写法的对照。我的建议是:看文档只看当前稳定版本对应的示例,其他版本示例全部跳过。尤其是用搜索引擎找 Salvo 相关问题,很容易搜到半年前的旧代码,新版已经改了,比如路由参数从花括号变成尖括号、Response 的 render 方法取代了 set_body。从装好环境到页面打印出 Hello World,当时已经过去了将近一个小时。

1.3 搞懂 Handler、Depot、Response 三件套,比学 API 本身重要

Hello World 能跑之后,我停下来复盘了一下,发现 Salvo 请求处理模型其实简单得吓人。你可以把一次请求的处理过程理解成一条流水线,上面有几个关键岗位:

  • Request:包装了 HTTP 请求的路径、查询参数、请求头、Body。
  • Depot:框架自带的一个请求级 KV 存储,你可以往里塞连接池、用户信息、中间件统计结果。
  • Response:封装 HTTP 响应,处理完就把它写回去。
  • FlowCtrl:流程控制器,里面的 skip_rest 可以跳过后续还没执行的处理器。

理解了这个模型,前面所有乱七八糟的写法瞬间就能串起来了。#[handler]宏的作用,只是根据函数参数列表自动从 Request、Depot、Response 里把你要的东西摘出来。你写async fn hello(depot: &mut Depot),它就知道你要 Depot;写async fn hello(req: &mut Request)就知道你要请求体。想通这一点,我对 Salvo 的好奇心才算真正被吊起来。

#[handler] async fn user_info(req: &mut Request, depot: &mut Depot, res: &mut Response) { let id: i64 = req.param("id").unwrap_or(-1); let db = depot.get::<SqlitePool>("db").unwrap(); // 业务处理 res.render(Json(user)); }

这里有个细节新手很容易忽略:#[handler]宏展开后,函数会被包装成异步块,所以内部照样可以用?和await,不用担心框架的限制。真正需要关注的反而是"返回值要能转成响应"这件事,后面在统一响应结构时我还会细说。

2. 路由的艺术:尖括号参数、多层嵌套和路径守卫

2.1 路径参数与 Query 参数:一改就懂的快乐

用 actix-web 写路径参数,习惯写法是 path! 宏加提取器。Salvo 这边更直接,路径上写<id>,handler 里req.param("id")一把梭。我觉得这个设计的妙处在于路由和数据提取是强关联的,读代码的时候一眼就能看到一个路径下面有哪些动态段。

举个实际例子,我要写的接口长这样:

let api = Router::new() .push(Router::with_path("api/v1/users").get(list_users).post(create_user)) .push(Router::with_path("api/v1/users/<id>").get(get_user).put(update_user).delete(delete_user)) .push(Router::with_path("api/v1/orders").hoop(auth).get(list_orders));

注意第三个路由,我挂了一个.hoop(auth),这个就是 Salvo 的中间件挂载姿势。只要写上这行,整个子路由树下的所有 handler 都会先过 auth 这一关。权限分离用这种写法非常舒服——数据接口归数据接口,鉴权归鉴权,路由结构读起来就是一棵权限树。

Query 参数更简单:

#[handler] async fn search(req: &mut Request, res: &mut Response) { let q = req.query::<String>("q").unwrap_or_default(); let page: i64 = req.query("page").unwrap_or(1); let size: i64 = req.query("size").unwrap_or(20); }

不需要写Query<T>提取器,直接按名字取,没有就填默认值,懒人福音。下午五点多,我觉得自己已经能拿 Salvo 写点真东西了。

2.2 JSON Body 解析:parse_json 配合 serde,清爽到有点不真实

POST 接口的 JSON 解析是 Web 框架的兵家必争之地。actix 有 Json 提取器,axum 用 serde_path_to_error 做报错,都好用。Salvo 这边我一开始试的是:

use salvo::extract::JsonBody; #[handler] async fn create_user(req: &mut Request, res: &mut Response) { let user: User = req.parse_json().await?; // 继续业务流程 }

等一下,这里有个隐藏坑:req.parse_json().await?这个问号,如果客户端传的 JSON 格式不对,这里会直接返回一个 500 错误,而不是 400。你得自己做错误映射。更麻烦的是,网上不少旧示例直接在 parse_json 后面跟.unwrap(),我把那段代码复制下来之后,只要客户端发一个字段缺失的 JSON,线程直接 panic,整个服务重启。所以看到网上代码带unwrap()一定要警惕,尤其是解析外部输入的时候。

正确的姿势是显式处理错误:

#[handler] async fn create_user(req: &mut Request, res: &mut Response) -> Result<(), StatusError> { let user = match req.parse_json::<User>().await { Ok(user) => user, Err(_) => { res.render(StatusError::bad_request().summary("invalid json")); return Ok(()); } }; // 继续业务流程 }

这是 Salvo 比较典型的错误处理姿势,函数返回Result<(), StatusError>,错误就用StatusError渲染出去,状态码语义非常清晰。我 24 小时里踩得最深的一个坑也在这里:老版本和新版本的 parse_json 错误类型完全不一样,很多旧示例里直接.unwrap(),复制下来就是一个定时炸弹。

2.3 路由组织的心智模型:树比扁平列表更接近业务

用 Salvo 写路由给我的另一个新鲜感是,路由不是一堆扁平 URL 字符串的集合,而是一棵真的树。你写Router::new().push(...).push(...),每个子路由还能继续往下 push。对于层级深的权限模型,这种嵌套是最自然的映射——管理员接口挂一个 admin 结点,下面挂 user、order、setting 各自的分支,资源归属一目了然。

Salvo 在路由匹配上也做了优化,树形结构让它在路由数量多的时候匹配效率也稳定。我那个 demo 里路由数量不算多,感受不出性能差异,但代码的"组织感"和"可维护性"是立马能感受到的。如果你之前写的是 Flask 那种装饰器挂路由,转到 Salvo 可能会需要半天适应期,但适应之后你会发现,路由和 handler 放在同一棵树里,比到处飞装饰器好找得多。

3. 中间件、Depot 与鉴权:第一个晚上被绕晕,后半夜突然开窍

3.1 Depot 到底是什么?为什么会让人懵

如果只用 Hello World,Depot 这东西你根本不会遇到。但只要开始写鉴权、写日志、注入数据库连接池,Depot 就成了躲不开的概念。我第一次看到depot.insert("db", pool)时,脑子里立刻蹦出 actix 的 Data——不都是全局状态吗?但用着用着发现它比 Data 更轻、更自由,因为它本质就是一个HashMap<String, Box<dyn Any>>,你可以往里塞任何类型。

而且 Depot 的生命周期不是全局的,它跟着单次请求走。路由上挂的中间件往 Depot 里写的信息,后面的业务 handler 能读到。举个例子,我写的鉴权中间件长这样:

#[handler] async fn auth(req: &mut Request, depot: &mut Depot, ctrl: &mut FlowCtrl) -> Result<(), StatusError> { let auth_header = req.headers() .get("Authorization") .and_then(|v| v.to_str().ok()) .unwrap_or(""); let user = verify_token(auth_header).map_err(|_| { ctrl.skip_rest(); StatusError::unauthorized() })?; depot.insert::<UserInfo>("user", user); Ok(()) }

注意我用ctrl.skip_rest()跳过了后续 handler,这是一种提前终止请求流水线的姿势。如果认证失败,整个请求链就被掐断了,不会往下执行。如果认证通过,user信息就放在 Depot 里,后面的业务 handler 用depot.get::<UserInfo>("user")直接取。

由于 Depot 类型是Box<dyn Any>,取数据时必须带上类型标注。类型写错了会怎么样?我实际试了一下,不会编译期报错,而是运行时 panic。这一点要提醒新手:depot.get的泛型必须和depot.insert的类型完全一致,否则就是运行时炸,不是编译错误,排查起来比编译错误隐蔽得多。

3.2 多个中间件的执行顺序:hoop 的语义是"先到先得"吗

Salvo 里控制中间件顺序的 API 主要是hoop和路由挂载。实测顺序就是注册顺序,你可以想象成洋葱模型,请求进来先过最外层,再一层层往里。我一开始以为hoop只对当前结点生效,结果发现它也会被子路由继承,这一点在写全局日志和 CORS 时特别重要——搞错了顺序,CORS 头没设置,前端全部跨域报错。

我当时遇到的场景是:全局 CORS 中间件在 Router 顶层挂,鉴权中间件挂在/admin子路由上:

let root = Router::new() .hoop(cors) .push(Router::with_path("admin").hoop(auth).push(...)) .push(Router::with_path("public").push(...));

CORS 在顶层对所有请求生效,auth 只在 admin 子树生效。这套组合拳打下来,权限边界非常清晰。如果你想把某个中间件只挂在一个 handler 上,也可以直接在那个 handler 前显式调用,或者单独用.hoop挂到叶子路由。灵活度很高,但也要注意别把中间件挂重复了——同一个中间件在父路由和子路由都挂的话,请求会执行两遍。

3.3 藏在异步块里的坑:Rust 所有权与借用的博弈

Salvo 官方示例里经常能看到这种写法:

let user = depot.get::<UserInfo>("user").unwrap();

但如果你要在闭包或者异步块里用这个 user,借用检查器会教你做人。我第一次写的时候用req.param拿到 id,又想在tokio::spawn的异步任务里借用它,结果 Rust 报了一堆生命周期错误,那一刻我真有点想砸键盘。

后面我学乖了,对于这种需要跨作用域使用的数据,直接用Clone或者把需要的字段提取成String、i64再进闭包,别想着省那一次拷贝。Rust 的所有权模型在 Salvo 这种 async 场景下,本质上就是逼迫你提前想清楚数据归属,想清楚了代码会简洁很多。

let user = depot.get::<UserInfo>("user").unwrap(); let user_name = user.name.clone(); tokio::spawn(async move { // 这里面只使用 user_name,避开借用问题 });

晚饭后开始写鉴权,前前后后折腾到十一点,中间至少有一半时间花在"编译器教我做所有权"上。但熬过这一轮之后,后面写数据库代码反而顺畅了,因为借用规则已经刻进肌肉记忆里了。

4. 数据库接入与统一响应:真香体验的最后一公里

4.1 sqlx + SQLite:从"连接池放哪"到三行搞定

我的 demo 用的存储是 SQLite,ORM 选了 sqlx 而不是 diesel,因为 sqlx 的异步支持和零配置对快速原型更友好。连接池初始化很常规:

use sqlx::sqlite::SqlitePoolOptions; let pool = SqlitePoolOptions::new() .max_connections(5) .connect("sqlite:app.db") .await?;

关键问题是这个 pool 怎么放进 Salvo 的 handler 里。我看过有人的做法是每次请求都 new 一个连接,这太浪费了。正确姿势是启动时把 pool 放进 Depot,然后在 handler 里取。因为 Depot 是Box<dyn Any>,直接塞连接池完全没问题,也不会像 axum 那样需要为每个状态写State<T>提取器。

具体到业务 handler:

#[handler] async fn list_users(depot: &mut Depot, res: &mut Response) { let pool = depot.get::<SqlitePool>("db").unwrap(); let users = sqlx::query_as::<_, User>("SELECT id, name FROM users") .fetch_all(pool) .await; match users { Ok(list) => res.render(Json(list)), Err(err) => res.render(StatusError::internal_server_error().summary(&err.to_string())), } }

这里面有一个我踩过的小坑:如果把&SqlitePool的引用类型弄混,编译期会卡很久,因为 Salvo 的 handler 要求返回类型必须是Writer,而 sqlx 的fetch_all里传&SqlitePool是对的,传pool所有权反而报错。第一次接触时,我一度以为是 Salvo 的问题,后来发现是 async 借用生命周期的问题——和框架无关,是我对 Rust 还不够熟。

第二天上午接着搞数据库和统一响应。SQLite 在本地开发是真方便,sqlx 的query!宏还能在编译期校验 SQL 语法,配合 Salvo 的日志中间件,调试接口的速度比我预想快很多。

4.2 统一 JSON 响应结构:一边写业务一边定义返回协议

后端写久了的人,对 REST 接口的返回标准都会形成一种习惯——成功和失败都要包一层{code, message, data}。Rust 这边返回结构你得自己搭,Salvo 的好处是它的Response::render接受任何实现了Writertrait 的类型,这意味着你可以自己定义一个全局响应结构体,让它实现Writer,然后所有 handler 直接res.render(ApiResult::ok(data)),爽到飞起。

我当时的简化实现是这样(示意代码,具体 trait 签名以你用的版本为准):

#[derive(serde::Serialize)] struct ApiResult<T> { code: i32, message: String, #[serde(skip_serializing_if = "Option::is_none")] data: Option<T>, } impl<T: serde::Serialize + Send + 'static> salvo::writing::Writer for ApiResult<T> { async fn write(self, _req: &mut Request, _depot: &mut Depot, res: &mut Response) { res.render(Json(self)); } } impl<T> ApiResult<T> { fn ok(data: T) -> Self { ApiResult { code: 0, message: "ok".into(), data: Some(data) } } fn fail(msg: String) -> Self { ApiResult { code: -1, message: msg, data: None } } }

如果你不想碰自定义Writer,退而求其次,可以用一个宏来统一渲染:

macro_rules! api_ok { ($res:expr, $data:expr) => { $res.render(Json(ApiResult::ok($data))) }; }

两种做法我都试过,核心思路是一样的:把成功、失败的公共逻辑收敛到一处,业务 handler 只关心自己的数据。写出来之后,handler 的表达力直线上升:

#[handler] async fn get_user(depot: &mut Depot, res: &mut Response) { let pool = depot.get::<SqlitePool>("db").unwrap(); let user = get_one_from_db(pool, 1).await; match user { Some(u) => res.render(ApiResult::ok(u)), None => res.render(ApiResult::fail("user not found".into())), } }

这种风格非常贴近实际业务代码。要说明的是,Writertrait 的签名在不同版本 Salvo 里会有微调,到了你手边要以官方文档为准。思路比代码本身重要——Salvo 的Writertrait 是开放的,你可以用它做非常多自定义输出格式,这一点是 actix 里HttpResponse给不了的灵活度。

5. 24 小时复盘:真香的地方,和几个依然想吐槽的地方

5.1 越用越顺手的设计

平心而论,Salvo 对我这个"计划一天上手"的人来说,完成度已经很高了:

  • 中文文档规模够大,例子覆盖场景多,搜问题比搜其他 Rust 框架的英文 issue 快得多。
  • 常见功能一把梭。CORS、JWT、静态文件、SSE、WebSocket 基本都内置或由官方维护的模块提供,不用拼积木。
  • 编译体验不错,debug 模式启动快,release 二进制体积在 Rust 框架里相当能打。
  • 路由树设计让"权限"这个概念变得很直观,不用在代码里到处写if。

5.2 新手最容易被卡住的坑

坑点主要集中在版本与习惯差异:新版 Salvo 改了不少 API,网上的旧示例会让人直接用出 panic。其次是 Depot 的类型擦除带来的运行时风险,get泛型写错了不会编译报错。第三是中间件/Handler 的 trait 约束比较严格,如果自己定义返回类型,很多新手会卡在Writertrait 的实现上——只要记住"让 res 去 render 一切"这个总原则,就不太会迷路。

还有一个不大不小的坑:Salvo 的 handler 参数列表顺序很随意,但如果你把同一个参数写了两遍,比如req: &mut Request和req2: &Request,编译器会提示你函数签名不满足 Handler 约束。这个提示信息有时候很绕,因为它看起来不像类型错误,更像宏展开失败。遇到这种情况,先检查参数是不是重复了,能省不少查文档的时间。

5.3 给新手的路径建议

如果让我重新走一遍这 24 小时,我可能会按下面这个顺序来,能少踩一半的坑:

  1. 跑通 Hello World 后,先别急着写业务,花半小时把官方 Router 示例从头抄一遍。
  2. 用curl实测每个 handler 函数里打印日志,理解 handler 入参到底能拿什么。
  3. 写一个带鉴权中间件的 admin 子路由,跑通"授权通过/失败"两种路径。
  4. 再接数据库,先跑只读查询,再写增删改。
  5. 最后统一封装响应结构。

按这个顺序,大概率不会出现我最初那种"Hello World 能跑但下一步不知道写啥"的现象。

睡前最后复盘了一下这 24 小时。我自己最大的体会是,Salvo 不像 actix-web 那样给你一整套固定的应用骨架,它更像一盒零件,你按自己的业务形状去组装。刚开始会有点摸不到套路,但一旦理解了 Handler、Depot、FlowCtrl 三者之间的配合,后面写接口的速度真的可以用"手追不上脑"来形容。如果你也正打算在 Rust 项目里评估 Web 框架,我建议给 Salvo 留出两三天时间,它值得你认真试一次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询