Salvo 是一个基于 Rust 语言构建的异步、高性能、功能丰富的 Web 服务器框架。它设计简洁、模块化,易于使用和扩展,并提供了强大的路由、中间件、数据提取、WebSocket、TLS (支持 OpenSSL/Rustls 证书热重载)、HTTP/3、反向代理、请求超时控制等支持。
此示例演示了如何使用 Salvo 的
concurrency-limiter
中间件来限制可以同时处理的并发请求数量。这对于控制资源密集型操作(如文件上传)的负载非常有用。
此示例设置了两个文件上传端点:
/limited
:此路径应用了 max_concurrency(1)
中间件,意味着一次只允许处理一个上传请求。后续的请求将排队等待,直到当前请求完成。/unlimit
:此路径没有并发限制,可以同时处理任意数量的上传请求。两个端点都使用同一个 upload
处理器,该处理器模拟了一个耗时 10 秒的操作(tokio::time::sleep
),以清晰地展示并发限制的效果。index
处理器提供一个包含两个上传表单的 HTML 页面,分别指向这两个端点。
Cargo.toml
: 定义项目依赖,并通过 features = ["concurrency-limiter"]
启用了 Salvo 的并发限制器功能。src/main.rs
: 包含主要的应用程序逻辑:
index
处理器以提供 HTML 表单。upload
处理器来处理文件上传并模拟延迟。/limited
路径应用 max_concurrency(1)
中间件。temp
用于存储上传的文件。0.0.0.0:5800
。此示例展示了如何使用 Salvo 的 CSRF (跨站请求伪造) 保护功能,并将 CSRF token 存储在会话 (Session) 中。它演示了多种不同的加密/签名算法来保护 token。
为了实现 CSRF 保护,该示例依赖于 Salvo 的 session
功能来存储每个客户端的 CSRF token。主要步骤包括:
SessionHandler
,使用 MemoryStore
(内存存储) 和一个密钥来管理会话数据。FormFinder
指定从表单的哪个字段 (csrf_token
) 提取 token。bcrypt_session_csrf
, hmac_session_csrf
等)。get_page
) 中,通过 depot.csrf_token()
获取(或生成新的)CSRF token,并将其嵌入到返回的 HTML 表单中。post_page
) 中,Salvo 的 CSRF 中间件会自动验证请求中提交的 token 是否与会话中存储的匹配。如果验证通过,处理器逻辑才会执行;否则,将返回错误。处理器随后会再次生成新的 token 用于下一次请求。/bcrypt/
, /hmac/
, /aes_gcm/
, /ccp/
),每个路径应用对应算法的 CSRF 中间件。所有这些路径都共享 get_page
和 post_page
处理器。此示例的核心在于演示如何结合会话管理来实现有状态的 CSRF 保护,并提供了多种安全级别的算法选项。
Cargo.toml
: 引入 salvo
依赖,并启用 csrf
和 session
特性。同时需要 serde
用于表单数据的序列化/反序列化。src/main.rs
:
home
, get_page
, post_page
处理器。SessionHandler
和各种基于 Session 的 Csrf
中间件。get_page
中生成并显示 CSRF token。post_page
中解析表单数据(包含 csrf_token
),CSRF 中间件隐式执行验证。此示例演示了如何使用 Salvo 和 Quinn (一个 QUIC 协议实现) 来创建一个 WebTransport 服务器。WebTransport 是一种基于 HTTP/3 的现代双向通信协议,提供低延迟的数据传输,适用于实时应用。
该示例实现了一个简单的 echo 服务器,可以通过 WebTransport 连接:
QuinnListener
来处理 QUIC 连接,并通过 join
与一个标准的 TcpListener
(使用 Rustls 处理 TLS) 结合,允许服务器同时处理 HTTP/1.1 或 HTTP/2(用于提供静态文件和可能的备用连接)和 HTTP/3 (用于 WebTransport)。connect
处理器通过 req.web_transport_mut().await
获取 WebTransport 会话。session.open_bi
) 向客户端发送消息。session.accept_datagram()
接收不可靠的数据报,并在前面加上 "Response: " 后 echo 回去。session.accept_uni()
接收客户端发起的单向流,读取所有数据,然后通过服务器端打开的对应单向流 (session.open_uni
) 将数据分块 echo 回去。session.accept_bi()
接收客户端发起的双向流,将其分割为读写两半,然后同样将接收到的数据分块 echo 回去。StaticDir
提供了一个简单的 HTML/JS 客户端页面 (static/client.html
, static/client.js
),用户可以通过浏览器访问此页面来与 WebTransport 服务器交互。此示例展示了 Salvo 处理高级网络协议 (如 WebTransport) 的能力,以及如何管理其不同的通信模式(数据报、单向/双向流)。
Cargo.toml
:
salvo
的 quinn
(提供 WebTransport 和 HTTP/3 支持) 和 serve-static
(提供静态文件服务) 特性。anyhow
(错误处理), bytes
(数据处理), futures-util
(异步流处理) 等辅助库。src/main.rs
:
connect
处理器,核心的 WebTransport 会话处理逻辑。echo_stream
和 send_chunked
辅助函数处理流数据。RustlsConfig
加载证书。QuinnListener
和 TcpListener
,并将它们绑定到端口 5800。/counter
路径指向 connect
处理器,并将其他路径用于提供静态文件。certs/
: 包含用于本地测试的自签名证书 (cert.pem
) 和私钥 (key.pem
)。static/
:
client.html
: 提供给用户的 WebTransport 客户端界面。client.js
, client.css
: 客户端的 JavaScript 逻辑和样式。此示例演示了 Salvo 如何捕获和处理来自处理器 (Handler) 的不同类型的错误,包括标准库错误、
anyhow::Error
、eyre::Report
以及自定义错误类型。
Salvo 处理器可以返回 Result<T, E>
。如果返回 Ok(T)
,则正常处理 T
(通常 T
实现了 Writer
trait 来写入响应)。如果返回 Err(E)
,Salvo 会尝试处理这个错误 E
:
salvo::Error
, std::io::Error
等)有内置的处理逻辑,通常会转换成合适的 HTTP 状态码和响应体。Writer
Trait 处理: 如果错误类型 E
实现了 salvo::Writer
trait,Salvo 会调用其 write
方法,允许你完全自定义错误对应的 HTTP 响应。示例中的 CustomError
就是这样处理的,它实现了 Writer
trait,将响应状态码设置为 500 并写入自定义消息 "custom error"。anyhow
和 eyre
: 当启用 salvo
的 anyhow
或 eyre
特性时,Salvo 会自动为 anyhow::Error
和 eyre::Report
提供 Writer
实现。这意味着你可以直接在处理器中返回 Result<(), anyhow::Error>
或 Result<(), eyre::Result>
,Salvo 会捕获这些错误并生成一个通常包含错误信息的 500 Internal Server Error 响应。此示例通过三个不同的处理器展示了这几种情况:
handle_anyhow
: 返回 anyhow::anyhow!("handled anyhow error")
。handle_eyre
: 返回 eyre::Report::msg("handled eyre error")
。handle_custom
: 返回自定义的 Err(CustomError)
.访问对应的路径 (/anyhow
, /eyre
, /custom
) 会触发相应的错误,并观察 Salvo 如何根据错误类型生成响应。
Cargo.toml
:
salvo
的 anyhow
和 eyre
特性以集成这两种错误处理库。anyhow
和 eyre
依赖。src/main.rs
:
CustomError
结构体。CustomError
实现 Writer
trait来自定义错误响应。handle_anyhow
, handle_eyre
, handle_custom
三个处理器,分别返回不同类型的错误。此示例演示了如何让 Salvo 服务器同时监听多个 TCP 地址和端口。
通过 TcpListener
的 join()
方法,可以将多个监听器配置组合在一起。当调用 bind().await
时,Salvo 会尝试绑定所有指定的地址和端口。服务器随后会接受来自任何一个监听器的连接。
在这个具体的例子中:
TcpListener::new("0.0.0.0:5800")
创建一个监听 5800 端口的配置。.join(TcpListener::new("0.0.0.0:5801"))
将监听 5801 端口的配置加入。bind().await
实际执行绑定操作,使服务器同时在 5800 和 5801 端口上接受连接。访问 http://localhost:5800
或 http://localhost:5801
都会路由到同一个 hello
处理器,并得到 "Hello World" 响应。
Cargo.toml
: 包含基本的 salvo
和 tokio
依赖。src/main.rs
:
hello
处理器。TcpListener::new(...).join(...)
配置多端口监听。bind().await
绑定监听器。此示例展示了如何使用 Salvo 的
oapi
(OpenAPI) 功能来构建一个带有交互式 API 文档的 TODO List RESTful API。
核心特性和步骤:
Cargo.toml
中启用 salvo
的 oapi
特性。Todo
结构体,并使用 #[derive(Serialize, Deserialize, ToSchema)]
让它能被序列化/反序列化,并自动生成 OpenAPI Schema。可以使用 #[salvo(schema(example = ...))]
提供示例值。#[endpoint]
宏标记处理 HTTP 请求的函数。这个宏会自动处理请求解析、响应序列化,并为 OpenAPI 文档收集元数据。oapi::extract::*
中的提取器(如 QueryParam
, JsonBody
, PathParam
)来声明和获取请求参数/体。这些提取器会自动将参数信息添加到 OpenAPI 文档。#[endpoint]
中添加 tags(...)
, status_codes(...)
, parameters(...)
等属性来丰富 OpenAPI 文档信息。list_todos
, create_todo
, update_todo
, delete_todo
,它们操作一个内存中的 STORE
(使用 tokio::sync::Mutex<Vec<Todo>>
) 来模拟数据库。OpenApi
实例,提供 API 的标题和版本。.merge_router(&router)
将路由信息和从 #[endpoint]
宏收集到的元数据合并到 OpenAPI 文档对象中。OpenApi
文档对象转换为一个路由,通常提供 JSON 格式的文档 (doc.into_router("/api-doc/openapi.json")
)。SwaggerUi
, Scalar
, RapiDoc
, ReDoc
。它们都指向之前生成的 JSON 文档路径。访问 /swagger-ui
, /scalar
, /rapidoc
, 或 /redoc
可以查看和交互式地测试 TODO API。
Cargo.toml
: 启用 salvo
的 oapi
特性,并包含 serde
(数据序列化) 和 tokio
(异步运行时) 依赖。src/main.rs
:
Todo
数据结构和内存 STORE
。#[endpoint]
定义 CRUD 操作的处理器 (list_todos
, create_todo
, update_todo
, delete_todo
)。Router
注册业务 API 路径。OpenApi
实例并合并路由信息。index
处理器提供一个简单的 HTML 页面链接到各个文档 UI。此示例演示了如何在 Salvo 应用中集成 MongoDB 数据库,实现基本的 CRUD (创建、读取、更新、删除 - 此示例主要展示创建和读取) 操作。
主要步骤和概念:
Cargo.toml
中添加 mongodb
crate。main
函数中,使用 mongodb::Client::with_uri_str
连接到 MongoDB 实例。连接 URI 通常从环境变量读取或使用默认值。std::sync::OnceLock
将 Client
实例存储为全局静态变量 MONGODB_CLIENT
。提供了一个辅助函数 get_mongodb_client()
来获取它。User
结构体,并使用 #[derive(Debug, Deserialize, Serialize)]
使其可以与 BSON (MongoDB 的文档格式) 进行相互转换。注意 _id
字段通常是 Option<ObjectId>
类型。create_username_index
函数,在启动时为 users
集合的 username
字段创建一个唯一索引,以确保用户名的唯一性并提高查询性能。add_user
:
User
数据 (req.parse_json::<User>()
)。client.database(DB_NAME).collection::<Document>(COLL_NAME)
)。注意这里使用了 Document
类型,也可以直接用 User
类型 если BSON 结构匹配。User
数据转换为 BSON Document
(doc!{ ... }
)。coll_users.insert_one()
将文档插入数据库。get_users
:
User
类型的集合 (collection::<User>(COLL_NAME)
)。coll_users.find(None, None)
查询所有文档,返回一个 Cursor
。try_stream_ext::TryStreamExt
(通过 cursor.try_next().await?
) 迭代 Cursor
,将结果收集到 Vec<User>
中。res.render(Json(vec_users))
)。get_user
:
username
(req.param::<String>("username")
)。coll_users.find_one(doc! { "username": &username }, None)
按用户名查询单个文档。Router
并将相应的 HTTP 方法和路径 (/users
, /users/{username}
) 映射到对应的处理器。Error
枚举来包装 mongodb::error::Error
,并为其实现了 Writer
trait(尽管在此示例中 write
方法为空,实际应用中应提供更有意义的错误响应)。处理器返回 AppResult<T>
(即 Result<T, Error>
)。Cargo.toml
: 添加 mongodb
, serde
, serde_json
, thiserror
(用于定义自定义错误) 依赖。futures
通常是 mongodb
的间接依赖,这里显式列出可能用于 TryStreamExt
。src/main.rs
:
User
模型和 Error
枚举。Error
的 Writer
trait。OnceLock
中。add_user
, get_users
, get_user
处理器,包含数据库操作逻辑。create_username_index
函数。此示例展示了如何在 Salvo 中处理 WebSocket 连接。WebSocket 提供了一种在客户端和服务器之间进行全双工通信的协议,常用于实时应用。
主要步骤:
Cargo.toml
中启用 salvo
的 websocket
特性。Upgrade: websocket
, Connection: Upgrade
)来请求协议升级。connect
)。salvo::websocket::WebSocketUpgrade::new().upgrade(req, res, |mut ws| async move { ... })
来处理升级请求。upgrade
方法接收一个闭包,该闭包在 WebSocket 连接成功建立后执行。闭包接收一个 WebSocket
对象 ws
作为参数。async move
块中,你可以使用 ws.recv().await
来异步地接收来自客户端的消息(Message
类型),并使用 ws.send(msg).await
来异步地向客户端发送消息。recv()
可能返回的 None
(表示连接已关闭) 或 Err
(表示发生错误)。同样,send()
也可能返回 Err
(通常表示客户端已断开连接)。req.parse_queries::<User>()
),并将这些数据(如用户信息)传递到 WebSocket 处理闭包中使用。/ws
)添加到路由器。INDEX_HTML
),其中使用 JavaScript 的 WebSocket
API 连接到服务器的 /ws
端点。它还演示了如何在 URL 查询字符串中传递参数 (?id=123&name=chris
)。Cargo.toml
: 启用 salvo
的 websocket
特性。需要 serde
用于解析查询参数中的 User
结构。futures-util
可能被 salvo
内部或 WebSocket 处理需要。src/main.rs
:
User
结构体用于从查询参数解析数据。connect
处理器,使用 WebSocketUpgrade
处理连接升级和后续的 WebSocket 消息收发(echo 逻辑)。index
处理器提供包含客户端 JavaScript 的 HTML 页面。/ws
指向 connect
处理器。此示例演示了如何使用 Salvo 实现 Server-Sent Events (SSE)。SSE 是一种允许服务器向客户端单向推送更新的标准 HTTP 协议,常用于实时通知或数据流。
核心概念:
Cargo.toml
中启用 salvo
的 sse
特性。Content-Type: text/event-stream
),由一系列事件组成。event: <event_name>
, data: <event_data>
, id: <event_id>
, retry: <reconnection_time>
。事件之间用空行分隔。Salvo
提供了 SseEvent
结构体来方便地构建这些事件。futures_util::Stream<Item = Result<SseEvent, E>>
的异步流,其中 E
是某种错误类型(如果是 infallible stream,可以用 std::convert::Infallible
)。tokio::time::interval
创建一个每秒触发一次的定时器。tokio_stream::wrappers::IntervalStream
将定时器转换为一个异步流。.map()
处理流中的每个项(定时器触发信号),递增一个计数器 counter
,并调用 sse_counter(counter)
函数将计数器值包装成一个 SseEvent
(具体是只包含 data
字段的事件)。handle_tick
)中,获取到准备好的事件流 event_stream
。salvo::sse::stream(res, event_stream)
。这个函数会设置正确的响应头 (Content-Type: text/event-stream
等),并将事件流中的每个 SseEvent
格式化后写入响应体。EventSource
API 来连接 SSE 端点并接收事件。本示例未包含客户端代码,但标准的 EventSource
用法即可。此示例实现了一个简单的计数器服务,每秒向连接的客户端发送一个包含递增数字的 SSE 事件。
Cargo.toml
: 启用 salvo
的 sse
特性。需要 tokio-stream
用于将 tokio::time::interval
转换为流,以及 futures-util
来操作流 (StreamExt
)。src/main.rs
:
sse_counter
函数,将 u64
转换为 Result<SseEvent, Infallible>
。handle_tick
处理器:
IntervalStream
作为事件源。.map()
将定时器信号转换为 SseEvent
结果流。sse::stream()
将事件流发送给客户端。/ticks
指向 handle_tick
。这是一个最简单的 Salvo OpenAPI (
oapi
) 示例,演示了如何为一个基本的 "Hello World" 端点自动生成 OpenAPI 文档。
核心步骤:
Cargo.toml
中启用 salvo
的 oapi
特性。#[endpoint]
宏标记 hello
函数。
name
(类型 QueryParam<String, false>
)。QueryParam
是 salvo::oapi::extract
提供的提取器,它会自动从请求中提取参数,并将其信息添加到 OpenAPI 文档。false
表示该参数是可选的。String
。#[endpoint]
宏会确保返回值被正确处理并添加到 OpenAPI 文档的响应部分。Router
并注册 hello
端点到 /hello
路径。OpenApi
实例,提供 API 名称和版本。.merge_router(&router)
合并路由和端点元数据。OpenApi
对象转换为提供 OpenAPI JSON 的路由 (doc.into_router("/api-doc/openapi.json")
)。SwaggerUi
路由 (SwaggerUi::new("/api-doc/openapi.json").into_router("/swagger-ui")
) 以提供交互式 UI。访问 /swagger-ui
可以看到自动生成的文档,其中包含 /hello
端点,描述了其可选的 name
查询参数和返回的字符串响应。
Cargo.toml
: 启用 salvo
的 oapi
特性。src/main.rs
:
#[endpoint]
定义 hello
处理器,使用 QueryParam
提取参数。OpenApi
实例并合并路由。此示例演示了如何使用
rust-embed
crate 将静态文件(如 HTML, CSS, JS, 图像)直接嵌入到编译后的二进制文件中,并通过 Salvo 提供这些嵌入的文件服务。这对于创建单文件可执行分发很有用。
主要步骤:
Cargo.toml
中添加 rust-embed
crate 和 salvo
(需要 serve-static
特性,虽然这里没直接用 StaticDir
,但 EmbeddedFileExt
可能依赖相关功能)。Assets
)。#[derive(RustEmbed)]
宏标记该结构体。#[folder = "static"]
属性指定包含要嵌入文件的目录(相对于 Cargo.toml
的路径)。rust-embed
会在编译时扫描此目录并将文件内容包含在内。serve_file
)。"{**rest}"
捕获路径的剩余部分作为文件名。Assets::get(&path)
尝试从嵌入的资源中获取文件。Assets::get()
返回一个 Option<rust_embed::EmbeddedFile>
。Some(file)
):
file.render(req, res)
。EmbeddedFileExt
trait(由 salvo::serve_static::EmbeddedFileExt
提供,需要 use
进来)为 EmbeddedFile
添加了 render
方法。这个方法会自动设置合适的 Content-Type
响应头(基于文件扩展名或 Mime Guess)并将文件内容写入响应体。None
):
StatusCode::NOT_FOUND
。Router::with_path("{**rest}")
) 指向 serve_file
处理器。static/
目录下文件的路径(如 /test1.txt
, /test2.txt
)将返回嵌入的文件内容。Cargo.toml
: 添加 rust-embed
和 salvo
(带有 serve-static
特性) 依赖。src/main.rs
:
Assets
结构体,使用 #[derive(RustEmbed)]
和 #[folder = "static"]
。salvo::serve_static::EmbeddedFileExt
。serve_file
处理器,使用 Assets::get()
获取嵌入文件,并使用 file.render()
发送响应。serve_file
。static/
: 包含要嵌入到二进制文件中的静态文件。