【Rust探索之旅】构建安全、高效的本地待办事项工具 TodoLite(系列二)
本文介绍了如何将TodoLite应用的数据存储逻辑重构为健壮的Rust模块。主要内容包括:1)创建独立的storage模块封装文件I/O操作;2)使用BufReader/BufWriter提升I/O性能;3)构建自定义错误类型AppError实现完善错误处理。通过实现From trait和Result类型,用?操作符取代expect(),使程序能优雅处理错误而非崩溃。该重构使代码更清晰、高效且可靠
TodoLite 系列 (二):处理数据底座,封装文件 I/O 与错误处理

1. 回顾:从单薄到健壮
之前我们成功地将 Task 列表序列化并写入了 tasks.json 文件。我们使用了 std::fs::write 和 .expect(),代码简洁,但代价是牺牲了健壮性。生产级的应用绝不能如此脆弱。当 I/O 操作失败时,我们希望程序能优雅地处理错误,而不是直接崩溃。
现在的目标就是: 重构存储逻辑,将其封装到一个独立的模块中,并引入 Rust 强大的错误处理体系。
2. 封装存储逻辑
首先,让我们创建 src/storage.rs 文件。我们将在这里定义两个核心函数:save_tasks 和 load_tasks。
// src/storage.rs
use crate::model::Task; // 使用 crate:: 引用项目根目录下的模块
const FILE_PATH: &str = "tasks.json";
// 这里我们暂时还使用 expect,稍后会替换它
pub fn save_tasks(tasks: &[Task]) {
let json_data = serde_json::to_string_pretty(tasks).expect("Serialization failed");
std::fs::write(FILE_PATH, json_data).expect("File write failed");
}
pub fn load_tasks() -> Vec<Task> {
// 如果文件不存在,返回一个空的 Vec
if !std::path::Path::new(FILE_PATH).exists() {
return Vec::new();
}
let json_data = std::fs::read_to_string(FILE_PATH).expect("File read failed");
serde_json::from_str(&json_data).expect("Deserialization failed")
}
同时,修改 src/main.rs,声明并使用这个新模块:
// src/main.rs
mod model;
mod storage; // 声明 storage 模块
use model::Task;
use chrono::Utc;
fn main() {
// 尝试加载任务,如果文件不存在,load_tasks 会返回空列表
let mut tasks = storage::load_tasks();
// 如果任务列表为空,添加一些示例任务
if tasks.is_empty() {
println!("任务列表为空,正在添加示例任务...");
tasks.push(Task {
id: 1,
content: "学习 Rust 的错误处理".to_string(),
creation_date: Utc::now(),
due_date: None,
is_completed: false,
});
}
println!("当前任务: {:#?}", tasks);
// 保存任务
storage::save_tasks(&tasks);
println!("任务已保存。");
}
现在运行 cargo run,程序的行为和之前类似,但逻辑已经被清晰地分离到了 storage 模块。这是重构的第一步。
3. 深入 std::fs 和 std::io:更精细的控制
std::fs::write 和 read_to_string 是便捷的“一体化”函数。为了更高的效率和更精细的控制,我们应该使用 std::fs::File 搭配 std::io 中的 BufReader 和 BufWriter。
File::create/File::open:前者用于创建(或清空并打开)文件以供写入,后者用于打开已存在的文件以供读取。它们返回的都是Result<File, std::io::Error>。BufWriter:它为File提供了一个内存缓冲区。当你写入数据时,数据先被写入缓冲区,只有当缓冲区满了或你手动flush时,才会一次性写入磁盘。这大大减少了昂贵的系统调用次数,提升了写入性能。BufReader:同理,它会一次性从磁盘读取一块数据到缓冲区,后续的读取操作直接从内存缓冲区进行,速度更快。
让我们用它们来改造 storage.rs:
// src/storage.rs
use crate::model::Task;
use std::fs::File;
use std::io::{BufReader, BufWriter, Write}; // 引入 Write trait 来使用 flush
// ... const FILE_PATH ...
pub fn save_tasks(tasks: &[Task]) {
let file = File::create(FILE_PATH).expect("Failed to create file");
let mut writer = BufWriter::new(file); // 创建带缓冲的写入器
serde_json::to_writer_pretty(&mut writer, tasks).expect("Serialization failed");
writer.flush().expect("Failed to flush writer"); // 确保所有缓冲数据都写入磁盘
}
pub fn load_tasks() -> Vec<Task> {
if !std::path::Path::new(FILE_PATH).exists() {
return Vec::new();
}
let file = File::open(FILE_PATH).expect("Failed to open file");
let reader = BufReader::new(file); // 创建带缓冲的读取器
let tasks = serde_json::from_reader(reader).expect("Deserialization failed");
tasks
}
注意:
serde_json提供了直接操作Reader和Writer的函数to_writer_pretty和from_reader,这比先转换成String更高效,因为它避免了在内存中创建完整的数据拷贝。
4. 构建强大的错误处理:Rust 的 Result 学问

