外观
第 13 章 收尾大项目:故事搜索器
小螃蟹说
恭喜你走到这一章!这是基础卷的最后一章,我们做一个真正像样的工具——故事搜索器。
它干的事很实在:你给它一个关键词和一本故事文件,它立刻告诉你:哪些行提到了这个词,在第几行。图书馆管理员翻一整天书才能找到的东西,它一眨眼就找到了。
更特别的是:这个项目把基础卷的所有知识串在了一起——结构体、错误处理、测试、模块、闭包迭代器……每一样都在里面重逢。做完它,你就是基础卷的毕业生了!
这也是我们第一次做"命令行工具":不用菜单,不用对话,直接在终端里像真正的程序员一样敲命令:
bash
cargo run -- 小螃蟹 童话.txt(-- 后面是传给程序的参数:关键词"小螃蟹",文件"童话.txt"。)
13.1 预览:我们要做什么
- 接收命令行参数:关键词 + 文件路径
- 打开故事文件,一行行找关键词
- 打印命中行和行号
- 彩蛋:设置环境变量,搜索可以忽略大小写
运行结果
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同款):从参数里造出ConfigResult<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,活不过 contentcontent.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() 变小写,这样 Ferris 和 ferris 就一样了。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>结果.txt1>结果.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 干净了,但搜索逻辑还没写 |
| v3 | TDD 写 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 | 变量、函数、控制流 | 每一行代码 |
| 2 | match、parse | Config::build 的参数检查 |
| 3 | 所有权、借用、切片 | &content、&str、'a |
| 4 | 结构体、方法 | Config、build 关联函数 |
| 5 | 枚举、Option | 没有,但 Option 的亲戚 Result 处处在 |
| 6 | 模块、pub、use | lib.rs + main.rs 分工 |
| 7 | Vec、元组、String | args、Vec<(usize, &str)> |
| 8 | Result、?、错误处理 | run 和 build 的 ? |
| 9 | 泛型、trait、生命周期 | <'a> 保质期 |
| 10 | 测试、TDD | search 的考卷 |
| 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 -- "" 童话.txttext
关键词不能是空的!
用法:story_searcher 关键词 文件路径错误信息从 build 一路传到 main 的 eprintln!——第 8 章的错误传播,闭环了。
练习二:倒序搜索
搜索结果从第 1 行开始打印。改成从最后一行开始(找到的第 5 行先打印)。
提示:迭代器有个方法 rev()(第 11 章适配器的亲戚,意思就是"反过来")。results 是 Vec,遍历时 .iter().rev() 就行。注意 iter() 递出来的是引用,解引用一下(&line_number → line_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 能搜到大写 Ferriscargo test 三个测试全绿cargo build --release,然后直接运行 release 版的可执行文件