资讯详情

资讯详情

从懵逼到真香:Salvo 框架 24 小时上手实战

作为一个写了几年 Rust 却在 Web 领域反复骑墙的人我对 Rust 后端框架的态度一直很纠结。Actix-web 性能强但路由写法让我总隔着一层Axum 类型设计漂亮但动不动就要跟 trait 搏斗Rocket 的宏魔法好用可又依赖 nightly 特性。直到某天刷 crates.io看到 Salvo 框架的热度在稳步上升而且文档标题都透着一股我不想让你挠头的劲儿我决定拿出一个周末的碎片时间从零把它跑通。结果就是标题里那个状态变化一开始是真懵逼用明白之后确实真香。Salvo 是一个基于 Hyper 构建的异步 Web 框架路由、中间件、参数提取、OpenAPI 文档生成都是内置能力你不需要像拼积木一样去组装一堆第三方 crate。它解决的核心问题很实在在 Rust 生态里找一个打字少、心智负担低、但又不至于像玩具框架那样啥都得自己造的 Web 方案。这篇文章适合三类人刚接触 Rust 后端、想快速写出能跑服务的初学者已经熟悉 Axum 或 Actix-web、想横向对比的开发者以及翻过 Salvo 官方文档但还是不知道从哪下手的 Rust 爱好者。我会按 24 小时的真实推进节奏把环境搭建、路由机制、中间件、状态管理和频繁踩坑的地方完整讲一遍。1. 初见 Salvo为什么我会押注这个框架1.1 Rust Web 框架的三巨头之外Rust 后端框架的版图里绕不开的基本是这几家。Actix-web 用 actor 模型性能常年霸榜但你要写出优雅的声明式路由学习成本不低而且它的类型系统在某些场景下会把你绕晕。Axum 是 tokio 官方团队在维护类型安全做到极致但这恰恰是新手最痛苦的地方——你想从请求里拿一个参数可能得先搞明白好几个 trait 之间的关系。Rocket 的开发体验确实好宏用起来很爽可它对 nightly Rust 的依赖是个长期存在的摩擦点版本升级时偶尔会有惊喜。三巨头之外Salvo 最让我眼前一亮的地方是它的 API 设计明显在往下沉路由就是路由handler 就是 handler没有刻意炫技的抽象。我第一次打开 Salvo 官方文档时看到示例代码觉得非常朴素。朴素到什么程度就是那种我一眼能看懂每一行在干嘛的程度。但朴素不等于简陋它把复杂的东西藏在了#[handler]宏和类型推导后面。这个设计哲学很合我胃口日常写业务代码的人不应该为框架的抽象复杂度买单。你只需要关心这个请求进来我要做什么剩下的事情框架帮你搞定。1.2 24 小时能做出什么成果先管理一下预期。我说的 24 小时不是让你连续写一天一夜代码而是指一个周末里能挤出的实际动手时间大概六到八个小时。在这个时间段里我完成的事情是搭了一个带用户增删查、登录鉴权、静态文件托管和 OpenAPI 文档生成的演示服务彻底理解了 Salvo 的路由嵌套和中间件执行顺序把同样需求用 Axum 写了一遍做对比记录了代码量的差异还摸清了官方文档里几处语焉不详、需要自己踩坑才知道的知识点。如果你平时的工作内容是写内部管理系统、快速原型验证、个人项目的后端这个学习投入产出比完全可以接受。Salvo 的编译速度在主流 Rust Web 框架里算快的修改后增量编译基本能控制在两秒内这让改一行、跑一下、看效果的循环非常顺畅对新手尤其友好。我甚至觉得如果只想快速验证一个想法Salvo 的启动成本比 Axum 更低因为一开始你不需要理解那么庞大的类型体系。1.3 哪些人不适合 Salvo这个问题我有必要讲在前面免得大家带着错误期待入坑。如果你在做的是一个需要海量中间件生态支撑的生产级项目比如复杂的网关、大型微服务那 Actix-web 或 Axum 背后丰富的社区库会让你更省心如果你的核心需求是 gRPC 或者 protobuf 那一套Tonic 显然是更正确的选择如果你只想要一个能跑通的 demo那用 Axum 其实也完全可以。Salvo 最合适的场景是中小型 Web 服务、内部工具、快速原型以及愿意给新框架一点信任的开发者。我说这话不是劝退而是希望大家选型时心里有数。框架只是工具适合的才是最好的。Salvo 的社区还在成长阶段这既是缺点也是优点缺点是有问题不一定能立刻搜到答案优点是你会有机会参与到框架演进过程中提 issue、看源码、写插件这种参与感在大框架里很难体验到。2. 环境准备与第一个 Hello World2.1 环境要求与项目初始化我假设你已经装好了 Rust 工具链并且知道cargo new是干嘛的。如果还没装去 rustup.rs 装一个稳定版工具链就行。Salvo 对 Rust 版本要求不苛刻stable 通道就能编译但我建议顺手把 Rust 升级到最新版因为新版本的编译器对异步代码的优化更好报错信息也更友好对你排查问题有直接帮助。项目初始化非常简单cargo new salvo-demo cd salvo-demo然后打开Cargo.toml添加两个基础依赖。如果你是 cargo 的老手直接cargo add salvo tokio也可以不过 tokio 的 features 建议手动配一下。[dependencies] salvo 0.74 tokio { version 1, features [macros, rt-multi-thread] }这里有两个细节值得注意。第一Salvo 的迭代速度很快我在写这篇文章时用的是 0.74你看到这里时大概率已经出了更新版本。不要纠结具体版本号直接让 cargo 解析最新稳定版就好。第二tokio 必须开启macros和rt-multi-thread这两个 feature否则#[tokio::main]这个宏用不了运行时也跑不起来。如果你担心漏 feature初期可以用features [full]偷个懒编译时间会长一点但对新手来说更省心。2.2 最小 Hello World 的完整代码把src/main.rs里的内容替换成下面这段use salvo::prelude::*; #[handler] async fn hello() - static str { Hello, Salvo! } #[tokio::main] async fn main() - Result(), Boxdyn std::error::Error { let router Router::new().get(hello); let acceptor TcpListener::new(127.0.0.1:5800).bind().await; Server::new(acceptor).serve(router).await?; Ok(()) }然后cargo run浏览器访问http://127.0.0.1:5800。当你看到页面上出现Hello, Salvo!的时候第一个里程碑就完成了。这个过程比我想象中顺它甚至不需要像某些框架那样先配置一层层的外围设施一个文件就能跑。这里有一段值得讲清楚的内容。#[handler]是 Salvo 提供的最核心宏它会把你的普通异步函数在编译期翻译成框架需要的 Handler 实现。你在函数签名里写- static str宏会自动帮你生成把字符串写入 Response 的代码。如果你写的函数没有参数宏就知道你不需要读取请求内容。这种你只管写业务逻辑框架负责对接口的模式一开始让我有点不踏实总想掀开宏看看底下到底发生了什么。后面我会专门拆解。2.3 新手常见的小麻烦Hello World 跑不起来的情况我身边朋友遇到过几类。端口被占用是最常见的Mac 或 Linux 下用lsof -i:5800就能看到进程kill 掉再来Windows 下用netstat -ano | findstr 5800找 PID再taskkill //PID pid //F处理。还有一种情况是代理环境变量捣乱如果你脚本里设置了HTTP_PROXY之类的变量某些网络请求会受影响服务起没起来跟这个没关系但它会影响 cargo 拉依赖的速度可以先确认一下。还有一个容易忽略的点如果你改了端口记得同步改浏览器地址。这看起来像是废话但我真的因为复制粘贴 URL 而对着 404 页面迷茫了好几秒。细节虽小却很能影响初学体验。3. 从懵逼到真香路由与 Handler 的核心机制3.1 Handler 宏到底做了什么现在开始进入正题。为什么一个函数加上#[handler]之后就能被路由直接使用这是我最初最困惑的点也是理解 Salvo 的钥匙。如果你读过 Salvo 源码会看到它定义了一个Handlertrait这个 trait 的方法签名需要处理Request、Depot、Response、FlowCtrl这几个核心类型。手写这个 trait 的 impl 非常啰嗦而且每个 handler 的签名还可能因为参数不同而变化。#[handler]宏存在的意义就是在编译期根据你函数的参数列表自动生成对应的 trait 实现。这就是为什么写 Salvo 代码时你经常感觉自己在写一个普通函数而不是在对接框架——因为这些对接工作被宏接走了。具体来说宏会分析你函数签名的参数类型。如果第一个参数是mut Request它就让你直接操作原始请求如果参数是某个实现了FromStr的类型它就从路径参数里解析并尝试转换如果参数是JsonBodyT这种包装类型它就做好 JSON 反序列化并把错误处理接到框架的错误链路上。想通这一点之后很多困惑都会迎刃而解。你写的不是一个普通的 Rust 函数而是一个通过宏展开后真正实现了框架要求的 Handler 代码。一旦接受这个设定再看 Salvo 的示例代码就会觉得处处顺眼。那种从这宏到底在搞什么到原来如此的转变就是懵逼到真香的拐点。3.2 路由系统的正确打开方式Salvo 的路由声明方式和 Axum 有明显区别。Axum 是用Router::new().route(/users/{id}, get(get_user))这种方式路由字符串直接写在方法参数里。Salvo 则是把路径信息写进Router::with_path(users/{id})然后通过链式调用绑定 handler。还有一个必须注意的语法差异路径参数用的是大括号{}不是冒号:id。我第一次写的时候顺手写了:id编译没报错但运行起来就是匹配不上排查了一会儿才发现问题出在语法上。路由可以嵌套这是实际项目里特别有用的特性。比如一个用户模块可以组织成这样let user_router Router::with_path(users) .get(list_users) .push(Router::with_path({id}).get(get_user).patch(update_user)); let admin_router Router::with_path(admin) .hoop(admin_auth) .push(user_router);这种树状结构的优势在于每个子路由可以独立挂载中间件。权限控制、日志、限流都能按模块粒度去切而不是一股脑堆在全局。我在做实战项目时把公开接口和需要登录的接口拆成了两个顶层 Router分别挂不同的中间件代码结构一下清晰了。查看路由表时你看到的组织方式和代码的组织方式是一致的这比那种靠字符串定义路由的框架更符合直觉。3.3 路径参数与查询参数提取路径参数的提取方式Salvo 和大多数 Rust 框架大同小异。假设你定义了Router::with_path(users/{id})在 handler 里取这个id的写法是#[handler] async fn get_user(req: mut Request) - ResultString, () { let id: i64 req.params().get(id).unwrap_or_default().parse().unwrap(); Ok(format!(查询到的用户 ID 是{id})) }这段代码能跑但unwrap_or_default()加parse().unwrap()的写法只适合演示。正常项目里绝不能这么写因为它一旦遇到 URL 里传了abc或者压根没传参数服务就直接 500 了。更稳妥的写法是先判断参数是否存在再做parse::i64()失败时返回一个业务错误把它交给统一错误处理去转成 400。#[handler] async fn get_user(req: mut Request) - ResultString, ApiError { let id req.params().get(id).ok_or(ApiError::BadRequest(缺少 id 参数.into()))?; let id: i64 id.parse().map_err(|_| ApiError::BadRequest(id 参数格式错误.into()))?; Ok(format!(查询到的用户 ID 是{id})) }虽然长了几行但每个分支都清晰可控这才是真正能上生产环境的写法。这个差异新手往往要栽几次跟头才能真正重视起来。3.4 请求数据绑定JSON 和表单Salvo 对请求体的绑定方式也走的是参数注入路线。你定义一个实现了Deserialize的结构体然后在 handler 参数里声明这个类型框架就会帮你完成反序列化。一个典型的 JSON 创建接口长这样use serde::Deserialize; #[derive(Deserialize)] struct CreateUserRequest { name: String, age: u8, } #[handler] async fn create_user(body: JsonBodyCreateUserRequest) - String { format!(创建用户{}年龄 {} 岁, body.name, body.age) }JsonBodyT是 Salvo 提供的提取器它在反序列化失败时会自动把错误接入框架的错误链路你不需要自己处理 400。如果你对错误格式有特殊要求也可以手动读取请求体再解析let payload: CreateUserRequest req.parse_json().await.map_err(|_| ApiError::BadRequest(JSON 格式错误.into()))?;两种方式我都用过。快速原型选JsonBodyT最舒服代码量最少正式项目如果要对错误格式做统一封装我会选手动解析灵活度更高。表单数据同理FormBodyT和req.parse_form()都能用写法几乎一样。这种声明式提取的思路贯穿 Salvo 处理请求的全过程熟悉之后写接口的效率提升很明显。4. 中间件与状态管理Salvo 的积木式设计4.1 中间件执行模型Salvo 把中间件叫做 Hoop——我第一次看到这个词愣了一下后来想想还挺贴切像一圈圈套在路由上的环。它的执行模型和多数框架的洋葱模型类似请求先经过外层中间件再进入内层最后到达真正的 handler响应返回时再逆序经过每一层中间件。一个简单的日志中间件长这样async fn log_middleware( req: mut Request, depot: mut Depot, res: mut Response, ctrl: mut FlowCtrl, ) { println!(收到请求{} {}, req.method(), req.uri().path()); ctrl.call_next().await; println!(请求处理完成{}, res.status_code()); }关键在于FlowCtrl这个类型。ctrl.call_next().await表示继续执行后续的 handler。如果某个中间件判定请求不合法它可以不调用call_next而是直接设置一个状态码并结束链路这样后面所有 handler 都不会执行。这种显式控制流程的风格一开始我不太习惯总觉得流程还能不能继续要走一步看一步但写多了反而觉得比隐式的next.run()更直观——你一眼就能看出哪个环节会短路不会出现中间件明明挂了但不知道为什么没生效的困惑。4.2 状态共享的正确姿势Web 开发绕不开状态共享数据库连接池、全局配置、当前登录用户信息这些东西需要在一个请求的生命周期里被多个 handler 访问。Salvo 用Depot充当请求上下文容器它的本质是一个带类型擦除的 Map。你可以通过inject放入值再用obtain取出。#[derive(Clone)] struct AppState { app_name: String, } #[handler] async fn inject_state(depot: mut Depot) { let state AppState { app_name: salvo-demo.to_string() }; depot.inject(state); } #[handler] async fn index(depot: mut Depot) - String { let state depot.obtain::AppState().unwrap(); format!(当前应用{}, state.app_name) } let router Router::new() .hoop(inject_state) .get(index);这里必须提醒一个坑Depot是类型擦除的意味着它不会在编译期帮你检查类型是否匹配。如果你在 A 中间件里inject了AppState在 B handler 里obtain时写成了别的类型运行时直接 panic。这不算 Salvo 的缺陷是类型擦除容器的通病但它对你的代码组织能力提出了要求。我的习惯是所有需要共享的状态类型集中放在一个模块里统一管理每次obtain之后用if let Some(...)显式处理绝不图省事直接unwrap。这样的代码跑起来稳出了错也容易定位。4.3 错误处理与统一响应Salvo 的错误处理非常灵活但也因为灵活新手容易写出逻辑混乱的代码。我实践下来的推荐做法是定义自己的业务错误枚举在路由层级挂一个错误处理中间件把错误统一转换成 JSON 返回给客户端。#[derive(Debug, thiserror::Error)] enum ApiError { #[error(资源不存在)] NotFound, #[error(请求参数错误{0})] BadRequest(String), } #[handler] async fn error_handler(res: mut Response, ctrl: mut FlowCtrl) { ctrl.call_next().await; if let Some(error) res.error() { let status match error { ApiError::NotFound StatusCode::NOT_FOUND, ApiError::BadRequest(_) StatusCode::BAD_REQUEST, }; res.status_code(status); res.render(Json(json!({ code: status.as_u16(), message: error.to_string() }))); } }这段代码的精髓在于先让后续的 handler 执行让它把错误信息写进Response再由这个中间件统一转换成客户端友好的 JSON。这样一来每个 handler 只需要负责返回Result_, ApiError不用关心 HTTP 状态码怎么映射、错误 JSON 长什么样。对比在每个 handler 里重复写错误转换的方式这种集中式处理让维护成本直线下降接口结构也统一得多。5. 实战用 Salvo 拼一个用户管理服务5.1 需求分析与路由设计理论讲再多不如来一个能跑的完整小项目。我构建这个用户管理服务时定的需求很简单提供用户列表查询、创建用户、删除用户三个接口附带一个静态页面方便浏览器直接调试同时在/openapi路径挂一个 OpenAPI 文档方便前端同学对接。路由组织成下面这个样子let api Router::with_path(api/v1) .push(Router::with_path(users).get(list_users).post(create_user)) .push(Router::with_path(users/{id}).delete(delete_user)); let doc Router::with_path(openapi) .get(openapi_json); let router Router::new() .push(api) .push(doc) .push(Router::with_path(static).get(static_files));这种设计看起来平淡无奇但它是 Salvo 项目里非常实用的组织方式。每个模块的入口是独立 Router逐层往上拼资源路径天然形成树状结构。你可以在users子树上单独挂Authentication、RequestLimit之类的中间件却对openapi区域完全放开限制。以后新增模块也不需要去动全局路由配置只需要在某棵子树下继续push子路由就行。这种增量式组织方式让项目路由的演进能跟上代码演进的速度。5.2 核心业务逻辑实现从数据到接口由于不引入真实数据库我选择用ArcMutexVecUser来模拟存储把它注入 Depot 后每个 handler 都能共享同一个数据集。用户结构体定义如下#[derive(Clone, Debug)] struct User { id: u64, name: String, age: u8, }列表接口的实现非常直接从 Depot 里拿数据集转成 JSON 返回即可#[handler] async fn list_users(depot: mut Depot) - ResultJsonVecUser, ApiError { let users depot.obtain::ArcMutexVecUser().unwrap(); let users users.lock().unwrap().clone(); Ok(Json(users)) }创建用户的逻辑复杂一点解析请求体、生成新的 ID、把用户插入集合。这里我建议你在实际开发里一定要加字段校验比如 name 不能为空、age 范围要合理否则空数据接口会让前端很难做。删除用户则是最容易出错的环节。如果传入的 ID 在集合中不存在是返回 404 还是 204我的选择是返回 404同时抛出一个ApiError::NotFound让上面的统一错误处理中间件接手生成一个{ code: 404, message: 资源不存在 }的 JSON。整个过程里handler 内部不需要任何 HTTP 状态码的代码这种只管业务、不管协议细节的开发方式用久了会让人上瘾。5.3 借助内置能力补齐周边功能Salvo 的另一个让我满意的地方是开箱即用的周边功能。CORS 中间件不需要额外引入一堆传递依赖静态文件服务也是单独模块用的时候再引入即可不会增加无谓的编译负担。静态文件托管代码很简单use salvo::serve_static::StaticDir; let static_files StaticDir::new(vec![public]); let router Router::with_path(static).get(static_files);你只需要在项目根目录建立一个public文件夹把 HTML、CSS、JS 放进去浏览器访问/static/xxx就能直接拿到。这对快速原型验证来说特别方便——后端接口写好了前端页面可以直接起一个静态服务器调试不用再折腾 Nginx 或者单独开一个 Node 服务。OpenAPI 文档的接入是另一个亮点。Salvo 的 oapi 模块可以做到从 handler 签名自动推导文档结构不需要手写 YAML 或 JSON。对前端对接者来说这个文档简直救命请求参数、返回结构一目了然。不过要注意的是要想文档生成得漂亮你的请求体和响应体类型尽量都派生出ToSchema这样文档字段名和 JSON 字段名完全对得上。6. 新手最容易踩的坑与排查实录6.1 常见问题速查表这一节我把自己折腾过的问题整理成一张表建议直接收藏。很多问题不是代码难写而是没想到是这里出错。症状可能原因解决办法编译报错找不到Handler或Routercrate 版本差异导致模块路径变化优先用use salvo::prelude::*引入访问{id}路由始终 404路径参数写成了:id而不是{id}改用花括号语法req.parse_json()报错请求体不是合法 JSON或 Content-Type 不对先打印原始 body确认请求头depot.obtain::T()返回 None类型不匹配或没有提前 inject统一类型定义显式处理 None 分支端口被占用上次启动的进程未退出用 lsof / netstat 查找并 kill静态文件 404目录路径不对或工作目录错误确认启动时的工作目录与public的相对路径中间件没生效中间件挂在了错误的 Router 层级检查路由树结构中间件只对当前子树生效6.2 两个独家避坑技巧第一个技巧在 handler 里尽量少用unwrap()。我因为depot.obtain的类型写错运行时 panic 过好几次排查半天才发现是类型不匹配。后来养成了习惯所有从外部拿数据的地方一律用if let Some(...)或match写即便确定不会出错也留个兜底分支。这不会让代码长太多但能显著减少调试时间。尤其是在重构代码时一旦类型改名或者 inject 的位置移动了unwrap()那行代码会直接变成隐藏炸弹而显式处理分支则会在测试时立刻暴露问题。第二个技巧善用日志把中间件执行顺序打印出来。Salvo 中间件如果没写对最典型的困惑是为什么我的 handler 根本没被执行。你可以在每个中间件入口和出口各打印一条日志标记进入和退出。这样请求进来之后你一眼就能看到链路在哪个环节被截断了。是权限校验没通过还是某个中间件忘了调用ctrl.call_next()日志不会说谎它比对着代码一行行猜效率高太多。6.3 版本升级带来的惊喜Salvo 迭代速度很快这带来一个反向问题网上很多教程写得早现在可能已经过时了。我初学时参考了一篇几个月前的博客代码风格跟我拉下来的最新版有明显差异报错信息也对不上折腾了好久才发现是版本差异。这里给你一个实用建议遇到编译报错时先确认你用的版本和教程示例是否一致。最好的做法是去官方文档或 GitHub 仓库的 examples 目录里找最新代码那里永远跟当前版本同步。另外一个相关的心得是cargo update升级 Salvo 之后最好把整个项目重新编译一遍并跑一遍接口测试。框架内部的 breaking change 有时候不会在 changelog 里说得特别明显但测试会诚实地告诉你哪里坏了。对于小项目来说这种升级成本是可控的但对大型项目就要谨慎评估了。6.4 关于性能与并发的一点观察Salvo 基于 Hyper性能底子是有保障的。我在本地用简单的压力测试跑了一下并发请求下处理器都能稳定响应内存占用也比较克制。但我建议新手不要过度关注性能指标至少在入门阶段你的瓶颈大概率在业务逻辑和数据结构设计上而不是框架本身。把路由组织好、错误处理好、状态管理做好这些对实际体验的影响远比每秒多处理几千个请求更直接。7. 尾声一些个人体会写到这里我的 24 小时 Salvo 体验接近尾声。最后想分享一个细节我最初注意到 Salvo不是因为它的性能数据而是因为它名字的含义——Salvo 在意大利语里有保留、安全的意思。一个框架愿意用这样的词命名总让我觉得它想把稳放在炫前面。实际用下来它确实给了我这种感觉。如果你现在正处于想找个好上手、又不想牺牲 Rust 性能的 Web 框架的阶段我的建议是别犹豫太久直接照着这篇博客从 Hello World 开始花一个周末亲自体验。重点是把路由、中间件、状态管理三者跑通理解#[handler]宏背后的生成逻辑。等你走到那一步大概率也会和我一样从最初这宏到底干了什么的疑惑变成下一个功能什么时候能上线的摩拳擦掌。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →