《Rust 实战指南》实战项目 B:云原生微服务——构建高并发短链接系统

项目导读

作为 Java 开发者,你一定熟悉 Spring Boot 的“约定大于配置”,但也忍受过启动时漫长的 Bean 扫描,以及空载时 300MB 起步的内存占用。
作为 Python 开发者,你享受 FastAPI 的开发速度,但当 QPS 上来时,你不得不面对 GIL 锁带来的吞吐量瓶颈,甚至要把业务逻辑迁移到 Go。

Rust 的 Web 生态已经成熟。特别是 Axum(基于 Tokio)和 Sqlx(异步数据库驱动)的组合,被称为现代后端开发的“黄金搭档”。

在这个实战项目中,我们将构建一个短链接服务(URL Shortener)。这不仅仅是一个 Demo,它包含了商业项目必备的要素:数据库连接池、依赖注入(状态管理)、统一错误处理、结构化日志以及 Docker 极小镜像构建。

准备好见证奇迹了吗?我们将把一个完整的 Web 服务打包进 20MB 的 Docker 镜像中,且运行时内存仅需 15MB


🎯 本项目学习目标

框架选型:掌握 Axum 框架的核心概念(路由、提取器、中间件)。数据持久化:使用 Sqlx 操作 PostgreSQL,体验编译期 SQL 检查的黑科技。架构设计:学习如何在 Rust 中实现类似 Spring 的“依赖注入”(通过
AppState
)。错误处理:构建全局统一的错误处理机制(
IntoResponse
),告别混乱的
try-catch
云原生交付:编写多阶段构建的
Dockerfile
,产出 Distroless 级别的超小镜像。AI 辅助:利用 AI 生成 SQL 迁移脚本和集成测试代码。


B.1 项目初始化与架构规划

不同于 Python 的随意文件结构,Rust 商业项目讲究模块化。

B.1.1 创建项目与依赖


cargo new short_link_service
cd short_link_service

编辑
Cargo.toml
,我们要引入“全家桶”:


[package]
name = "short_link_service"
version = "0.1.0"
edition = "2021"

[dependencies]
# Web 框架,Tokio 官方出品,人体工程学极佳
axum = "0.7"
# 异步运行时
tokio = { version = "1.0", features = ["full"] }
# 序列化
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# 数据库 ORM/Mapper
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
# 环境变量管理
dotenvy = "0.15"
# 结构化日志与追踪
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 唯一 ID 生成
nanoid = "0.4.0"
# 错误处理
thiserror = "1.0"

B.1.2 目录结构设计

我们采用经典的分层架构,但比 Java 更轻量:


src/
├── main.rs          # 程序入口,组装路由和状态
├── config.rs        # 配置加载
├── db.rs            # 数据库连接池初始化
├── handlers.rs      # 控制器(Controller)逻辑
├── models.rs        # 数据模型(DTO/POJO)
└── errors.rs        # 全局错误定义

B.2 数据库先行:Sqlx 的魔法

Java 的 Hibernate/MyBatis 需要 xml 配置或大量的注解,且 SQL 写错了要等到运行时才知道。Sqlx 颠覆了这一点:它在编译时连接数据库检查 SQL 语法。

B.2.1 启动 PostgreSQL

为了方便,我们使用 Docker 启动数据库。


docker run --name pg -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:15-alpine

在项目根目录创建
.env
文件:


DATABASE_URL=postgres://postgres:password@localhost:5432/postgres
RUST_LOG=debug

B.2.2 AI 辅助生成 Schema

我们需要一张表存短链接。

Prompt 建议

“我正在用 Rust 和 Postgres 做短链接系统。请帮我写一个
CREATE TABLE
的 SQL 语句。字段包括:id (char 6, 主键), original_url (text, 非空), created_at (timestamp), visits (int)。请考虑性能索引。”

AI 会给出 SQL。我们需要安装
sqlx-cli
来管理迁移:


cargo install sqlx-cli
sqlx database create
sqlx migrate add init_schema

将 AI 生成的 SQL 填入
migrations/xxxx_init_schema.up.sql


CREATE TABLE IF NOT EXISTS links (
    id CHAR(6) PRIMARY KEY,
    original_url TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    visits INT NOT NULL DEFAULT 0
);

执行迁移:
sqlx migrate run


B.3 核心代码实现

B.3.1 定义数据模型 (
src/models.rs
)


