rust学习 - 构建mini 命令行工具
rust 的运行速度、安全性、单二进制文件输出和跨平台支持使其成为构建命令行程序的最佳选择。
实现一个命令行搜索工具grep,可以在指定文件中搜索指定的字符串。想实现这个功能呢,可以按照以下逻辑流程处理:
- 获取输入文件路径、需要搜索的字符串
- 读取文件;
- 在文件内容中查找字符串所在的行
- 打印包含字符串所在的行信息
创建项目ifun-grep
$> cargo new ifun-grep
项目在运行时,可以获取到传递的参数。比如cargo run -- hboot hello.txt,在文件hello.txt查找字符串hboot
读取参数
首先要先获取到传入的参数。通过标准库std::env::args获取
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
dbg!(args);
}
collect()方法可以将传入的参数转换为一个集合。对于变量args必须注明集合类型。
参数的第一个值是二进制文件的名称。可以用于程序调试或者打印出文件路径,取出另外两个参数,保存进对应的变量。方便后续传参数使用。
let search = &args[1];
let file_path = &args[2];
println!("will search {} in {}", search, file_path)
读取文件
首先创建测试文件hello.txt,并写入一段文字。
独立寒秋,湘江北去,橘子洲头。
看万山红遍,层林尽染;漫江碧透,百舸争流。
鹰击长空,鱼翔浅底,万类霜天竞自由。
怅寥廓,问苍茫大地,谁主沉浮?
携来百侣曾游,忆往昔峥嵘岁月稠。
恰同学少年,风华正茂;书生意气,挥斥方遒。
指点江山,激扬文字,粪土当年万户侯。
曾记否,到中流击水,浪遏飞舟
读取文件,并打印出文件中的内容。
let content = fs::read_to_string(file_path).expect("you should permission to read the file");
println!("read the content:\n{content}")
通过fs模块的read_to_string方法读取文件内容。expect则用于处理读取文件时发生的错误的提示信息,这在下面的错误处理会有说明。
模块拆分与错误处理
现在所有的处理业务都放在src/main.rs中。取参和读取文件是两个不同功能的逻辑处理,当功能越来越复杂的时候,就应该关注分离。这在我们设计时可提前考虑好
main.rs只被用来处理程序的执行。其他需要处理的逻辑则可以放在srr/lib.rs中。
定义一个解析取参的函数parse_args,现在仍然定义在src/main.rs中。
fn parse_args(args: &Vec<String>) -> (&str, &str) {
let search = &args[1];
let file_path = &args[2];
(search, file_path)
}
fn main(){
let args: Vec<String> = env::args().collect();
let (search, file_path) = parse_args(&args);
println!("will search {} in {}", search, file_path);
}
这样main函数不再处理哪个参数对应哪个变量。
我们可以将这一组相关的变量通过结构体定义相互关联起来。这样函数返回将不再使用元组,并且可以通过结构体实例可以访问到每一个属性。
struct Config {
search: String,
file_path: String,
}
fn parse_args(args: &Vec<String>) -> Config {
let search = args[1].clone();
let file_path = args[2].clone();
Config { search, file_path }
}
fn main(){
let args: Vec<String> = env::args().collect();
let config = parse_args(&args);
println!("will search {} in {}", search, file_path);
}
在结构体中,实例化赋值需要拥有这些变量值的所有权。而变量args是所有权的拥有者,通过clone()方法拷贝一份数据。
可以看到parse_args返回来一个结构体 Config 的实例,可以通过定义结构体的内部方法来创建实例。
impl Config {
fn new(args: &Vec<String>) -> Self {
let search = args[1].clone();
let file_path = args[2].clone();
Config { search, file_path }
}
}
fn main(){
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("will search {} in {}", search, file_path);
}
这样,就不需要parse_args函数了,通过结构体的内部方法实例化实例。
错误处理
如果我们执行cargo run时,不传递任何参数,则程序会报错。这样的提示对于用户并不友好。
首先可以通过判断参数需要的参数信息,说明错误信息。
impl Config {
fn new(args: &Vec<String>) -> Self {
if args.len() < 3 {
panic!("至少传入2个参数")
}
// ...
}
}
提示用户必须传入 2 个从参数,因为有一个默认的路径参数。所以判断不能少于3
除了直接提示错误信息并中断程序,也可以使用Result传递错误,让主函数做决定如何去处理。
impl Config {
fn build(args: &Vec<String>) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("至少传入2个参数");
}
let search = args[1].clone();
let file_path = args[2].clone();
Ok(Config { search, file_path })
}
}
现在提供了一个方法build来处理这个逻辑,之前的new不用了(这里是语义话定义,new常常表示不会产生错误),当有错误时,不是直接终止程序,而是返回一个Err值。
在src/main.rs中调用并处理结果。对于错误信息给用户输出有好的提示信息,并以非零错误process::exit(1)退出命令行。
use std::{env, fs, process};
fn main(){
let config = Config::build(&args).unwrap_or_else(|err| {
println!("error occurred parseing args:{err}");
process::exit(1);
});
// ...
}
unwrap_or_else可以进行自定义错误处理。这是一个闭包,它调用内部的匿名函数,并通过|err|传递的参数供内部使用。当返回Ok时,则返回内部的值。
提取读取文件的逻辑
参数的取参逻辑经由结构体内部方法处理。现在吧文件读取的逻辑提取出来,并采用传递错误的方式Result返回错误信息。
use std::error::Error;
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let content = fs::read_to_string(config.file_path)?;
println!("read the content:\n{content}");
Ok(())
}
使用了 trait 对象 Box<dyn Error>返回实现Errortrait 的类型,不用指定具体的错误类型。灵活性更高dyn表示动态的
接着可以在主函数中调用run()函数,并处理可能出现的错误。
fn main (){
// ...
if let Err(e) = run(config) {
println!("something error:{e}");
process::exit(1);
}
}
拆分代码到库
以上定义了结构体,处理取参函数;拆离了读取文件逻辑。但是这些都是在src/main.rs中,有复杂逻辑时,这会让文件行数很多,看起来很让人头疼。
将这一部分拆离的放到其他文件中去。新建src/lib.rs,将这些定义移动到该文件中。
use std::error::Error;
use std::fs;
pub struct Config {
pub search: String,
pub file_path: String,
}
impl Config {
pub fn build(args: &Vec<String>) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("至少传入2个参数");
}
let search = args[1].clone();
let file_path = args[2].clone();
Ok(Config { search, file_path })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let content = fs::read_to_string(config.file_path)?;
println!("read the content:\n{content}");
Ok(())
}
可以看到通过pub将这些结构体、函数都公有化。包括结构里的字段,这就是一个可以测试的公有 API 的 crate 库。
然后再src/main.rs需要导入
use ifun_grep::{run, Config};
通过use引入作用域。ifun-grep是项目名称,作为前缀。
增加测试
通过测试驱动开发的模式来逐渐增加逻辑。期望从给定的内容中查找出字符串,并打印出所在行。
在src/lib.rs增加测试示例
#[cfg(test)]
mod test {
use super::*;
#[test]
fn on_result() {
let search = "hboot";
let content = "\
nice. rust
I'm hboot.
hello world.
";
assert_eq!(vec!["I'm hboot."], find(search, content));
}
}
搜索字符串hboot,它在文本的第二行。所以期待搜索输出结果为I'm hboot.。
提供一个find函数,用于处理搜索逻辑,先不写搜索逻辑,返回一个空的结果值。
pub fn find<'a>(search: &str, content: &'a str) -> Vec<&'a str> {
vec![]
}
利用显示生命周期'a来表明参数content参数与返回值的生命周期相关联。它们存在的时间一样久
执行测试cargo test,理所应当的输出失败,结果返回了一个空的vec![],和预期不匹配。
增加搜索逻辑,按行执行过滤,包含指定的字符串,则存储在结果中。
pub fn find<'a>(search: &str, content: &'a str) -> Vec<&'a str> {
let mut result = vec![];
for line in content.lines() {
if line.contains(search) {
// 符合,包含了指定字符串
result.push(line);
}
}
result
}
通过迭代器遍历给定文本内容lines().字符串判断是否包含contains()方法。将结果值放进result中,并返回。
测试用例测试没有问题,完善一下run函数,搜索出符合的内容并打印出来
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let content = fs::read_to_string(config.file_path)?;
// println!("read the content:\n{content}");
for line in find(&config.search, &content) {
println!("{line}");
}
Ok(())
}
执行脚本cargo run -- 山 hello.txt,可以看到打印输出两行
增加环境变量
功能已经到到预期,可以搜索出想要包含字符串的文本段落。增加一个额外的功能大小写敏感处理环境变量,当然也可以通过再多传一个参数处理。
更改文本内容为应为
Let life be beautiful like summer flowers.
The world has kissed my soul with its pain.
Eyes are raining for her.
you also miss the stars.
先测试当前程序是否大小写敏感,文本中首个英文单词是大写的,按照小写搜索
$> cargo run -- let hello.txt
没有任何的打印输出,说明当前的搜索逻辑是大小写敏感的,通过传递变量来控制逻辑,修改测试用例,增加两个测试示例:大小写敏感和不敏感测试。
#[cfg(test)]
mod test {
use super::*;
#[test]
fn case_sensitive() {
let search = "rust";
let content = "\
nice. rust
I'm hboot.
hello world.
Rust
";
assert_eq!(vec!["nice. rust"], find(search, content));
}
#[test]
fn case_insensitive() {
let search = "rust";
let content = "\
nice. rust
I'm hboot.
hello world.
Rust
";
assert_eq!(vec!["nice. rust", "Rust"], find_insensitive(search, content));
}
}
原来的函数find大小写敏感,逻辑不变。增加一个大小写不敏感的函数find_insensitive,在处理搜索时,查询的字符和被搜索的文本行都转小写后,然后在执行查找。
pub fn find_insensitive<'a>(search: &str, content: &'a str) -> Vec<&'a str> {
let mut result = vec![];
// 搜索 字符串转小写
let search = search.to_lowercase();
for line in content.lines() {
// 文本行内容转小写
if line.to_lowercase().contains(&search) {
// 符合,包含了指定字符串
result.push(line);
}
}
result
}
多了一个操作to_lowercase()将文本内容转成小写。to_lowercase()会新创建一个 String,contains()方法参数需要的是一个引用。
再次执行测试cargo teset.用例全部通过。逻辑写好了,需要通过增加一个配置来处理是否大小写敏感。
修改结构体定义ingore_case表示来忽略大小写。
pub struct Config {
pub search: String,
pub file_path: String,
pub ignore_case: bool,
}
通过ingore_case字段判断是否调用哪个函数,修改run函数
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let content = fs::read_to_string(config.file_path)?;
// println!("read the content:\n{content}");
let mut result = vec![];
if config.ignore_case {
result = find_insensitive(&config.search, &content)
} else {
result = find(&config.search, &content)
}
for line in result {
println!("{line}");
}
Ok(())
}
处理接受变量IGNORE_CASE,通过库std::env处理环境变量。
impl Config {
pub fn build(args: &Vec<String>) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("至少传入2个参数");
}
let search = args[1].clone();
let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
search,
file_path,
ignore_case,
})
}
}
env::var()返回值为 Result 类型,通过它自己的方法is_ok()判断什么状态,如果设置值则返回 true;未设置则返回 false。
进行测试,不设置变量时,查询小写的let是查询不到的,因为首写的因为单词字母是大些的。
$> cargo run -- let hello.txt
通过设置环境变量,执行程序
$> IGNORE_CASE=1 cargo run -- let hello.txt
可以查到目标文本内容。
错误信息处理
我们所预先知道的错误信息都通过程序执行println!打印在控制台,这是一种标准输出.
对于出现错误信息,希望它即时打印输出,而对于程序执行的结果记录下来,保存到文件中,方便查看。
现在使用println!标准输出流重定向到文件中,它会将错误信息也保存到起来,且不会打印。
$> cargo run >output.txt
屏幕上没有任何输出,以为程序执行正常,其实文件中的内容是error occurred parseing args:至少传入2个参数。
这就造成了一个问题,不管成功、失败,只有打开文件才能看到。错误输出使用标准错误展示用于错误信息,将错误打印的println!改为eprintln!
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
// println!("error occurred parseing args:{err}");
eprintln!("error occurred parseing args:{err}");
process::exit(1);
});
println!("will search {} in {}", config.search, config.file_path);
if let Err(e) = run(config) {
// println!("something error:{e}");
eprintln!("something error:{e}");
process::exit(1);
}
}
重新执行cargo run >output.txt,错误打印到控制台,而文件output.txt没有输出。
再执行,可以查到数据的命令cargo run -- Let hello.txt > output.txt,查看output.txt,可以看到预期的查找到的内容在文件中。
发布 crate 到Crate.io
crates.io 库,可以这里找找想要的功能库,也可以将自己的 crate 发布到这里。
Rust 的发布配置都有一套默认的、可定制的配置。
cargo build采用的是 dev 配置构建程序cargo build --release是 release 配置,有更好的发布构建的配置
可以在文件Cargo.toml中通过[profile.*]修改设置默认值。
[profile.dev]
opt-level = 0
[profile.release]
opt-level = 3
dev 构建和发布构建定义不同的优化等级。opt-level定义何种程度优化,0-3可配置值。dev 默认为 0,release 默认为 3.
如果想 dev 模式下需要一些优化,则可以更改为
[profile.dev]
opt-level = 1
增加文档注释
一个好的模块包,是有很好的文档说明,以方便其他人轻易上手。通过文档注释///已支持 markdown 格式化文本。
给每一个函数增加注释说明,这里只展示部分。
/// the struct `Config` defines command line params.
///
/// # Example
///
/// ```
/// let search = String::from("let");
/// let config = ifun_grep::Config {
/// search,
/// file_path:String::from("hello.txt"),
/// ignore_case:false,
/// };
///
/// ```
pub struct Config {
pub search: String,
pub file_path: String,
pub ignore_case: bool,
}
/// the fun is used to execute search
///
/// # example
/// ```
/// let search = String::from("let");
/// let config = ifun_grep::Config {
/// search,
/// file_path:String::from("hello.txt"),
/// ignore_case:false,
/// };
///
/// let result = ifun_grep::run(config);
///
/// assert!(result.is_ok());
/// ```
///
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let content = fs::read_to_string(config.file_path)?;
// println!("read the content:\n{content}");
let result;
if config.ignore_case {
result = find_insensitive(&config.search, &content);
} else {
result = find(&config.search, &content);
}
for line in result {
println!("{line}");
}
Ok(())
}
在使用 vscode 时,注释文档上方会有一个执行操作 run doctest。可以单独执行当前写的测试示例是否可以通过执行。
也可以通过cargo test来测试所有的测试示例。不仅会执行mod test的测试示例,也会执行doc test的注释测试示例。
通过命令cargo doc --open来生成在线文档。
$> cargo doc --open
可以通过//!对当前文件进行注释说明,必须是在第一行。
//! ifun_grep is a string search library
//!
//! Supports case sensitive search.
//!
注册 crate.io 账户并发布
目前只能使用 github 账号进行授权登录。在个人账号信息中,API Tokens生成 token 授权操作。
$> cargo login 你的token
如果登录不成功,看下提示错误,我是加了参数--registry crates-io才成功的。
$> cargo login 你的token --registry crates-io
登录之后就可以发布了,通过Cargo.toml增加一些仓库元信息,比如仓库名、作者、开源协议、描述等等。
$> cargo publish
发布之前需要验证你登录的账号邮箱,不然发布不了。个人的元信息有几项是必填的,包括name\version\description\license
发布时,如果发布不成功,看错误提示,可能还需要加--registry crates-io
撤销某个版本
如果你发布的版本有很大的问题,可以撤销改版本。不能删除仓库,已发布的代码时永久存在的,只能通过撤销来阻止其他项目引用它。
$> cargo yank --vers 0.1.0
使得当前版本不可用。也可以恢复当前版本的使用
$> cargo yank --vers 0.1.0 --undo
浙公网安备 33010602011771号