第 15 篇

Serde 与 JSON 保存

引入 Serde 和 serde_json,验证任务文件的结构与业务规则,并把旧 TSV 数据迁移到不覆盖原文件的新 JSON。

Hex4C59 12 分钟阅读 更新于 2026-10-17

编程·原创·实战教程

本章把待办任务保存成 JSON(JavaScript Object Notation)文本,并提供一条迁移命令,把旧 TSV 数据转换到新文件。新增、完成、筛选和删除继续复用第十三章的逻辑,文件错误则采用第十四章的类型化处理方式。

你会第一次给业务项目引入第三方库。Serde 提供序列化和反序列化接口:序列化按字段读出 Rust 值,反序列化读取输入中的字段并构造 Rust 值;serde_json 负责把这些字段写成 JSON 文本,或从 JSON 文本中读出。本章完成后,你应该能添加依赖、理解派生实现、区分格式错误与业务错误,并检查迁移是否保留原数据。

先确定工作目录和依赖 #

本章使用下载包的 rust-course/practice/taskbook-json。所有 Cargo 命令在 rust-course/practice 工作区执行,第一步是:

cargo fetch --locked
cargo run -p taskbook-json --example serde_basics

首次获取第三方依赖需要网络。下载包包含 practice/Cargo.lock;--locked 要求按现有锁文件解析,不允许这个命令悄悄修改依赖选择。它不保证脱离当前工具链和系统后仍然能运行。

本章验证使用 Serde 1.0.229 和 serde_json 1.0.154,清单将它们固定为这两个版本。这样正文与配套源码使用相同接口,没有把所有未来版本的行为当作已验证结果。

taskbook-json/Cargo.toml 的完整内容:

[package]
name = "taskbook-json"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
publish = false

[dependencies]
taskbook = { path = "../../taskbook" }
taskbook-plus = { path = "../taskbook-plus" }
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.154"

features = ["derive"] 启用 Serde 的派生宏,让 #[derive(Serialize, Deserialize)] 可以生成对应实现。派生 Serialize 时,生成的代码读取结构体字段并交给格式库;派生 Deserialize 时,生成的代码按字段名称和类型构造结构体。这些代码在编译时生成,真正转换文本时才由下面的 JSON 函数调用。

serde_json 实现具体格式。它们都不是 Rust 标准库的一部分,use serde::... 能工作,是因为这个包的清单已经声明了依赖。

如果以后自己创建另一个项目,通常可以用 cargo add serde --features derive 和 cargo add serde_json 添加依赖。Cargo 会选择当时可用的版本;这与复制本章固定版本的清单不同。学习本章时使用下载包或下面的清单即可。

用一个小对象做往返转换 #

配套文件 taskbook-json/examples/serde_basics.rs 的完整内容:

use serde::{Deserialize, Serialize};

#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Settings {
    name: String,
    active: bool,
}

fn main() -> Result<(), serde_json::Error> {
    let settings = Settings {
        name: String::from("Rust 练习"),
        active: true,
    };
    let json = serde_json::to_string(&settings)?;
    println!("{json}");
    let restored: Settings = serde_json::from_str(&json)?;
    assert_eq!(restored, settings);
    println!("读回:{},启用:{}", restored.name, restored.active);
    Ok(())
}

运行后输出:

{"name":"Rust 练习","active":true}
读回:Rust 练习,启用:true

to_string 把 Rust 值编码成文本,from_str 把文本解析成 Rust 值。后者需要知道目标类型;这里通过 let restored: Settings 提供类型信息,也可以写 serde_json::from_str::<Settings>(&json)。这次转换先从原对象读出名称和启用状态,生成 JSON 字符串,再从字符串构造一个拥有名称文本的新对象,两个对象各自保有自己的字段内容。

active 是 JSON 布尔值 true,没有引号;"true" 是字符串,无法直接作为这里的 bool 字段读入。结构体的派生实现帮助你检查字段类型,但它不会自动判断标题是否为空、任务编号是否重复。

双引号、反斜杠等字符由库按 JSON 规则编码。不要用 format! 手工把标题拼进 JSON,再逐个补转义规则。

给任务文件加一个版本字段 #

本章的文件形状是一个对象,包含 version 和 tasks:

{
  "version": 1,
  "tasks": [
    {
      "id": 7,
      "title": "读 \"Rust\"",
      "done": true
    }
  ]
}