use serde::{Deserialize, Serialize};
use sqlx::FromRow;

// 对应数据库表结构
#[derive(Debug, FromRow, Serialize)]
pub struct Link {
    pub id: String,
    pub original_url: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub visits: i32,
}

// 接收前端创建请求的 DTO
#[derive(Debug, Deserialize)]
pub struct CreateLinkReq {
    pub url: String,
}

// 返回给前端的 DTO
#[derive(Debug, Serialize)]
pub struct LinkRes {
    pub short_url: String,
}

B.3.2 全局错误处理 (
src/errors.rs
)

这是 Java 开发者最容易困惑的地方:没有
try-catch
,怎么统一处理异常?
答案是:自定义 Error Enum + 实现
IntoResponse


use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("Database error: {0}")]
    DatabaseError(#[from] sqlx::Error),
    #[error("Link not found")]
    NotFound,
    #[error("Invalid URL format")]
    InvalidUrl,
}

// 核心魔法:告诉 Axum 如何把我们的错误变成 HTTP 响应
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::DatabaseError(e) => {
                // 生产环境不要把详细 SQL 错误暴露给前端,记录日志即可
                tracing::error!("Database error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error")
            }
            AppError::NotFound => (StatusCode::NOT_FOUND, "Link not found"),
            AppError::InvalidUrl => (StatusCode::BAD_REQUEST, "Invalid URL"),
        };

        let body = Json(json!({
            "error": message
        }));

        (status, body).into_response()
    }
}

B.3.3 业务逻辑与控制器 (
src/handlers.rs
)

这里展示 Axum 的依赖注入机制。
AppState
包含数据库连接池,通过函数参数自动注入。


use axum::{
    extract::{Path, State},
    response::{IntoResponse, Redirect},
    Json,
};
use nanoid::nanoid;
use sqlx::PgPool;
use crate::{errors::AppError, models::{CreateLinkReq, Link, LinkRes}};

// 定义共享状态
#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
}

// 1. 创建短链接
pub async fn create_link(
    State(state): State<AppState>, // 注入 DB Pool
    Json(payload): Json<CreateLinkReq>, // 自动反序列化 JSON
) -> Result<Json<LinkRes>, AppError> { // 返回 Result,错误自动转 HTTP 响应
    
    if !payload.url.starts_with("http") {
        return Err(AppError::InvalidUrl);
    }

    let id = nanoid!(6); // 生成 6 位随机 ID

    // SQLX 宏:编译期会检查 SQL 语法!如果表名写错,编译会失败。
    sqlx::query!(
        "INSERT INTO links (id, original_url) VALUES ($1, $2)",
        id,
        payload.url
    )
    .execute(&state.db)
    .await?;

    Ok(Json(LinkRes {
        short_url: format!("http://localhost:3000/{}", id),
    }))
}

// 2. 访问重定向
pub async fn redirect_link(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<Redirect, AppError> {
    // 查询并更新访问量(原子操作)
    let result = sqlx::query_as!(
        Link,
        "UPDATE links SET visits = visits + 1 WHERE id = $1 RETURNING *",
        id
    )
    .fetch_optional(&state.db) // 返回 Option<Link>
    .await?;

    match result {
        Some(link) => Ok(Redirect::to(&link.original_url)),
        None => Err(AppError::NotFound),
    }
}

B.3.4 组装入口 (
src/main.rs
)


mod errors;
mod handlers;
mod models;

use axum::{routing::{get, post}, Router};
use dotenvy::dotenv;
use sqlx::postgres::PgPoolOptions;
use std::env;
use handlers::AppState;
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenv().ok();
    // 初始化日志系统
    tracing_subscriber::fmt::init();

    // 初始化数据库连接池
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = PgPoolOptions::new()
        .max_connections(50)
        .connect(&database_url)
        .await?;

    let state = AppState { db: pool };

    // 路由定义:看起来是不是有点像 Flask/Django?
    let app = Router::new()
        .route("/", post(handlers::create_link))
        .route("/:id", get(handlers::redirect_link))
        .with_state(state); // 注入状态

    let addr = "0.0.0.0:3000";
    let listener = TcpListener::bind(addr).await?;
    tracing::info!("Listening on {}", addr);
    
    axum::serve(listener, app).await?;
    Ok(())
}

B.4 运行与验证

启动服务
cargo run
创建短链接


curl -X POST -H "Content-Type: application/json" -d '{"url": "https://www.rust-lang.org"}' http://localhost:3000/
# 返回: {"short_url": "http://localhost:3000/x7zK9p"}

