Rust Web开发指南 第四章(让Axum从 “崩溃裸奔” 到高可用!统一错误处理 + 跨域 + 优雅关闭全落地)

一、前置准备:理解核心差距

在开始前,你要认识到,以下哪一种问题出现都会将你的程序送入地狱。你上一章节的代码,除了有一些技术实践和验证上的价值,但却丝毫没有真正的应用价值。正是由于你的程序,没有针对以下各方面进行有效的处理,这才使你的代码没有任何可用价值。

改进维度老版本问题新版本解决方案
错误处理直接 unwrap 崩溃、返回字符串,前端无法统一解析自定义 AppError 类型 + 结构化 JSON 错误响应
响应格式成功 / 失败响应格式混乱统一成功字符串 / JSON + 错误 JSON 结构
中间件支持无跨域、无请求限制、无请求日志集成 tower_http 中间件(CORS、限流、Trace)
异常兜底404/405 直接返回默认错误自定义 fallback 路由,区分 404/405 并日志记录
稳定性无 Panic 捕获、错误日志不完整Panic 钩子记录崩溃信息、错误日志含上下文

经过本章及后续章节之后,你会打造一个完善且健壮的Axum服务器应用,它完全可以满足绝大部分的中小企业的开发需求。

当你完成这个服务器应用之后,你会很有信心的发现所有的服务器代码和技术组件完全在自己的掌控之中,而不是由一些工具或脚手架程序来生成的一堆海量代码。这些代码效率不高,来不及审查和理解,甚至无法控制。


二、环境准备:升级依赖(Step 1)

首先需更新 Cargo.toml,添加新版本所需的依赖(如错误处理 thiserror、中间件 tower_http 等)。

操作步骤:

1、打开项目的 Cargo.toml,替换 / 补充依赖:

[dependencies]
# Axum 核心(确保版本 ≥ 0.8,支持状态管理和新提取器)
axum = { version = "0.8", features = ["multipart", "json"] }
# 异步运行时
tokio = { version = "1.0", features = ["full"] }
# 日志相关
tracing = "0.1"
tracing-subscriber = "0.3"
# 序列化/反序列化
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# 哈希与编码
sha2 = "0.10"
hex = "0.4"
# 错误处理(自动生成 Error trait 实现)
thiserror = "1.0"
# HTTP 中间件(CORS、限流、Trace)
tower-http = { version = "0.5", features = ["cors", "limit", "trace"] }

2、执行 cargo check 确保依赖可正常解析(无编译错误)

D:\rust_projects\axum-tutorial>cargo check
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.36s

三、基础重构:统一错误响应格式(Step 2)

当前程序的错误处理依赖 unwrap(崩溃)或返回字符串,前端无法区分 “业务错误” 和 “系统错误”。第一步先定义 结构化错误响应,让前端能统一解析。

目标:

定义 ErrorResponse 结构体,所有错误均返回 JSON 格式(含错误码、描述、详情)。

操作步骤:

1、在代码顶部导入 serde::Serialize(用于 JSON 序列化):

// 新增:序列化依赖(用于错误响应 JSON 输出)
use serde::Serialize;

2、定义 ErrorResponse 结构体:

// 统一错误响应格式
#[derive(Serialize)]
struct ErrorResponse {
    code: &'static str,       // 错误码(如 "JSON_PARSE_ERROR",前端可枚举)
    message: String,          // 错误描述(用户友好提示)
    #[serde(skip_serializing_if = "Option::is_none")]
    detail: Option<String>,   // 错误详情(可选,如具体解析失败原因)
}

为什么这么做?

  • 前端无需猜测响应格式:无论什么错误,都能通过 code 判断错误类型(如 PAYLOAD_TOO_LARGE),通过 message 显示用户提示。
  • 避免 “字符串硬编码”:错误信息和错误码解耦,便于后期维护。

四、核心能力:自定义错误类型(Step 3)

当前程序未定义统一错误类型,导致错误处理混乱。使用 thiserror 宏定义 AppError,覆盖所有可能的错误场景(IO、JSON 解析、表单错误等)。

目标:

用 AppError 包裹所有可能的错误,支持自动转换(如 std::io::Error → AppError)。

操作步骤:

1、导入必要依赖:

// 新增:错误处理相关导入
use thiserror::Error;
use axum::extract;                  // 用于表单解析错误
use axum::extract::multipart::MultipartError; // 修正:Axum 0.8+ 正确的 Multipart 错误导入
use axum::http::StatusCode;         // 用于错误对应的 HTTP 状态码

2、定义 AppError 枚举

// 自定义错误类型(覆盖所有业务/系统错误场景)
#[derive(Error, Debug)]
pub enum AppError {
    // IO 错误(文件读写、网络等,自动从 std::io::Error 转换)
    #[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),

    // 文件上传错误(Multipart 格式错误)
    #[error("文件上传错误: {0}")]
    Multipart(#[from] MultipartError),

    // 请求体过大(超过限制)
    #[error("请求体过大(最大允许10MB)")]
    PayloadTooLarge,

    // 文件已存在(避免覆盖上传)
    #[error("文件已存在: {0}")]
    FileExists(String),

    // 输入验证错误(如用户名为空、邮箱格式错误)
    #[error("输入无效: {message}")]
    InvalidInput { field: String, message: String },

    // 资源未找到(404)
    #[error("资源未找到")]
    NotFound,

    // 方法不允许(405)
    #[error("请求方法不允许")]
    MethodNotAllowed,

    // 兜底未知错误
    #[error("未知错误: {0}")]
    Unknown(String),
}

关键说明:

  • #[derive(Error, Debug)]thiserror 宏自动生成 std::error::Error trait 实现,无需手动写 description 等方法。
  • #[from] 注解:允许错误自动转换(如 fs::read_to_string 返回的 std::io::Error,可通过 ? 直接转为 AppError)。
  • 覆盖所有场景:从系统错误(IO、解析)到业务错误(输入无效、文件存在),避免 “未捕获错误” 导致程序崩溃。

五、响应绑定:错误转 HTTP 响应(Step 4)

定义 AppError 后,需实现 Axum 的 IntoResponse trait,让错误自动转为 HTTP 响应(含正确的状态码和 JSON 体)。

目标:

  • 不同错误对应不同 HTTP 状态码(如 InvalidInput → 400,NotFound → 404)。
  • 错误日志自动记录(含错误详情,便于排查)。

操作步骤:

1、导入 Axum 响应相关依赖:

use axum::response::{IntoResponse, Json as AxumJson, Response};
use tracing::{error, warn}; // 新增:日志记录错误

2、为 AppError 实现 IntoResponse

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        // 1. 记录错误日志(含错误上下文,如错误类型、详情)
        error!(error = ?self, "请求处理失败");

        // 2. 匹配错误类型,设置 HTTP 状态码、错误码、详情
        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()), // 传递具体解析错误(如“字段 email 缺失”)
            ),
            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(self.to_string()), // 传递“字段[xx]: 错误信息”
            ),
            AppError::NotFound => (StatusCode::NOT_FOUND, "NOT_FOUND", None),
            AppError::MethodNotAllowed => (
                StatusCode::METHOD_NOT_ALLOWED,
                "METHOD_NOT_ALLOWED",
                None,
            ),
            AppError::Unknown(msg) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "UNKNOWN_ERROR",
                Some(msg.clone()),
            ),
        };

        // 3. 构建 JSON 响应体
        let error_body = ErrorResponse {
            code,
            message: self.to_string(), // 错误描述(如“输入无效: 用户名不能为空”)
            detail,
        };

        // 4. 返回 HTTP 响应(状态码 + JSON 体)
        (status, AxumJson(error_body)).into_response()
    }
}

验证效果:

此时若手动返回 Err(AppError::InvalidInput { field: "username".into(), message: "不能为空".into() }),前端会收到:

{
  "code": "INVALID_INPUT",
  "message": "输入无效: 不能为空",
  "detail": "字段[username]: 不能为空"
}

且 HTTP 状态码为 400。


六、简化开发:定义 AppResult 类型(Step 5)

当前程序业务函数返回 String 或直接崩溃,新版本需返回 Result<T, AppError>。为避免重复写 Result<T, AppError>,定义 AppResult 别名。