这是一份完整的示例 JSON,可以作为单独的练习数据文件。版本 1 是本教材定义的格式版本,不是 Rust、Serde 或应用程序的版本。

读取时只接受版本 1。未来若调整字段,程序需要明确决定怎样识别旧格式和迁移,不能假定增加了一个版本号就已经解决兼容问题。

空任务列表写成 {"version":1,"tasks":[]}。一个空的现有文件不是有效 JSON,会报错;文件完全不存在时,工具仍允许首次启动并返回空列表。这与第十二章“空 TSV 文件就是空列表”的规则不同。

单独定义用于保存的结构体 #

旧库的 Task 没有实现 Serde 的 trait,本章又希望保留第 1~12 章只用标准库的项目,所以不直接修改它。我们在新包里定义 StoredTask,保存相同的三个字段,再显式转换成原来的 Task。

这让文件结构和业务结构有各自的位置。当前转换确实只有三个字段,但它仍是一段需要检查的代码;如果漏掉完成状态,JSON 格式正确也可能把任务读错。

新建 taskbook-json/src/lib.rs,完整内容只有:

pub mod storage;

再新建 src/storage.rs,下面是完整业务代码。测试模块在后面追加:

use std::collections::HashSet;
use std::error::Error;
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::Path;

use serde::{Deserialize, Serialize};
use taskbook::{Task, validate_title};

#[derive(Debug)]
pub enum StoreError {
    Io(io::Error),
    Json(serde_json::Error),
    InvalidData(String),
}

impl fmt::Display for StoreError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io(_) => write!(f, "文件操作失败"),
            Self::Json(_) => write!(f, "JSON 内容无法解析或生成"),
            Self::InvalidData(message) => write!(f, "{message}"),
        }
    }
}

impl Error for StoreError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Io(error) => Some(error),
            Self::Json(error) => Some(error),
            Self::InvalidData(_) => None,
        }
    }
}

impl From<io::Error> for StoreError {
    fn from(error: io::Error) -> Self {
        Self::Io(error)
    }
}

impl From<serde_json::Error> for StoreError {
    fn from(error: serde_json::Error) -> Self {
        Self::Json(error)
    }
}

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Document {
    version: u32,
    tasks: Vec<StoredTask>,
}

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredTask {
    id: u64,
    title: String,
    done: bool,
}

fn validate(tasks: &[Task]) -> Result<(), StoreError> {
    let mut ids = HashSet::new();
    for task in tasks {
        if task.id == 0 || !ids.insert(task.id) {
            return Err(StoreError::InvalidData(format!(
                "任务编号无效或重复:{}",
                task.id
            )));
        }
        validate_title(&task.title).map_err(StoreError::InvalidData)?;
    }
    Ok(())
}

pub fn encode(tasks: &[Task]) -> Result<String, StoreError> {
    validate(tasks)?;
    let document = Document {
        version: 1,
        tasks: tasks
            .iter()
            .map(|task| StoredTask {
                id: task.id,
                title: task.title.clone(),
                done: task.done,
            })
            .collect(),
    };
    Ok(serde_json::to_string_pretty(&document)?)
}

pub fn decode(input: &str) -> Result<Vec<Task>, StoreError> {
    let document: Document = serde_json::from_str(input)?;
    if document.version != 1 {
        return Err(StoreError::InvalidData(format!(
            "不支持文件格式版本 {}",
            document.version
        )));
    }
    let tasks: Vec<Task> = document
        .tasks
        .into_iter()
        .map(|task| Task {
            id: task.id,
            title: task.title,
            done: task.done,
        })
        .collect();
    validate(&tasks)?;
    Ok(tasks)
}

pub fn load(path: &Path) -> Result<Vec<Task>, StoreError> {
    match fs::read_to_string(path) {
        Ok(content) => decode(&content),
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
        Err(error) => Err(StoreError::Io(error)),
    }
}

pub fn save(path: &Path, tasks: &[Task]) -> Result<(), StoreError> {
    let content = encode(tasks)?;
    fs::write(path, content)?;
    Ok(())
}

pub fn migrate(source: &Path, destination: &Path) -> Result<usize, StoreError> {
    let input = fs::read_to_string(source)?;
    let tasks = taskbook::storage::decode(&input).map_err(StoreError::InvalidData)?;
    let content = encode(&tasks)?;
    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(destination)?;
    file.write_all(content.as_bytes())?;
    Ok(tasks.len())
}

编码和解码怎样分工 #

encode 先验证业务数据,再构建 Document,最后调用 to_string_pretty。这个接口增加缩进和换行,便于直接查看文件;它不会改变字段代表的业务含义。

编码接收 &[Task],不能把借来的标题移出任务,所以构建 StoredTask 时明确克隆标题。解码得到的 Document 则拥有字段内容,转换时可以把 String 直接移进 Task,不需要再克隆。

decode 先解析结构和字段类型,再检查文件版本,最后检查正整数编号、重复编号与标题规则。任何一步失败,都不会生成可交给后续命令修改的成功列表。

JSON 能解析,不代表任务有效 #

以下两种输入会在不同层次失败:

  • done 写成 "false":字段类型不对,返回 StoreError::Json。
  • id 写成 0:它是能解析的整数,但不符合业务规则,返回 StoreError::InvalidData。

标题仍沿用前面项目的约束,不允许空白标题、制表符或换行。JSON 本身能表示这些字符,并不意味着我们已经决定改变业务规则。

#[serde(deny_unknown_fields)] 让 Document 和 StoredTask 拒绝未定义字段。例如把 done 拼成别的名字,应报告错误,不能假装这个字段已正确读入。这个选择也意味着未来新增字段时,旧程序可能拒绝它;格式演进需要单独设计。

这里既没有给必需字段设置默认值,也没有允许未知字段,所以缺少完成状态和多出一个拼错的状态字段都会失败,而不会悄悄补成未完成。

自定义错误与第三方错误怎么连起来 #

StoreError::Json 保存 serde_json::Error,Io 保存 io::Error;两者通过 From 接到 ?,通过 source 提供底层原因。InvalidData(String) 保存业务检查消息,没有附加底层错误。

新结构体在当前包里定义,因此可以为它派生 Serde 的实现。这里只是把旧 Task 的字段映射到新结构,没有尝试给外部库的类型直接增加外部 trait 实现。

在入口选择 JSON 路径 #

taskbook-json/src/main.rs 的完整内容:

use std::env;
use std::error::Error;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use taskbook::{add_task, mark_done, render_tasks};
use taskbook_json::storage::{self, StoreError};
use taskbook_plus::{Command, parse_args, pending, remove_task, render_selection, search};

fn run() -> Result<(), StoreError> {
    let args: Vec<String> = env::args().skip(1).collect();
    if args.first().map(String::as_str) == Some("migrate") {
        let [_, source, destination] = args.as_slice() else {
            return Err(StoreError::InvalidData(String::from(
                "用法:taskbook-json migrate <TSV源文件> <JSON新文件>",
            )));
        };
        let count = storage::migrate(Path::new(source), Path::new(destination))?;
        println!("已迁移 {count} 条任务");
        return Ok(());
    }
    let command = parse_args(&args).map_err(|message| {
        let message = if message == taskbook_plus::USAGE {
            message.replace("taskbook-plus", "taskbook-json")
        } else {
            message
        };
        StoreError::InvalidData(message)
    })?;
    let path = env::var_os("TASKBOOK_JSON_FILE")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("tasks.json"));
    let mut tasks = storage::load(&path)?;
    match command {
        Command::Base(taskbook::Command::Add(title)) => {
            let id = add_task(&mut tasks, &title).map_err(StoreError::InvalidData)?;
            storage::save(&path, &tasks)?;
            println!("已添加任务 {id}");
        }
        Command::Base(taskbook::Command::List) => print!("{}", render_tasks(&tasks)),
        Command::Base(taskbook::Command::Done(id)) => {
            mark_done(&mut tasks, id).map_err(StoreError::InvalidData)?;
            storage::save(&path, &tasks)?;
            println!("已完成任务 {id}");
        }
        Command::Pending => print!("{}", render_selection(&pending(&tasks))),
        Command::Search(query) => {
            let selected = search(&tasks, &query).map_err(StoreError::InvalidData)?;
            print!("{}", render_selection(&selected));
        }
        Command::Remove(id) => {
            remove_task(&mut tasks, id).map_err(StoreError::InvalidData)?;
            storage::save(&path, &tasks)?;
            println!("已删除任务 {id}");
        }
    }
    Ok(())
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("错误:{error}");
            let mut source = error.source();
            while let Some(cause) = source {
                eprintln!("原因:{cause}");
                source = cause.source();
            }
            ExitCode::FAILURE
        }
    }
}

普通命令使用 TASKBOOK_JSON_FILE,没有设置时使用当前工作目录下的 tasks.json。它与 TSV 工具的 TASKBOOK_FILE 是不同的环境变量,文件名也不同。

这里复用 taskbook_plus::parse_args 和筛选、删除函数,把它们返回的 String 错误包装成 InvalidData。原接口仍只返回文本;若以后调用方还需要区分“关键词为空”和“任务不存在”,相应核心接口也需要换成更具体的错误类型。

迁移命令单独接收源、目标两个路径。它不通过环境变量猜测路径,参数形式错误时直接给出迁移用法。迁移分支完成后立即返回,因此不会再加载普通命令的默认任务文件;迁移的数据只由这两个显式路径决定。

先运行新的 JSON 工具 #

在 macOS / Linux 的 practice 工作区执行:

json_practice_dir=$(mktemp -d)
TASKBOOK_JSON_FILE="$json_practice_dir/tasks.json" cargo run -p taskbook-json -- add '读 "Rust"'
TASKBOOK_JSON_FILE="$json_practice_dir/tasks.json" cargo run -p taskbook-json -- add "写练习"
TASKBOOK_JSON_FILE="$json_practice_dir/tasks.json" cargo run -p taskbook-json -- done 1
TASKBOOK_JSON_FILE="$json_practice_dir/tasks.json" cargo run -p taskbook-json -- list
cat "$json_practice_dir/tasks.json"

list 应输出:

1 [x] 读 "Rust"
2 [ ] 写练习

查看文件时,标题中的双引号会以 JSON 转义形式保存。重新读入后,标题仍包含原来的双引号。pending、search 和 remove 也可以用同一个文件继续运行。

文件损坏时,程序会在加载阶段报错,不会把它当成空列表再保存。保存仍使用覆盖写入,没有事务、文件锁或写入中断恢复;本章只检查读取和校验失败不会触发后续保存。

把旧 TSV 迁移到新文件 #

migrate 的顺序是:读取源文件,按旧规则解码,按新规则验证并编码,最后创建目标文件并写入。源文件只被读取。

create_new(true) 要求目标此前不存在;如果已经存在,打开就失败。不要先用 exists() 检查,再用普通覆盖写入,那两个操作之间目标仍可能被其他程序创建。

在前面新建的练习目录中,写一份专用旧数据,再迁移:

printf '7\t1\t读 "Rust"\n12\t0\t写练习\n' > "$json_practice_dir/source.tsv"
cargo run -p taskbook-json -- migrate "$json_practice_dir/source.tsv" "$json_practice_dir/migrated.json"
TASKBOOK_JSON_FILE="$json_practice_dir/migrated.json" cargo run -p taskbook-json -- list
cat "$json_practice_dir/source.tsv"

应看到 已迁移 2 条任务,随后列表仍为:

7 [x] 读 "Rust"
12 [ ] 写练习

编号、顺序、标题和状态都保留,源文件仍是原 TSV 文本。再对同一目标运行迁移,应失败并保留已存在的 JSON 文件。源与目标写成同一个已有路径时,同样不能覆盖源文件。

坏格式或缺失的源文件在创建目标之前就失败,不会新建目标。写入途中若失败,已创建的新目标可能留下不完整内容;本例没有自动清理或恢复流程。看到失败时先检查这份新文件,不能把它当作成功迁移的结果。

测试格式、业务规则和迁移 #

