Web 与生态
这一课你将能做到
- 用 axum 搭起 HTTP 服务:路由、提取器、共享状态三件套
- 用 serde 完成 JSON 的编解码,理解它对比 Jackson 的性能来源
- 认清 Rust Web 生态的全景图,知道每个领域该用什么 crate
为什么要学
Java Web 你熟的是 Spring Boot:注解扫描、反射调用、内嵌 Tomcat。Rust 的 axum 是另一条路:函数即 handler、路由显式注册、无反射——启动是毫秒级,内存是个位数 MB,QPS 上限高一个量级。判题系统这种「高频提交 + 低延迟查询」的服务,正是这类框架的价值区。
这一课把前面所有知识串起来:enum 建模状态(第 5 课)、Result 错误处理(第 6 课)、trait 约束(第 7 课)、Arc/Mutex 共享状态(第 9 课)、derive 宏(第 10 课)——全部落到一个能跑的接口服务上。
先建立图像
Spring Boot axum
@RestController + @GetMapping ↔ Router::new().route("/x", get(handler))
@RequestBody DTO ↔ Json<SubmitReq> 提取器
@Service @Autowired ↔ State<AppState>(编译期注入,无反射)
Jackson ObjectMapper ↔ serde_json(编译期生成编解码器)
内嵌 Tomcat(线程池) ↔ hyper + tokio(async,事件驱动)
跟着做
步骤 1:建项目与依赖
cargo new judge-api
cd judge-api
Cargo.toml:
[package]
name = "judge-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
四个依赖各司其职:axum 是 Web 框架,tokio 是它脚下的 async 运行时,serde 负责序列化,serde_json 是 JSON 后端。
步骤 2:数据模型——derive 一把梭
use serde::{Deserialize, Serialize};
#[derive(Serialize, Clone, Debug)]
struct Submission {
id: u64,
language: String,
status: String, // PENDING / RUNNING / AC / WA
}
#[derive(Deserialize)]
struct SubmitReq {
language: String,
code: String,
}
请求体 SubmitReq 只需 Deserialize,响应体 Submission 只需 Serialize——按需 derive,编解码器编译期生成。
步骤 3:共享状态与服务骨架
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use std::{collections::HashMap, sync::{Arc, Mutex}};
#[derive(Clone, Default)]
struct AppState {
store: Arc<Mutex<HashMap<u64, Submission>>>, // 真实系统换成数据库
}
#[tokio::main]
async fn main() {
let state = AppState::default();
let app = Router::new()
.route("/submissions", post(submit)) // 提交判题
.route("/submissions/:id", get(query)) // 查询结果
.with_state(state); // 状态注入所有 handler
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
对照 Spring:Router 替代 @RestController 的路径注册,with_state 替代依赖注入,且全程无反射、无启动扫描。AppState 实现 Clone 是因为 axum 每个 handler 调用都会克隆一份外壳(内部是 Arc,实际共享同一份数据)。
步骤 4:两个 handler——提取器的世界
// POST /submissions:接收 JSON,返回 201 + 创建的资源
async fn submit(
State(state): State<AppState>, // 提取器 1:共享状态,必须是第一个参数
Json(req): Json<SubmitReq>, // 提取器 2:JSON 体自动反序列化,非法 JSON 自动 400
) -> (StatusCode, Json<Submission>) {
let mut store = state.store.lock().unwrap();
let id = store.len() as u64 + 1;
let sub = Submission { id, language: req.language, status: "PENDING".into() };
store.insert(id, sub.clone());
(StatusCode::CREATED, Json(sub))
}
// GET /submissions/:id:路径参数,查询失败返回 404
async fn query(
State(state): State<AppState>,
Path(id): Path<u64>, // 路径段自动解析成 u64,非数字自动 400
) -> Result<Json<Submission>, StatusCode> {
state.store.lock().unwrap().get(&id).cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND) // Result 直接映射为 HTTP 状态码
}
提取器是 axum 的灵魂:参数声明即文档,类型不匹配由框架统一拒绝,handler 里不再有手工解析。Result<Json<T>, StatusCode> 可直接作为返回值,Ok 走 200,Err 走声明的状态码——第 6 课的错误处理思想直接映射到 HTTP。
步骤 5:跑起来验证
cargo run
另开终端:
curl -X POST http://localhost:3000/submissions \
-H "Content-Type: application/json" \
-d '{"language":"rust","code":"fn main(){print!(42)}"}'
# {"id":1,"language":"rust","status":"PENDING"}
curl http://localhost:3000/submissions/1
# {"id":1,"language":"rust","status":"PENDING"}
curl http://localhost:3000/submissions/999 -i
# HTTP/1.1 404 Not Found
再提交一个非法 JSON 看框架自动返回 400——全程没写一行解析和校验代码。
讲清楚要点
- axum 的分层:tokio(运行时)→ hyper(HTTP 协议)→ axum(路由与提取器);每层都是独立 crate,性能是手写级别。
- 提取器顺序有约束:
State必须是第一个参数;Json是消费 body 的提取器,一个 handler 只能有一个;路径/查询参数用 Path/Query。 - 状态共享三板斧:AppState 用
#[derive(Clone)]+ 内部 Arc;可变部分用 Mutex;async 里跨 await 的锁用 tokio::sync::Mutex(第 9 课的坑在这里同样生效)。 - 错误即响应:handler 返回
Result<T, E>,E 实现 IntoResponse 即可;进阶用自定义 AppError enum 统一映射业务错误到状态码。 - serde 是生态公约数:axum 的 JSON、reqwest 的请求体、sqlx 的行映射全部走 serde derive——一套建模,全栈通用。
你可能会懵的地方
-
没有 Spring 的分层注解,项目大了怎么组织? 按 Router 拆模块:每个领域一个
fn router() -> Router<AppState>,主入口 merge。依赖注入的需求用 trait + 泛型参数解决(第 7 课),编译期即验证,无容器魔法。 -
中间件怎么加?
tower生态:.layer(TraceLayer::new())、超时、限流、压缩都是 layer。对标 Servlet Filter/拦截器,但类型安全且可组合。 -
为什么启动这么快、内存这么小? 无类路径扫描、无反射元数据、无 JIT 预热;路由表和序列化代码全部编译期确定。冷启动毫秒级意味着容器/Serverless 场景的天然优势。
-
数据库怎么接? sqlx(编译期校验 SQL 的 async 驱动)或 sea-orm(ActiveRecord 风格)。sqlx 的
query!宏在编译期连库检查 SQL 与类型——Java 里 MyBatis 的字段对不上要跑到运行时才发现。 -
怎么部署?
cargo build --release出单个静态可执行文件,扔进 scratch 容器即可运行,镜像 10MB 量级;没有 JVM、没有 fat jar、没有运行时依赖。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| main 没有 #[tokio::main] | axum::serve 无法 await | main 标 #[tokio::main] async fn |
| State 不是 handler 第一个参数 | error[E0277] 提取器顺序 | State 放最前,Body 类提取器放最后 |
| handler 里持锁跨 await | 阻塞 runtime,甚至死锁 | 临界区缩短到无 await,或换 tokio Mutex |
| 提交两个 body 类提取器 | error:Json 只能消费一次 | 用一次 Json,其余信息走 Path/Query |
| 字段名风格对不上(problemId vs problem_id) | 反序列化缺字段 400 | #[serde(rename_all = "camelCase")] 统一映射 |
卡点急救
- error[E0277]
State提取失败:检查三处——handler 第一参数是State(state): State<AppState>、Router 调用了with_state、AppState 实现了 Clone。 - POST 返回 415 Unsupported Media Type:curl 忘了
-H "Content-Type: application/json";axum 按类型严格匹配。 - E0382 moved value(handler 里 clone 报错):返回体和 store 都要持有 Submission 时,clone 一份再 insert;Json 序列化后所有权即可释放,别在 handler 里留长期引用。
面试常问
-
axum 与 Spring Boot 的架构差异? axum 是编译期模型:显式路由、提取器类型化解析、无反射无扫描,运行时是 tokio 事件驱动,冷启动毫秒级、内存 MB 级;Spring 是运行时模型:注解扫描 + 代理 + 反射,换来的是生态集成与开发效率。判题网关这类延迟敏感场景 axum 占优,复杂企业集成 Spring 占优。
-
serde 为什么比 Jackson 快? 编译期为每个类型生成专用序列化器,无反射查找、无中间 Map 分配、可被编译器内联;Jackson 靠运行时反射与内省缓存。基准差距通常 2-5 倍,且 serde 无预热问题。
-
Rust Web 服务高并发的来源是什么? tokio 的事件驱动调度让 IO 等待不占线程(对标虚拟线程但无 GC 抖动),加上每请求无 GC 停顿、无 JIT 热身,尾延迟稳定;内存占用低使单机可跑更多实例。
自测三问
- 提取器
State、Path、Json各从哪里取数据?顺序上有什么约束? Result<Json<Submission>, StatusCode>的 Ok/Err 分别如何映射为 HTTP 响应?- AppState 为什么要 derive Clone?clone 的是数据还是壳?
小结一句话
axum + serde 把 Spring Boot 的运行时魔法全部前移到编译期:路由显式、状态类型化、JSON 零反射——这就是 Rust 高频接口性能优势的完整拼图。
延伸
- std::net:标准库网络模块
- 下一课:综合练习——亲手拼出一个带并发判题内核的迷你判题服务。