现在,是时候彻底告别 .expect() 了。我们将创建一个自定义的错误类型,它可以包含所有可能发生的错误,并让我们的函数返回 Result。
在 storage.rs 的顶部添加我们的自定义错误枚举 AppError:
// src/storage.rs
// ... use 语句 ...
#[derive(Debug)]
pub enum AppError {
Io(std::io::Error),
Serialization(serde_json::Error),
}
AppError 有两个变体:一个用于包装 I/O 错误,另一个用于包装 serde 的序列化/反序列化错误。
接下来,为了让 ? 操作符能够在这两种错误类型上工作,我们需要为 AppError 实现 From trait。? 操作符在遇到 Err 时,会尝试使用 From::from() 将内部的错误类型转换为当前函数返回的 Result 的错误类型。
// 在 AppError 定义下方添加
impl From<std::io::Error> for AppError {
fn from(error: std::io::Error) -> Self {
AppError::Io(error)
}
}
impl From<serde_json::Error> for AppError {
fn from(error: serde_json::Error) -> Self {
AppError::Serialization(error)
}
}
有了这个,我们就可以改造 load/save 函数了,让它们返回 Result:
// src/storage.rs
// ... (之前的代码) ...
// 定义一个类型别名,简化代码
pub type AppResult<T> = Result<T, AppError>;
pub fn save_tasks(tasks: &[Task]) -> AppResult<()> {
let file = File::create(FILE_PATH)?; // ? 会在出错时自动转换 io::Error 并返回
let mut writer = BufWriter::new(file);
serde_json::to_writer_pretty(&mut writer, tasks)?; // ? 会自动转换 serde_json::Error
writer.flush()?;
Ok(()) // 一切顺利,返回 Ok
}
pub fn load_tasks() -> AppResult<Vec<Task>> {
if !std::path::Path::new(FILE_PATH).exists() {
return Ok(Vec::new()); // 文件不存在是正常情况,返回 Ok(空Vec)
}
let file = File::open(FILE_PATH)?;
let reader = BufReader::new(file);
let tasks = serde_json::from_reader(reader)?;
Ok(tasks)
}
看到代码的变化了吗?所有的 .expect() 都被 ? 操作符取代了。代码不仅没有变得更复杂,反而更加清晰地表达了“如果这里出错了,就将错误传递上去”的意图。
最后,我们需要在 main.rs 中处理这些 Result:
// src/main.rs
// ...
fn main() {
match storage::load_tasks() {
Ok(mut tasks) => {
// 加载成功
if tasks.is_empty() {
println!("任务列表为空,正在添加示例任务...");
tasks.push(Task {
id: 1,
content: "学习 Rust 的错误处理".to_string(),
creation_date: Utc::now(),
due_date: None,
is_completed: false,
});
}
println!("当前任务: {:#?}", tasks);
if let Err(e) = storage::save_tasks(&tasks) {
eprintln!("错误:保存任务失败! {:?}", e);
} else {
println!("任务已保存。");
}
}
Err(e) => {
// 加载失败
eprintln!("错误:加载任务失败! {:?}", e);
}
}
}
在 main 函数中,我们使用 match 表达式来处理 load_tasks 可能返回的 Ok 或 Err。这才是专业、健壮的错误处理方式。
总结
本篇,我们完成了从一个脆弱的原型到一个拥有坚实数据底座的重大转变。
- 将存储逻辑封装到了独立的
storage模块。 - 使用了带缓冲的 I/O 来提升性能。
- 定义了自定义错误类型,并利用
Fromtrait 和?操作符构建了优雅、清晰的错误处理链。
这充分体现了 Rust 在语言层面就鼓励开发者编写可靠代码的设计学问。现在,我们的 TodoLite 已经准备好迎接更复杂的功能了。在下一篇中,预计我们将为它加上至关重要的安全层——数据加密。
更多推荐


所有评论(0)