Rust Web开发指南 第三章(Axum 请求体解析:处理 JSON、表单与文件上传)
在 HTTP 交互中,除了路径参数和查询参数,请求体(Request Body) 是传递复杂数据的主要方式(如表单提交、API 数据传输)。Axum 提供了多种灵活的提取器(Extractor)用于解析不同格式的请求体,本文将详细讲解如何处理 JSON、表单数据、文件上传等常见场景。
一、请求体解析基础:核心概念与依赖准备
1.1 什么是请求体?
请求体是 HTTP 请求中携带的实际数据,通常用于 POST
、PUT
、PATCH
等方法,用于向服务器提交数据(如用户注册信息、订单数据)。常见的请求体格式包括:
- JSON:API 交互的主流格式,轻量且易于解析;
- 表单数据:分
application/x-www-form-urlencoded
(普通表单)和multipart/form-data
(带文件上传的表单); - 原始字节:如二进制文件、自定义协议数据。
1.2 必备依赖
解析请求体需要额外依赖,在 Cargo.toml
中添加(serde和multer依赖项):
[package]
name = "axum-tutorial"
version = "0.1.0"
edition = "2024"[dependencies]
# Axum 核心框架(处理路由、请求/响应等)
axum = { version = "0.8.4", features = ["multipart"] } # 启用 multipart 特性
# 异步运行时(仅保留核心特性,减少冗余)
tokio = { version = "1.47.1", features = ["rt-multi-thread", "net", "macros","signal","fs", "io-util"] }
# 日志相关(初始化日志输出)
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } # 添加env-filter特性# 数据序列化/反序列化(JSON 处理依赖,启用 derive 宏)
serde = { version = "1.0.219", features = ["derive"] }# 新增:处理multipart表单(文件上传)
multer = "3.1.0" # Axum推荐的multipart解析库# 新增:处理http请求体大小的限制
tower-http = { version = "0.6.6", features = ["limit"] }# 新增以下两行(sha2 和 hex 依赖)
sha2 = "0.10" # 提供 SHA-256 哈希算法
hex = "0.4" # 将字节数组转为十六进制字符串
二、解析 JSON 请求体:Json
提取器
JSON 是前后端 API 交互的标准格式,Axum 提供 axum::extract::Json
提取器,结合 serde
可轻松将 JSON 请求体解析为 Rust 结构体。
2.1 基础用法:解析简单 JSON
步骤 1:定义数据结构
首先定义与 JSON 对应的 Rust 结构体,需派生 serde::Deserialize
trait 以支持反序列化:
use serde::Deserialize;/// 用户注册数据(JSON格式)
#[derive(Debug, Deserialize)]
struct RegisterUser {username: String,email: String,age: Option<u8>, // 可选字段:允许为null或不存在
}
步骤 2:创建处理函数
使用 Json
提取器接收请求体,并返回解析结果:
use axum::extract::Json;/// 处理用户注册(JSON请求体)
async fn register_user(Json(user): Json<RegisterUser>) -> String {// 解析后的user是RegisterUser实例,可直接使用其字段format!("注册成功!用户信息:用户名={}, 邮箱={}, 年龄={:?}",user.username, user.email, user.age)
}
步骤 3:注册路由(POST 方法)
JSON 数据通常通过 POST
方法提交,需使用 routing::post
绑定路由:
// 在之前的say_hello_routes或新的路由组中添加
use axum::routing::post;fn user_routes() -> Router {Router::new().route("/register", post(register_user)) // POST /user/register
}// 在main函数的app中嵌套
let app = Router::new().route("/", get(root)).nest("/user", user_routes()); // 新增用户相关路由
程序的全部代码:
// 导入必要的组件
// Axum框架核心组件:用于创建路由和处理HTTP请求
use axum::{routing::{get,post}, // 导入GET、POST请求处理函数Router, // 导入路由构建器,用于定义请求路由规则extract::Json, // 从Axum框架的extract模块中导入Json提取器,用于解析HTTP请求体中Content-Type为application/json的JSON数据
};
// 标准库中的网络地址类型,用于指定服务器绑定的IP和端口
use std::net::SocketAddr;
// 日志相关组件:用于记录信息日志和错误日志
use tracing::{info, error};
// 日志订阅器组件:用于配置和初始化日志系统
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// 新增:导入信号处理模块
use tokio::signal;
// 导入serde库的Deserialize trait,用于实现数据反序列化
// 反序列化是指将外部数据格式(如JSON、表单数据)转换为Rust结构体的过程
// 在Axum中处理请求体(如JSON、表单)时,需要通过该trait将请求数据解析为定义的结构体
// 需配合#[derive(Deserialize)]属性使用,自动为结构体生成反序列化代码
use serde::Deserialize;/// 用户注册数据(JSON格式)
#[derive(Debug, Deserialize)]
struct RegisterUser {username: String,email: String,age: Option<u8>, // 可选字段:允许为null或不存在
}// 异步主函数:Axum基于Tokio运行时,必须使用#[tokio::main]宏标记
// 该宏会自动设置Tokio异步运行时环境
#[tokio::main]
async fn main() {// 初始化日志系统:配置日志的过滤规则和输出格式tracing_subscriber::registry() // 创建日志订阅器注册表.with(// 设置日志过滤规则:优先从环境变量读取,若未设置则使用默认规则// 默认规则:本程序(axum_tutorial)和tower_http库输出debug级别及以上日志tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "axum_tutorial=debug,tower_http=debug".into())).with(tracing_subscriber::fmt::layer()) // 添加日志格式化输出层.init(); // 初始化日志系统,使其生效// 记录信息日志:提示服务器开始启动info!("Starting axum server...");// 创建路由表:定义不同路径的请求由哪个函数处理let app = Router::new()// 注册根路径("/")的处理规则:GET请求由root函数处理.route("/", get(root)).nest("/user", user_routes()); // 新增用户相关路由// 定义服务器绑定的地址:0.0.0.0表示监听所有可用网络接口,端口为8080let addr = SocketAddr::from(([0, 0, 0, 0], 8080));// 记录信息日志:提示正在绑定的地址info!("Binding to address: {}", addr);// 创建TCP监听器并绑定到指定地址// 使用match表达式处理可能的绑定结果(成功/失败)let listener = match tokio::net::TcpListener::bind(addr).await {Ok(listener) => listener, // 绑定成功:获取监听器对象Err(e) => { // 绑定失败:记录错误并优雅退出error!("Failed to bind to address {}: {}", addr, e);return; // 退出程序,不再继续执行}};// 启动Axum服务器:使用监听器接收请求,并通过路由表处理// 记录信息日志:提示服务器已启动并监听指定地址info!("Server started, listening on {}", addr);// 新增:创建服务器并配置优雅关闭let server = axum::serve(listener, app);// 新增:添加优雅关闭处理if let Err(e) = server.with_graceful_shutdown(shutdown_signal()).await {// 服务器运行出错时记录错误信息error!("Server error: {}", e);}// 记录信息日志:提示服务器已正常关闭info!("Server shutdown");
}/// 根路径("/")的请求处理函数
/// 异步函数:返回静态字符串作为HTTP响应体
async fn root() -> &'static str {"Hello world!" // 响应内容:简单的"Hello world!"字符串
}// 新增:处理关闭信号的函数
async fn shutdown_signal() {// 等待Ctrl+C信号let ctrl_c = async {signal::ctrl_c().await.expect("Failed to install Ctrl+C handler");};// 在Unix系统上额外监听终止信号#[cfg(unix)]let terminate = async {signal::unix::signal(signal::unix::SignalKind::terminate()).expect("Failed to install SIGTERM handler").recv().await;};// 等待任一信号#[cfg(unix)]tokio::select! {_ = ctrl_c => {},_ = terminate => {},}#[cfg(not(unix))]ctrl_c.await;info!("Received shutdown signal, starting graceful shutdown...");
}fn user_routes() -> Router {Router::new().route("/register", post(register_user)) // POST /user/register
}/// 处理用户注册(JSON请求体)
async fn register_user(Json(user): Json<RegisterUser>) -> String {// 解析后的user是RegisterUser实例,可直接使用其字段format!("注册成功!用户信息:用户名={}, 邮箱={}, 年龄={:?}",user.username, user.email, user.age)
}
测试接口
使用 curl
发送 JSON 请求:
D:\>curl -X POST http://localhost:8080/user/register -H "Content-Type: application/json" -d "{\"username\":\"alice\", \"email\":\"alice@example.com\", \"age\":25}"
注册成功!用户信息:用户名=alice, 邮箱=alice@example.com, 年龄=Some(25)
预期响应正确:注册成功!用户信息:用户名=alice, 邮箱=alice@example.com, 年龄=Some(25)
2.2 关键知识点
Json
提取器:将请求体按application/json
格式解析,若请求头Content-Type
不匹配或格式错误,Axum 会自动返回400 Bad Request
;- 可选字段:使用
Option<T>
标记可选字段,JSON 中可省略或设为null
; - 反序列化失败:若 JSON 字段与结构体不匹配(如类型错误),Axum 会返回详细错误信息(需在日志中查看)。
三、解析表单数据:Form
提取器
对于 HTML 表单提交或 application/x-www-form-urlencoded
格式的请求,需使用 axum::extract::Form
提取器。
3.1 处理普通表单(x-www-form-urlencoded
)
步骤 1:定义表单结构体
同样需要派生 serde::Deserialize
:
#[derive(Debug, Deserialize)]
struct LoginForm {username: String,password: String,remember_me: bool, // 复选框类型,表单中会传递"true"或"false"
}
步骤 2:创建表单处理函数
use axum::extract::Form;/// 处理登录表单(x-www-form-urlencoded)
/// 密码不能明文显示(要加密)
use sha2::{Sha256, Digest}; // SHA-256哈希算法
use hex::encode; // 字节转十六进制字符串
async fn login(Form(form): Form<LoginForm>) -> String {// 1. 计算密码的SHA-256哈希(将明文转为字节数组后处理)let password_hash = Sha256::digest(form.password.as_bytes());// 2. 将哈希结果(字节数组)转为十六进制字符串(便于显示)let password_hash_hex = encode(password_hash);format!("登录信息:用户名={}, 记住登录={}, 密码哈希(SHA-256)={}",form.username, form.remember_me, password_hash_hex)
}
步骤 3:注册路由
// 在user_routes中添加
fn user_routes() -> Router {Router::new().route("/register", post(register_user)).route("/login", post(login)) // POST /user/login
}
程序全部代码:
// 导入必要的组件
// Axum框架核心组件:用于创建路由和处理HTTP请求
use axum::{routing::{get,post}, // 导入GET、POST请求处理函数Router, // 导入路由构建器,用于定义请求路由规则extract::{Json, // 从Axum框架的extract模块中导入Json提取器,用于解析HTTP请求体中Content-Type为application/json的JSON数据Form // 从Axum框架的extract模块中导入Form提取器},
};
// 标准库中的网络地址类型,用于指定服务器绑定的IP和端口
use std::net::SocketAddr;
// 日志相关组件:用于记录信息日志和错误日志
use tracing::{info, error};
// 日志订阅器组件:用于配置和初始化日志系统
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// 新增:导入信号处理模块
use tokio::signal;
// 导入serde库的Deserialize trait,用于实现数据反序列化
// 反序列化是指将外部数据格式(如JSON、表单数据)转换为Rust结构体的过程
// 在Axum中处理请求体(如JSON、表单)时,需要通过该trait将请求数据解析为定义的结构体
// 需配合#[derive(Deserialize)]属性使用,自动为结构体生成反序列化代码
use serde::Deserialize;// 用户注册数据(JSON格式)
#[derive(Debug, Deserialize)]
struct RegisterUser {username: String,email: String,age: Option<u8>, // 可选字段:允许为null或不存在
}// Form的数据结构体
#[derive(Debug, Deserialize)]
struct LoginForm {username: String,password: String,remember_me: bool, // 复选框类型,表单中会传递"true"或"false"
}// 异步主函数:Axum基于Tokio运行时,必须使用#[tokio::main]宏标记
// 该宏会自动设置Tokio异步运行时环境
#[tokio::main]
async fn main() {// 初始化日志系统:配置日志的过滤规则和输出格式tracing_subscriber::registry() // 创建日志订阅器注册表.with(// 设置日志过滤规则:优先从环境变量读取,若未设置则使用默认规则// 默认规则:本程序(axum_tutorial)和tower_http库输出debug级别及以上日志tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "axum_tutorial=debug,tower_http=debug".into())).with(tracing_subscriber::fmt::layer()) // 添加日志格式化输出层.init(); // 初始化日志系统,使其生效// 记录信息日志:提示服务器开始启动info!("Starting axum server...");// 创建路由表:定义不同路径的请求由哪个函数处理let app = Router::new()// 注册根路径("/")的处理规则:GET请求由root函数处理.route("/", get(root)).nest("/user", user_routes()); // 新增用户相关路由// 定义服务器绑定的地址:0.0.0.0表示监听所有可用网络接口,端口为8080let addr = SocketAddr::from(([0, 0, 0, 0], 8080));// 记录信息日志:提示正在绑定的地址info!("Binding to address: {}", addr);// 创建TCP监听器并绑定到指定地址// 使用match表达式处理可能的绑定结果(成功/失败)let listener = match tokio::net::TcpListener::bind(addr).await {Ok(listener) => listener, // 绑定成功:获取监听器对象Err(e) => { // 绑定失败:记录错误并优雅退出error!("Failed to bind to address {}: {}", addr, e);return; // 退出程序,不再继续执行}};// 启动Axum服务器:使用监听器接收请求,并通过路由表处理// 记录信息日志:提示服务器已启动并监听指定地址info!("Server started, listening on {}", addr);// 新增:创建服务器并配置优雅关闭let server = axum::serve(listener, app);// 新增:添加优雅关闭处理if let Err(e) = server.with_graceful_shutdown(shutdown_signal()).await {// 服务器运行出错时记录错误信息error!("Server error: {}", e);}// 记录信息日志:提示服务器已正常关闭info!("Server shutdown");
}/// 根路径("/")的请求处理函数
/// 异步函数:返回静态字符串作为HTTP响应体
async fn root() -> &'static str {"Hello world!" // 响应内容:简单的"Hello world!"字符串
}// 新增:处理关闭信号的函数
async fn shutdown_signal() {// 等待Ctrl+C信号let ctrl_c = async {signal::ctrl_c().await.expect("Failed to install Ctrl+C handler");};// 在Unix系统上额外监听终止信号#[cfg(unix)]let terminate = async {signal::unix::signal(signal::unix::SignalKind::terminate()).expect("Failed to install SIGTERM handler").recv().await;};// 等待任一信号#[cfg(unix)]tokio::select! {_ = ctrl_c => {},_ = terminate => {},}#[cfg(not(unix))]ctrl_c.await;info!("Received shutdown signal, starting graceful shutdown...");
}fn user_routes() -> Router {Router::new().route("/register", post(register_user)) // POST /user/register.route("/login", post(login)) // POST /user/login
}/// 处理用户注册(JSON请求体)
async fn register_user(Json(user): Json<RegisterUser>) -> String {// 解析后的user是RegisterUser实例,可直接使用其字段format!("注册成功!用户信息:用户名={}, 邮箱={}, 年龄={:?}",user.username, user.email, user.age)
}/// 处理登录表单(x-www-form-urlencoded)
use sha2::{Sha256, Digest}; // SHA-256哈希算法
use hex::encode; // 字节转十六进制字符串
async fn login(Form(form): Form<LoginForm>) -> String {// 1. 计算密码的SHA-256哈希(将明文转为字节数组后处理)let password_hash = Sha256::digest(form.password.as_bytes());// 2. 将哈希结果(字节数组)转为十六进制字符串(便于显示)let password_hash_hex = encode(password_hash);format!("登录信息:用户名={}, 记住登录={}, 密码哈希(SHA-256)={}",form.username, form.remember_me, password_hash_hex)
}
测试接口
# 模拟表单提交
D:\>curl -X POST http://localhost:8080/user/login -H "Content-Type: application/x-www-form-urlencoded" -d "username=alice&password=123456&remember_me=true"
登录信息:用户名=alice, 记住登录=true, 密码哈希(SHA-256)=8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
预期响应正确:登录信息:用户名=alice, 记住登录=true, 密码哈希(SHA-256)=8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
3.2 关键知识点
Form
提取器:仅处理application/x-www-form-urlencoded
格式,与Json
提取器互斥(同一路由不能同时使用);布尔值处理:表单中布尔值通过字符串
"true"
/"false"
传递,serde
会自动转换为 Rust 的bool
;字段编码:表单数据会自动处理 URL 编码(如空格转为
+
),提取器会自动解码。
四、处理文件上传:Multipart
提取器
对于包含文件的表单(如头像上传),需使用 multipart/form-data
格式,Axum 推荐结合 multer
库处理这类请求。
4.1 基础文件上传实现
步骤 1:创建文件上传处理函数
use axum::extract::Multipart;/// 处理文件上传(multipart/form-data)
async fn upload_file(mut multipart: Multipart) -> String {// 遍历multipart表单中的所有字段while let Some(field) = multipart.next_field().await.unwrap() {// 1. 先保存所有需要的元数据(此时仅借用field,不移动)let name = field.name().unwrap_or("未知字段").to_string();let filename = field.file_name().unwrap_or("未知文件名").to_string();let content_type = field.content_type().unwrap_or("未知类型").to_string();// 2. 消费field获取字节数据(可以处理文件数据,也可以保存到硬盘)let data = field.bytes().await.unwrap();// 3. 使用保存的元数据和数据长度构建响应return format!("文件上传成功!字段名:{}, 文件名:{}, 类型:{}, 大小:{}字节",name, filename, content_type, data.len());}"未找到文件字段".to_string()
}
步骤 2:注册路由
// 在user_routes中添加
fn user_routes() -> Router {Router::new().route("/register", post(register_user)).route("/login", post(login)).route("/upload", post(upload_file)) // POST /user/upload
}
程序全部代码:
// 导入必要的组件
// Axum框架核心组件:用于创建路由和处理HTTP请求
use axum::{routing::{get,post}, // 导入GET、POST请求处理函数Router, // 导入路由构建器,用于定义请求路由规则extract::{Json, // 从Axum框架的extract模块中导入Json提取器,用于解析HTTP请求体中Content-Type为application/json的JSON数据Form, // 从Axum框架的extract模块中导入Form提取器Multipart // 从Axum框架的extract模块中导入Multipart提取器,用于传输文件},
};
// 标准库中的网络地址类型,用于指定服务器绑定的IP和端口
use std::net::SocketAddr;
// 日志相关组件:用于记录信息日志和错误日志
use tracing::{info, error};
// 日志订阅器组件:用于配置和初始化日志系统
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// 新增:导入信号处理模块
use tokio::signal;
// 导入serde库的Deserialize trait,用于实现数据反序列化
// 反序列化是指将外部数据格式(如JSON、表单数据)转换为Rust结构体的过程
// 在Axum中处理请求体(如JSON、表单)时,需要通过该trait将请求数据解析为定义的结构体
// 需配合#[derive(Deserialize)]属性使用,自动为结构体生成反序列化代码
use serde::Deserialize;// 用户注册数据(JSON格式)
#[derive(Debug, Deserialize)]
struct RegisterUser {username: String,email: String,age: Option<u8>, // 可选字段:允许为null或不存在
}// Form的数据结构体
#[derive(Debug, Deserialize)]
struct LoginForm {username: String,password: String,remember_me: bool, // 复选框类型,表单中会传递"true"或"false"
}// 异步主函数:Axum基于Tokio运行时,必须使用#[tokio::main]宏标记
// 该宏会自动设置Tokio异步运行时环境
#[tokio::main]
async fn main() {// 初始化日志系统:配置日志的过滤规则和输出格式tracing_subscriber::registry() // 创建日志订阅器注册表.with(// 设置日志过滤规则:优先从环境变量读取,若未设置则使用默认规则// 默认规则:本程序(axum_tutorial)和tower_http库输出debug级别及以上日志tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "axum_tutorial=debug,tower_http=debug".into())).with(tracing_subscriber::fmt::layer()) // 添加日志格式化输出层.init(); // 初始化日志系统,使其生效// 记录信息日志:提示服务器开始启动info!("Starting axum server...");// 创建路由表:定义不同路径的请求由哪个函数处理let app = Router::new()// 注册根路径("/")的处理规则:GET请求由root函数处理.route("/", get(root)).nest("/user", user_routes()); // 新增用户相关路由// 定义服务器绑定的地址:0.0.0.0表示监听所有可用网络接口,端口为8080let addr = SocketAddr::from(([0, 0, 0, 0], 8080));// 记录信息日志:提示正在绑定的地址info!("Binding to address: {}", addr);// 创建TCP监听器并绑定到指定地址// 使用match表达式处理可能的绑定结果(成功/失败)let listener = match tokio::net::TcpListener::bind(addr).await {Ok(listener) => listener, // 绑定成功:获取监听器对象Err(e) => { // 绑定失败:记录错误并优雅退出error!("Failed to bind to address {}: {}", addr, e);return; // 退出程序,不再继续执行}};// 启动Axum服务器:使用监听器接收请求,并通过路由表处理// 记录信息日志:提示服务器已启动并监听指定地址info!("Server started, listening on {}", addr);// 新增:创建服务器并配置优雅关闭let server = axum::serve(listener, app);// 新增:添加优雅关闭处理if let Err(e) = server.with_graceful_shutdown(shutdown_signal()).await {// 服务器运行出错时记录错误信息error!("Server error: {}", e);}// 记录信息日志:提示服务器已正常关闭info!("Server shutdown");
}/// 根路径("/")的请求处理函数
/// 异步函数:返回静态字符串作为HTTP响应体
async fn root() -> &'static str {"Hello world!" // 响应内容:简单的"Hello world!"字符串
}// 新增:处理关闭信号的函数
async fn shutdown_signal() {// 等待Ctrl+C信号let ctrl_c = async {signal::ctrl_c().await.expect("Failed to install Ctrl+C handler");};// 在Unix系统上额外监听终止信号#[cfg(unix)]let terminate = async {signal::unix::signal(signal::unix::SignalKind::terminate()).expect("Failed to install SIGTERM handler").recv().await;};// 等待任一信号#[cfg(unix)]tokio::select! {_ = ctrl_c => {},_ = terminate => {},}#[cfg(not(unix))]ctrl_c.await;info!("Received shutdown signal, starting graceful shutdown...");
}fn user_routes() -> Router {Router::new().route("/register", post(register_user)) // POST /user/register.route("/login", post(login)) // POST /user/login.route("/upload", post(upload_file)) // POST /user/upload
}/// 处理用户注册(JSON请求体)
async fn register_user(Json(user): Json<RegisterUser>) -> String {// 解析后的user是RegisterUser实例,可直接使用其字段format!("注册成功!用户信息:用户名={}, 邮箱={}, 年龄={:?}",user.username, user.email, user.age)
}/// 处理登录表单(x-www-form-urlencoded)
use sha2::{Sha256, Digest}; // SHA-256哈希算法
use hex::encode; // 字节转十六进制字符串
async fn login(Form(form): Form<LoginForm>) -> String {// 1. 计算密码的SHA-256哈希(将明文转为字节数组后处理)let password_hash = Sha256::digest(form.password.as_bytes());// 2. 将哈希结果(字节数组)转为十六进制字符串(便于显示)let password_hash_hex = encode(password_hash);format!("登录信息:用户名={}, 记住登录={}, 密码哈希(SHA-256)={}",form.username, form.remember_me, password_hash_hex)
}/// 处理文件上传(multipart/form-data)
async fn upload_file(mut multipart: Multipart) -> String {// 遍历multipart表单中的所有字段while let Some(field) = multipart.next_field().await.unwrap() {// 1. 先保存所有需要的元数据(此时仅借用field,不移动)let name = field.name().unwrap_or("未知字段").to_string();let filename = field.file_name().unwrap_or("未知文件名").to_string();let content_type = field.content_type().unwrap_or("未知类型").to_string();// 2. 消费field获取字节数据(可以处理文件数据,也可以保存到硬盘)let data = field.bytes().await.unwrap();// 3. 使用保存的元数据和数据长度构建响应return format!("文件上传成功!字段名:{}, 文件名:{}, 类型:{}, 大小:{}字节",name, filename, content_type, data.len());}"未找到文件字段".to_string()
}
测试接口
使用 curl
上传文件
D:\>curl -X POST http://localhost:8080/user/upload -F "avatar=@./rust_logo.jpeg"
文件上传成功!字段名:avatar, 文件名:rust_logo.jpeg, 类型:image/jpeg, 大小:14695字节
预期响应正确:文件上传成功!字段名:avatar, 文件名:rust_logo.jpeg, 类型:image/jpeg, 大小:???????字节
4.2 进阶:保存文件到磁盘
步骤 1:创建文件上传和保存函数
// 添加文件操作所需的额外导入
use std::path::Path;
use tokio::fs::{self, File};
use tokio::io::AsyncWriteExt;/// 处理文件上传(multipart/form-data)并保存到本地目录
async fn upload_save_file(mut multipart: Multipart) -> String {// 1. 定义上传目录(项目根目录下的 uploads 文件夹)let upload_dir = Path::new("./uploads");// 2. 确保上传目录存在(不存在则创建,包括父目录)match fs::create_dir_all(upload_dir).await {Ok(_) => {}, // 目录创建成功,继续Err(e) => return format!("创建上传目录失败:{}", e), // 错误提示}// 3. 遍历 Multipart 表单字段(处理外层 Result 类型)while let Ok(Some(field)) = multipart.next_field().await {// 3.1 获取文件元信息(转为 String,释放 field 借用)let field_name = field.name().unwrap_or("未知字段").to_string();let original_filename = field.file_name().unwrap_or("未知文件名").to_string(); // 拥有所有权的 Stringlet content_type = field.content_type().unwrap_or("未知类型").to_string();// 3.2 读取文件内容(此时 field 无借用,可安全移动)let file_data = match field.bytes().await {Ok(data) => data,Err(e) => return format!("读取文件内容失败(字段:{}):{}", field_name, e),};// 3.3 构建文件保存路径:用 &original_filename 借用,不转移所有权(关键修复!)let save_path = upload_dir.join(&original_filename); // 加 & 符号,借用而非移动// 3.4 检查文件是否已存在(避免覆盖)if save_path.exists() {return format!("文件已存在:{}", save_path.display());}// 3.5 创建文件并写入内容let mut file = match File::create(&save_path).await {Ok(f) => f,Err(e) => return format!("创建文件失败(路径:{}):{}", save_path.display(), e),};// 3.6 写入文件内容并同步磁盘if let Err(e) = file.write_all(&file_data).await {return format!("写入文件内容失败(路径:{}):{}", save_path.display(), e);}if let Err(e) = file.sync_all().await {return format!("同步文件到磁盘失败(路径:{}):{}", save_path.display(), e);}// 3.7 上传成功,返回详细信息(此时 original_filename 所有权仍在,可正常使用)return format!("文件上传成功!\n字段名:{}\n原始文件名:{}\n文件类型:{}\n文件大小:{} 字节\n保存路径:{}",field_name,original_filename, // 所有权未转移,可正常使用content_type,file_data.len(),save_path.display());}// 4. 遍历结束仍未找到文件字段"未在表单中找到文件字段(请确保使用 multipart/form-data 格式上传)".to_string()
}
步骤 2:注册路由
fn user_routes() -> Router {Router::new().route("/register", post(register_user)) // POST /user/register.route("/login", post(login)) // POST /user/login.route("/upload", post(upload_file)) // POST /user/upload.route("/upload_and_save", post(upload_save_file)) // POST /user/upload
}
程序全部代码:
// 导入必要的组件
// Axum框架核心组件:用于创建路由和处理HTTP请求
use axum::{routing::{get,post}, // 导入GET、POST请求处理函数Router, // 导入路由构建器,用于定义请求路由规则extract::{Json, // 从Axum框架的extract模块中导入Json提取器,用于解析HTTP请求体中Content-Type为application/json的JSON数据Form, // 从Axum框架的extract模块中导入Form提取器Multipart // 从Axum框架的extract模块中导入Multipart提取器,用于传输文件},
};
// 标准库中的网络地址类型,用于指定服务器绑定的IP和端口
use std::net::SocketAddr;
// 日志相关组件:用于记录信息日志和错误日志
use tracing::{info, error};
// 日志订阅器组件:用于配置和初始化日志系统
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// 新增:导入信号处理模块
use tokio::signal;
// 导入serde库的Deserialize trait,用于实现数据反序列化
// 反序列化是指将外部数据格式(如JSON、表单数据)转换为Rust结构体的过程
// 在Axum中处理请求体(如JSON、表单)时,需要通过该trait将请求数据解析为定义的结构体
// 需配合#[derive(Deserialize)]属性使用,自动为结构体生成反序列化代码
use serde::Deserialize;// 用户注册数据(JSON格式)
#[derive(Debug, Deserialize)]
struct RegisterUser {username: String,email: String,age: Option<u8>, // 可选字段:允许为null或不存在
}// Form的数据结构体
#[derive(Debug, Deserialize)]
struct LoginForm {username: String,password: String,remember_me: bool, // 复选框类型,表单中会传递"true"或"false"
}// 异步主函数:Axum基于Tokio运行时,必须使用#[tokio::main]宏标记
// 该宏会自动设置Tokio异步运行时环境
#[tokio::main]
async fn main() {// 初始化日志系统:配置日志的过滤规则和输出格式tracing_subscriber::registry() // 创建日志订阅器注册表.with(// 设置日志过滤规则:优先从环境变量读取,若未设置则使用默认规则// 默认规则:本程序(axum_tutorial)和tower_http库输出debug级别及以上日志tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "axum_tutorial=debug,tower_http=debug".into())).with(tracing_subscriber::fmt::layer()) // 添加日志格式化输出层.init(); // 初始化日志系统,使其生效// 记录信息日志:提示服务器开始启动info!("Starting axum server...");// 创建路由表:定义不同路径的请求由哪个函数处理let app = Router::new()// 注册根路径("/")的处理规则:GET请求由root函数处理.route("/", get(root)).nest("/user", user_routes()); // 新增用户相关路由// 定义服务器绑定的地址:0.0.0.0表示监听所有可用网络接口,端口为8080let addr = SocketAddr::from(([0, 0, 0, 0], 8080));// 记录信息日志:提示正在绑定的地址info!("Binding to address: {}", addr);// 创建TCP监听器并绑定到指定地址// 使用match表达式处理可能的绑定结果(成功/失败)let listener = match tokio::net::TcpListener::bind(addr).await {Ok(listener) => listener, // 绑定成功:获取监听器对象Err(e) => { // 绑定失败:记录错误并优雅退出error!("Failed to bind to address {}: {}", addr, e);return; // 退出程序,不再继续执行}};// 启动Axum服务器:使用监听器接收请求,并通过路由表处理// 记录信息日志:提示服务器已启动并监听指定地址info!("Server started, listening on {}", addr);// 新增:创建服务器并配置优雅关闭let server = axum::serve(listener, app);// 新增:添加优雅关闭处理if let Err(e) = server.with_graceful_shutdown(shutdown_signal()).await {// 服务器运行出错时记录错误信息error!("Server error: {}", e);}// 记录信息日志:提示服务器已正常关闭info!("Server shutdown");
}/// 根路径("/")的请求处理函数
/// 异步函数:返回静态字符串作为HTTP响应体
async fn root() -> &'static str {"Hello world!" // 响应内容:简单的"Hello world!"字符串
}// 新增:处理关闭信号的函数
async fn shutdown_signal() {// 等待Ctrl+C信号let ctrl_c = async {signal::ctrl_c().await.expect("Failed to install Ctrl+C handler");};// 在Unix系统上额外监听终止信号#[cfg(unix)]let terminate = async {signal::unix::signal(signal::unix::SignalKind::terminate()).expect("Failed to install SIGTERM handler").recv().await;};// 等待任一信号#[cfg(unix)]tokio::select! {_ = ctrl_c => {},_ = terminate => {},}#[cfg(not(unix))]ctrl_c.await;info!("Received shutdown signal, starting graceful shutdown...");
}fn user_routes() -> Router {Router::new().route("/register", post(register_user)) // POST /user/register.route("/login", post(login)) // POST /user/login.route("/upload", post(upload_file)) // POST /user/upload.route("/upload_and_save", post(upload_save_file)) // POST /user/upload
}/// 处理用户注册(JSON请求体)
async fn register_user(Json(user): Json<RegisterUser>) -> String {// 解析后的user是RegisterUser实例,可直接使用其字段format!("注册成功!用户信息:用户名={}, 邮箱={}, 年龄={:?}",user.username, user.email, user.age)
}/// 处理登录表单(x-www-form-urlencoded)
use sha2::{Sha256, Digest}; // SHA-256哈希算法
use hex::encode; // 字节转十六进制字符串
async fn login(Form(form): Form<LoginForm>) -> String {// 1. 计算密码的SHA-256哈希(将明文转为字节数组后处理)let password_hash = Sha256::digest(form.password.as_bytes());// 2. 将哈希结果(字节数组)转为十六进制字符串(便于显示)let password_hash_hex = encode(password_hash);format!("登录信息:用户名={}, 记住登录={}, 密码哈希(SHA-256)={}",form.username, form.remember_me, password_hash_hex)
}/// 处理文件上传(multipart/form-data)
async fn upload_file(mut multipart: Multipart) -> String {// 遍历multipart表单中的所有字段while let Some(field) = multipart.next_field().await.unwrap() {// 1. 先保存所有需要的元数据(此时仅借用field,不移动)let name = field.name().unwrap_or("未知字段").to_string();let filename = field.file_name().unwrap_or("未知文件名").to_string();let content_type = field.content_type().unwrap_or("未知类型").to_string();// 2. 消费field获取字节数据(可以处理文件数据,也可以保存到硬盘)let data = field.bytes().await.unwrap();// 3. 使用保存的元数据和数据长度构建响应return format!("文件上传成功!字段名:{}, 文件名:{}, 类型:{}, 大小:{}字节",name, filename, content_type, data.len());}"未找到文件字段".to_string()
}// 添加文件操作所需的额外导入
use std::path::Path;
use tokio::fs::{self, File};
use tokio::io::AsyncWriteExt;/// 处理文件上传(multipart/form-data)并保存到本地目录
async fn upload_save_file(mut multipart: Multipart) -> String {// 1. 定义上传目录(项目根目录下的 uploads 文件夹)let upload_dir = Path::new("./uploads");// 2. 确保上传目录存在(不存在则创建,包括父目录)match fs::create_dir_all(upload_dir).await {Ok(_) => {}, // 目录创建成功,继续Err(e) => return format!("创建上传目录失败:{}", e), // 错误提示}// 3. 遍历 Multipart 表单字段(处理外层 Result 类型)while let Ok(Some(field)) = multipart.next_field().await {// 3.1 获取文件元信息(转为 String,释放 field 借用)let field_name = field.name().unwrap_or("未知字段").to_string();let original_filename = field.file_name().unwrap_or("未知文件名").to_string(); // 拥有所有权的 Stringlet content_type = field.content_type().unwrap_or("未知类型").to_string();// 3.2 读取文件内容(此时 field 无借用,可安全移动)let file_data = match field.bytes().await {Ok(data) => data,Err(e) => return format!("读取文件内容失败(字段:{}):{}", field_name, e),};// 3.3 构建文件保存路径:用 &original_filename 借用,不转移所有权(关键修复!)let save_path = upload_dir.join(&original_filename); // 加 & 符号,借用而非移动// 3.4 检查文件是否已存在(避免覆盖)if save_path.exists() {return format!("文件已存在:{}", save_path.display());}// 3.5 创建文件并写入内容let mut file = match File::create(&save_path).await {Ok(f) => f,Err(e) => return format!("创建文件失败(路径:{}):{}", save_path.display(), e),};// 3.6 写入文件内容并同步磁盘if let Err(e) = file.write_all(&file_data).await {return format!("写入文件内容失败(路径:{}):{}", save_path.display(), e);}if let Err(e) = file.sync_all().await {return format!("同步文件到磁盘失败(路径:{}):{}", save_path.display(), e);}// 3.7 上传成功,返回详细信息(此时 original_filename 所有权仍在,可正常使用)return format!("文件上传成功!\n字段名:{}\n原始文件名:{}\n文件类型:{}\n文件大小:{} 字节\n保存路径:{}",field_name,original_filename, // 所有权未转移,可正常使用content_type,file_data.len(),save_path.display());}// 4. 遍历结束仍未找到文件字段"未在表单中找到文件字段(请确保使用 multipart/form-data 格式上传)".to_string()
}
测试接口
使用 curl
上传文件
D:\>curl -X POST http://localhost:8080/user/upload_and_save -F "avatar=@./rust_logo.jpeg"
文件上传成功!
字段名:avatar
原始文件名:rust_logo.jpeg
文件类型:image/jpeg
文件大小:14695 字节
保存路径:./uploads\rust_logo.jpeg
与预测的结果一致。
4.3 关键知识点
Multipart
提取器:用于处理multipart/form-data
格式,需在Cargo.toml
中添加multer
依赖;- 字段遍历:通过
multipart.next_field().await
逐个获取表单字段(包括普通字段和文件字段); - 文件安全:实际应用中需验证文件类型、限制大小,并对文件名进行 sanitize(如去除特殊字符),避免路径遍历攻击。
五、解析原始请求体:Bytes
与 String
提取器
对于非 JSON / 表单格式的请求体(如纯文本、二进制数据),可直接提取为原始字节或字符串。这时非标Web服务的常见场景。
5.1 提取为字符串(String
)
/// 处理纯文本请求体
async fn handle_text(body: String) -> String {format!("收到文本:{}(长度:{}字符)", body, body.len())
}
注册路由:
.route("/text", post(handle_text))
全部代码:
// 导入必要的组件
// Axum框架核心组件:用于创建路由和处理HTTP请求
use axum::{routing::{get,post}, // 导入GET、POST请求处理函数Router, // 导入路由构建器,用于定义请求路由规则extract::{Json, // 从Axum框架的extract模块中导入Json提取器,用于解析HTTP请求体中Content-Type为application/json的JSON数据Form, // 从Axum框架的extract模块中导入Form提取器Multipart // 从Axum框架的extract模块中导入Multipart提取器,用于传输文件},
};
// 标准库中的网络地址类型,用于指定服务器绑定的IP和端口
use std::net::SocketAddr;
// 日志相关组件:用于记录信息日志和错误日志
use tracing::{info, error};
// 日志订阅器组件:用于配置和初始化日志系统
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// 新增:导入信号处理模块
use tokio::signal;
// 导入serde库的Deserialize trait,用于实现数据反序列化
// 反序列化是指将外部数据格式(如JSON、表单数据)转换为Rust结构体的过程
// 在Axum中处理请求体(如JSON、表单)时,需要通过该trait将请求数据解析为定义的结构体
// 需配合#[derive(Deserialize)]属性使用,自动为结构体生成反序列化代码
use serde::Deserialize;// 用户注册数据(JSON格式)
#[derive(Debug, Deserialize)]
struct RegisterUser {username: String,email: String,age: Option<u8>, // 可选字段:允许为null或不存在
}// Form的数据结构体
#[derive(Debug, Deserialize)]
struct LoginForm {username: String,password: String,remember_me: bool, // 复选框类型,表单中会传递"true"或"false"
}// 异步主函数:Axum基于Tokio运行时,必须使用#[tokio::main]宏标记
// 该宏会自动设置Tokio异步运行时环境
#[tokio::main]
async fn main() {// 初始化日志系统:配置日志的过滤规则和输出格式tracing_subscriber::registry() // 创建日志订阅器注册表.with(// 设置日志过滤规则:优先从环境变量读取,若未设置则使用默认规则// 默认规则:本程序(axum_tutorial)和tower_http库输出debug级别及以上日志tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "axum_tutorial=debug,tower_http=debug".into())).with(tracing_subscriber::fmt::layer()) // 添加日志格式化输出层.init(); // 初始化日志系统,使其生效// 记录信息日志:提示服务器开始启动info!("Starting axum server...");// 创建路由表:定义不同路径的请求由哪个函数处理let app = Router::new()// 注册根路径("/")的处理规则:GET请求由root函数处理.route("/", get(root)).nest("/user", user_routes()); // 新增用户相关路由// 定义服务器绑定的地址:0.0.0.0表示监听所有可用网络接口,端口为8080let addr = SocketAddr::from(([0, 0, 0, 0], 8080));// 记录信息日志:提示正在绑定的地址info!("Binding to address: {}", addr);// 创建TCP监听器并绑定到指定地址// 使用match表达式处理可能的绑定结果(成功/失败)let listener = match tokio::net::TcpListener::bind(addr).await {Ok(listener) => listener, // 绑定成功:获取监听器对象Err(e) => { // 绑定失败:记录错误并优雅退出error!("Failed to bind to address {}: {}", addr, e);return; // 退出程序,不再继续执行}};// 启动Axum服务器:使用监听器接收请求,并通过路由表处理// 记录信息日志:提示服务器已启动并监听指定地址info!("Server started, listening on {}", addr);// 新增:创建服务器并配置优雅关闭let server = axum::serve(listener, app);// 新增:添加优雅关闭处理if let Err(e) = server.with_graceful_shutdown(shutdown_signal()).await {// 服务器运行出错时记录错误信息error!("Server error: {}", e);}// 记录信息日志:提示服务器已正常关闭info!("Server shutdown");
}/// 根路径("/")的请求处理函数
/// 异步函数:返回静态字符串作为HTTP响应体
async fn root() -> &'static str {"Hello world!" // 响应内容:简单的"Hello world!"字符串
}// 新增:处理关闭信号的函数
async fn shutdown_signal() {// 等待Ctrl+C信号let ctrl_c = async {signal::ctrl_c().await.expect("Failed to install Ctrl+C handler");};// 在Unix系统上额外监听终止信号#[cfg(unix)]let terminate = async {signal::unix::signal(signal::unix::SignalKind::terminate()).expect("Failed to install SIGTERM handler").recv().await;};// 等待任一信号#[cfg(unix)]tokio::select! {_ = ctrl_c => {},_ = terminate => {},}#[cfg(not(unix))]ctrl_c.await;info!("Received shutdown signal, starting graceful shutdown...");
}fn user_routes() -> Router {Router::new().route("/register", post(register_user)) // POST /user/register.route("/login", post(login)) // POST /user/login.route("/upload", post(upload_file)) // POST /user/upload.route("/upload_and_save", post(upload_save_file)) // POST /user/upload.route("/text", post(handle_text))
}/// 处理用户注册(JSON请求体)
async fn register_user(Json(user): Json<RegisterUser>) -> String {// 解析后的user是RegisterUser实例,可直接使用其字段format!("注册成功!用户信息:用户名={}, 邮箱={}, 年龄={:?}",user.username, user.email, user.age)
}/// 处理登录表单(x-www-form-urlencoded)
use sha2::{Sha256, Digest}; // SHA-256哈希算法
use hex::encode; // 字节转十六进制字符串
async fn login(Form(form): Form<LoginForm>) -> String {// 1. 计算密码的SHA-256哈希(将明文转为字节数组后处理)let password_hash = Sha256::digest(form.password.as_bytes());// 2. 将哈希结果(字节数组)转为十六进制字符串(便于显示)let password_hash_hex = encode(password_hash);format!("登录信息:用户名={}, 记住登录={}, 密码哈希(SHA-256)={}",form.username, form.remember_me, password_hash_hex)
}/// 处理文件上传(multipart/form-data)
async fn upload_file(mut multipart: Multipart) -> String {// 遍历multipart表单中的所有字段while let Some(field) = multipart.next_field().await.unwrap() {// 1. 先保存所有需要的元数据(此时仅借用field,不移动)let name = field.name().unwrap_or("未知字段").to_string();let filename = field.file_name().unwrap_or("未知文件名").to_string();let content_type = field.content_type().unwrap_or("未知类型").to_string();// 2. 消费field获取字节数据(可以处理文件数据,也可以保存到硬盘)let data = field.bytes().await.unwrap();// 3. 使用保存的元数据和数据长度构建响应return format!("文件上传成功!字段名:{}, 文件名:{}, 类型:{}, 大小:{}字节",name, filename, content_type, data.len());}"未找到文件字段".to_string()
}// 添加文件操作所需的额外导入
use std::path::Path;
use tokio::fs::{self, File};
use tokio::io::AsyncWriteExt;/// 处理文件上传(multipart/form-data)并保存到本地目录
async fn upload_save_file(mut multipart: Multipart) -> String {// 1. 定义上传目录(项目根目录下的 uploads 文件夹)let upload_dir = Path::new("./uploads");// 2. 确保上传目录存在(不存在则创建,包括父目录)match fs::create_dir_all(upload_dir).await {Ok(_) => {}, // 目录创建成功,继续Err(e) => return format!("创建上传目录失败:{}", e), // 错误提示}// 3. 遍历 Multipart 表单字段(处理外层 Result 类型)while let Ok(Some(field)) = multipart.next_field().await {// 3.1 获取文件元信息(转为 String,释放 field 借用)let field_name = field.name().unwrap_or("未知字段").to_string();let original_filename = field.file_name().unwrap_or("未知文件名").to_string(); // 拥有所有权的 Stringlet content_type = field.content_type().unwrap_or("未知类型").to_string();// 3.2 读取文件内容(此时 field 无借用,可安全移动)let file_data = match field.bytes().await {Ok(data) => data,Err(e) => return format!("读取文件内容失败(字段:{}):{}", field_name, e),};// 3.3 构建文件保存路径:用 &original_filename 借用,不转移所有权(关键修复!)let save_path = upload_dir.join(&original_filename); // 加 & 符号,借用而非移动// 3.4 检查文件是否已存在(避免覆盖)if save_path.exists() {return format!("文件已存在:{}", save_path.display());}// 3.5 创建文件并写入内容let mut file = match File::create(&save_path).await {Ok(f) => f,Err(e) => return format!("创建文件失败(路径:{}):{}", save_path.display(), e),};// 3.6 写入文件内容并同步磁盘if let Err(e) = file.write_all(&file_data).await {return format!("写入文件内容失败(路径:{}):{}", save_path.display(), e);}if let Err(e) = file.sync_all().await {return format!("同步文件到磁盘失败(路径:{}):{}", save_path.display(), e);}// 3.7 上传成功,返回详细信息(此时 original_filename 所有权仍在,可正常使用)return format!("文件上传成功!\n字段名:{}\n原始文件名:{}\n文件类型:{}\n文件大小:{} 字节\n保存路径:{}",field_name,original_filename, // 所有权未转移,可正常使用content_type,file_data.len(),save_path.display());}// 4. 遍历结束仍未找到文件字段"未在表单中找到文件字段(请确保使用 multipart/form-data 格式上传)".to_string()
}/// 处理纯文本请求体
async fn handle_text(body: String) -> String {format!("收到文本:{}(长度:{}字符)", body, body.len())
}
测试:
D:\>curl -X POST http://localhost:8080/user/text -H "Content-Type: text/plain" -d "hello raw text"
收到文本:hello raw text(长度:14字符)
与预测结果一致。
5.2 提取为字节(Bytes
)
/// 处理二进制请求体
use axum::body::Bytes;
async fn handle_binary(body: Bytes) -> String {format!("收到二进制数据,长度:{}字节", body.len())
}
注册路由:
// 注册路由.route("/binary", post(handle_binary))
完整代码:
// 导入必要的组件
// Axum框架核心组件:用于创建路由和处理HTTP请求
use axum::{routing::{get,post}, // 导入GET、POST请求处理函数Router, // 导入路由构建器,用于定义请求路由规则extract::{Json, // 从Axum框架的extract模块中导入Json提取器,用于解析HTTP请求体中Content-Type为application/json的JSON数据Form, // 从Axum框架的extract模块中导入Form提取器Multipart // 从Axum框架的extract模块中导入Multipart提取器,用于传输文件},
};
// 标准库中的网络地址类型,用于指定服务器绑定的IP和端口
use std::net::SocketAddr;
// 日志相关组件:用于记录信息日志和错误日志
use tracing::{info, error};
// 日志订阅器组件:用于配置和初始化日志系统
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// 新增:导入信号处理模块
use tokio::signal;
// 导入serde库的Deserialize trait,用于实现数据反序列化
// 反序列化是指将外部数据格式(如JSON、表单数据)转换为Rust结构体的过程
// 在Axum中处理请求体(如JSON、表单)时,需要通过该trait将请求数据解析为定义的结构体
// 需配合#[derive(Deserialize)]属性使用,自动为结构体生成反序列化代码
use serde::Deserialize;// 用户注册数据(JSON格式)
#[derive(Debug, Deserialize)]
struct RegisterUser {username: String,email: String,age: Option<u8>, // 可选字段:允许为null或不存在
}// Form的数据结构体
#[derive(Debug, Deserialize)]
struct LoginForm {username: String,password: String,remember_me: bool, // 复选框类型,表单中会传递"true"或"false"
}// 异步主函数:Axum基于Tokio运行时,必须使用#[tokio::main]宏标记
// 该宏会自动设置Tokio异步运行时环境
#[tokio::main]
async fn main() {// 初始化日志系统:配置日志的过滤规则和输出格式tracing_subscriber::registry() // 创建日志订阅器注册表.with(// 设置日志过滤规则:优先从环境变量读取,若未设置则使用默认规则// 默认规则:本程序(axum_tutorial)和tower_http库输出debug级别及以上日志tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "axum_tutorial=debug,tower_http=debug".into())).with(tracing_subscriber::fmt::layer()) // 添加日志格式化输出层.init(); // 初始化日志系统,使其生效// 记录信息日志:提示服务器开始启动info!("Starting axum server...");// 创建路由表:定义不同路径的请求由哪个函数处理let app = Router::new()// 注册根路径("/")的处理规则:GET请求由root函数处理.route("/", get(root)).nest("/user", user_routes()); // 新增用户相关路由// 定义服务器绑定的地址:0.0.0.0表示监听所有可用网络接口,端口为8080let addr = SocketAddr::from(([0, 0, 0, 0], 8080));// 记录信息日志:提示正在绑定的地址info!("Binding to address: {}", addr);// 创建TCP监听器并绑定到指定地址// 使用match表达式处理可能的绑定结果(成功/失败)let listener = match tokio::net::TcpListener::bind(addr).await {Ok(listener) => listener, // 绑定成功:获取监听器对象Err(e) => { // 绑定失败:记录错误并优雅退出error!("Failed to bind to address {}: {}", addr, e);return; // 退出程序,不再继续执行}};// 启动Axum服务器:使用监听器接收请求,并通过路由表处理// 记录信息日志:提示服务器已启动并监听指定地址info!("Server started, listening on {}", addr);// 新增:创建服务器并配置优雅关闭let server = axum::serve(listener, app);// 新增:添加优雅关闭处理if let Err(e) = server.with_graceful_shutdown(shutdown_signal()).await {// 服务器运行出错时记录错误信息error!("Server error: {}", e);}// 记录信息日志:提示服务器已正常关闭info!("Server shutdown");
}/// 根路径("/")的请求处理函数
/// 异步函数:返回静态字符串作为HTTP响应体
async fn root() -> &'static str {"Hello world!" // 响应内容:简单的"Hello world!"字符串
}// 新增:处理关闭信号的函数
async fn shutdown_signal() {// 等待Ctrl+C信号let ctrl_c = async {signal::ctrl_c().await.expect("Failed to install Ctrl+C handler");};// 在Unix系统上额外监听终止信号#[cfg(unix)]let terminate = async {signal::unix::signal(signal::unix::SignalKind::terminate()).expect("Failed to install SIGTERM handler").recv().await;};// 等待任一信号#[cfg(unix)]tokio::select! {_ = ctrl_c => {},_ = terminate => {},}#[cfg(not(unix))]ctrl_c.await;info!("Received shutdown signal, starting graceful shutdown...");
}fn user_routes() -> Router {Router::new().route("/register", post(register_user)) // POST /user/register.route("/login", post(login)) // POST /user/login.route("/upload", post(upload_file)) // POST /user/upload.route("/upload_and_save", post(upload_save_file)) // POST /user/upload.route("/text", post(handle_text)).route("/binary", post(handle_binary))
}/// 处理用户注册(JSON请求体)
async fn register_user(Json(user): Json<RegisterUser>) -> String {// 解析后的user是RegisterUser实例,可直接使用其字段format!("注册成功!用户信息:用户名={}, 邮箱={}, 年龄={:?}",user.username, user.email, user.age)
}/// 处理登录表单(x-www-form-urlencoded)
use sha2::{Sha256, Digest}; // SHA-256哈希算法
use hex::encode; // 字节转十六进制字符串
async fn login(Form(form): Form<LoginForm>) -> String {// 1. 计算密码的SHA-256哈希(将明文转为字节数组后处理)let password_hash = Sha256::digest(form.password.as_bytes());// 2. 将哈希结果(字节数组)转为十六进制字符串(便于显示)let password_hash_hex = encode(password_hash);format!("登录信息:用户名={}, 记住登录={}, 密码哈希(SHA-256)={}",form.username, form.remember_me, password_hash_hex)
}/// 处理文件上传(multipart/form-data)
async fn upload_file(mut multipart: Multipart) -> String {// 遍历multipart表单中的所有字段while let Some(field) = multipart.next_field().await.unwrap() {// 1. 先保存所有需要的元数据(此时仅借用field,不移动)let name = field.name().unwrap_or("未知字段").to_string();let filename = field.file_name().unwrap_or("未知文件名").to_string();let content_type = field.content_type().unwrap_or("未知类型").to_string();// 2. 消费field获取字节数据(可以处理文件数据,也可以保存到硬盘)let data = field.bytes().await.unwrap();// 3. 使用保存的元数据和数据长度构建响应return format!("文件上传成功!字段名:{}, 文件名:{}, 类型:{}, 大小:{}字节",name, filename, content_type, data.len());}"未找到文件字段".to_string()
}// 添加文件操作所需的额外导入
use std::path::Path;
use tokio::fs::{self, File};
use tokio::io::AsyncWriteExt;/// 处理文件上传(multipart/form-data)并保存到本地目录
async fn upload_save_file(mut multipart: Multipart) -> String {// 1. 定义上传目录(项目根目录下的 uploads 文件夹)let upload_dir = Path::new("./uploads");// 2. 确保上传目录存在(不存在则创建,包括父目录)match fs::create_dir_all(upload_dir).await {Ok(_) => {}, // 目录创建成功,继续Err(e) => return format!("创建上传目录失败:{}", e), // 错误提示}// 3. 遍历 Multipart 表单字段(处理外层 Result 类型)while let Ok(Some(field)) = multipart.next_field().await {// 3.1 获取文件元信息(转为 String,释放 field 借用)let field_name = field.name().unwrap_or("未知字段").to_string();let original_filename = field.file_name().unwrap_or("未知文件名").to_string(); // 拥有所有权的 Stringlet content_type = field.content_type().unwrap_or("未知类型").to_string();// 3.2 读取文件内容(此时 field 无借用,可安全移动)let file_data = match field.bytes().await {Ok(data) => data,Err(e) => return format!("读取文件内容失败(字段:{}):{}", field_name, e),};// 3.3 构建文件保存路径:用 &original_filename 借用,不转移所有权(关键修复!)let save_path = upload_dir.join(&original_filename); // 加 & 符号,借用而非移动// 3.4 检查文件是否已存在(避免覆盖)if save_path.exists() {return format!("文件已存在:{}", save_path.display());}// 3.5 创建文件并写入内容let mut file = match File::create(&save_path).await {Ok(f) => f,Err(e) => return format!("创建文件失败(路径:{}):{}", save_path.display(), e),};// 3.6 写入文件内容并同步磁盘if let Err(e) = file.write_all(&file_data).await {return format!("写入文件内容失败(路径:{}):{}", save_path.display(), e);}if let Err(e) = file.sync_all().await {return format!("同步文件到磁盘失败(路径:{}):{}", save_path.display(), e);}// 3.7 上传成功,返回详细信息(此时 original_filename 所有权仍在,可正常使用)return format!("文件上传成功!\n字段名:{}\n原始文件名:{}\n文件类型:{}\n文件大小:{} 字节\n保存路径:{}",field_name,original_filename, // 所有权未转移,可正常使用content_type,file_data.len(),save_path.display());}// 4. 遍历结束仍未找到文件字段"未在表单中找到文件字段(请确保使用 multipart/form-data 格式上传)".to_string()
}/// 处理纯文本请求体
async fn handle_text(body: String) -> String {format!("收到文本:{}(长度:{}字符)", body, body.len())
}/// 处理二进制请求体
use axum::body::Bytes;
async fn handle_binary(body: Bytes) -> String {format!("收到二进制数据,长度:{}字节", body.len())
}
测试:
D:\>curl -X POST http://localhost:8080/user/binary -H "Content-Type: application/octet-stream" --data-binary @./test.bin
收到二进制数据,长度:10字节
与预期结果相同。
5.3 关键知识点
String
提取器:自动将请求体按 UTF-8 编码解析为字符串,若编码错误返回400
;Bytes
提取器:直接获取原始字节数据,适用于二进制文件、自定义协议等场景;- Content-Type:这两种提取器不验证
Content-Type
,需自行处理不同格式的数据。