访问短链接
在浏览器访问返回的 URL,应该会自动跳转。查看数据库
你会发现数据库里多了一条记录,且
visits
增加了。

⚙️ 老司机提示 (Veteran’s Tip)

如果你在编译时遇到
error: relation "links" does not exist
,这是因为 Sqlx 宏需要在编译时连接数据库。
确保你的 Postgres 正在运行且
DATABASE_URL
正确。
在 CI/CD 环境中,可以使用
sqlx prepare
生成元数据文件,从而在没有数据库的情况下编译。


B.5 性能对比:Rust vs Spring Boot

这是一个震撼的时刻。我们在本机做一个简单的对比:

维度 Java (Spring Boot) Rust (Axum)
编译产物 30MB+ (.jar) 5MB (Binary)
空闲内存 250MB – 500MB 8MB – 15MB
冷启动时间 3s – 10s < 0.1s
QPS (单核) 约 8,000 约 40,000+

结论:对于 I/O 密集型的 Web 服务,Rust 的吞吐量通常是 Java 的 3-5 倍,而资源消耗仅为 Java 的 1/20。这就是为什么云原生时代大家都在谈论 Rust——它帮你省下的云服务器账单是实实在在的。


B.6 云原生交付:构建 15MB 的 Docker 镜像

Java 镜像通常依赖
openjdk
基础镜像,动辄 300MB。
Python 镜像依赖解释器和依赖包,通常 100MB+。
Rust 可以通过 Multi-stage Build(多阶段构建)Distroless 镜像,做到极致轻量。

创建
Dockerfile


# 阶段 1:构建环境 (Build Stage)
FROM rust:1.75 as builder
WORKDIR /app
COPY . .
# 针对 Sqlx 的离线检查模式(如果不想在构建容器里连数据库)
ENV SQLX_OFFLINE=true
RUN cargo build --release

# 阶段 2:运行时环境 (Runtime Stage)
# gcr.io/distroless/cc-debian12 是一个剥离了 Shell 等所有无关文件的极简镜像
FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/short_link_service /
COPY .env / # 实际生产中应通过 K8s ConfigMap 注入
CMD ["./short_link_service"]

构建并查看大小:


docker build -t short-link .
docker images | grep short-link

结果通常在 20MB 左右。这是微服务架构中最理想的“集装箱”。


B.7 AI 辅助集成测试

在开发 API 时,编写测试用例很繁琐。

实战场景
我们写好了
create_link
接口,想写一个集成测试。

Prompt 建议

“我有一个基于 Axum 的 Rust Web 服务。请使用
tower::Service

axum::test_helpers
为我的 POST / 接口编写一个集成测试。测试逻辑是:发送 JSON,断言返回 200,并解析返回的 JSON 中的 short_url 字段。”

AI 会生成一段不需要启动 TCP 端口就能直接测试 Handler 逻辑的代码,这是 Axum 架构的一大优势。


B.8 本章小结

在这个实战项目中,我们完成了一次从传统后端到 Rust 现代后端的跃迁:

Axum:让你用类似 Flask/Spring 的路由语法,写出高性能的异步服务。Sqlx:用强类型的 Rust 结构体承载 SQL 结果,且在编译期杜绝 SQL 语法错误。AppError:展示了 Rust 如何优雅地将底层错误转化为 HTTP 响应,而不是抛出异常。资源效率:亲眼见证了 Rust 微服务惊人的内存效率和镜像体积。

你现在拥有的,是一个不仅跑得快,而且跑得省,还很难崩溃的短链接服务。


📝 思考与扩展练习

基础题:目前的
create_link
每次都生成新 ID,即使 URL 相同。请修改逻辑:在插入前先查询
original_url
是否已存在,如果存在直接返回旧的短链接。(提示:给数据库添加唯一索引)。进阶题(Redis 缓存):为了进一步提升重定向速度,引入
redis
crate。在访问
/:id
时,先查 Redis,命中直接跳转;未命中查 DB 并回写 Redis。这是高并发系统的标准做法。工程题:为服务添加一个简单的中间件(Middleware),记录每个请求的耗时和来源 IP。Axum 的中间件基于
tower
生态,去查查文档怎么写?

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
紫薇忘了水葫芦的头像 - 鹿快
评论 抢沙发

请登录后发表评论

    暂无评论内容