如果你最近在 Rust 里挑 Web 框架应该会经历一段很具体的纠结期axum 文档最全、生态最大actix-web 性能名声在外Rocket 的宏写法接近魔法。我原来一直偏向 axum直到上周接了个小需求要三天内把一个内部工具的 CRUD 后端跑起来。结果连续两天读 axum 的路由写法、State 和错误处理的样板代码越写越觉得卡手。朋友推荐我试试 Salvo 框架说“你想省事就直接上它”。我抱着怀疑态度花了一个晚上搭原型结果 24 小时内把接口、自动文档、中间件全部配齐体验可以用四个字概括从懵到香。如果你正在为 Rust Web 后端选型发愁或者刚入门想找一个容易上手的框架这篇实测记录应该能给你省下不少时间。我这一天的路线大致是先对比框架选型然后搭 Hello World接着理解 Salvo 的路由和宏再撸一个完整 Todo API最后踩了一堆编译和运行期的坑。每一部分我都会把关键代码贴出来并把代码背后的设计理由讲清楚。1. 初见 Salvo框架定位与选型其实我第一次看到 Salvo 是在 cargo search 里搜 web framework当时它还是另一个名字 Poetry后来才改成现在的 Salvo。第一眼觉得它名气不如 axum 和 actix-web中文资料也没那么多差点直接跳过。但仔细翻了文档和示例之后发现这个框架的设计逻辑非常对我的胃口。源码给人最直观的感受是“克制的抽象”。Salvo 底层跑在 hyper 和 tokio 上异步运行时没有自己造轮子整体可靠性有保证。它在 hyper 之上封装了 Request、Response、Router 和 Handler 这一套模型把 Web 开发里最常用的几个概念做得非常顺手。真正让我决定试下去的有三点。一是宏的使用频率恰到好处。axum 几乎不用宏所有东西都得手动拼装Rocket 则是宏驱动虽然方便但学习和查错成本高。Salvo 处在中间位置核心的 handler 用#[handler]标记路由用普通函数构建既保留类型安全又不会让我整天对着宏展开的报错发愁。二是原生支持 OpenAPI。现在做后端接口不配一份 Swagger 文档都不好意思交付。很多框架要引入第三方库写一堆标注Salvo 直接在框架层面支持派生一个ToSchema就能把接口信息收进去。三是中文文档的可读性。Salvo 的官方示例覆盖了常见的鉴权、数据库、静态文件、WebSocket 等场景跟着跑一遍基本能覆盖真实需求。1.1 与主流 Rust Web 框架的横向对比框架底层实现宏使用程度OpenAPI 支持上手门槛维护活跃度axumhyper / tokio少需第三方库中等很高tokio 官方actix-web自研 actix极少需第三方库较高高Rocket自研异步大量宏需第三方库中等一般Salvohyper / tokio适中原生集成较低社区维护活跃拿 axum 来说它其实非常优秀路由层级清晰生态完善。但它的参数提取、状态共享、错误处理都比较“程序员友好”也就是你要自己写不少胶水代码。比如从路径里取一个参数Salvo 里一句req.param::u64(id)就完了axum 里则依赖 extractor 的组合和顺序新手理解成本高一些。actix-web 是老牌框架性能很强但它的 actor 模型和 app 结构对一个只想快速写业务接口的人来说有点重。Rocket 的宏体验虽然好但编译时间一直在劝退我迭代几次之后就有点不耐烦。所以我的结论是如果你喜欢“框架替你搞定样板代码”的开发方式同时又不希望被太多魔法掩盖细节Salvo 是当前 Rust 社区里一个很值得关注的平衡点。它不需要我理解宏背后的复杂展开就能用起来真遇到问题也能顺着代码追到核心逻辑。2. 零到一把第一个 Salvo 服务跑起来选型完之后就是动手。这部分我记录了从新建项目到第一个 HTTP 请求返回的全过程耗时大概半小时主要时间都花在等依赖编译上。2.1 环境准备与依赖清单我的本地环境是 Rust 1.80 以上版本系统是 macOS理论上 Linux 和 Windows 完全一样。先创建一个新项目cargo new salvo-lab cd salvo-lab然后编辑Cargo.toml加上两个核心依赖[dependencies] salvo { version 0.74, features [full] } tokio { version 1, features [macros, rt-multi-thread] }这里解释一下为什么features [full]。Salvo 的功能模块是可以裁剪的比如 HTTP 解析、WebSocket、OpenAPI、静态文件等如果只做简单接口不启用完整功能也能跑。但新手阶段我建议直接开 full先把所有能力打开跑通之后再按需裁剪。如果你对编译时间敏感可以只开自己需要的 feature比如features [http2, openapi, swagger-ui]。但第一次就别折腾了用默认的 full 省心。cargo add salvo --features full运行cargo build的时候会拉很多依赖第一次编译大概要几分钟这属于 Rust 项目的正常节奏不用慌。2.2 Hello World 逐行拆解新建src/main.rs写入use salvo::prelude::*; #[handler] async fn hello() - static str { Hello, Salvo! } #[tokio::main] async fn main() { let service Service::new(Router::new().get(hello)); Server::new(service).bind(0.0.0.0:5800).await; }然后编译运行cargo run另一个终端里执行curl http://127.0.0.1:5800/你会看到输出Hello, Salvo!。这段代码我第一次看的时候有点懵因为 axum 的结构不是这样的。但拆开来看其实很清晰#[handler]宏把一个普通 async 函数变成 Salvo 的处理器。它会对函数签名做分析把某些参数比如Request、Depot自动注入返回值也可以灵活转成响应。Router::new().get(hello)表示创建一个路由器注册了一个 GET/的处理器。这里没有显式写路径默认就是根路径。Service::new把路由包装成 Salvo 的服务Server::new负责监听端口。bind接收一个地址字符串0.0.0.0:5800表示监听所有网卡接口端口是 5800我习惯用它来做本地开发端口。2.3 请求背后发生了什么一个请求到这个服务实际经历的过程是tokio 运行 hyper 接收 TCP 连接解析 HTTP 报文Salvo 把解析结果包装成Request对象然后交给Router做路径匹配匹配成功后调用对应的 handlerhandler 的返回值再被转换成 HTTP 响应写回给客户端。#[handler]宏在这个过程里做了两件重要的事。第一它把你的函数签名转换成一个实现了Handlertrait 的结构体让 Salvo 可以统一调用第二它根据函数参数的类型自动生成“参数提取”代码Request、Depot、FlowCtrl这些由框架注入什么参数需要自己从请求里取宏会按类型帮你编排。这个过程设计得相当聪明。你不用像在 axum 里那样手动组合 extractorSalvo 直接通过函数参数的可变引用和类型推导来解决问题读代码的时候非常直观。3. 核心概念拆解路由、处理器与数据提取Hello World 跑通之后我开始尝试把 Salvo 的各个核心概念摸了一遍。这几个概念互相纠缠真正理解之后写接口速度快很多。3.1 Handler 与宏的原理Salvo 里的处理器本质上就是一个async fn。由于 Rust 的异步函数不是 trait objectSalvo 用#[handler]宏帮你包了一层内部生成一个结构体并为它实现Handlertrait。这个 trait 的核心方法签名大概是async fn handle(self, req: mut Request, depot: mut Depot, res: mut Response, ctrl: mut FlowCtrl);所以你在 handler 函数里写req、depot、res、ctrl这几个参数时本质上是让宏帮你把这个函数转换成它 trait 的实现。编译器会在编译期检查参数类型是否合法这也是为什么很多类型错误在编译期就能暴露出来。handler 的返回值也很灵活。它可以是static str、String、JsonT、ResultT, StatusError等。Salvo 会为这些类型实现一个响应转换的 trait宏会调用对应转换逻辑。我实际开发中比较喜欢返回Result类型因为业务的正常流和错误流可以用?运算符统一处理代码干净很多。3.2 路由系统嵌套、路径参数与优先级Salvo 的路由是一个树形结构。最外层是根Router你可以用.push挂子路由每个子路由又可以有它自己的子路由。来看一个稍复杂的例子use salvo::prelude::*; #[handler] async fn get_user(req: mut Request) - String { let uid req.param::u64(id).unwrap_or(0); format!(user {uid}) } #[handler] async fn get_user_posts(req: mut Request) - String { let uid req.param::u64(id).unwrap_or(0); let page req.query::u64(page).unwrap_or(1); format!(posts of user {uid}, page {page}) } fn build_router() - Router { Router::new() .push(Router::with_path(users).push( Router::with_path(id) .get(get_user) .push(Router::with_path(posts).get(get_user_posts)), )) }这里有两个重点。第一路径参数用id这种尖括号语法在 handler 里通过req.param::u64(id)取出。为什么是尖括号而不是像很多框架那样用冒号前缀我记得官方解释是为了在编译期做更严格的路径匹配避免和静态路径混淆。第二路由匹配是按下推顺序进行的。users/id/posts和users/id是两个不同的路径段如果你把posts注册在id的层级外面会导致匹配不到或者错配。所以在嵌套路由时要先想清楚层级关系。build_router执行后GET /users/42返回user 42GET /users/42/posts?page3返回posts of user 42, page 3。路径参数的解析是自动的你不用手动做字符串切割。3.3 参数提取Path、Query、Json 与状态管理Salvo 的参数提取有一条主线从Request里取。路径参数用req.param查询参数用req.queryJSON body 用parse_json。#[handler] async fn create_data(req: mut Request) - ResultJsonMyData, StatusError { let data: MyData req.parse_json().await.map_err(|_| StatusError::bad_request())?; Ok(Json(data)) }parse_json是异步方法因为需要从 HTTP body 中读取二进制流并反序列化。它返回Result我把错误映射成了 400 Bad Request。如果你需要在整个应用层面共享数据比如数据库连接池、配置信息Salvo 的做法是把它放到Depot里。Depot是一个请求级的容器类似很多框架里的 context。你可以通过中间件往depot里插入数据然后在 handler 里取#[handler] async fn with_state(depot: mut Depot) - String { let pool depot.get::DbPool(db).unwrap(); format!(db connected: {}, pool.is_ok()) }不过我在 demo 阶段用的是全局静态变量后面会讲为什么这样能先把业务跑起来它是一个足够快的原型方案。3.4 中间件机制与 FlowCtrl 控制流Salvo 的中间件本质上也是一个 handler只不过它在调用下一个处理者之前和之后插入自己的逻辑。看一个打日志的中间件use std::time::Instant; #[handler] async fn access_log(req: mut Request, depot: mut Depot, res: mut Response, ctrl: mut FlowCtrl) { let start Instant::now(); let method format!({}, req.method()); let path req.uri().path().to_owned(); ctrl.call_next(req, depot, res).await; let cost start.elapsed(); eprintln!({method} {path} took {cost:?}); }ctrl.call_next是关键它会调用路由链上的下一个处理器。你可以把日志、鉴权、跨域处理这些横切逻辑写在一个中间件里然后用.hoist(access_log)挂到全局路由上。Router::new() .hoist(access_log) .push(Router::with_path(users).get(get_user));中间件的执行顺序和挂载顺序有关。全局hoist的中间件会在路由匹配前执行这样即使请求 404你也能在访问日志里看到它。我后来给 Todo API 加上鉴权逻辑时就是写了一个简单的 token 检查中间件挂在需要认证的路由节点上非常方便。4. 实战24 小时内搭一个完整 Todo API看完核心概念我在第二个半天就开始真正干活了。这次需求非常简单做一个 Todo 清单的 CRUD 接口先不连数据库用内存存储重点是把 Salvo 的流程走通。4.1 需求拆分与项目结构接口需要这几个方法路径功能GET/todos获取全部 TodoPOST/todos创建 TodoGET/todos/id获取单个 TodoPUT/todos/id更新 TodoDELETE/todos/id删除 Todo数据模型很简单包含 id、title、completed 三个字段。我一开始想用文件存储但后面发现没这个必要先跑通 API 再说。4.2 完整代码实现存储这一块demo 阶段我不想被 Salvo 的状态注入细节打断节奏所以先用一个全局静态变量use std::sync::{LazyLock, Mutex}; use salvo::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] struct Todo { id: u64, title: String, completed: bool, } static TODOS: LazyLockMutexVecTodo LazyLock::new(|| Mutex::new(Vec::new()));LazyLock是 Rust 1.80 标准库里的惰性初始化类型可以让我在静态变量里安全地放一个可变容器。注意Mutex是标准库的同步锁跨 await 时不能在 lock 的持有期间做异步操作这里因为是内存操作没这个问题。接着写所有 handler#[handler] async fn list_todos() - JsonVecTodo { let todos TODOS.lock().unwrap(); Json(todos.clone()) } #[handler] async fn create_todo(req: mut Request) - ResultJsonTodo, StatusError { let todo: Todo req.parse_json().await.map_err(|_| StatusError::bad_request())?; let mut todos TODOS.lock().unwrap(); let new_id todos.iter().map(|t| t.id).max().unwrap_or(0) 1; let new_todo Todo { id: new_id, title: todo.title, completed: todo.completed, }; todos.push(new_todo.clone()); Ok(Json(new_todo)) } #[handler] async fn get_todo(req: mut Request) - ResultJsonTodo, StatusError { let id req.param::u64(id).unwrap_or(0); let todos TODOS.lock().unwrap(); todos .iter() .find(|t| t.id id) .cloned() .map(Json) .ok_or_else(|| StatusError::not_found()) } #[handler] async fn update_todo(req: mut Request) - ResultJsonTodo, StatusError { let id req.param::u64(id).unwrap_or(0); let patch: Todo req.parse_json().await.map_err(|_| StatusError::bad_request())?; let mut todos TODOS.lock().unwrap(); let target todos .iter_mut() .find(|t| t.id id) .ok_or_else(|| StatusError::not_found())?; target.title patch.title; target.completed patch.completed; Ok(Json(target.clone())) } #[handler] async fn delete_todo(req: mut Request) - Result(), StatusError { let id req.param::u64(id).unwrap_or(0); let mut todos TODOS.lock().unwrap(); let before todos.len(); todos.retain(|t| t.id ! id); if todos.len() before { Err(StatusError::not_found()) } else { Ok(()) } }最后是主函数#[tokio::main] async fn main() { let router Router::new() .push( Router::with_path(todos) .get(list_todos) .post(create_todo) .push( Router::with_path(id) .get(get_todo) .put(update_todo) .delete(delete_todo), ), ); let service Service::new(router); Server::new(service).bind(0.0.0.0:5800).await; }这里用到了同一个路由节点挂多个 HTTP 方法.get().post()的写法。id子路由挂在todos下面所以路径是/todos/id它和/todos是不同层级不会冲突。4.3 接口验证与错误路径测试代码写完直接cargo run然后逐条用 curl 验证curl -X POST http://127.0.0.1:5800/todos \ -H Content-Type: application/json \ -d {id: 0, title: 学 Salvo, completed: false} curl http://127.0.0.1:5800/todos curl http://127.0.0.1:5800/todos/1 curl -X PUT http://127.0.0.1:5800/todos/1 \ -H Content-Type: application/json \ -d {id: 1, title: 学 Salvo 并做项目, completed: true} curl -X DELETE http://127.0.0.1:5800/todos/1这里有一个小坑POST 的 body 里我传了id: 0但 create handler 会忽略它重新用 max 1 计算。我最初设计时其实想直接让用户不传 id但既然 Todo 结构体里 id 是必填字段serde 反序列化时缺少字段会直接报 400。这个细节说明在真实项目中DraftTodo 和 Todo 还是应该分开建模或者把 id 类型改成Optionu64。错误路径也值得测GET /todos/999应该返回 404POST /todos发送非法 JSON 应该返回 400DELETE /todos/999应该返回 404实测下来 Salvo 返回的 body 是默认的空白错误页状态码正确。如果你想让客户端看到更友好的错误信息可以在响应里写一个错误处理中间件或者让 handler 返回ResultJsonErrorResp, StatusError自由度比较高。4.4 自动生成 OpenAPI 文档接口全部通了之后接下来我想给它配一份 OpenAPI 文档工作量几乎为零。先给结构体加一个ToSchema派生use salvo::oapi::{ToSchema}; #[derive(Serialize, Deserialize, Clone, Debug, ToSchema)] struct Todo { id: u64, title: String, completed: bool, }然后给 handler 加上接口描述。不同版本写法略有差异我这里基于常用示例代码类似下面这样#[endpoint(summary 获取单个 Todo, parameters(id u64))] async fn get_todo(req: mut Request) - ResultJsonTodo, StatusError { // ... }最后在 main 里聚合所有路由并挂 Swagger UI。Salvo 提供SwaggerUI这个组件你可以把它理解成一个特殊的处理器专门用来渲染文档页面。let openapi OpenApi::new(Todo API, 0.1.0).merge(router); let router router.push(SwaggerUI::new(openapi).path(swagger-ui).into_router());跑起来后访问http://127.0.0.1:5800/swagger-ui/就能看到一个可交互的接口调试页面。这个功能对后端开发来说实在太实用了前端同事不需要你逐条截图直接把文档地址发过去就行。这也是我从“懵”转向“真香”的最大转折点。5. 踩坑实录与排查技巧24 小时体验里当然不是一路顺畅。这里把我真实踩过的坑和排查方法整理一下很多是通用经验换了别的框架也适用。5.1 编译错误最让人头疼的三种场面第一个坑是生命周期问题。Salvo 的 handler 支持从Request里借用数据但如果你写了一个需要跨await的引用编译器会毫不留情地报错。比如我想在parse_json之前先保存请求的 path然后在之后打印第一次写就是这样let path req.uri().path(); let todo req.parse_json::Todo().await?; eprintln!({path});这看起来没问题但在异步函数中req的借用跨越了.await编译器会提示 lifetime 冲突。解决方式很直接需要的数据在 await 之前全部提取成 owned 类型比如let path req.uri().path().to_owned();之后再用。第二个坑是 trait bound 报错。当一个 handler 需要作为Handlertrait object 放进 Router 时它必须是Send Sync static的。如果 handler 内部捕获了某些非 Send 的变量编译器就会提示一堆 trait 约束不满足。这类报错信息通常很长但定位方法很简单逐行看最底下的错误提示中间过程几乎都是推导噪音。第三个坑是#[handler]宏处理后的代码不直观。遇到宏相关错误时我第一反应是别硬看报错直接展开宏cargo install cargo-expand cargo expandcargo expand可以生成宏展开后的完整 Rust 代码虽然工程很大但定位宏观错误很有效。有一次我漏写了返回值类型展开后立刻看到生成的 trait impl 里存在类型不匹配。5.2 运行期才浮现的问题编译过去了运行期也有一些问题。最经典的是 404。新手最容易踩的坑是路由路径没挂对比如你在 handler 里用id但外层注册的是todos/int或者子路由的/todos写成了/todos/尾部多一个斜杠也会导致匹配不上。Salvo 的路由匹配对路径比较严格我的排查方法是先写一个简单的全局日志中间件打印每次请求的实际 path对照注册路由逐段确认。第二个典型问题是 Mutex 中毒导致的 panic。标准库的Mutex在持有锁的线程 panic 后会进入“中毒”状态之后任何lock()都会抛异常。我的代码里大量用了.unwrap()在 demo 阶段无所谓但如果这个 Todo API 上了生产环境这就是一颗定时炸弹。排查起来也很恶心因为 panic 发生在后台线程RUST_BACKTRACE1才能看到完整堆栈。建议业务代码里尽量不要用MutexVecT这种简单结构至少换成RwLock或者直接用 DashMap、数据库连接池这些更专业的并发容器。至少给lock()的错误写一个自定义 panic 信息方便排查。第三个问题就是端口占用。有时候改完代码重新cargo run会报Address already in use这是因为上一次的服务没有正常退出。排查用lsof -i :5800找到进程号直接 kill。如果你经常改代码我强烈建议用cargo-watch做热重载cargo install cargo-watch cargo watch -x run它的体验虽然比不上 Node 的 nodemon 那么丝滑但至少省了手动切终端重新启动的麻烦。5.3 测试与常见配置技巧Salvo 自带测试工具你可以不启动服务器就对 handler 做测试。写测试时用TestClient它能直接构造请求对象并调用内部的服务逻辑。#[tokio::test] async fn test_list_todos() { use salvo::test::TestClient; let router Router::new().get(list_todos); let mut response TestClient::get(http://127.0.0.1:5800/).send(router).await; assert_eq!(response.status_code, Some(StatusCode::OK)); }这个测试不需要真的监听端口直接调用 Router 内部逻辑跑得很快。我给 Todo API 补了几个简单测试验证了基本 CRUD 和 404 场景对后续改代码很有安全感。另外日志是一个容易被忽略的配置项。Salvo 自带了一些 logging 集成但如果你嫌默认日志不够详细就自己在 main 里加tracing_subscriber[toolchain] # 你可以单独引入 tracing-subscriber 做日志格式化 tracing-subscriber 0.3tracing_subscriber::fmt().init();这样中间件里的tracing::info!才能正常输出。没有初始化 subscriber 的话日志静默丢失排查问题会非常痛苦。最后说点个人体会这一天的体验让我对“框架选型”这件事有了新的判断。以前我总觉得 Rust Web 框架的成熟度完全取决于生态数量但 Salvo 让我意识到框架本身是否贴合你写业务代码的思维习惯同样重要。我在这 24 小时里最大的收获不是学会了某个框架的 API而是找回了“用什么工具最顺手”的直觉。如果用一句话总结 Salvo 的定位它是一个让你在一个下午就能把 CRUD 接口带文档跑起来的框架适合快速原型、内部工具、中小型 API 服务也适合 Rust 新人建立完整项目认知。它当然不是万能的在超大流量、超复杂分布式场景下你可能需要更底层的控制能力但 90% 的后端业务其实不需要那种控制力。最后再分享一个小建议如果你决定试 Salvo先别急着上数据库和鉴权把 Hello World 跑通再自己实现一遍 Todo API然后开着日志写接口测试。这 24 小时走完你对 Salvo 的掌握程度绝对够用来接下一个真实项目了。