Skip to content

第 13 章 收尾大项目:故事搜索器

小螃蟹说

恭喜你走到这一章!这是基础卷的最后一章,我们做一个真正像样的工具——故事搜索器

它干的事很实在:你给它一个关键词和一本故事文件,它立刻告诉你:哪些行提到了这个词,在第几行。图书馆管理员翻一整天书才能找到的东西,它一眨眼就找到了。

更特别的是:这个项目把基础卷的所有知识串在了一起——结构体、错误处理、测试、模块、闭包迭代器……每一样都在里面重逢。做完它,你就是基础卷的毕业生了!

这也是我们第一次做"命令行工具":不用菜单,不用对话,直接在终端里像真正的程序员一样敲命令:

bash
cargo run -- 小螃蟹 童话.txt

(-- 后面是传给程序的参数:关键词"小螃蟹",文件"童话.txt"。)

13.1 预览:我们要做什么

  1. 接收命令行参数:关键词 + 文件路径
  2. 打开故事文件,一行行找关键词
  3. 打印命中行和行号
  4. 彩蛋:设置环境变量,搜索可以忽略大小写
运行结果
text
$ cargo run -- 小螃蟹 童话.txt
找到 3 处:小螃蟹
第 1 行:今天是小螃蟹 Ferris 的生日。
第 4 行:大家一起唱生日歌,小螃蟹开心得钳子都合不拢了。
第 5 行:许愿的时候,小螃蟹悄悄说:"希望明年还能和大家一起过生日!"

$ cargo run -- 恐龙 童话.txt
没有找到 恐龙。

$ cargo run -- 小螃蟹
参数不够!要告诉我:关键词 和 文件路径
用法:story_searcher 关键词 文件路径

这一章你会学到:

知识是什么重逢在第几章
命令行参数程序启动时收到的指令全新!
Config 结构体把参数打包成一捆第 5 章结构体
错误处理参数不够、文件找不到第 8 章 Result 和 ?
TDD 测试先写考卷再写答案第 10 章
环境变量程序外部的"开关"全新!
stderr错误走另一个喇叭全新!

13.2 动手做

步骤一:认识命令行参数

老规矩,建项目:

bash
cargo new story_searcher
cd story_searcher

先用一个小实验认识命令行参数(command line arguments)。把 src/main.rs 替换成:

rust
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    println!("你传了 {} 个参数。", args.len());
    for (index, arg) in args.iter().enumerate() {
        println!("第 {} 个:{}", index, arg);
    }
}

运行(注意 -- 后面才是我们要传的参数):

bash
cargo run -- 小螃蟹 童话.txt
运行结果
text
你传了 3 个参数。
第 0 个:target\debug\story_searcher.exe
第 1 个:小螃蟹
第 2 个:童话.txt

三个新知识:

env::args():标准库的"环境"部门(env,第 0 章见过 env 这个词吗?rustup 也是这么用的),args() 返回程序收到的所有参数——排着队的迭代器(第 11 章老朋友)。.collect() 把它收成 Vec<String>(第 9 章学的)。

第 0 个参数是程序自己args[0] 永远是"我叫什么名字"——Rust 从 0 数编号的老规矩(第 1 章数组)。

cargo run --:-- 是"接下来的东西别给 Cargo,给程序"。没有 --,cargo run 小螃蟹 会被 Cargo 当成自己的参数吃掉。

步骤二:读参数,读文件

现在写搜索器的第一版。把 main.rs 替换成:

rust
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("正在搜索:{}", query);
    println!("在文件:{}", file_path);

    let content = fs::read_to_string(file_path).expect("应该能读到文件");

    println!("文件里有 {} 个字。", content.chars().count());
}

先准备一本故事书。用第 3 章的 童话.txt(没有的话,在项目文件夹里新建一个,贴入第 3 章的故事),然后运行:

bash
cargo run -- 小螃蟹 童话.txt
运行结果
text
正在搜索:小螃蟹
在文件:童话.txt
文件里有 184 个字。

能读到文件了!args[1] 是关键词,args[2] 是文件路径,fs::read_to_string(第 3 章)把故事读进来。

不过这个版本有个大毛病:试试 cargo run -- 小螃蟹(少给一个参数)——args[2] 直接越界崩溃(第 7 章的 [i] 越界 panic!)。还有,整个逻辑都挤在 main 里,又长又乱。下一步,我们给它动手术——重构(第 6 章学的,让代码更有条理)。

步骤三:重构:Config 结构体

把"参数"打包成一个结构体,让 main 只负责喊"开工"。创建 src/lib.rs,写:

rust
use std::env;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, String> {
        if args.len() < 3 {
            return Err("参数不够!要告诉我:关键词 和 文件路径".to_string());
        }

        let query = args[1].clone();
        let file_path = args[2].clone();
        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

全是老朋友:

  • struct Config:把三个设置打包成一捆(第 5 章)
  • build 是关联函数(第 4 章揭晓过的 String::new 同款):从参数里造出 Config
  • Result<Config, String> + return Err(...) + ?(第 8 章):参数不够就"带着人话错误回家"
  • .clone()(第 7 章):参数是借来的 &String,Config 要自己拥有,所以复印

ignore_case 我们等会儿用——它是"忽略大小写"开关。

步骤四:TDD 写搜索函数

核心的搜索逻辑,用第 10 章的测试驱动开发写。先在 lib.rs 末尾加测试:

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

    #[test]
    fn search_finds_matching_lines() {
        let content = "小螃蟹的生日\n今天天气真好\n小螃蟹开心极了";
        let results = search("小螃蟹", content);

        assert_eq!(results.len(), 2);
    }

    #[test]
    fn search_reports_line_numbers() {
        let content = "第一行\n第二行小螃蟹\n第三行";
        let results = search("小螃蟹", content);

        assert_eq!(results[0], (2, "第二行小螃蟹"));
    }
}

cargo test——红灯!search 还不存在(第 10 章的老流程)。

现在写答案,让考卷变绿。在 lib.rs 里加:

rust
pub fn search<'a>(query: &str, content: &'a str) -> Vec<(usize, &'a str)> {
    let mut results = Vec::new();

    for (line_number, line) in content.lines().enumerate() {
        if line.contains(query) {
            results.push((line_number + 1, line));
        }
    }

    results
}

这里浓缩了四章的知识:

  • <'a>(第 9 章):返回的是借来的行(&'a str),要声明保质期——借自 content,活不过 content
  • content.lines().enumerate()(第 7 章):一行行看,顺便编号
  • line.contains(query)(第 3 章):这一行里有没有关键词
  • Vec<(usize, &'a str)>(第 7 章元组):每一条结果是"(行号, 这一行)"

cargo test——绿灯:

运行结果
text
running 2 tests
test tests::search_finds_matching_lines ... ok
test tests::search_reports_line_numbers ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

步骤五:run 函数组装

再写 run 函数——"搜索官":负责读文件、调用搜索、打印结果。加在 search 上面:

rust
pub fn run(config: &Config) -> Result<(), String> {
    let content = fs::read_to_string(&config.file_path)
        .map_err(|error| format!("读文件失败:{}", error))?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &content)
    } else {
        search(&config.query, &content)
    };

    if results.is_empty() {
        println!("没有找到 {}。", config.query);
        return Ok(());
    }

    println!("找到 {} 处:{}", results.len(), config.query);
    for (line_number, line) in results {
        println!("第 {} 行:{}", line_number, line);
    }

    Ok(())
}

注意 search_case_insensitive 还不存在——我们先写一个假的占位版本(直接调用 search),等会儿换成真的:

rust
pub fn search_case_insensitive<'a>(query: &str, content: &'a str) -> Vec<(usize, &'a str)> {
    search(query, content)
}

(这样程序现在能跑,步骤六再实现真功能——先跑通,再升级,也是重构的老规矩。)