把下面完整测试模块追加到 src/storage.rs,或查看下载包里的同一模块:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trips_unicode_quotes_and_completion() {
        let tasks = vec![Task {
            id: 7,
            title: String::from("读 \"Rust\",再练习 🦀"),
            done: true,
        }];
        let text = encode(&tasks).unwrap();
        assert_eq!(decode(&text).unwrap(), tasks);
        assert!(text.contains("\\\"Rust\\\""));
        assert_eq!(decode(&encode(&[]).unwrap()).unwrap(), []);
    }

    #[test]
    fn distinguishes_json_syntax_from_business_validation() {
        assert!(matches!(decode("not-json"), Err(StoreError::Json(_))));
        for input in [
            r#"{"version":2,"tasks":[]}"#,
            r#"{"version":1,"tasks":[{"id":0,"title":"Rust","done":false}]}"#,
            r#"{"version":1,"tasks":[{"id":1,"title":" ","done":false}]}"#,
            r#"{"version":1,"tasks":[{"id":1,"title":"Rust","done":false},{"id":1,"title":"重复","done":true}]}"#,
        ] {
            assert!(
                matches!(decode(input), Err(StoreError::InvalidData(_))),
                "{input}"
            );
        }
        for input in [
            r#"{"version":1,"tasks":[{"id":1,"title":"Rust","done":"false"}]}"#,
            r#"{"version":1,"tasks":[{"id":1,"title":"Rust"}]}"#,
            r#"{"version":1,"tasks":[],"typo":true}"#,
            r#"{"version":1,"tasks":[{"id":1,"title":"Rust","done":false,"typo":true}]}"#,
        ] {
            assert!(matches!(decode(input), Err(StoreError::Json(_))), "{input}");
        }
    }

    #[test]
    fn encoding_also_rejects_invalid_task_fields() {
        for task in [
            Task {
                id: 0,
                title: String::from("Rust"),
                done: false,
            },
            Task {
                id: 1,
                title: String::from("bad\ttitle"),
                done: false,
            },
        ] {
            assert!(matches!(encode(&[task]), Err(StoreError::InvalidData(_))));
        }
        let task = || Task {
            id: 1,
            title: String::from("Rust"),
            done: false,
        };
        assert!(encode(&[task(), task()]).is_err());
    }
}

在 practice 工作区运行:

cargo test -p taskbook-json --lib
cargo test -p taskbook-json --test cli

库里的 3 个测试检查编码和解码,包括中文、引号、空列表、类型错误、未知字段、无效版本和重复编号。CLI 测试另检查重启后数据仍在、查询没有修改文件、损坏数据不会被保存成新列表,以及迁移保留源文件并拒绝已有目标。

测试里的“旧数据保持不变”对应明确的读取、校验失败和目标已存在条件,没有覆盖磁盘写入中断的恢复保证。

练习:缺少字段时提供默认值 #

另写一个 Note 结构体,包含 title: String 和 tags: Vec<String>。旧 JSON 只有标题时,让标签默认为空列表;标签字段存在但类型错误时,仍要失败。这个练习独立于任务文件,不修改它的版本或业务规则。

参考答案:先自己完成再展开

下面是配套示例 taskbook-json/examples/answer15.rs 的完整内容:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Note {
    title: String,
    #[serde(default)]
    tags: Vec<String>,
}

fn main() -> Result<(), serde_json::Error> {
    let note: Note = serde_json::from_str(r#"{"title":"Rust 练习"}"#)?;
    assert!(note.tags.is_empty());
    println!("{}", serde_json::to_string(&note)?);
    assert!(serde_json::from_str::<Note>(r#"{"title":"Rust","tags":"学习"}"#).is_err());
    Ok(())
}

运行 cargo run -p taskbook-json --example answer15,输出:

{"title":"Rust 练习","tags":[]}

#[serde(default)] 在字段缺失时使用该类型的默认值,Vec<String> 的默认值就是空列表。它不会把一个现有的字符串自动当作标签列表;字段已经提供却类型不对时,仍是输入错误。再次序列化时,这个空列表也会作为标签字段写进 JSON,而不是继续省略。

为旧文件提供默认值是一种兼容选择,不应随便用于必需字段。任务编号或格式版本缺失时若悄悄补 0,反而会隐藏数据问题。本章的任务格式仍要求这些字段存在。

完成后再选择下一项改动 #

到这里,你已经使用过本地库、第三方依赖、借用视图、类型化错误、序列化和迁移。可以先不看答案重做其中一项,例如新增筛选命令,或把一个 String 错误接口改成枚举,再用测试确认原行为。

后面三章会继续完成具体项目:第十六章先做手动备份并恢复到新文件,第十七章用 clap 整理命令行接口,第十八章用本机 HTTP 服务练习请求和 JSON 响应。临时文件替换、异步和并发则需要再分别确定行为和测试。

官方延伸阅读 #

外部文档查阅日期:2026-10-13。


教材目录与源码下载 · 上一章:自定义错误类型 · 下一章:备份与恢复

一起讨论

选中正文中的文字,可以针对这段内容发起讨论。

正在读取讨论…

在 GitHub 查看与管理讨论 ↗

文章讨论