操作步骤:

在 AppError 下方添加:

// 简化 Result 类型(避免重复写 Result<T, AppError>)
pub type AppResult<T> = Result<T, AppError>;

为什么这么做?

  • 减少冗余代码:业务函数可直接返回 AppResult<String>,而非 Result<String, AppError>
  • 统一返回类型:所有业务函数的错误都通过 AppResult 传播,便于中间件统一处理。

七、增强稳定性:添加核心中间件(Step 6)

老版本无中间件支持,导致跨域失败、大文件上传崩溃、无请求日志。集成 tower_http 中间件,解决这些问题。

目标:

  • 支持跨域(前端可从不同域名请求)。
  • 限制请求体大小(避免大文件攻击)。
  • 记录请求日志(方法、路径、耗时、状态码)。

操作步骤:

1、导入中间件相关依赖:

use axum::{
    extract::{DefaultBodyLimit, ConnectInfo},
    http::Method,
    routing::get,
};
use tower_http::{
    cors::{Any, CorsLayer},
    limit::RequestBodyLimitLayer,
    trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer},
};
use std::net::SocketAddr; // 用于 ConnectInfo

2、在 main 函数中修改路由构建逻辑(替换原来老版本的 Router::new() 部分):

// 构建路由表(添加中间件)
let app = Router::new()
    .route("/", get(root)) // 保留老版本根路由
    .nest("/user", user_routes()) // 保留用户路由
    // 1. 禁用默认请求体限制,改用自定义限制(10MB = 10 * 1024 * 1024 字节)
    .layer(DefaultBodyLimit::disable())
    .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024))
    // 2. CORS 支持(开发环境允许所有域名/方法/头,生产环境需替换为具体域名)
    .layer(
        CorsLayer::new()
            .allow_origin(Any)
            .allow_methods(Any)
            .allow_headers(Any),
    )
    // 3. 请求跟踪日志(记录请求详情,便于排查问题)
    .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); // 后续步骤实现的兜底路由

关键中间件说明:

  • CorsLayer:解决前端跨域问题(如前端运行在 http://localhost:3000,后端在 8080,无 CORS 会被浏览器拦截)。
  • RequestBodyLimitLayer:限制请求体最大 10MB,避免恶意大文件上传导致服务器磁盘 / 内存耗尽。
  • TraceLayer:自动记录请求日志,例如:
INFO request{method=POST uri=/user/register version=HTTP/1.1}: tower_http::trace::on_request: started processing request
INFO request{method=POST uri=/user/register version=HTTP/1.1}: tower_http::trace::on_response: finished processing request status=200 OK duration=123.45µs

八、业务改造:完善错误处理与输入验证(Step 7)

老版本业务函数(如 register_userlogin)使用 unwrap 崩溃,且无输入验证。现在用 AppResult 改造,添加输入验证,避免无效数据进入业务逻辑。

目标:

  • 所有业务函数返回 AppResult<T>,不再崩溃。
  • 新增输入验证(如用户名不能为空、邮箱含 @、密码长度 ≥6)。

操作步骤(以 register_user 为例):

1、老版本代码(问题:无验证、直接返回字符串):

async fn register_user(Json(user): Json<RegisterUser>) -> String {
    format!(
        "注册成功!用户信息:用户名={}, 邮箱={}, 年龄={:?}",
        user.username, user.email, user.age
    )
}

2、新版本改造(添加验证、返回 AppResult<String>):

async fn register_user(Json(user): Json<RegisterUser>) -> AppResult<String> {
    // 1. 输入验证(业务规则:用户名非空、邮箱含 @)
    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(),
        });
    }

    // 2. 验证通过,返回成功信息
    Ok(format!(
        "注册成功!\n用户名:{}\n邮箱:{}\n年龄:{:?}",
        user.username, user.email, user.age
    ))
}

其他业务函数改造(类似逻辑):

1、login 函数(添加密码长度验证):

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(),
        });
    }

    // 密码哈希(保留老版本逻辑)
    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
    ))
}

2、upload_save_file 函数(替换 unwrap 为 ?):