最后,把 main.rs 换成"总指挥":

rust
use std::env;
use std::process;

use story_searcher::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = match Config::build(&args) {
        Ok(config) => config,
        Err(message) => {
            eprintln!("{}", message);
            eprintln!("用法:story_searcher 关键词 文件路径");
            process::exit(1);
        }
    };

    if let Err(message) = story_searcher::run(&config) {
        eprintln!("程序出错:{}", message);
        process::exit(1);
    }
}

eprintln! 是今天的新朋友(马上细讲),process::exit(1) 是"带着 1 号退出码离开"(1 表示"出错了",0 表示"一切正常"——这是全世界的规矩)。

运行:

bash
cargo run -- 小螃蟹 童话.txt
运行结果
text
找到 3 处:小螃蟹
第 1 行:今天是小螃蟹 Ferris 的生日。
第 4 行:大家一起唱生日歌,小螃蟹开心得钳子都合不拢了。
第 5 行:许愿的时候,小螃蟹悄悄说:"希望明年还能和大家一起过生日!"

故事搜索器,第一版完工!试试搜"生日"、"海"、"蛋糕"……

步骤六:大小写开关(环境变量)

故事里有 Ferris(大写 F)。搜 ferris 试试:

bash
cargo run -- ferris 童话.txt
运行结果
text
没有找到 ferris。

找不到——因为 Ferris 是大写,ferris 是小写。这是"严格模式"。现在我们加一个环境变量开关:IGNORE_CASE(忽略大小写)。

在终端里设置环境变量再运行(Windows 的 PowerShell):

powershell
$env:IGNORE_CASE = "1"
cargo run -- ferris 童话.txt
Remove-Item Env:IGNORE_CASE

(Linux 和 Mac 是 IGNORE_CASE=1 cargo run -- ferris 童话.txt。)

咦?还是没反应!因为我们还没实现真本事。把 search_case_insensitive 的占位版换成真的:

rust
pub fn search_case_insensitive<'a>(query: &str, content: &'a str) -> Vec<(usize, &'a str)> {
    let query_lower = query.to_lowercase();

    let mut results = Vec::new();

    for (line_number, line) in content.lines().enumerate() {
        if line.to_lowercase().contains(&query_lower) {
            results.push((line_number + 1, line));
        }
    }

    results
}

思路:两边都先"变成小写"再比——query.to_lowercase() 把关键词变小写,每一行也 to_lowercase() 变小写,这样 Ferrisferris 就一样了。to_lowercase 是"全部变成小写"(第 7 章 String 家族的新成员)。

再设置环境变量运行:

运行结果
text
找到 1 处:ferris
第 1 行:今天是小螃蟹 Ferris 的生日。

找到了!IGNORE_CASE 一开,大小写就不分家了。再给它补两个测试(第 10 章老规矩,正反面都测):

rust
    #[test]
    fn search_case_insensitive_finds_mixed_case() {
        let query = "ferris";
        let content = "Ferris 今天很开心\n小螃蟹 ferris 也好开心";

        assert_eq!(search_case_insensitive(query, content).len(), 2);
        assert_eq!(search(query, content).len(), 1);
    }

同一个内容:search_case_insensitive 找到 2 处(大小写都算),严格版 search 只找到 1 处(只有小写)。

环境变量是什么?

环境变量(environment variable)是程序外面的"设置面板":终端里设一个 IGNORE_CASE=1,跑的程序都能看到。我们的代码用 env::var("IGNORE_CASE") 去查面板——is_ok() 是"这个开关存在吗?"存在就是 Ok,开关打开(第 2 章信封,env::var 返回 Result)。真实的程序都这么干:数据库地址、调试开关、语言设置……全是环境变量。

步骤七:错误走另一个喇叭

你有没有好奇,eprintln!println! 有什么区别?

程序有两个"喇叭"——标准输出(stdout)和标准错误(stderr):

  • println! → 标准输出:喊"结果"
  • eprintln! → 标准错误:喊"出错啦"

两个喇叭分开,是有大用处的。在终端试一个"魔法":

powershell
cargo run -- 小螃蟹 童话.txt 1>结果.txt

1>结果.txt 是"把标准输出装进文件"——结果被装进了 结果.txt(用记事本打开看看)。现在试试出错的情况:

powershell
cargo run -- 小螃蟹 不存在的.txt 1>结果.txt 2>错误.txt
运行结果
text
(终端里一片安静,但……)
结果.txt 里:空的
错误.txt 里:程序出错:读文件失败:系统找不到指定的文件。(os error 2)

看到了吗?错误消息没有混进结果文件——它走了另一个喇叭,被 2>错误.txt 接到了错误文件里。以后你写真正的程序,用户就可以"结果归结果,错误归错误",不会一团乱麻。

13.3 知识深挖

13.3.1 命令行参数

程序不是只能"被运行时问问题"——它还能出生时就带着指令:

rust
let args: Vec<String> = env::args().collect();
  • env::args():标准库给程序递上"出生时收到的参数"(迭代器)
  • 第 0 个是程序自己的名字,第 1 个起才是用户传的
  • cargo run -- 参数 是"把参数递给程序"(cargo 自己不吃)

命令行参数是程序的出生证明:程序一启动就知道"我是谁、用户要我干嘛"。我们的 Config::build 就是"读出生证明,检查有没有漏写"。

13.3.2 重构:小步走,别一步登天

这个故事搜索器不是一步写出来的,它走了四步:

版本干了什么问题
v1全挤在 main,args[1] 直接用参数少一个就崩,代码一团
v2抽出 Config + build,错误处理main 干净了,但搜索逻辑还没写
v3TDD 写 search,再写 run能跑了,但大小写不能忽略
v4环境变量开关 + stderr完工!

每步都能编译、能运行,只是越变越好。这就是重构(refactoring,第 6 章提过):小步走,每一步都是绿的。别想着一步到位——先让程序跑起来,再一点一点变漂亮。这一路,我们用上了:结构体打包(第 5 章)、错误传播 ?(第 8 章)、TDD 测试(第 10 章)、借用生命周期 'a(第 9 章)、迭代器与元组(第 7/11 章)——基础卷的全部武功,在这一章打了一套完整的组合拳。

13.3.3 环境变量:程序外的开关

环境变量是"操作系统级别的设置":不写在代码里,写在终端里,程序启动时去查。

rust
let ignore_case = env::var("IGNORE_CASE").is_ok();
  • env::var("名字")Result:Ok(值) 说明开关存在,Err 说明没设
  • .is_ok() → "存在吗?" 返回 bool(第 2 章信封的 is_ok 检查)

这样"是否忽略大小写"这个设置,不用改代码、不用重新编译——用户自己在终端设一下就行。程序的核心逻辑和用户的偏好,从此分家。

13.3.4 stdout 和 stderr:两个喇叭

标准输出 stdout标准错误 stderr
代码println!eprintln!
喊什么结果、答案错误、警告
在终端都显示(混在一起)都显示
重定向1> 接住2> 接住

平常在终端里,两个喇叭的声音听起来一样;但一旦"接住"(重定向),就分道扬镳了。规矩:结果走 stdout,错误走 stderr。 这样别人用你的程序时,可以把结果存文件、把错误发邮件,各得其所。

13.3.5 全书知识地图

基础卷到这里,收官!回头看看这 13 章的地图:

知识在故事搜索器里的身影
1变量、函数、控制流每一行代码
2match、parseConfig::build 的参数检查
3所有权、借用、切片&content&str'a
4结构体、方法Configbuild 关联函数
5枚举、Option没有,但 Option 的亲戚 Result 处处在
6模块、pub、uselib.rs + main.rs 分工
7Vec、元组、StringargsVec<(usize, &str)>
8Result、?、错误处理runbuild?
9泛型、trait、生命周期<'a> 保质期
10测试、TDDsearch 的考卷
11闭包、迭代器args() 迭代器、collect
12工作空间、文档库 + 主程序的分工结构
13命令行参数、环境变量、stderr今天的新朋友

