本教程让 你从上一章节的 Axum 的 “基础业务接口” 升级为 “可生产级 Web 服务”。带领你以上一章节的 Axum 代码为基础,逐步过渡到新增静态资源服务、环境区分、响应压缩、文件上传安全控制等功能的新版代码。我们会循序渐进拆解每个新增特性,结合代码讲解原理,最终掌握可直接用于生产环境的 Axum 服务框架。
要在版基础上实现四大核心增强:
- 静态资源服务:通过
tower-http
的ServeFile
/ServeDir
托管 HTML/CSS/ 图片,支持预压缩。 - 环境适配:通过环境变量区分开发 / 生产,动态配置缓存和压缩策略。
- 安全增强:文件上传增加类型 / 大小限制,文件名净化防止路径遍历。
- 用户体验优化:自定义 404 静态页面,统一错误响应格式。
一、教程前提与目标
- 前置基础:已掌握 Rust 基础语法、Axum 基本路由与提取器(如
Json
、Form
、Multipart
)用法。 - 学习目标:
- 实现静态资源(HTML/CSS/ 图片)服务
- 区分开发 / 生产环境并配置不同缓存策略
- 启用响应压缩优化性能
- 增强文件上传安全性(类型 / 大小限制、文件名净化)
- 自定义 404 静态页面
- 最终效果:一个支持静态页面、安全文件上传、性能优化的完整 HTTP 服务。
二、第一步:环境准备与依赖更新
新版代码的核心增强依赖于 tower-http
的扩展特性,首先需要更新 Cargo.toml
并理解新增依赖的作用。
1. 依赖对比与说明
依赖项 | 旧版配置 | 新版配置 | 新增 / 修改原因 |
---|---|---|---|
tower-http | 仅 limit /cors /trace 特性 | 新增 fs /compression-gzip /set-header | fs 用于静态资源;compression-gzip 用于响应压缩;set-header 用于设置缓存头 |
tower | 0.4.13 | 0.5.2 | tower-http 0.6.6 依赖 tower 0.5+ 版本 |
headers | 无 | 0.4 | 辅助处理 HTTP 头(本示例暂用 Axum 内置头处理,预留扩展) |
hyper | 无 | 1.7.0 | Axum 底层依赖,确保与 tower 版本兼容 |
2. 完整 Cargo.toml
配置
直接替换旧版配置,执行 cargo update
安装依赖:
[package]
name = "axum-tutorial"
version = "0.1.0"
edition = "2024"
[dependencies]
# Axum 核心框架(含 multipart 支持)
axum = { version = "0.8.4", features = ["multipart"] }
# 异步运行时(仅保留必要特性)
tokio = { version = "1.47.1", features = ["rt-multi-thread", "net", "macros", "signal", "fs", "io-util"] }
# 日志系统(完整特性支持)
tracing = "0.1"
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] }
# 序列化/反序列化(JSON 处理)
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.108"
# 文件上传(multipart 底层依赖)
multer = "3.1.0"
# HTTP 工具(静态资源、限流、CORS、压缩、缓存)
tower-http = { version = "0.6.6", features = [
"limit",
"cors",
"trace",
"fs", # 静态资源服务
"compression-gzip", # Gzip 压缩
"set-header" # 设置响应头(缓存控制)
] }
# 哈希与编码(密码处理)
sha2 = "0.10.8"
hex = "0.4.3"
# 错误处理(自定义错误类型)
thiserror = "2.0.16"
# Tower 基础依赖(服务构建)
tower = "0.5.2"
# 缓存控制头(扩展用)
headers = "0.4"
# Axum 底层 HTTP 依赖
hyper = "1.7.0"
三、第二步:静态资源服务实现
静态资源(HTML、CSS、图片等)是 Web 服务的基础,新版代码通过 tower-http
的 ServeFile
(单文件)和 ServeDir
(目录)实现静态资源托管。
1. 静态资源目录结构
首先在项目根目录创建 static
文件夹,并按以下结构组织文件(后续会提供完整文件内容):
axum-tutorial/
├─ static/ # 静态资源根目录
│ ├─ index.html # 根路径首页
│ ├─ 404.html # 404 页面
│ ├─ css/
│ │ └─ style.css # 样式文件
│ └─ images/
│ └─ rust-logo.svg # 图片资源
├─ Cargo.toml
└─ src/
└─ main.rs
2. 静态资源路由配置
在 main.rs
的路由构建部分,新增静态资源路由(核心是 route_service
和 nest_service
):
关键代码片段(main 函数内)
// 8.3 构建路由表(先初始化静态资源路由)
let mut app = Router::new()
// 1. 根路径 / 指向静态首页(单文件服务)
.route_service("/", ServeFile::new("static/index.html"));
// 后续继续添加其他路由...
// 2. /static 路径:托管整个 static 目录(所有子文件/文件夹)
app = app.nest_service(
"/static",
ServeDir::new("static")
.precompressed_gzip() // 支持预压缩的 .gz 文件(优化性能)
.precompressed_br() // 支持预压缩的 .br 文件(更高压缩率)
);
知识点讲解
route_service
vsnest_service
:route_service("/", ServeFile::new(...))
:将单个路径(/
)绑定到单个文件,适合首页。nest_service("/static", ServeDir::new(...))
:将路径前缀(/static
)绑定到目录,自动匹配子路径(如/static/css/style.css
对应static/css/style.css
文件)。
- 预压缩支持:
precompressed_gzip()
:如果静态资源存在.gz
后缀的预压缩文件(如style.css.gz
),且客户端支持 gzip,直接返回压缩文件,减少服务器实时压缩开销。- 生产环境可通过工具(如
gzip
命令)预先压缩静态资源,进一步优化性能。
3. 静态文件内容实现
3.1 static/index.html
(首页)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Axum 静态首页</title>
<!-- 加载静态 CSS 文件 -->
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1>Axum 教程演示</h1>
<nav>
<a href="/">首页</a>
</nav>
</header>
<main>
<section>
<h2>欢迎使用新版 Axum 服务</h2>
<p>本页面为静态资源托管示例,包含 CSS 和图片加载。</p>
<!-- 加载静态图片 -->
<img src="/static/images/rust-logo.svg" alt="Rust 语言图标" class="logo">
</section>
</main>
<footer>
<p>© 2024 Axum 进阶教程</p>
</footer>
</body>
</html>
3.2 static/css/style.css
(样式文件)
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
header {
margin-bottom: 30px;
padding-bottom: 10px;
border-bottom: 1px solid #ddd;
}
nav a {
color: #007bff;
text-decoration: none;
}
nav a:hover {
text-decoration: underline;
}
.logo {
max-width: 200px;
margin: 20px 0;
}
footer {
margin-top: 30px;
padding-top: 10px;
border-top: 1px solid #ddd;
font-size: 0.9em;
color: #666;
}
3.3 static/404.html
(404 页面)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 页面未找到</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1>Axum 教程演示</h1>
</header>
<main>
<section>
<h2>404 - 页面不存在</h2>
<p>你访问的路径不存在,请检查 URL 是否正确。</p>
<p><a href="/">返回首页</a></p>
</section>
</main>
<footer>
<p>© 2024 Axum 进阶教程</p>
</footer>
</body>
</html>
3.4 图片资源
从 Rust 官网 下载 Rust 图标,重命名为 rust-logo.svg
放入 static/images/
目录。
四、第三步:环境区分与缓存控制
开发环境需要实时看到代码修改效果(禁用缓存),生产环境需要启用缓存减少重复请求 —— 新版代码通过环境变量区分环境,并通过 SetResponseHeaderLayer
设置缓存头。
1. 环境变量读取与判断
在 main
函数中添加环境判断逻辑:
// 8.2 初始化日志系统后,读取环境变量
// Windows 终端:set APP_ENV=development
// Linux/macOS 终端:export APP_ENV=development
let env = std::env::var("APP_ENV").unwrap_or_else(|_| "production".to_string());
let is_dev = env == "development"; // 开发环境标记
info!("Axum 服务器启动中...");
info!("运行环境: {}", if is_dev { "开发" } else { "生产" });
2. 动态设置缓存头
根据环境设置不同的 Cache-Control
头(核心是 SetResponseHeaderLayer
中间件):
// 根据环境设置缓存策略
let cache_header = if is_dev {
// 开发环境:禁用缓存(每次请求都获取最新资源)
HeaderValue::from_static("no-cache, no-store, must-revalidate")
} else {
// 生产环境:缓存 30 天(86400秒/天 × 30天 = 2592000秒)
HeaderValue::from_static("public, max-age=2592000")
};
// 为所有响应设置缓存头(先添加到根路由)
app = app.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL, // HTTP 头名称
cache_header.clone(), // 头值
));
// 为静态资源目录单独设置缓存头(确保覆盖)
app = app.nest_service(
"/static",
ServeDir::new("static")
.precompressed_gzip()
.precompressed_br()
)
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
cache_header,
));
知识点:Cache-Control
头含义
no-cache, no-store, must-revalidate
(开发):no-cache
:强制浏览器验证资源是否最新(不直接使用本地缓存)。no-store
:不存储任何缓存(防止旧资源残留)。
public, max-age=2592000
(生产):public
:允许代理服务器(如 Nginx)缓存资源。max-age=2592000
:资源有效期 30 天,到期前无需请求服务器。
五、第四步:响应压缩优化
生产环境启用 Gzip 压缩(文本类资源如 HTML/CSS/JS 压缩率可达 70%+),减少网络传输量 —— 通过 CompressionLayer
实现。
1. 条件添加压缩中间件
仅在生产环境启用压缩(开发环境压缩会增加调试复杂度):
// 8.3 构建路由表的最后,条件添加压缩层
if !is_dev {
app = app.layer(CompressionLayer::new()); // 启用 Gzip 压缩
}
原理说明
CompressionLayer
会自动识别响应类型(如text/html
、application/json
),仅对文本类资源进行压缩(图片 / 视频等二进制资源压缩率低,无需处理)。- 客户端需通过
Accept-Encoding: gzip
请求头告知服务器支持压缩,Axum 会自动匹配并返回压缩响应。
六、第五步:文件上传安全增强
旧版文件上传无类型 / 大小限制,且未处理危险文件名(如 ../../etc/passwd
路径遍历攻击)。新版通过错误类型扩展、文件校验、文件名净化解决这些问题。
1. 新增错误类型
在 AppError
枚举中添加文件相关错误:
#[derive(Error, Debug)]
pub enum AppError {
// ... 原有错误类型 ...
// 新增:不支持的文件类型
#[error("不支持的文件类型: {0}")]
FileTypeNotAllowed(String),
// 新增:文件大小超过限制
#[error("文件大小超过限制(最大允许{0}MB)")]
FileTooLarge(usize),
}
并在 IntoResponse
实现中添加错误映射(返回正确的 HTTP 状态码):
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, detail) = match &self {
// ... 原有错误匹配 ...
// 新增:文件类型不支持 → 415 状态码
AppError::FileTypeNotAllowed(file_type) => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"FILE_TYPE_NOT_ALLOWED",
Some(file_type.clone()),
),
// 新增:文件过大 → 413 状态码
AppError::FileTooLarge(max_size) => (
StatusCode::PAYLOAD_TOO_LARGE,
"FILE_TOO_LARGE",
Some(format!("最大允许{}MB", max_size)),
),
};
// ... 构建响应体 ...
}
}
2. 文件名净化函数
添加 sanitize_filename
函数,过滤危险路径字符(如 ..
、/
),防止路径遍历攻击:
// 文件名净化函数:移除危险路径组件(如 ../../etc/passwd → etc_passwd)
fn sanitize_filename(filename: &str) -> String {
use std::path::{Component, PathBuf};
let path = PathBuf::from(filename);
// 过滤路径组件:只保留 "正常文件名"(Normal 类型),其他(如 ..、/)丢弃
let components = path.components()
.filter_map(|c| match c {
Component::Normal(s) => s.to_str().map(|s| s.to_string()), // 保留正常文件名
_ => None, // 丢弃 ..、/ 等危险组件
})
.collect::<Vec<String>>();
// 如果过滤后为空,返回默认名
if components.is_empty() {
return "unnamed".to_string();
}
// 用下划线连接组件(避免路径分隔符)
components.join("_")
}
3. 增强文件上传处理逻辑
修改 upload_file
和 upload_save_file
函数,添加类型检查和大小限制:
3.1 upload_file
函数(仅读取不保存)
async fn upload_file(mut multipart: Multipart) -> AppResult<String> {
// 1. 定义允许的文件类型(MIME 类型)和最大大小(5MB)
let allowed_types = ["image/jpeg", "image/png", "image/gif", "application/pdf"];
let max_file_size = 5 * 1024 * 1024; // 5MB = 5 × 1024KB × 1024B
while let Some(field) = multipart.next_field().await? {
let field_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. 检查文件类型是否允许
if !allowed_types.contains(&content_type.as_str()) {
return Err(AppError::FileTypeNotAllowed(content_type));
}
// 3. 读取文件内容并检查大小
let file_data = field.bytes().await?;
if file_data.len() > max_file_size {
return Err(AppError::FileTooLarge(5)); // 5MB 限制
}
// 返回成功信息
return Ok(format!(
"文件上传成功(未保存)!\n字段名:{}\n文件名:{}\n文件类型:{}\n文件大小:{} 字节",
field_name, filename, content_type, file_data.len()
));
}
// 未找到文件字段
Err(AppError::InvalidInput {
field: "file".to_string(),
message: "表单中未包含文件字段(请用 multipart/form-data 格式)".to_string(),
})
}
3.2 upload_save_file
函数(上传并保存)
在原有逻辑中添加类型 / 大小检查,并使用 sanitize_filename
处理文件名:
async fn upload_save_file(mut multipart: Multipart) -> AppResult<String> {
let upload_dir = Path::new("./uploads");
fs::create_dir_all(upload_dir).await?;
// 1. 定义允许的类型和大小
let allowed_types = ["image/jpeg", "image/png", "image/gif", "application/pdf"];
let max_file_size = 5 * 1024 * 1024; // 5MB
while let Some(field) = multipart.next_field().await? {
let field_name = field.name().unwrap_or("未知字段").to_string();
let original_filename = field.file_name().unwrap_or("未知文件名").to_string();
let content_type = field.content_type().unwrap_or("未知类型").to_string();
// 2. 检查文件类型
if !allowed_types.contains(&content_type.as_str()) {
return Err(AppError::FileTypeNotAllowed(content_type));
}
// 3. 检查文件大小
let file_data = field.bytes().await?;
if file_data.len() > max_file_size {
return Err(AppError::FileTooLarge(5));
}
// 4. 文件名净化(防止路径遍历)
let sanitized_filename = sanitize_filename(&original_filename);
let save_path = upload_dir.join(&sanitized_filename);
// 5. 避免文件覆盖
if save_path.exists() {
return Err(AppError::FileExists(save_path.display().to_string()));
}
// 保存文件
let mut file = File::create(&save_path).await?;
file.write_all(&file_data).await?;
file.sync_all().await?; // 强制刷盘,确保数据写入
return Ok(format!(
"文件上传并保存成功!\n字段名:{}\n原始文件名:{}\n净化后文件名:{}\n文件类型:{}\n文件大小:{} 字节\n保存路径:{}",
field_name, original_filename, sanitized_filename, content_type, file_data.len(), save_path.display()
));
}
Err(AppError::InvalidInput {
field: "file".to_string(),
message: "表单中未包含文件字段(请用 multipart/form-data 格式)".to_string(),
})
}
七、第六步:404 页面优化
旧版 404 仅返回 JSON 错误,新版改为优先返回静态 404 页面,提升用户体验。
1. 改造 fallback
函数
修改兜底处理逻辑,尝试读取 static/404.html
,不存在则返回纯文本:
async fn fallback(
method: Method,
uri: Uri,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> AppResult<Response> {
// 已注册的业务路由列表
let registered_routes = vec![
"/user/register", "/user/login", "/user/upload",
"/user/upload_and_save", "/user/text", "/user/binary"
];
// 判断是 405(方法不允许)还是 404(资源未找到)
if registered_routes.contains(&uri.path()) {
warn!("方法不允许:{} {}(客户端:{})", method, uri, addr);
Err(AppError::MethodNotAllowed)
} else {
warn!("资源未找到:{} {}(客户端:{})", method, uri, addr);
// 尝试读取静态 404 页面
match fs::read_to_string("static/404.html").await {
Ok(html) => {
// 返回 HTML 格式的 404 页面
let response = Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "text/html; charset=utf-8") // 指定 HTML 类型
.body(html.into()) // 将字符串转为响应体
.unwrap();
Ok(response)
}
Err(_) => {
// 404.html 不存在时,返回纯文本
let response = Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain; charset=utf-8")
.body("404 Not Found".into())
.unwrap();
Ok(response)
}
}
}
}
八、第七步:完整代码运行与测试
1. 完整代码整合
将上述所有修改整合,最终的 main.rs
代码如下(已包含所有新增特性):
// 1. 核心依赖导入(按功能分类,避免冗余)
use axum::{
body::Bytes,
extract::{
self, ConnectInfo, DefaultBodyLimit, Form, Json, Multipart,
},
http::{header, HeaderValue, Method, StatusCode, Uri},
response::{IntoResponse, Json as AxumJson, Response},
routing::post,
Router,
};
// 关键修正:从 axum 提取模块导入 MultipartError(而非 multer 根模块)
use axum::extract::multipart::MultipartError;
use hex::encode;
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
use std::{
net::SocketAddr,
path::Path,
panic,
};
use thiserror::Error;
use tokio::{
fs::{self, File},
io::AsyncWriteExt,
signal,
};
// 修改后的导入
use tower_http::{
cors::{Any, CorsLayer},
limit::RequestBodyLimitLayer,
set_header::SetResponseHeaderLayer,
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer},
services::{ServeDir, ServeFile},
compression::CompressionLayer,
};
use tracing::{info, error, warn};
// 2. 关键 traits 导入(解决日志初始化问题)
use tracing_subscriber::{
layer::SubscriberExt, // 用于 Registry 的 with 方法
util::SubscriberInitExt, // 用于 Registry 的 init 方法
};
// 3. 统一错误响应格式(给前端的结构化 JSON)
#[derive(Serialize)]
struct ErrorResponse {
code: &'static str,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
}
// 4. 自定义错误类型(覆盖所有场景,支持自动转换)
#[derive(Error, Debug)]
pub enum AppError {
// IO 错误(文件读写、网络等)
#[error("IO操作失败: {0}")]
Io(#[from] std::io::Error),
// JSON 解析错误(请求体格式/字段不匹配)
#[error("JSON解析失败: {0}")]
JsonParse(#[from] serde_json::Error),
// 表单解析错误(x-www-form-urlencoded 格式错误)
#[error("表单解析失败: {0}")]
FormParse(#[from] extract::rejection::FormRejection),
// 关键修正:使用 axum 封装的 MultipartError(而非 multer 原生错误)
#[error("文件上传错误: {0}")]
Multipart(#[from] MultipartError),
// 请求体过大(超过 10MB 限制)
#[error("请求体过大(最大允许10MB)")]
PayloadTooLarge,
// 文件已存在(避免覆盖上传)
#[error("文件已存在: {0}")]
FileExists(String),
// 输入验证错误(用户名/邮箱/密码格式无效)
#[error("输入无效: {message}")]
InvalidInput { field: String, message: String },
// 资源未找到(404)
#[error("资源未找到")]
NotFound,
// 方法不允许(405)
#[error("请求方法不允许")]
MethodNotAllowed,
// 新增文件类型错误
#[error("不支持的文件类型: {0}")]
FileTypeNotAllowed(String),
// 新增文件大小错误
#[error("文件大小超过限制(最大允许{0}MB")]
FileTooLarge(usize),
// 未知错误(兜底)
#[error("未知错误: {0}")]
Unknown(String),
}
// 5. 实现错误转 HTTP 响应(统一格式+正确状态码)
impl IntoResponse for AppError {
fn into_response(self) -> Response {
// 记录错误日志(含上下文,便于排查)
error!(error = ?self, "请求处理失败");
// 匹配错误类型,设置状态码和错误码
let (status, code, detail) = match &self {
AppError::Io(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"IO_ERROR",
None,
),
AppError::JsonParse(e) => (
StatusCode::BAD_REQUEST,
"JSON_PARSE_ERROR",
Some(e.to_string()),
),
AppError::FormParse(e) => (
StatusCode::BAD_REQUEST,
"FORM_PARSE_ERROR",
Some(e.to_string()),
),
AppError::Multipart(e) => (
StatusCode::BAD_REQUEST,
"MULTIPART_ERROR",
Some(e.to_string()),
),
AppError::PayloadTooLarge => (
StatusCode::PAYLOAD_TOO_LARGE,
"PAYLOAD_TOO_LARGE",
None,
),
AppError::FileExists(path) => (
StatusCode::CONFLICT,
"FILE_EXISTS",
Some(path.clone()),
),
AppError::InvalidInput { field, message } => (
StatusCode::BAD_REQUEST,
"INVALID_INPUT",
Some(format!("字段[{}]: {}", field, message)),
),
AppError::NotFound => (StatusCode::NOT_FOUND, "NOT_FOUND", None),
AppError::MethodNotAllowed => (
StatusCode::METHOD_NOT_ALLOWED,
"METHOD_NOT_ALLOWED",
None,
),
// 新增文件类型错误处理
AppError::FileTypeNotAllowed(file_type) => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"FILE_TYPE_NOT_ALLOWED",
Some(file_type.clone()),
),
// 新增文件大小错误处理
AppError::FileTooLarge(max_size) => (
StatusCode::PAYLOAD_TOO_LARGE,
"FILE_TOO_LARGE",
Some(format!("最大允许{}MB", max_size)),
),
AppError::Unknown(msg) => (
StatusCode::INTERNAL_SERVER_ERROR,
"UNKNOWN_ERROR",
Some(msg.clone()),
),
};
// 构建 JSON 响应体
let error_body = ErrorResponse {
code,
message: self.to_string(),
detail,
};
(status, AxumJson(error_body)).into_response()
}
}
// 6. 简化 Result 类型(避免重复写 AppError)
pub type AppResult<T> = Result<T, AppError>;
// 7. 业务数据结构体(请求体映射)
/// 用户注册 JSON 请求体
#[derive(Debug, Deserialize)]
struct RegisterUser {
username: String,
email: String,
age: Option<u8>, // 可选字段(允许不填)
}
/// 登录表单请求体(x-www-form-urlencoded)
#[derive(Debug, Deserialize)]
struct LoginForm {
username: String,
password: String,
remember_me: bool, // 复选框(true/false)
}
// 文件名净化函数
fn sanitize_filename(filename: &str) -> String {
use std::path::Component;
use std::path::PathBuf;
let path = PathBuf::from(filename);
let components = path.components()
.filter_map(|c| match c {
Component::Normal(s) => s.to_str().map(|s| s.to_string()),
_ => None,
})
.collect::<Vec<String>>();
if components.is_empty() {
return "unnamed".to_string();
}
components.join("_")
}
// 8. 主函数(服务入口)
#[tokio::main]
async fn main() {
// 8.1 设置 Panic 钩子(捕获崩溃,记录详细信息)
panic::set_hook(Box::new(|panic_info| {
let location = panic_info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or("未知位置".to_string());
let message = panic_info
.payload()
.downcast_ref::<&str>()
.unwrap_or(&"未知错误");
error!(
"服务崩溃!位置:{}, 信息:{}",
location, message
);
}));
// 8.2 初始化日志系统(按环境变量或默认规则过滤)
tracing_subscriber::registry()
.with(
// 日志过滤:优先读取 RUST_LOG 环境变量,否则用默认规则
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "axum_tutorial=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer()) // 格式化输出(含时间、级别、模块)
.init(); // 初始化全局日志
// 检测运行环境
// 在终端窗口输入 set APP_ENV=development 或 set APP_ENV=production
let env = std::env::var("APP_ENV").unwrap_or_else(|_| "production".to_string());
let is_dev = env == "development";
info!("Axum 服务器启动中...");
info!("运行环境: {}", if is_dev { "开发" } else { "生产" });
// 8.3 构建路由表(模块化拆分,避免臃肿)
let mut app = Router::new()
// -------------------------- 静态资源路由(核心新增) --------------------------
// 1. 根路径 / 指向静态首页
.route_service("/", ServeFile::new("static/index.html"));
// 根据环境设置缓存头
let cache_header = if is_dev {
HeaderValue::from_static("no-cache, no-store, must-revalidate")
} else {
HeaderValue::from_static("public, max-age=2592000") // 30天缓存
};
app = app.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
cache_header.clone(),
));
// 继续构建路由
app = app
// /static 路径:处理所有静态文件(CSS/JS/图片)
.nest_service(
"/static",
ServeDir::new("static")
.precompressed_gzip() // 支持预压缩的gzip文件
.precompressed_br() // 支持预压缩的brotli文件
)
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
cache_header,
))
// -------------------------- 原有业务路由 --------------------------
.route("/user/register", post(register_user)) // 注册(JSON)
.route("/user/login", post(login)) // 登录(表单)
.route("/user/upload", post(upload_file)) // 文件上传(仅读取)
.route("/user/upload_and_save", post(upload_save_file)) // 文件上传并保存
.route("/user/text", post(handle_text)) // 纯文本请求体
.route("/user/binary", post(handle_binary)) // 二进制请求体
// -------------------------- 中间件 --------------------------
// 请求体大小限制(10MB)
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(10 * 1024 * 1024))
// CORS 支持(允许跨域)
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
)
// 请求跟踪(日志记录)
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().include_headers(true))
.on_request(DefaultOnRequest::new().level(tracing::Level::INFO))
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO))
.on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)),
)
.with_state(()) // Axum 0.8+ 必需:明确状态(空状态用 ())
.fallback(fallback); // 404/405 兜底处理
// 条件添加压缩层
if !is_dev {
app = app.layer(CompressionLayer::new());
}
// 8.4 绑定地址并启动服务
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
info!("绑定服务器地址:{}", addr);
// 创建 TCP 监听器(处理绑定错误)
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => listener,
Err(e) => {
error!("地址绑定失败:{},错误:{}", addr, e);
return;
}
};
info!("服务器启动成功,监听地址:{}", addr);
// 启动服务并支持优雅关闭(等待当前请求完成)
let server = axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
);
// 处理服务运行错误(如端口被占用、网络异常)
if let Err(e) = server.with_graceful_shutdown(shutdown_signal()).await {
error!("服务器运行错误:{}", e);
}
info!("服务器已优雅关闭");
}
/// 优雅关闭信号处理(监听 Ctrl+C/SIGTERM)
async fn shutdown_signal() {
// 监听 Ctrl+C 信号(全平台支持)
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("无法注册 Ctrl+C 处理器");
};
// 监听 SIGTERM 信号(仅 Unix 系统,如 Linux/macOS)
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("无法注册 SIGTERM 处理器")
.recv()
.await;
};
// 等待任一信号触发
#[cfg(unix)]
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
#[cfg(not(unix))]
ctrl_c.await;
info!("收到关闭信号,开始优雅关闭...");
}
/// 用户注册处理(JSON 请求体)
async fn register_user(Json(user): Json<RegisterUser>) -> AppResult<String> {
// 输入验证(生产环境必需,避免无效数据)
if user.username.is_empty() {
return Err(AppError::InvalidInput {
field: "username".to_string(),
message: "用户名不能为空".to_string(),
});
}
if !user.email.contains('@') {
return Err(AppError::InvalidInput {
field: "email".to_string(),
message: "邮箱格式无效(需包含 @)".to_string(),
});
}
// 验证通过,返回成功信息
Ok(format!(
"注册成功!\n用户名:{}\n邮箱:{}\n年龄:{:?}",
user.username, user.email, user.age
))
}
/// 登录处理(表单请求体)
async fn login(Form(form): Form<LoginForm>) -> AppResult<String> {
// 输入验证
if form.username.is_empty() {
return Err(AppError::InvalidInput {
field: "username".to_string(),
message: "用户名不能为空".to_string(),
});
}
if form.password.len() < 6 {
return Err(AppError::InvalidInput {
field: "password".to_string(),
message: "密码长度不能少于 6 位".to_string(),
});
}
// 密码哈希(生产环境需加盐,此处仅演示 SHA-256)
let password_hash = Sha256::digest(form.password.as_bytes());
let password_hash_hex = encode(password_hash);
Ok(format!(
"登录验证成功!\n用户名:{}\n记住登录:{}\n密码哈希(SHA-256):{}",
form.username, form.remember_me, password_hash_hex
))
}
/// 文件上传(仅读取信息,不保存)
async fn upload_file(mut multipart: Multipart) -> AppResult<String> {
// 定义允许的文件类型和最大文件大小
let allowed_types = ["image/jpeg", "image/png", "image/gif", "application/pdf"];
let max_file_size = 5 * 1024 * 1024; // 5MB
// 遍历 multipart 表单字段(可能包含多个字段,此处取第一个文件)
while let Some(field) = multipart.next_field().await? {
let field_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();
// 检查文件类型
if !allowed_types.contains(&content_type.as_str()) {
return Err(AppError::FileTypeNotAllowed(content_type));
}
// 读取文件内容并检查大小
let file_data = field.bytes().await?;
if file_data.len() > max_file_size {
return Err(AppError::FileTooLarge(5)); // 5MB限制
}
return Ok(format!(
"文件上传成功(未保存)!\n字段名:{}\n文件名:{}\n文件类型:{}\n文件大小:{} 字节",
field_name, filename, content_type, file_data.len()
));
}
// 未找到文件字段
Err(AppError::InvalidInput {
field: "file".to_string(),
message: "表单中未包含文件字段(请用 multipart/form-data 格式)".to_string(),
})
}
/// 文件上传并保存到本地(./uploads 目录)
async fn upload_save_file(mut multipart: Multipart) -> AppResult<String> {
// 定义上传目录(项目根目录下的 uploads)
let upload_dir = Path::new("./uploads");
// 确保目录存在(不存在则创建,包括父目录)
fs::create_dir_all(upload_dir).await?;
// 定义允许的文件类型和最大文件大小
let allowed_types = ["image/jpeg", "image/png", "image/gif", "application/pdf"];
let max_file_size = 5 * 1024 * 1024; // 5MB
// 处理文件字段
while let Some(field) = multipart.next_field().await? {
let field_name = field.name().unwrap_or("未知字段").to_string();
let original_filename = field.file_name().unwrap_or("未知文件名").to_string();
let content_type = field.content_type().unwrap_or("未知类型").to_string();
// 检查文件类型
if !allowed_types.contains(&content_type.as_str()) {
return Err(AppError::FileTypeNotAllowed(content_type));
}
// 读取文件内容
let file_data = field.bytes().await?;
// 检查文件大小
if file_data.len() > max_file_size {
return Err(AppError::FileTooLarge(5)); // 5MB限制
}
// 文件名净化处理
let sanitized_filename = sanitize_filename(&original_filename);
let save_path = upload_dir.join(&sanitized_filename);
// 避免覆盖已存在文件
if save_path.exists() {
return Err(AppError::FileExists(save_path.display().to_string()));
}
// 写入文件到本地
let mut file = File::create(&save_path).await?;
file.write_all(&file_data).await?;
file.sync_all().await?; // 强制刷盘,确保数据写入
return Ok(format!(
"文件上传并保存成功!\n字段名:{}\n原始文件名:{}\n净化后文件名:{}\n文件类型:{}\n文件大小:{} 字节\n保存路径:{}",
field_name, original_filename, sanitized_filename, content_type, file_data.len(), save_path.display()
));
}
// 未找到文件字段
Err(AppError::InvalidInput {
field: "file".to_string(),
message: "表单中未包含文件字段(请用 multipart/form-data 格式)".to_string(),
})
}
/// 纯文本请求体处理(text/plain)
async fn handle_text(body: String) -> AppResult<String> {
Ok(format!(
"收到纯文本请求!\n内容:{}\n字符长度:{}",
body, body.len()
))
}
/// 二进制请求体处理(application/octet-stream)
async fn handle_binary(body: Bytes) -> AppResult<String> {
Ok(format!(
"收到二进制数据!\n字节长度:{}",
body.len()
))
}
/// 404(资源未找到)和 405(方法不允许)兜底处理
async fn fallback(
method: Method,
uri: Uri,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> AppResult<Response> {
// 已注册的路由列表(用于区分 404 和 405)
let registered_routes = vec![
"/user/register",
"/user/login",
"/user/upload",
"/user/upload_and_save",
"/user/text",
"/user/binary",
];
// 判断是 405 还是 404
if registered_routes.contains(&uri.path()) {
warn!(
"方法不允许:{} {}(客户端:{})",
method, uri, addr
);
Err(AppError::MethodNotAllowed)
} else {
warn!(
"资源未找到:{} {}(客户端:{})",
method, uri, addr
);
// 尝试读取 404.html 文件
match fs::read_to_string("static/404.html").await {
Ok(html) => {
let response = Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "text/html; charset=utf-8")
.body(html.into())
.unwrap();
Ok(response)
}
Err(_) => {
// 如果文件不存在,返回简单的404文本
let response = Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain; charset=utf-8")
.body("404 Not Found".into())
.unwrap();
Ok(response)
}
}
}
}
2. 运行与测试步骤
- 创建静态资源目录:按第二步的结构创建
static
文件夹及相关文件。 - 启动开发环境:
- Windows:
set APP_ENV=development && cargo run
- Linux/macOS:
export APP_ENV=development && cargo run
- Windows:
D:\rust_projects\axum-tutorial>set APP_ENV=development
D:\rust_projects\axum-tutorial>cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
Running `target\debug\axum-tutorial.exe`
2025-08-25T01:03:10.219989Z INFO axum_tutorial: Axum 服务器启动中...
2025-08-25T01:03:10.220649Z INFO axum_tutorial: 运行环境: 开发
2025-08-25T01:03:10.222203Z INFO axum_tutorial: 绑定服务器地址:0.0.0.0:8080
2025-08-25T01:03:10.224813Z INFO axum_tutorial: 服务器启动成功,监听地址:0.0.0.0:8080
- 测试核心功能:
访问 http://localhost:8080
:查看静态首页(确认 CSS 和图片加载正常)。
(1)测试根路径
首先以POST方式访问服务器根目录,但由于服务器根目录的路由只有GET配置而没有POST配置。所以服务器会返回“HTTP/1.1 405 Method Not Allowed...”,这是我们正确的预期。
D:\>curl -v -X POST http://localhost:8080
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8080...
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080
* using HTTP/1.x
> POST / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.13.0
> Accept: */*
>
< HTTP/1.1 405 Method Not Allowed
< allow: GET,HEAD
< cache-control: no-cache, no-store, must-revalidate
< vary: origin, access-control-request-method, access-control-request-headers
< access-control-allow-origin: *
< content-length: 0
< date: Mon, 25 Aug 2025 01:26:26 GMT
<
* Connection #0 to host localhost left intact
那么,同时服务器端的控制台(日志)显示也如预期POST服务(返回405):
2025-08-25T01:26:26.967870Z INFO request{method=POST uri=/ version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*"}}: tower_http::trace::on_request: started processing request
2025-08-25T01:26:26.969182Z INFO request{method=POST uri=/ version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*"}}: tower_http::trace::on_response: finished processing request latency=1 ms status=405
接下来,以GET方式访问服务器根目录,由于服务器根目录的路由配置为GET,所以能正常返回结果(服务状态码:200)。
D:\>curl -v http://localhost:8080
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8080...
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080
* using HTTP/1.x
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.13.0
> Accept: */*
>
< HTTP/1.1 200 OK
< content-type: text/html
< accept-ranges: bytes
< last-modified: Mon, 25 Aug 2025 00:29:38 GMT
< content-length: 744
< cache-control: no-cache, no-store, must-revalidate
< vary: origin, access-control-request-method, access-control-request-headers
< access-control-allow-origin: *
< date: Mon, 25 Aug 2025 01:39:20 GMT
<
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Page</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1>Test Site</h1>
<nav>
<a href="/">Home</a>
</nav>
</header>
<main>
<section>
<h2>Welcome</h2>
<p>This is a simple test page for static file serving.</p>
<img src="/static/images/rust-logo.svg" alt="Rust programming language logo" class="logo">
</section>
</main>
<footer>
<p>© 2023 Test Site</p>
</footer>
</body>
</html>
* Connection #0 to host localhost left intact
注意:由于服务器开启的是开发环境(而非生产环境),服务器关闭了客户端缓存,所以可以看到“< cache-control: no-cache, no-store, must-revalidate...”。而当服务器通过环境变量“set APP_ENV=production”来设置生产环境后,就会打开客户端缓存,所以就会看到“< cache-control: public, max-age=2592000...”。
那么,服务器端的控制台日志也会对应的显示为GET服务状态码为200:
2025-08-25T01:36:12.101689Z INFO request{method=GET uri=/ version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*"}}: tower_http::trace::on_request: started processing request
2025-08-25T01:36:12.104728Z INFO request{method=GET uri=/ version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*"}}: tower_http::trace::on_response: finished processing request latency=3 ms status=200
(2)测试错误路径
访问 http://localhost:8080/abc
:查看自定义 404 页面。
D:\>curl -v -X POST http://localhost:8080/abc
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8080...
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080
* using HTTP/1.x
> POST /abc HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.13.0
> Accept: */*
>
< HTTP/1.1 404 Not Found
< content-type: text/html; charset=utf-8
< content-length: 629
< date: Mon, 25 Aug 2025 01:43:43 GMT
<
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Not Found</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<header>
<h1>Test Site</h1>
</header>
<main>
<section>
<h2>404 - Page Not Found</h2>
<p>The page you're looking for doesn't exist.</p>
<p><a href="/">Return to homepage</a></p>
</section>
</main>
<footer>
<p>© 2023 Test Site</p>
</footer>
</body>
</html>
* Connection #0 to host localhost left intact
查看服务器控制台对应的日志:
2025-08-25T01:43:43.891262Z WARN axum_tutorial: 资源未找到:POST /abc(客户端:127.0.0.1:15026)
(3)测试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)
服务器日志:
2025-08-25T01:58:10.668408Z INFO request{method=POST uri=/user/register version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/json", "content-length": "59"}}: tower_http::trace::on_request: started processing request
2025-08-25T01:58:10.671673Z INFO request{method=POST uri=/user/register version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/json", "content-length": "59"}}: tower_http::trace::on_response: finished processing request latency=3 ms status=200
错误通讯测试(格式错误)
D:\>curl -X POST http://localhost:8080/user/register -H "Content-Type: application/json" -d "{\"username\":\"alice\", \"email\":\"alice#example.com\", \"age\":25}"
{"code":"INVALID_INPUT","message":"输入无效: 邮箱格式无效(需包含 @)","detail":"字段[email]: 邮箱格式无效(需包含 @)"}
服务器日志(状态码400):
如果用户提交的数据由自定义的AppError截获,则返回StatusCode::BAD_REQUEST(400)
2025-08-25T02:00:30.938594Z INFO request{method=POST uri=/user/register version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/json", "content-length": "59"}}: tower_http::trace::on_request: started processing request
2025-08-25T02:00:30.939530Z ERROR request{method=POST uri=/user/register version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/json", "content-length": "59"}}: axum_tutorial: 请求处理失败 error=InvalidInput { field: "email", message: "邮箱格式无效(需包含 @)" }
2025-08-25T02:00:30.940954Z INFO request{method=POST uri=/user/register version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/json", "content-length": "59"}}: tower_http::trace::on_response: finished processing request latency=2 ms status=400
(4)FORM测试
正确提交FORM
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
服务器日志:
2025-08-25T02:04:29.145042Z INFO request{method=POST uri=/user/login version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/x-www-form-urlencoded", "content-length": "47"}}: tower_http::trace::on_request: started processing request
2025-08-25T02:04:29.148773Z INFO request{method=POST uri=/user/login version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/x-www-form-urlencoded", "content-length": "47"}}: tower_http::trace::on_response: finished processing request latency=4 ms status=200
提交数据格式错误
D:\>curl -X POST http://localhost:8080/user/login -H "Content-Type: application/x-www-form-urlencoded" -d "password=123456&remember_me=true"
Failed to deserialize form body: missing field `username`
服务器日志(状态码422):
2025-08-25T02:09:51.310629Z INFO request{method=POST uri=/user/login version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/x-www-form-urlencoded", "content-length": "32"}}: tower_http::trace::on_request: started processing request
2025-08-25T02:09:51.314810Z INFO request{method=POST uri=/user/login version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/x-www-form-urlencoded", "content-length": "32"}}: tower_http::trace::on_response: finished processing request latency=4 ms status=422
如果出现在自定义错误(预期)之外的情况,由上层的系统错误机制捕获,此时状态码为422。
(5)文件上传
正确上传
D:\>curl -X POST http://localhost:8080/user/upload -F "avatar=@./rust_logo.jpeg"
文件上传成功(未保存)!
字段名:avatar
文件名:rust_logo.jpeg
文件类型:image/jpeg
文件大小:14695 字节
服务器日志(200):
2025-08-25T02:29:09.911768Z INFO request{method=POST uri=/user/upload version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-length": "14901", "content-type": "multipart/form-data; boundary=------------------------vnVQ0GXdT8USsSsmwoscHO"}}: tower_http::trace::on_request: started processing request
2025-08-25T02:29:09.914201Z INFO request{method=POST uri=/user/upload version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-length": "14901", "content-type": "multipart/form-data; boundary=------------------------vnVQ0GXdT8USsSsmwoscHO"}}: tower_http::trace::on_response: finished processing request latency=2 ms status=200
文件大小超过限制
D:\>curl -X POST http://localhost:8080/user/upload -F "avatar=@./rust_logo.jpeg"
文件上传成功(未保存)!
字段名:avatar
文件名:rust_logo.jpeg
文件类型:image/jpeg
文件大小:14695 字节
D:\>curl -X POST http://localhost:8080/user/upload -F "avatar=@./too_large.jpeg"
length limit exceeded
服务器日志(状态码413):
2025-08-25T02:33:42.935235Z INFO request{method=POST uri=/user/upload version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-length": "13551822", "content-type": "multipart/form-data; boundary=------------------------0RRSzDKXd9trQJgvGhFdah", "expect": "100-continue"}}: tower_http::trace::on_request: started processing request
2025-08-25T02:33:42.937946Z INFO request{method=POST uri=/user/upload version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-length": "13551822", "content-type": "multipart/form-data; boundary=------------------------0RRSzDKXd9trQJgvGhFdah", "expect": "100-continue"}}: tower_http::trace::on_response: finished processing request latency=3 ms status=413
文件类型错误
D:\>curl -X POST http://localhost:8080/user/upload -F "avatar=@./test1.bin"
{"code":"FILE_TYPE_NOT_ALLOWED","message":"不支持的文件类型: application/octet-stream","detail":"application/octet-stream"}
服务器日志(状态码415)
2025-08-25T02:52:02.785957Z INFO request{method=POST uri=/user/upload version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-length": "225", "content-type": "multipart/form-data; boundary=------------------------6U6JQWuJkcBFnSRB7oIVlL"}}: tower_http::trace::on_request: started processing request
2025-08-25T02:52:02.789676Z ERROR request{method=POST uri=/user/upload version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-length": "225", "content-type": "multipart/form-data; boundary=------------------------6U6JQWuJkcBFnSRB7oIVlL"}}: axum_tutorial: 请求处理失败 error=FileTypeNotAllowed("application/octet-stream")
2025-08-25T02:52:02.793574Z INFO request{method=POST uri=/user/upload version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-length": "225", "content-type": "multipart/form-data; boundary=------------------------6U6JQWuJkcBFnSRB7oIVlL"}}: tower_http::trace::on_response: finished processing request latency=7 ms status=415
(6)原始请求
字符
D:\>curl -X POST http://localhost:8080/user/text -H "Content-Type: text/plain" -d "hello raw text"
收到纯文本请求!
内容:hello raw text
字符长度:14
服务器日志:
2025-08-25T02:37:38.467268Z INFO request{method=POST uri=/user/text version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "text/plain", "content-length": "14"}}: tower_http::trace::on_request: started processing request
2025-08-25T02:37:38.470356Z INFO request{method=POST uri=/user/text version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "text/plain", "content-length": "14"}}: tower_http::trace::on_response: finished processing request latency=3 ms status=200
二进制
D:\>curl -X POST http://localhost:8080/user/binary -H "Content-Type: application/octet-stream" --data-binary @./test1.bin
收到二进制数据!
字节长度:10
服务器日志(状态码200):
2025-08-25T02:43:53.608696Z INFO request{method=POST uri=/user/binary version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/octet-stream", "content-length": "10"}}: tower_http::trace::on_request: started processing request
2025-08-25T02:43:53.612129Z INFO request{method=POST uri=/user/binary version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/octet-stream", "content-length": "10"}}: tower_http::trace::on_response: finished processing request latency=4 ms status=200
数据量超过限制
D:\>curl -X POST http://localhost:8080/user/binary -H "Content-Type: application/octet-stream" --data-binary @./test2.bin
length limit exceeded
服务器日志(状态码413):
2025-08-25T02:48:16.753207Z INFO request{method=POST uri=/user/binary version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/octet-stream", "content-length": "13551616", "expect": "100-continue"}}: tower_http::trace::on_request: started processing request
2025-08-25T02:48:16.755754Z INFO request{method=POST uri=/user/binary version=HTTP/1.1 headers={"host": "localhost:8080", "user-agent": "curl/8.13.0", "accept": "*/*", "content-type": "application/octet-stream", "content-length": "13551616", "expect": "100-continue"}}: tower_http::trace::on_response: finished processing request latency=3 ms status=413
(7)测试传输压缩
通过“set APP_ENV=production”设置环境变量,之后再启动服务器:
D:\rust_projects\axum-tutorial>set APP_ENV=production
D:\rust_projects\axum-tutorial>cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
Running `target\debug\axum-tutorial.exe`
2025-08-25T03:18:19.746487Z INFO axum_tutorial: Axum 服务器启动中...
2025-08-25T03:18:19.747174Z INFO axum_tutorial: 运行环境: 生产
2025-08-25T03:18:19.749139Z INFO axum_tutorial: 绑定服务器地址:0.0.0.0:8080
2025-08-25T03:18:19.751949Z INFO axum_tutorial: 服务器启动成功,监听地址:0.0.0.0:8080
读取服务器静态资源:
curl -v --compressed http://localhost:8080
结果如下:
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8080...
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080
* using HTTP/1.x
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.13.0
> Accept: */*
> Accept-Encoding: deflate, gzip
>
< HTTP/1.1 200 OK
< content-type: text/html
< access-control-allow-origin: *
< last-modified: Mon, 25 Aug 2025 00:29:38 GMT
< vary: origin, access-control-request-method, access-control-request-headers
< vary: accept-encoding
< cache-control: public, max-age=2592000
< content-encoding: gzip
< transfer-encoding: chunked
< date: Mon, 25 Aug 2025 03:20:54 GMT
<
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Page</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1>Test Site</h1>
<nav>
<a href="/">Home</a>
</nav>
</header>
<main>
<section>
<h2>Welcome</h2>
<p>This is a simple test page for static file serving.</p>
<img src="/static/images/rust-logo.svg" alt="Rust programming language logo" class="logo">
</section>
</main>
<footer>
<p>© 2023 Test Site</p>
</footer>
</body>
</html>
* Connection #0 to host localhost left intact
可以看到“< content-encoding: gzip...”,这说明压缩传输已经开启。但想要直观的看到压缩效果,可以通过以下方式,将读取的内容保存到不同的文件中,再直观的比较大小差异。
# 保存未压缩的内容
curl -s -H "Accept-Encoding: identity" http://localhost:8080 > uncompressed.html
# 保存压缩的内容(不自动解压缩)
curl -s -H "Accept-Encoding: gzip" --raw http://localhost:8080 > compressed.gz
2025/08/25 周一 11:15 414 compressed.gz
2025/08/25 周一 11:15 744 uncompressed.html
2 个文件 1,158 字节
可以清楚的看到,744字节已经被压缩成414字节。而如果服务器没有启用响应体压缩时,尽管客户端也会要求使用“压缩”,但服务器返回的结果却只能是未压缩的,这会导致两个文件的结果是一样的(没有任何压缩)。
2025/08/25 周一 11:46 744 compressed.gz
2025/08/25 周一 11:46 744 uncompressed.html
2 个文件 1,488 字节