老版本用 field.bytes().await.unwrap(),新版本改为:

async fn upload_save_file(mut multipart: Multipart) -> AppResult<String> {
    let upload_dir = Path::new("./uploads");
    fs::create_dir_all(upload_dir).await?; // 用 ? 传播 IO 错误

    while let Some(field) = multipart.next_field().await? { // 用 ? 传播 Multipart 错误
        let original_filename = field.file_name().unwrap_or("未知文件名").to_string();
        let file_data = field.bytes().await?; // 用 ? 传播字节读取错误

        let save_path = upload_dir.join(&original_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保存路径:{}",
            original_filename, save_path.display()
        ));
    }

    // 未找到文件字段,返回业务错误
    Err(AppError::InvalidInput {
        field: "file".to_string(),
        message: "表单中未包含文件字段(请用 multipart/form-data 格式)".to_string(),
    })
}

验证效果:

  • 若用户注册时邮箱不含 @,前端会收到 400 错误 + JSON 响应。
  • 若文件上传时路径不存在,会自动返回 500 错误 + IO_ERROR 码,且日志记录具体错误。

九、兜底处理:添加 404/405 路由(Step 8)

老版本访问不存在的路由(如 /foo)或用错误方法(如 GET /user/register)会返回 Axum 默认错误,体验差。添加 fallback 路由,统一处理这些场景。

操作步骤:

1、导入必要依赖:

use axum::http::Uri; // 用于获取请求路径

2、实现 fallback 函数:

/// 404(资源未找到)和 405(方法不允许)兜底处理
async fn fallback(
    method: Method,          // 获取请求方法(如 GET/POST)
    uri: Uri,                // 获取请求路径(如 /foo)
    ConnectInfo(addr): ConnectInfo<SocketAddr>, // 获取客户端 IP
) -> 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
        );
        Err(AppError::NotFound)
    }
}

为什么这么做?

  • 区分 404 和 405:用户用 GET /user/register(正确路径,错误方法)会收到 405,而非 404,便于排查问题。
  • 日志记录上下文:记录客户端 IP、请求方法和路径,便于定位恶意请求或用户误操作。

十、最终增强:Panic 捕获与优雅关闭(Step 9)

老版本 Panic 会直接崩溃且无日志,优雅关闭仅处理 Ctrl+C。新版本添加 Panic 钩子记录崩溃信息,并确保服务关闭时完成当前请求。

操作步骤:

1、导入 Panic 相关依赖:

use std::panic; // 用于设置 Panic 钩子

2、在 main 函数开头添加 Panic 钩子:

#[tokio::main]
async fn main() {
    // 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
        );
    }));

    // 后续代码(日志初始化、路由构建等)...
}

3、修正服务启动逻辑(支持 ConnectInfo,用于 fallback 路由获取客户端 IP):

老版本服务启动代码:

let server = axum::serve(listener, app);

新版本替换为:

let server = axum::serve(
    listener,
    app.into_make_service_with_connect_info::<SocketAddr>(), // 启用 ConnectInfo
);

验证效果:

  • 若代码中出现 panic!("测试崩溃"),日志会记录:服务崩溃!位置:src/main.rs:123:4:5,信息:测试崩溃
  • 关闭服务时(Ctrl+C),服务会等待当前处理的请求完成后再退出,避免请求中断。

终于,升级完成!

最终代码

Cargo.toml:

[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"] }

# 哈希与编码(密码处理)
sha2 = "0.10.8"
hex = "0.4.3"

# 错误处理(自定义错误类型)
thiserror = "2.0.16"

# Tower 基础依赖(服务构建)
tower = "0.4.13"

main.rs:

// 1. 核心依赖导入(按功能分类,避免冗余)
use axum::{
    body::Bytes,
    extract::{
        self, ConnectInfo, DefaultBodyLimit, Form, Json, Multipart,
    },
    http::{Method, StatusCode, Uri},
    response::{IntoResponse, Json as AxumJson, Response},
    routing::{get, 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,
    trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer},
};
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}")]
    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::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)
}

