引言
在上一篇文章中,我们成功构建了一个强大的命令行工具 greprs,并领略了 Rust 在工程化和可靠性方面的优势。现在,让我们将目光从本地工具转向互联网服务的基石------Web API,探索 Rust 如何在这一竞争最激烈的领域中,凭借其无与伦比的性能和安全性,成为一颗冉冉升起的新星。
本文不是一篇简单的"Hello, World"教程。我们将手把手带你使用当前备受推崇的 Axum 框架,构建一个功能完整的 To-Do List RESTful API。但更重要的是,我们将引入专业的项目结构、优雅的全局错误处理、请求日志中间件、以及输入数据验证。这些都是从一个玩具项目迈向一个真正生产级应用所必备的关键要素。
读完本文,你将收获的不仅仅是一个能跑的 API,更是一套用 Rust 构建可靠网络服务的思想和最佳实践。你将理解为什么 Rust 的类型系统和错误处理机制能在 Web 开发中大放异彩,以及如何利用 Axum 框架的特性来优雅地组织你的代码。
第一步:Projet Scaffolding & Dependencies (项目脚手架与依赖)
一个专业的项目始于清晰的结构和明确的依赖。混乱的开始是项目后期维护困难的根源。我们将从一开始就建立一个良好、可扩展的结构。
-
创建项目:
首先,我们使用 Cargo,Rust 的包管理器和构建工具,来创建一个新的二进制项目。
bashcargo new robust_todo_api cd robust_todo_apicargo new命令会为我们生成一个包含Cargo.toml配置文件和src/main.rs源文件的基本项目结构。
-
规划项目结构:
对于一个简单的应用,将所有代码都放在
main.rs中或许是可行的。但随着业务逻辑的增长,这会迅速变成一场噩梦。我们将告别这种"一把梭"的写法,通过拆分模块来让代码职责分明。在
src/目录下创建以下文件:main.rs: 程序的唯一入口。它的职责非常纯粹:组装所有模块、配置路由、初始化服务并启动。它不应该包含任何业务逻辑。models.rs: 存放我们应用的核心数据结构,例如Todo及其相关的输入模型。这里是数据世界的定义之处。handlers.rs: 存放处理具体 HTTP 请求的函数,也就是我们常说的Controller或Handler。每个函数对应一个 API 端点(Endpoint)的业务逻辑。errors.rs: 这是健壮性的核心。我们将在这里定义贯穿整个应用的自定义错误类型,并实现如何将这些内部错误优雅地转换为对外的 HTTP 响应。
创建完成后,你的
src目录看起来应该是这样的:
这种模块化的结构使得代码更易于阅读、导航和维护。当你想查找数据定义时,你知道去
models.rs;当你想修改某个 API 的逻辑时,你直接打开handlers.rs。 -
配置
Cargo.toml:Cargo.toml是我们项目的"心脏",它定义了项目元数据和所有依赖。打开这个文件,用下面的内容替换[dependencies]部分,引入我们这次"豪华套餐"所需的所有库。toml[dependencies] axum = "0.7" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4", "serde"] } # 日志 & 追踪 tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tower-http = { version = "0.5.0", features = ["trace"] } # 数据验证 validator = { version = "0.18", features = ["derive"] }
让我们深度解析一下这些依赖的作用:
-
核心框架与运行时:
axum: 一个符合人体工程学且模块化的 Web 框架,由构建了tokio的团队打造。它的设计哲学是简洁、可组合,并且无宏。tokio: Rust 社区的异步运行时标杆。网络服务是典型的 I/O 密集型应用,异步处理是实现高性能的关键。features = ["full"]开启了所有功能,包括异步的 TCP、文件系统、定时器等。
-
数据序列化与反序列化:
serde: Rust 生态中数据序列化的"瑞士军刀"。features = ["derive"]允许我们使用#[derive(Serialize, Deserialize)]宏,轻松地让我们的数据结构在 Rust 对象和 JSON 格式之间自动转换。serde_json: 为serde提供具体的 JSON 格式支持。
-
唯一标识符:
uuid: 用于生成和处理通用唯一标识符 (UUID)。我们将用它来为每一个 Todo 事项生成一个独一无二的 ID。features = ["v4", "serde"]指定了我们使用版本 4 的 UUID (基于随机数生成),并让 UUID 类型能与serde集成。
-
日志 & 追踪 (可观测性):
tracing: 一个强大的框架,用于对 Rust 程序进行 instrument,以收集结构化的、事件驱动的诊断信息。它不仅仅是打印文本,而是能理解程序的上下文,如哪个请求正在被处理。tracing-subscriber:tracing生态的一部分,用于收集、过滤和处理tracing产生的数据。我们用它来配置日志的格式和级别。tower-http: 提供了一系列与 HTTP 相关的Tower中间件。Tower是一个通用的服务抽象层,Axum正是构建于其上。我们使用features = ["trace"]来引入TraceLayer,一个强大的请求/响应日志中间件。
-
数据验证:
validator: 一个让我们能够基于结构体和字段进行数据验证的库。features = ["derive"]允许我们使用#[derive(Validate)]和字段上的属性宏,来声明式地定义验证规则,极大地简化了验证逻辑。
-
第二步:Models, Validation & Custom Errors (模型、验证与自定义错误)
这是构建健壮服务的第一块基石。在处理任何业务逻辑之前,我们必须清晰地定义我们的数据模型、输入验证规则以及统一的错误处理策略。
-
编写
src/models.rs:定义数据契约在这个文件中,我们定义应用的核心数据结构。对于一个 To-Do List API,最核心的自然是
Todo本身。此外,我们还需要为"创建"和"更新"操作定义专门的输入模型,这是一种良好的实践,可以避免 API 的输入和内部数据模型过度耦合。rustuse serde::{Deserialize, Serialize}; use uuid::Uuid; use validator::Validate; // 用于API响应和内部存储的核心Todo结构 #[derive(Debug, Serialize, Clone)] pub struct Todo { pub id: Uuid, pub text: String, pub completed: bool, } // 用于创建新Todo的输入模型 #[derive(Deserialize, Validate)] pub struct CreateTodo { #[validate(length(min = 1, message = "Todo text cannot be empty"))] pub text: String, } // 用于更新已存在Todo的输入模型 #[derive(Deserialize, Validate)] pub struct UpdateTodo { #[validate(length(min = 1, message = "Todo text cannot be empty"))] pub text: Option<String>, pub completed: Option<bool>, }
深度解析:
-
#[derive(Debug, Serialize, Clone)]:Serialize: 来源于serde,告诉编译器自动为Todo结构体生成代码,使其可以被序列化成 JSON 格式,用于 API 响应。Debug: 允许我们使用{:?}格式化符打印Todo实例,方便调试。Clone: 允许我们创建Todo的一个副本。因为我们的数据会存储在共享内存中,当从 handler 返回数据时,返回一个克隆版本而不是引用,可以避免所有权和生命周期的问题。
-
#[derive(Deserialize, Validate)]:Deserialize: 同样来源于serde,允许CreateTodo和UpdateTodo从请求的 JSON body 中反序列化而来。Validate: 来源于validator库,为这两个结构体启用了验证功能。
-
#[validate(length(min = 1, message = "Todo text cannot be empty"))]:这正是
validator库的威力所在!我们仅仅通过一个属性宏,就声明了一个业务规则:"待办事项的内容不能为空"。当我们在 handler 中调用.validate()方法时,这个规则就会被自动检查。这比手写if input.text.is_empty()的命令式代码要优雅得多,也更易于维护。当验证规则变多时,这种声明式验证的优势会更加明显。 -
Option<T>的使用 :在
UpdateTodo中,text和completed字段都被包裹在Option里。这是 RESTful API 设计中的一个重要模式,特别是对于PUT或PATCH请求。它表示这些字段是可选的。用户可以只更新text,或者只更新completed状态,或者两者都更新。如果某个字段的值是None,就意味着用户不打算更新它。
-
-
编写
src/errors.rs:构建优雅的错误处理中心这是本文的核心亮点 !在许多 Web 框架中,错误处理都比较分散。而在 Axum 中,我们可以通过创建一个全局的错误类型并为其实现
IntoResponsetrait,来集中处理所有错误。这意味着,无论我们的 handler 中发生了什么错误(数据库找不到记录、用户输入验证失败等),我们都可以将其转换为我们自定义的AppError,而 Axum 会自动调用我们实现的into_response方法,将其转换成一个标准的、对客户端友好的 HTTP 错误响应。rustuse axum::{ http::StatusCode, response::{IntoResponse, Response}, Json, }; use serde_json::json; // 我们自定义的全局错误枚举 pub enum AppError { ValidationError(String), // 验证错误 NotFound(String), // 资源未找到错误 InternalServerError, // 内部服务器错误 } // 为 AppError 实现 IntoResponse trait impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, error_message) = match self { AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), AppError::InternalServerError => ( StatusCode::INTERNAL_SERVER_ERROR, "An unexpected error occurred".to_string(), ), }; let body = Json(json!({ "error": error_message })); (status, body).into_response() } }
深度解析这段代码的魔力:
AppError枚举: 我们定义了一个枚举,它包含了我们应用中可能出现的所有"业务级"错误类型。这样做的好处是,所有的错误类型都被集中管理,清晰明了。impl IntoResponse for AppError: 这是 Axum 框架的精髓之一。IntoResponse是一个 trait,任何实现了这个 trait 的类型都可以被用作 handler 的返回值。通过为我们的AppError实现它,我们就告诉了 Axum:"嘿,如果你在一个 handler 的Result中收到了一个Err(AppError),请调用这个into_response方法来生成最终的 HTTP 响应。"match self: 在into_response方法内部,我们使用match表达式来处理不同的错误变体。ValidationError被映射到400 Bad Request状态码。NotFound被映射到404 Not Found状态码。InternalServerError被映射到500 Internal Server Error状态码,并且出于安全考虑,我们返回一个通用的错误信息,而不是暴露内部实现细节。
Json(json!({ "error": error_message })): 我们使用serde_json的json!宏来构建一个标准的 JSON 错误体,格式为{"error": "some message"}。这是一种很好的 API 设计实践,为客户端提供了机器可读的错误信息。- 解耦 : 这段代码最美妙的地方在于它实现了业务逻辑和 HTTP 协议的完美解耦。在我们的
handlers.rs中,我们只需要关心"发生了什么错误"(例如,AppError::NotFound),而完全不需要去想"应该返回什么 HTTP 状态码和 body"。所有的转换逻辑都集中在了errors.rs这个地方。这使得我们的 handler 代码异常干净,并且整个应用的错误响应格式高度一致。
第三步:The API Core Logic - Handlers (API 核心逻辑 - 处理器)
现在我们有了数据模型和错误处理机制,是时候编写具体的增删改查(CRUD)逻辑了。所有的这些逻辑都将放在 src/handlers.rs 中。
-
编写
src/handlers.rs:实现 API 端点为了简单起见,我们将使用一个内存中的
HashMap作为我们的"数据库"。为了能在多个请求之间共享这个"数据库",并且能够安全地修改它,我们需要使用Arc<Mutex<T>>。Arc(Atomically Referenced Counter): 允许多个所有者安全地共享同一个数据。Mutex(Mutual Exclusion): 确保在任何时候只有一个线程可以访问数据,防止并发写入导致的数据竞争。
rustuse axum::{ extract::{Path, State}, http::StatusCode, Json, }; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use uuid::Uuid; use validator::Validate; // 引入我们在其他模块中定义的类型 use crate::models::{CreateTodo, Todo, UpdateTodo}; use crate::errors::AppError; // 定义一个类型别名,方便使用 pub type Db = Arc<Mutex<HashMap<Uuid, Todo>>>; // ----- Handlers ----- // GET /todos - 获取所有待办事项 pub async fn get_all_todos(State(db): State<Db>) -> Json<Vec<Todo>> { let db = db.lock().unwrap(); let todos = db.values().cloned().collect(); Json(todos) } // POST /todos - 创建一个新的待办事项 pub async fn create_todo( State(db): State<Db>, Json(input): Json<CreateTodo>, ) -> Result<Json<Todo>, AppError> { // 1. 数据验证 input.validate().map_err(|e| AppError::ValidationError(e.to_string()))?; // 2. 业务逻辑 let mut db = db.lock().unwrap(); let todo = Todo { id: Uuid::new_v4(), text: input.text, completed: false, }; db.insert(todo.id, todo.clone()); // 3. 返回成功响应 Ok(Json(todo)) } // GET /todos/:id - 根据ID获取单个待办事项 pub async fn get_todo_by_id( State(db): State<Db>, Path(id): Path<Uuid>, ) -> Result<Json<Todo>, AppError> { let db = db.lock().unwrap(); if let Some(todo) = db.get(&id) { Ok(Json(todo.clone())) } else { Err(AppError::NotFound(format!("Todo with ID {} not found", id))) } } // PUT /todos/:id - 更新一个待办事项 pub async fn update_todo( State(db): State<Db>, Path(id): Path<Uuid>, Json(input): Json<UpdateTodo>, ) -> Result<Json<Todo>, AppError> { input.validate().map_err(|e| AppError::ValidationError(e.to_string()))?; let mut db = db.lock().unwrap(); if let Some(todo) = db.get_mut(&id) { if let Some(text) = input.text { todo.text = text; } if let Some(completed) = input.completed { todo.completed = completed; } Ok(Json(todo.clone())) } else { Err(AppError::NotFound(format!("Todo with ID {} not found", id))) } } // DELETE /todos/:id - 删除一个待办事项 pub async fn delete_todo( State(db): State<Db>, Path(id): Path<Uuid>, ) -> Result<StatusCode, AppError> { let mut db = db.lock().unwrap(); if db.remove(&id).is_some() { Ok(StatusCode::NO_CONTENT) // 204 No Content 是删除成功的标准响应 } else { Err(AppError::NotFound(format!("Todo with ID {} not found", id))) } }
深度解析:
-
Axum Extractors:
State(db): State<Db>: 这是 Axum 的状态提取器。它从应用的共享状态中提取出我们之前定义的Db类型。这是在不同 handler 之间共享资源(如数据库连接池)的标准方式。Json(input): Json<CreateTodo>: JSON 提取器。它会尝试将 HTTP 请求的 body 反序列化为CreateTodo结构体。如果 body 不是有效的 JSON 或者格式不匹配,Axum 会自动返回一个400 Bad Request响应,我们甚至不需要手动处理。Path(id): Path<Uuid>: 路径提取器。它从 URL 路径中提取动态段。例如,在/todos/some-uuid这个 URL 中,Path<Uuid>会自动解析some-uuid这部分并尝试将其转换为一个Uuid类型。如果解析失败,同样会自动返回400错误。
-
优雅的错误处理与
?操作符 :重点关注
create_todo中的这一行:input.validate().map_err(|e| AppError::ValidationError(e.to_string()))?;input.validate(): 调用我们之前通过#[derive(Validate)]获得的方法。它返回一个Result<(), ValidationError>。.map_err(|e| ...): 如果validate()返回Err,我们使用map_err将原始的ValidationError转换为我们自定义的AppError::ValidationError。?: 这是 Rust 错误处理的"语法糖"。如果Result是Ok,它会继续执行下一行代码;如果Result是Err,它会立即从当前函数返回这个Err。
结合起来,这一行代码就完成了:验证输入数据,如果失败,则立即中断函数执行,并返回一个AppError::ValidationError。由于AppError实现了IntoResponse,Axum 会捕获这个错误,并自动返回一个包含详细错误信息的400 Bad Request响应给客户端。这就是我们之前设计的错误处理机制的威力!
-
返回值:
Json<Vec<Todo>>: 对于获取所有 todos,我们返回一个Json包装的Todo向量。Axum 会自动将其序列化为 JSON 数组并设置Content-Type: application/json的响应头。Result<Json<Todo>, AppError>: 对于创建、获取单个和更新操作,返回值是一个Result。成功时返回Ok(Json<Todo>),失败时返回Err(AppError)。Result<StatusCode, AppError>: 对于删除操作,成功时我们不需要返回任何 body,只需要一个204 No Content状态码,所以返回Ok(StatusCode::NO_CONTENT)。
第四步:Assembly & Liftoff! (组装与启动!)
现在,我们已经拥有了所有的零件:模型、处理器和错误处理。最后一步是在 main.rs 中,像搭积木一样把它们组装起来,启动我们的 Web 服务。
-
编写
src/main.rs:应用的入口点main.rs的职责非常清晰:- 声明模块 : 使用
mod关键字告诉 Rust 编译器我们的项目包含了models,handlers, 和errors这几个模块。 - 初始化日志系统 : 配置
tracing,让它能够捕获并打印日志。 - 创建共享数据库状态 : 初始化我们的内存数据库
Db。 - 配置路由和中间件 : 创建 Axum
Router,定义 URL 路径与 handler 函数的映射关系,并添加日志中间件。 - 启动服务: 绑定端口,并让 Axum 开始监听 HTTP 请求。
rustuse axum::{ routing::{get, post, put, delete}, Router, }; use std::{collections::HashMap, net::SocketAddr, sync::{Arc, Mutex}}; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; // 声明模块 mod models; mod handlers; mod errors; // 引入 Db 类型别名 use handlers::Db; #[tokio::main] async fn main() { // 1. 初始化日志 tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( "robust_todo_api=debug,tower_http=debug" )) .with(tracing_subscriber::fmt::layer()) .init(); // 2. 创建内存数据库 let db: Db = Arc::new(Mutex::new(HashMap::new())); // 3. 定义路由 let app = Router::new() .route("/todos", get(handlers::get_all_todos).post(handlers::create_todo)) .route( "/todos/:id", get(handlers::get_todo_by_id) .put(handlers::update_todo) .delete(handlers::delete_todo), ) .with_state(db) // 将数据库状态注入到 Router .layer(TraceLayer::new_for_http()); // 添加日志中间件 // 4. 启动服务 let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); tracing::debug!(">> 服务正在监听 http://{}", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); }
深度解析:
-
#[tokio::main]: 这是一个宏,它会将一个普通的async fn main()函数转换为一个由 Tokio 运行时驱动的程序入口点。它负责启动运行时、处理异步任务等所有底层工作。 -
日志初始化:
tracing_subscriber::EnvFilter::new(...): 我们设置了一个日志过滤器。robust_todo_api=debug表示我们自己的应用代码(robust_todo_apicrate)只显示debug级别及以上的日志。tower_http=debug表示我们想看到tower-http中间件产生的debug级别的日志,这会打印出详细的请求和响应信息。这个配置可以通过环境变量RUST_LOG来覆盖,非常灵活。.with(tracing_subscriber::fmt::layer()): 添加一个格式化层,将日志以人类可读的格式打印到控制台。.init(): 安装这个 subscriber,使其成为全局默认的日志处理器。
-
路由配置 (
Router::new()...):.route("/todos", get(...).post(...)): 这是 Axum 声明路由的方式,非常直观。我们为/todos路径绑定了两个 HTTP 方法:GET请求由handlers::get_all_todos处理,POST请求由handlers::create_todo处理。.route("/todos/:id", ...): 这里的:id是一个动态路径参数。Axum 会捕获这部分,并由Path提取器解析。.with_state(db): 这个方法将我们创建的db实例注入到路由中。之后,任何 handler 都可以通过State提取器来访问它。.layer(TraceLayer::new_for_http()): 这是中间件的魅力!layer方法用于添加Tower中间件。TraceLayer会包裹我们所有的路由。对于每一个进来的请求,它都会在请求开始时打印一条日志,在请求结束时再打印一条包含状态码和延迟的日志。只用这一行代码,我们就为整个应用添加了强大的、结构化的请求/响应日志功能,这对于调试和线上问题排查至关重要。
- 声明模块 : 使用
第五步:Comprehensive Testing (全面测试) 与环境配置的"深坑"
代码已经写完,现在是检验成果的时刻。我们启动服务,然后使用 curl 这个强大的命令行工具来模拟 HTTP 客户端,测试我们 API 的所有功能,特别是我们精心设计的验证和错误处理机制。
启动服务
在项目根目录下运行:
bash
cargo run
然而,在这一步,很多开发者(尤其是在特定网络环境下的)会遇到一个非常典型且关键的问题。我们来完整地重现并解决这个过程,这本身就是一次宝贵的学习经历。
场景复现:"Connection Refused" 陷阱
当你第一次运行 cargo run 时,你可能会看到终端被类似下面的信息刷屏:
Updating crates.io index
warning: spurious network error (2 tries remaining): [35] SSL connect error (schannel: failed to receive handshake, SSL/TLS connection failed); class=Ssl (12)
warning: spurious network error (1 try remaining): [6] Couldn't resolve host name (Could not resolve host: index.crates.io); class=Net (1)
error: failed to get `axum` as a dependency of package ...