每一章都在这里重逢了。 你学到的不是十三个孤立的技巧,而是一套完整的思考方式:怎么组织代码、怎么防错、怎么测试、怎么分享。

基础卷,毕业!接下来是高级卷——那里有智能指针、并发、async 和你的第一个小网站。高级卷更刺激,但也可以随时回头复习基础卷。一切由你决定,这就是编程的自由。

13.4 动脑筋练习

练习一:挡住空关键词

现在搜一个空关键词会怎样?

bash
cargo run -- "" 童话.txt

会找到所有行——"每一行都包含空字符串"!这不算 bug,但很傻。在 Config::build 里加一个检查:关键词是空的,就报错"关键词不能是空的!"。

点开看答案

Config::build 里,query 取出来之后、file_path 之前加:

rust
        let query = args[1].clone();
        if query.is_empty() {
            return Err("关键词不能是空的!".to_string());
        }

        let file_path = args[2].clone();

is_empty() 是"是空的吗?"(第 7 章 Vec 用过,String 也有)。跑:

bash
cargo run -- "" 童话.txt
text
关键词不能是空的!
用法:story_searcher 关键词 文件路径

错误信息从 build 一路传到 main 的 eprintln!——第 8 章的错误传播,闭环了。

练习二:倒序搜索

搜索结果从第 1 行开始打印。改成从最后一行开始(找到的第 5 行先打印)。

提示:迭代器有个方法 rev()(第 11 章适配器的亲戚,意思就是"反过来")。resultsVec,遍历时 .iter().rev() 就行。注意 iter() 递出来的是引用,解引用一下(&line_numberline_number)。

点开看答案

run 里的打印循环改成:

rust
    for (line_number, line) in results.iter().rev() {
        println!("第 {} 行:{}", line_number, line);
    }

results.iter().rev():先转成迭代器,再倒过来——从队尾往队头递。跑:

text
找到 3 处:小螃蟹
第 5 行:许愿的时候,小螃蟹悄悄说:"希望明年还能和大家一起过生日!"
第 4 行:大家一起唱生日歌,小螃蟹开心得钳子都合不拢了。
第 1 行:今天是小螃蟹 Ferris 的生日。

倒序搜索完成!这种"从下往上找"的场景,现实中也有——比如日志文件,最新的错误总是写在最后面。

练习三:关键词一共出现几次

现在报告只统计"几行命中"。但一行里可能有好几个关键词(第 1 行"小螃蟹"出现一次,但有的行可能两次)。加一个统计:关键词在全文一共出现几次。

提示:第 3 章的老朋友 matches——line.matches(query).count() 数出一行里出现几次,把所有行加起来(第 3 章练习三的影子)。

点开看答案

lib.rs 加一个函数:

rust
pub fn count_occurrences(query: &str, content: &str) -> usize {
    let mut total = 0;

    for line in content.lines() {
        total += line.matches(query).count();
    }

    total
}

run 里打印:

rust
    println!("找到 {} 处:{}", results.len(), config.query);
    println!("关键词一共出现 {} 次。", count_occurrences(&config.query, &content));

运行:

text
找到 3 处:小螃蟹
关键词一共出现 3 次。

小螃蟹在故事里出现 3 次,正好 3 行——每行一次。想看到"一次多行"的差别?搜"的"试试:好多行都有好几个"的",行数和次数立刻不一样了。

13.5 完整代码清单

项目结构:

text
story_searcher/
├── Cargo.toml
├── 童话.txt        (故事文件,从第 3 章来)
└── src/
    ├── lib.rs      (Config、run、search + 测试)
    └── main.rs     (总指挥)

文件:Cargo.toml

