> 技术文档 > Rust Web开发指南 第五章(Axum http静态资源服务、安全优化与环境适配)

Rust Web开发指南 第五章(Axum http静态资源服务、安全优化与环境适配)

本教程让 你从上一章节的 Axum 的 “基础业务接口” 升级为 “可生产级 Web 服务”。带领你以上一章节的 Axum 代码为基础,逐步过渡到新增静态资源服务、环境区分、响应压缩、文件上传安全控制等功能的新版代码。我们会循序渐进拆解每个新增特性,结合代码讲解原理,最终掌握可直接用于生产环境的 Axum 服务框架。

要在版基础上实现四大核心增强:

  1. 静态资源服务:通过 tower-http 的 ServeFile/ServeDir 托管 HTML/CSS/ 图片,支持预压缩。
  2. 环境适配:通过环境变量区分开发 / 生产,动态配置缓存和压缩策略。
  3. 安全增强:文件上传增加类型 / 大小限制,文件名净化防止路径遍历。
  4. 用户体验优化:自定义 404 静态页面,统一错误响应格式。

一、教程前提与目标

  • 前置基础:已掌握 Rust 基础语法、Axum 基本路由与提取器(如 JsonFormMultipart)用法。
  • 学习目标
    1. 实现静态资源(HTML/CSS/ 图片)服务
    2. 区分开发 / 生产环境并配置不同缓存策略
    3. 启用响应压缩优化性能
    4. 增强文件上传安全性(类型 / 大小限制、文件名净化)
    5. 自定义 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 vs nest_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(首页)

   Axum 静态首页   

Axum 教程演示

欢迎使用新版 Axum 服务

本页面为静态资源托管示例,包含 CSS 和图片加载。

© 2024 Axum 进阶教程

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 页面)

   404 - 页面未找到  

Axum 教程演示

404 - 页面不存在

你访问的路径不存在,请检查 URL 是否正确。

返回首页

© 2024 Axum 进阶教程

3.4 图片资源

从 Rust 官网 下载 Rust 图标,重命名为 rust-logo.svg 放入 static/images/ 目录。


四、第三步:环境区分与缓存控制

开发环境需要实时看到代码修改效果(禁用缓存),生产环境需要启用缓存减少重复请求 —— 新版代码通过环境变量区分环境,并通过 SetResponseHeaderLayer 设置缓存头。

1. 环境变量读取与判断

在 main 函数中添加环境判断逻辑:

// 8.2 初始化日志系统后,读取环境变量// Windows 终端:set APP_ENV=development// Linux/macOS 终端:export APP_ENV=developmentlet 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/htmlapplication/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>(); // 如果过滤后为空,返回默认名 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 { // 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 { 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,) -> AppResult { // 已注册的业务路由列表 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,}// 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 = Result;// 7. 业务数据结构体(请求体映射)/// 用户注册 JSON 请求体#[derive(Debug, Deserialize)]struct RegisterUser { username: String, email: String, age: Option, // 可选字段(允许不填)}/// 登录表单请求体(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>(); 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::() .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::(), ); // 处理服务运行错误(如端口被占用、网络异常) 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) -> AppResult { // 输入验证(生产环境必需,避免无效数据) 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) -> AppResult { // 输入验证 if form.username.is_empty() { return Err(AppError::InvalidInput { field: \"username\".to_string(), message: \"用户名不能为空\".to_string(), }); } if form.password.len()  AppResult { // 定义允许的文件类型和最大文件大小 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 { // 定义上传目录(项目根目录下的 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 { Ok(format!( \"收到纯文本请求!\\n内容:{}\\n字符长度:{}\", body, body.len() ))}/// 二进制请求体处理(application/octet-stream)async fn handle_binary(body: Bytes) -> AppResult { Ok(format!( \"收到二进制数据!\\n字节长度:{}\", body.len() ))}/// 404(资源未找到)和 405(方法不允许)兜底处理async fn fallback( method: Method, uri: Uri, ConnectInfo(addr): ConnectInfo,) -> AppResult { // 已注册的路由列表(用于区分 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. 运行与测试步骤

  1. 创建静态资源目录:按第二步的结构创建 static 文件夹及相关文件。
  2. 启动开发环境
    • Windows:set APP_ENV=development && cargo run
    • Linux/macOS:export APP_ENV=development && cargo run
D:\\rust_projects\\axum-tutorial>set APP_ENV=developmentD:\\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:80802025-08-25T01:03:10.224813Z INFO axum_tutorial: 服务器启动成功,监听地址:0.0.0.0:8080
  1. 测试核心功能

访问 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 request2025-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<   Test Page  

Test Site

Welcome

This is a simple test page for static file serving.

© 2023 Test Site

* 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 request2025-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<   404 - Not Found  

Test Site

404 - Page Not Found

The page you\'re looking for doesn\'t exist.

Return to homepage

© 2023 Test Site

* 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 request2025-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 request2025-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 request2025-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 request2025-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 request2025-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 request2025-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 request2025-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 request2025-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 request2025-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.binlength 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 request2025-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=productionD:\\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:80802025-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<   Test Page  

Test Site

Welcome

This is a simple test page for static file serving.

© 2023 Test Site

* 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.gz2025/08/25 周一 11:15  744 uncompressed.html  2 个文件 1,158 字节

可以清楚的看到,744字节已经被压缩成414字节。而如果服务器没有启用响应体压缩时,尽管客户端也会要求使用“压缩”,但服务器返回的结果却只能是未压缩的,这会导致两个文件的结果是一样的(没有任何压缩)。

2025/08/25 周一 11:46  744 compressed.gz2025/08/25 周一 11:46  744 uncompressed.html  2 个文件 1,488 字节