// 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(); // 初始化全局日志

    info!("Axum 服务器启动中...");

    // 8.3 构建路由表(模块化拆分,避免臃肿)
    let app = Router::new()
        .route("/", get(root)) // 根路由
        .nest("/user", user_routes()) // 用户相关路由(模块化)
        // 中间件:请求体大小限制(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 兜底处理

    // 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!("服务器已优雅关闭");
}

// 9. 核心业务处理函数
/// 根路由处理函数(/)
async fn root() -> &'static str {
    "Hello world!"
}

/// 优雅关闭信号处理(监听 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!("收到关闭信号,开始优雅关闭...");
}

// 10. 用户模块路由(模块化拆分)
fn user_routes() -> Router {
    Router::new()
        .route("/register", post(register_user)) // 注册(JSON)
        .route("/login", post(login)) // 登录(表单)
        .route("/upload", post(upload_file)) // 文件上传(仅读取)
        .route("/upload_and_save", post(upload_save_file)) // 文件上传并保存
        .route("/text", post(handle_text)) // 纯文本请求体
        .route("/binary", post(handle_binary)) // 二进制请求体
}

/// 用户注册处理(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> {
    // 遍历 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();
        let file_size = field.bytes().await?.len();

        return Ok(format!(
            "文件上传成功(未保存)!\n字段名:{}\n文件名:{}\n文件类型:{}\n文件大小:{} 字节",
            field_name, filename, content_type, file_size
        ));
    }

    // 未找到文件字段
    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?;

    // 处理文件字段
    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();

        // 读取文件内容
        let file_data = field.bytes().await?;

        // 构建保存路径(避免覆盖已存在文件)
        let save_path = upload_dir.join(&original_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保存路径:{}",
            field_name, original_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
        );
        Err(AppError::NotFound)
    }
}

验证

服务器终于完成了升级!现在的服务器,不仅可以能正常的处理各种错误,并能通过中间件层跟踪用户的请求及响应。

2025-08-24T04:05:52.619194Z  INFO axum_tutorial: Axum 服务器启动中...        
2025-08-24T04:05:52.621129Z  INFO axum_tutorial: 绑定服务器地址:0.0.0.0:8080
2025-08-24T04:05:52.624877Z  INFO axum_tutorial: 服务器启动成功,监听地址:0.0.0.0:8080
2025-08-24T04:06:08.934462Z  WARN axum_tutorial: 资源未找到:POST /text(客户端:127.0.0.1:10835)
2025-08-24T04:06:08.935619Z ERROR axum_tutorial: 请求处理失败 error=NotFound
2025-08-24T04:06:11.835937Z  WARN axum_tutorial: 资源未找到:POST /text(客户端:127.0.0.1:10838)
2025-08-24T04:06:11.837803Z ERROR axum_tutorial: 请求处理失败 error=NotFound
2025-08-24T05:41:34.868530Z  INFO axum_tutorial: 收到关闭信号,开始优雅关闭...
2025-08-24T05:41:34.871878Z  INFO axum_tutorial: 服务器已优雅关闭

验证所有功能:

  1. 启动服务:cargo run
  2. 测试正常场景:
    • GET http://localhost:8080 → 返回 Hello world!
    • POST http://localhost:8080/user/register(JSON 体:{"username":"test","email":"test@example.com"})→ 注册成功。
  3. 测试错误场景:
    • POST http://localhost:8080/user/register(JSON 体:{"username":"","email":"test"})→ 400 错误(用户名空 + 邮箱格式错)。
    • GET http://localhost:8080/user/register → 405 错误(方法不允许)。
    • POST http://localhost:8080/foo → 404 错误(资源未找到)。

生产环境建议:

  1. CORS 限制:将 allow_origin(Any) 改为具体域名(如 allow_origin("http://localhost:3000".parse()?)),避免跨域安全风险。
  2. 密码安全:当前 login 函数仅用 SHA-256 哈希密码,生产环境需加盐(如 bcrypt 或 argon2)。
  3. 请求体限制:根据业务调整 RequestBodyLimitLayer(如上传大文件需调大,但需配合磁盘空间监控)。
  4. 日志持久化:将日志输出到文件(如用 tracing-appender),而非仅控制台。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值