toml
[package]
name = "story_searcher"
version = "0.1.0"
edition = "2024"

文件:童话.txt

text
今天是小螃蟹 Ferris 的生日。
它早早起了床,穿上最喜欢的红色新衣裳。
朋友们都来啦!小乌龟带来一篮海藻,小海马带来一串亮晶晶的泡泡,寄居蟹带来一颗漂亮的贝壳。
大家一起唱生日歌,小螃蟹开心得钳子都合不拢了。
许愿的时候,小螃蟹悄悄说:"希望明年还能和大家一起过生日!"
吃完蛋糕,大家在海边玩了一下午。海水拍打着沙滩,笑声传得好远好远。
这真是最棒的一天!

文件:src/main.rs

rust
use std::env;
use std::process;

use story_searcher::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = match Config::build(&args) {
        Ok(config) => config,
        Err(message) => {
            eprintln!("{}", message);
            eprintln!("用法:story_searcher 关键词 文件路径");
            process::exit(1);
        }
    };

    if let Err(message) = story_searcher::run(&config) {
        eprintln!("程序出错:{}", message);
        process::exit(1);
    }
}

文件:src/lib.rs

rust
use std::env;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, String> {
        if args.len() < 3 {
            return Err("参数不够!要告诉我:关键词 和 文件路径".to_string());
        }

        let query = args[1].clone();
        let file_path = args[2].clone();
        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: &Config) -> Result<(), String> {
    let content = fs::read_to_string(&config.file_path)
        .map_err(|error| format!("读文件失败:{}", error))?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &content)
    } else {
        search(&config.query, &content)
    };

    if results.is_empty() {
        println!("没有找到 {}。", config.query);
        return Ok(());
    }

    println!("找到 {} 处:{}", results.len(), config.query);
    for (line_number, line) in results {
        println!("第 {} 行:{}", line_number, line);
    }

    Ok(())
}

pub fn search<'a>(query: &str, content: &'a str) -> Vec<(usize, &'a str)> {
    let mut results = Vec::new();

    for (line_number, line) in content.lines().enumerate() {
        if line.contains(query) {
            results.push((line_number + 1, line));
        }
    }

    results
}

pub fn search_case_insensitive<'a>(query: &str, content: &'a str) -> Vec<(usize, &'a str)> {
    let query_lower = query.to_lowercase();

    let mut results = Vec::new();

    for (line_number, line) in content.lines().enumerate() {
        if line.to_lowercase().contains(&query_lower) {
            results.push((line_number + 1, line));
        }
    }

    results
}

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

    #[test]
    fn search_finds_matching_lines() {
        let content = "小螃蟹的生日\n今天天气真好\n小螃蟹开心极了";
        let results = search("小螃蟹", content);

        assert_eq!(results.len(), 2);
    }

    #[test]
    fn search_reports_line_numbers() {
        let content = "第一行\n第二行小螃蟹\n第三行";
        let results = search("小螃蟹", content);

        assert_eq!(results[0], (2, "第二行小螃蟹"));
    }

    #[test]
    fn search_case_insensitive_finds_mixed_case() {
        let query = "ferris";
        let content = "Ferris 今天很开心\n小螃蟹 ferris 也好开心";

        assert_eq!(search_case_insensitive(query, content).len(), 2);
        assert_eq!(search(query, content).len(), 1);
    }
}

怎么运行:

bash
cd story_searcher
cargo run -- 关键词 文件路径

运行检查单:

cargo run -- 小螃蟹 童话.txt 能找到 3 处,带行号
cargo run -- 恐龙 童话.txt 提示"没有找到"
cargo run -- 小螃蟹(少一个参数)报"参数不够"和用法,不崩溃
cargo run -- 小螃蟹 不存在的.txt 报"读文件失败",不崩溃
设置 IGNORE_CASE 后,cargo run -- ferris 童话.txt 能搜到大写 Ferris
cargo test 三个测试全绿
cargo build --release,然后直接运行 release 版的可执行文件