Rust生产级后端实战:用Axum+sqlx打造高性能短链接服务当我们在谈论后端开发时,“高性能”和“高可靠”是永恒的追求。正因如此,以安全和并发著称的Rust成为了越来越多开发者构建下一代服务的首选。但是,如何将Rust的语言优势,真正转化为一个健壮、高效、可维护的生产级应用呢
sqlx
打造高性能短链接服务当我们在谈论后端开发时,“高性能”和“高可靠”是永恒的追求。正因如此,以安全和并发著称的 Rust 成为了越来越多开发者构建下一代服务的首选。但是,如何将 Rust 的语言优势,真正转化为一个健壮、高效、可维护的生产级应用呢?
理论千遍,不如上手一战。
本文将摒弃空谈,通过一个最经典的后端项目——URL 短链接服务——来向您完整展示一个 Rust 生产级后端项目的诞生全过程。我们将使用当前最受欢迎的技术栈:Axum
作为 Web 框架,sqlx
作为数据库交互工具,从零开始,一步步“打造”我们的高性能服务。
跟随本文,您不仅能收获一个完整的项目,更将深入掌握:
sqlx
优雅地处理数据冲突,实现原子性操作。这篇文章是为所有渴望用 Rust 构建真实、可靠应用的开发者准备的。让我们即刻启程,探索 Rust 在生产环境中的真正实力!
sqlx
?因此,在本次实战中,我们选择 sqlx
作为数据库工具,它能让我们在享受类型安全的同时,发挥出原生 SQL 的最大威力。
使用 sqlx 不使用 orm ,但是得到了orm的好处。
建议阅读 sqlx 文档和 GitHub 下的 example 学习。
构建高效且复杂的 SQL 是每个工程师的基本功 构建一个 URL shortener
➜ cargo add sqlx --features postgres --features runtime-tokio --features tls-rustls
shortener.rs
use anyhow::Result;
use axum::{
Json, Router,
extract::{Path, State},
http::HeaderMap,
response::IntoResponse,
routing::{get, post},
};
use nanoid::nanoid;
use reqwest::{StatusCode, header::LOCATION};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use tokio::net::TcpListener;
use tracing::{info, level_filters::LevelFilter, warn};
use tracing_subscriber::{Layer as _, fmt::Layer, layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Debug, Deserialize)]
struct ShortenReq {
url: String,
}
#[derive(Debug, Serialize)]
struct ShortenRes {
url: String,
}
#[derive(Debug, Clone)]
struct AppState {
db: PgPool,
}
const LISTEN_ADDR: &str = "localhost:9876";
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv()?;
let layer = Layer::new().with_filter(LevelFilter::INFO);
tracing_subscriber::registry().with(layer).init();
let url = std::env::var("DATABASE_URL")?;
// 一般情况下,AppState 都需要使用 Arc 来包裹,因为每一次 state 被使用的时候,都会被 clone 出一个新的 AppState
// 这里不使用 Arc 是因为 PgPool 内部已经使用了 Arc,所以 AppState 内部不需要再包裹一层 Arc, #[derive(Debug, Clone)] 也会自动生成
// 如果内部没有使用 Arc,那么一定不要使用 Clone
// let state = Arc::new(AppState::try_new(&url).await?);
let state = AppState::try_new(&url).await?;
info!("Connected to database: {url}");
let listener = TcpListener::bind(LISTEN_ADDR).await?;
info!("listening on {}", LISTEN_ADDR);
let app = Router::new()
.route("/", post(shorten))
.route("/{id}", get(redirect))
.with_state(state);
axum::serve(listener, app.into_make_service()).await?;
Ok(())
}
async fn shorten(
State(state): State<AppState>, // 注意:这里如果 State(state) 在 Json 之后会导致编译错误 body extractor 只能有一个并且要放在最后
Json(data): Json<ShortenReq>,
) -> Result<impl IntoResponse, StatusCode> {
let id = state.shorten(&data.url).await.map_err(|e| {
warn!("Failed to shorten URL: {e}");
StatusCode::UNPROCESSABLE_ENTITY
})?;
let body = Json(ShortenRes {
url: format!("http://{}/{}", LISTEN_ADDR, id),
});
Ok((StatusCode::CREATED, body))
}
async fn redirect(
Path(id): Path<String>,
State(state): State<AppState>,
) -> Result<impl IntoResponse, StatusCode> {
let url = state
.get_url(&id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let mut headers = HeaderMap::new();
headers.insert(LOCATION, url.parse().unwrap());
Ok((StatusCode::FOUND, headers))
}
impl AppState {
async fn try_new(url: &str) -> Result<Self> {
let pool = PgPool::connect(url).await?;
// create tables if not exists
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS urls (
id CHAR(6) PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)"#,
)
.execute(&pool)
.await?;
Ok(Self { db: pool })
}
async fn shorten(&self, url: &str) -> Result<String> {
let id = nanoid!(6);
sqlx::query("INSERT INTO urls (id, url) VALUES ($1, $2)")
.bind(&id)
.bind(url)
.execute(&self.db)
.await?;
Ok(id)
}
async fn get_url(&self, id: &str) -> Result<String> {
let record: (String,) = sqlx::query_as("SELECT url FROM urls WHERE id = $1")
.bind(id)
.fetch_one(&self.db)
.await?;
Ok(record.0)
}
}
这段代码是一个使用 Rust 语言和 Axum Web 框架构建的高性能URL短链接服务。它的核心功能有两个:
1) 通过 POST
请求接收一个原始的长 URL,为其生成一个唯一的6位短 ID,存入 PostgreSQL 数据库,并返回完整的短链接地址。
2) 通过 GET
请求访问这个短链接(使用短 ID),服务器会从数据库中查询到对应的原始长 URL,并返回一个 HTTP 302 重定向响应,让浏览器跳转到原始地址。整个服务是异步的,利用 tokio
作为运行时,并使用 sqlx
库与数据库进行异步交互,nanoid
用于生成简短的唯一ID,tracing
用于日志记录。
main
函数中,关于 AppState
是否需要 Arc
包装。// 这里不使用 Arc 是因为 PgPool 内部已经使用了 Arc,所以 AppState 内部不需要再包裹一层 Arc, #[derive(Debug, Clone)] 也会自动生成
// 如果内部没有使用 Arc,那么一定不要使用 Clone
// let state = Arc::new(AppState::try_new(&url).await?);
let state = AppState::try_new(&url).await?;
一般情况下,AppState 都需要使用 Arc 来包裹,因为每一次 state 被使用的时候,都会被 clone 出一个新的 AppState。
这里不使用 Arc 是因为 PgPool 内部已经使用了 Arc,所以 AppState 内部不需要再包裹一层 Arc。
这是在 Axum (以及其他 Rust Web 框架) 中关于状态管理的重要设计模式。通常,当多个请求需要并发访问共享数据(如此处的数据库连接池 PgPool
)时,需要将这个共享状态 AppState
包装在 Arc
(Atomically Reference Counted, 原子引用计数指针) 中。Arc
允许多个所有者安全地共享数据而不会产生数据竞争。每次请求处理时克隆 Arc
的成本非常低,因为它只增加一个引用计数,而不是复制整个数据。
/// An alias for [`Pool`][crate::pool::Pool], specialized for Postgres.
pub type PgPool = crate::pool::Pool<Postgres>;
pub struct Pool<DB: Database>(pub(crate) Arc<PoolInner<DB>>);
然而,此处的代码是个特例。sqlx
的 PgPool
类型在内部已经实现为 Arc
包装的连接池。因此,PgPool
本身就是可以被安全且廉价地克隆的。在这种情况下,再用 Arc<AppState>
进行包装就显得多余了 (Arc<Arc<...>>
)。直接在 AppState
结构体上派生 #[derive(Clone)]
就足够了,其克隆操作实际上就是在高效地克隆内部的 PgPool
。
开发者在开发时,要了解你所使用的库的内部实现,以避免不必要的封装和复杂性。
shorten
函数签名中,关于参数顺序的注释。async fn shorten(
Json(data): Json<ShortenReq>,
State(state): State<AppState>,
) -> Result<impl IntoResponse, StatusCode> {
State(state) 在 Json 之后会导致编译错误。
rust-ecosystem-learning on main [!?] is 📦 0.1.0 via 🦀 1.88.0 took 25m 25.0s
➜ cargo run --example shortener
Compiling rust-ecosystem-learning v0.1.0 (/Users/qiaopengjun/Code/Rust/rust-ecosystem-learning)
error[E0277]: the trait bound `fn(Json<...>, ...) -> ... {shorten}: Handler<_, _>` is not satisfied
--> examples/shortener.rs:49:26
|
49 | .route("/", post(shorten))
| ---- ^^^^^^^ the trait `Handler<_, _>` is not implemented for fn item `fn(Json<ShortenReq>, State<AppState>) -> ... {shorten}`
| |
| required by a bound introduced by this call
|
= note: Consider using `#[axum::debug_handler]` to improve the error message
= help: the following other types implement trait `Handler<T, S>`:
`MethodRouter<S>` implements `Handler<(), S>`
`axum::handler::Layered<L, H, T, S>` implements `Handler<T, S>`
note: required by a bound in `post`
--> /Users/qiaopengjun/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/axum-0.8.4/src/routing/method_routing.rs:445:1
|
445 | top_level_handler_fn!(post, POST);
| ^^^^^^^^^^^^^^^^^^^^^^----^^^^^^^
| | |
| | required by a bound in this function
| required by this bound in `post`
= note: the full name for the type has been written to '/Users/qiaopengjun/Code/Rust/rust-ecosystem-learning/target/debug/examples/shortener-8b2d0ea2a3cea3c5.long-type-16073425591754131837.txt'
= note: consider using `--verbose` to print the full type name to the console
= note: this error originates in the macro `top_level_handler_fn` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0277`.
warning: `rust-ecosystem-learning` (example "shortener") generated 6 warnings
error: could not compile `rust-ecosystem-learning` (example "shortener") due to 1 previous error; 6 warnings emitted
async fn shorten(
State(state): State<AppState>, // 注意:这里如果 State(state) 在 Json 之后会导致编译错误 body extractor 只能有一个并且要放在最后
Json(data): Json<ShortenReq>,
) -> Result<impl IntoResponse, StatusCode> {
这是 Axum 框架的一个关键规则:处理请求体的提取器(Extractor)必须是处理函数参数列表中的最后一个参数。
在 shorten
函数中,Json<ShortenReq>
是一个提取器,它会读取并解析 HTTP 请求的主体(body)到一个 ShortenReq
结构体中。请求体是一个数据流,一旦被读取消耗后就不能再次读取。Axum 框架为了保证逻辑的正确性和防止意外错误,在编译时就强制规定,任何消耗请求体的提取器(如 Json
, Form
, Bytes
)都必须放在参数列表的末尾。State(state)
也是一个提取器,但它不消耗请求体,而是从应用的状态中提取共享数据。如果把 State(state)
放在 Json(data)
之后,就会违反这个规则,导致编译失败。
这里对于刚接触 Axum 的开发者来说是一个需要注意的点,要避免在参数顺序上犯错。
shortener
数据库
rust-ecosystem-learning on main [!?] is 📦 0.1.0 via 🦀 1.88.0
➜ psql
psql (17.4 (Homebrew))
Type "help" for help.
qiaopengjun=# \l
List of databases
Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges
-------------+-------------+----------+-----------------+---------+-------+--------+-----------+---------------------------------
blockscout | blockscout | UTF8 | libc | C | C | | | =Tc/blockscout +
| | | | | | | | blockscout=CTc/blockscout
edu_bazaar | qiaopengjun | UTF8 | libc | C | C | | | =Tc/qiaopengjun +
| | | | | | | | qiaopengjun=CTc/qiaopengjun +
| | | | | | | | edu_bazaar_user=CTc/qiaopengjun
postgres | qiaopengjun | UTF8 | libc | C | C | | |
qiaopengjun | qiaopengjun | UTF8 | libc | C | C | | |
template0 | qiaopengjun | UTF8 | libc | C | C | | | =c/qiaopengjun +
| | | | | | | | qiaopengjun=CTc/qiaopengjun
template1 | qiaopengjun | UTF8 | libc | C | C | | | =c/qiaopengjun +
| | | | | | | | qiaopengjun=CTc/qiaopengjun
vrf_service | qiaopengjun | UTF8 | libc | C | C | | |
(7 rows)
qiaopengjun=# create database shortener;
CREATE DATABASE
qiaopengjun=# \l
List of databases
Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges
-------------+-------------+----------+-----------------+---------+-------+--------+-----------+---------------------------------
blockscout | blockscout | UTF8 | libc | C | C | | | =Tc/blockscout +
| | | | | | | | blockscout=CTc/blockscout
edu_bazaar | qiaopengjun | UTF8 | libc | C | C | | | =Tc/qiaopengjun +
| | | | | | | | qiaopengjun=CTc/qiaopengjun +
| | | | | | | | edu_bazaar_user=CTc/qiaopengjun
postgres | qiaopengjun | UTF8 | libc | C | C | | |
qiaopengjun | qiaopengjun | UTF8 | libc | C | C | | |
shortener | qiaopengjun | UTF8 | libc | C | C | | |
template0 | qiaopengjun | UTF8 | libc | C | C |...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!