TodoLite 系列 (二):处理数据底座,封装文件 I/O 与错误处理

在这里插入图片描述

1. 回顾:从单薄到健壮

之前我们成功地将 Task 列表序列化并写入了 tasks.json 文件。我们使用了 std::fs::write.expect(),代码简洁,但代价是牺牲了健壮性。生产级的应用绝不能如此脆弱。当 I/O 操作失败时,我们希望程序能优雅地处理错误,而不是直接崩溃。

现在的目标就是: 重构存储逻辑,将其封装到一个独立的模块中,并引入 Rust 强大的错误处理体系

2. 封装存储逻辑

首先,让我们创建 src/storage.rs 文件。我们将在这里定义两个核心函数:save_tasksload_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::fsstd::io:更精细的控制

std::fs::writeread_to_string 是便捷的“一体化”函数。为了更高的效率和更精细的控制,我们应该使用 std::fs::File 搭配 std::io 中的 BufReaderBufWriter

  • 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 提供了直接操作 ReaderWriter 的函数 to_writer_prettyfrom_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 可能返回的 OkErr。这才是专业、健壮的错误处理方式。

总结

本篇,我们完成了从一个脆弱的原型到一个拥有坚实数据底座的重大转变。

  1. 将存储逻辑封装到了独立的 storage 模块。
  2. 使用了带缓冲的 I/O 来提升性能。
  3. 定义了自定义错误类型,并利用 From trait 和 ? 操作符构建了优雅、清晰的错误处理链

这充分体现了 Rust 在语言层面就鼓励开发者编写可靠代码的设计学问。现在,我们的 TodoLite 已经准备好迎接更复杂的功能了。在下一篇中,预计我们将为它加上至关重要的安全层——数据加密

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