紧接着,如果你急于打开另一个终端进行测试:
bash
curl http://127.0.0.1:3000/todos
你会得到一个冰冷的错误:
curl: (7) Failed to connect to 127.0.0.1 port 3000 after 0 ms: Connection refused

问题根源分析
这是一个由两个相互关联的现象组成的典型问题:
- Cargo 下载失败 :
Updating crates.io index和各种网络错误(Timeout, SSL error, Could not resolve host)都指向同一个根本原因:你的服务器无法稳定地连接到crates.io的官方服务器。crates.io是 Rust 的官方包仓库,在第一次编译项目时,Cargo 需要从那里下载所有依赖包的最新信息(索引)和实体文件。由于网络限制,这个连接过程极易失败。 - "Connection refused" (连接被拒绝) :这个错误的意思非常明确------在
127.0.0.1的3000端口上,没有任何程序在监听连接 。其根本原因就是cargo run命令卡在了第一步的下载阶段,从未成功开始编译和运行你的代码。因此,你的 Axum 服务自然也就没有启动。
解决方案:切换到国内镜像源
我们需要告诉 Cargo,以后下载包和索引都不要去官方服务器了,去访问速度飞快的国内镜像站。这个操作是一劳永逸的。
最终方案:切换到 Cargo 的"稀疏索引 (Sparse Index)"协议
传统的镜像配置需要克隆一个巨大的 git 仓库作为索引,这在不稳定的网络下依然可能失败。幸运的是,较新版本的 Cargo 引入了"稀疏索引"协议,它通过普通的 HTTPS 请求按需下载包信息,不再需要 git 克隆,非常适合我们的情况。
-
打开或创建 Cargo 的全局配置文件:
bashmkdir -p ~/.cargo vim ~/.cargo/config.toml -
配置稀疏索引:
进入 vim 后,删除所有旧内容,然后将下面这个使用清华大学 (TUNA) 镜像源的全新配置块复制并粘贴进去。
toml[source.crates-io] replace-with = 'tuna' [source.tuna] registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/" -
清理旧的、下载失败的索引文件(非常重要):
为了防止旧的损坏文件干扰,我们彻底清空它。
bashrm -rf ~/.cargo/registry/index/* -
再次运行
cargo run:现在,回到你的项目目录
~/robust_todo_api,再次运行cargo run。bashcargo run你会发现奇迹发生了!终端不再有漫长的
Updating index过程,而是几乎立刻开始Downloading和Compiling依赖包。
编译过程可能会出现一些警告(warning),例如:
warning: unused imports: 'delete', 'post', and 'put':编译器善意提醒你在main.rs引入的模块在route方法链式调用中被间接使用了。可以安全忽略。warning: variant 'InternalServerError' is never constructed':这说明你的代码很健壮,还没触发过内部服务器错误。这是个好现象 ,为未来扩展预留的错误类型目前还没用到。可以安全忽略。
当看到下面这行日志时,恭喜你,服务已成功启动!
Compiling robust_todo_api v0.1.0 (/path/to/your/robust_todo_api) Finished dev [unoptimized + debuginfo] target(s) in 15.33s Running `target/debug/robust_todo_api` DEBUG robust_todo_api: >> 服务正在监听 http://127.0.0.1:3000
开始真正的 API 测试
现在,打开一个新的终端,让我们用 curl 来验证所有功能。
-
成功创建一个 Todo:
我们发送一个 POST 请求,请求体是一个包含
text字段的 JSON。bashcurl -X POST -H "Content-Type: application/json" -d '{"text": "学习 Rust Web"}' http://127.0.0.1:3000/todos你应该会收到一个
200 OK的响应,body 中包含了新创建的 Todo 对象,带有一个由uuid生成的唯一 ID。
-
测试数据验证失败:
我们故意发送一个
text为空字符串的请求,来触发我们的验证规则。bashcurl -X POST -H "Content-Type: application/json" -d '{"text": ""}' http://127.0.0.1:3000/todos -v使用
-v参数可以看到详细的 HTTP 通信过程。
正如所料,我们收到了一个
400 Bad Request响应,并且响应体是我们自定义的 JSON 错误格式,其中包含了validator宏里我们定义的错误信息!这证明了我们的ValidationError->AppError->IntoResponse链路工作正常。

-
测试获取不存在的 Todo:
我们尝试获取一个不存在的 UUID,来测试我们的
NotFound错误处理。bashcurl http://127.0.0.1:3000/todos/11111111-1111-1111-1111-111111111111 -v
服务器正确地返回了
404 Not Found状态码,响应体也包含了我们格式化的错误信息。

-
检查日志:
回到运行
cargo run的终端,你会看到tower_http的TraceLayer中间件为我们打印的每一条请求日志,信息非常详尽,包括请求方法、路径、HTTP 版本、响应状态码和处理延迟。
结论
在这篇教程中,我们从零开始,构建的不仅仅是一个简单的 API,而是一个包含了模块化设计、自定义错误处理、数据验证和日志中间件的健壮 Web 服务。我们亲身体会到,Rust 和 Axum 如何通过其强大的类型系统、trait 和宏,让我们能够以一种极其优雅和安全的方式,构建出高可靠性的后端应用。
我们所实践的关键理念包括:
- 关注点分离 :通过将代码拆分为
models,handlers,errors等模块,使项目结构清晰、易于维护。 - 声明式验证 :使用
validator库,将业务规则直接声明在数据结构上,让代码更简洁、更具表现力。 - 集中式错误处理 :通过自定义
AppError并实现IntoResponse,将错误处理逻辑与业务逻辑解耦,确保了全应用错误响应的一致性。 - 中间件的力量 :仅用一行代码,就通过
TraceLayer为整个应用添加了生产级的请求日志功能。
你现在掌握的,是从业余项目迈向生产级应用的关键一步。Rust 在 Web 领域的能力远不止于此,连接真实数据库(如使用 sqlx)、实现用户认证与授权、进行异步性能优化等,都将是你可以继续探索的广阔天地。欢迎来到这个高性能、高安全的 Web 新时代!