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-headerfs 用于静态资源;compression-gzip 用于响应压缩;set-header 用于设置缓存头
tower0.4.130.5.2tower-http 0.6.6 依赖 tower 0.5+ 版本
headers0.4辅助处理 HTTP 头(本示例暂用 Axum 内置头处理,预留扩展)
hyper1.7.0Axum 底层依赖,确保与 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(首页)

<!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>&copy; 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>&copy; 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/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<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. 运行与测试步骤

  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=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
  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 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>&copy; 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>&copy; 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>&copy; 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 字节

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值