有感而发
最近,在和前端小伙伴聊天发现,在2024年,她们都有打算入局Rust学习的行列。毕竟前端现在太卷了,框架算是走到「穷途末路」了,无非就是在原有基础上修修补补。所有他们想在新的赛道弯道超车。但是,苦于各种原因,迟迟找不到入门之法。
确实如她们所言,Rust由于学习路径比较陡峭,加之和前端语言可以说是交集很少。然后,给大家一种学了马上就会忘记的感觉。并且,由于现在Rust在前端领域的应用少之又少。除了字节跳动的Rspack,还有Vivo的Vivo Blue OS(我们在国货之光?用Rust编写的Vivo Blue OS有过介绍),就很少听说其他国内互联网公司有相关的产品和应用。
相比国外,我们的道路还任重而道远。像国外很多耳熟能详的公司都早已布局Rust开发。最明显的就是PhotoShop,它已经将只能在桌面运行的PS搬入了浏览器上。(这个我们也在之前的师夷长技以制夷:跟着PS学前端技术中有过相关介绍)
不过,从最新的招聘网站中搜索Rust相关岗位,相比前几年有了很好的改观。并且很多岗位都和前端相关。这说明,Rust在国内已经有了自己的市场,也意味着在前端领域也有了一席之地。那么作为职业前端,不想在红海中继续卷,那势必就需要选择蓝海,方可在千军万马之中,杀出一条光明之路。
其实,像我在学习Rust也遇到很她们一样的困境。知识点看了,也理解了。但是隔断时间就会忘记。周而复始,就会对这门语言产生一种抗拒感。毕竟,编程也算是一种技术工种,唯手熟尔。
后面,我就转变思路,那就是动手做一些自己认为可以解决前端痛点的事。哪怕做这个事情,其他语言也可以胜任,但是为什么我们不做更进一步的尝试呢。现阶段,Rust在前端赋能的场景,大部分都是提高编译效率方向。像Rspack[1]/OXC[2]。
既然,大方向已经定了,然后就有了我们新的尝试。从那开始,就有了我们下面的尝试方向
- Rust 开发命令行工具(上)
- Rust 开发命令行工具(中)
- Rust 编译为 WebAssembly 在前端项目中使用
- Game = Rust + WebAssembly + 浏览器
- Rust 赋能前端-开发一款属于你的前端脚手架
就是基于上面的不断试错和尝试,到现在我们已经有了像f_cli[3]的npm包,并且已经部署到公司私库,并投入生产开发了。
同时,在最近的项目开发中,还利用Rust编写WebAssembly进行前端功能的处理。这块等有机会写一篇相关的文章。
前言
耽误了大家几分钟的时间,在上面絮叨了半天,其实就是想传达一个思想。Rust其实不可怕,可怕的是学了但是你没用到工作中。就是想着法都要让它贴切工作,应用于工作。
我们回到正题,其实Rust赋能前端这个方向我也在摸索,然后现阶段自我感觉能用到前端项目中的无非就两点
- 写一个脚手架,将一些繁琐操作工具化
- 写wasm模块,嵌入到前端逻辑中
大家不管是从哪个方面获取Rust知识点,想必大家尝试的第一个Rust应用就是Cli了。
那我们今天就来聊聊在Rust开发Cli时的神器 -clap[4]。
今天,我们只要是讲相关的概念,针对如何用Rust构建一个CLI,可以翻看我们之前的文章。
好了,天不早了,干点正事哇。
我们能所学到的知识点
- 项目初始化
- 编写子命令
- 添加命令标志
- 交互式cli
- 其他有用的库
1. 项目初始化
首先,让我们通过运行以下命令来初始化我们的项目:cargo init clap_demo。随后我们再配置一下项目的基础信息。(description等)
[package]
name = "clap_demo"
version = "0.1.0"
edition = "2021"
description = "front789带你学习clap"
我们可以通过运行以下命令将 clap 添加到我们的程序中:
cargo add clap -F derive
这样在Cargo.toml中的[dependencies]中就有了相关的信息。
[dependencies]
clap = { version = "4.5.1", features = ["derive"] }
其中-F表示,我们只需要clap中的derive特性。
图片
上述流程中,我们使用的clap的版本是最新版,有些和大家用过的语法有区别的话,需要大家甄别。
这里多说一嘴,如果对前端开发熟悉的同学是不是感觉到上述流程很熟悉。当我们创建一个前端项目时,是不是会遇到下面的步骤。
npm init
yarn add xx
项目实现
和前端开发类似,当我们把包下载到本地后,我们就需要在对应的入口文件中引入并执行。在前端开发中我们一般挑选的是项目根目录下的index.js。而对于Rust项目来讲,它的入口文件是src/main.rs。(作为二进制项目(Binary Projects)而言)
use clap::Parser;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
name: String
}
fn main() {
let cli = Cli::parse();
println!("Hello, {}!", cli.name);
}
我们来简单解释一下上面的代码。
在前端开发中我们一般使用import/require进行第三方库的引入,而在Rust中我们使用use来导入第三方库clap中的Parser trait。也就是说,通过use xx我们就可以使用clap中的特定功能。也就是把对应的功能引入到该作用域内。
定义了一个结构体,它使用 clap::Parser 的 derive 宏和command宏,并且只接受一个参数,即 name。
#[derive(Parser)]/#[command(version, about)]不是Rust内置的宏,它们是由clap库自定义的过程宏(procedural macros)。
Rust有两种类型的宏:
- 声明式宏(Declarative Macros):
这些是Rust内置的,使用macro_rules定义,例如vec!、println!等。
它们主要用于元编程(metaprogramming),在编译期执行代码生成。
- 过程宏(Procedural Macros):
- 这些是由外部crate定义的,在编译期间像函数一样被调用。
- 它们可以用来实现自定义的代码生成、lint检查、trait派生,解析、操作和生成 AST等操作。
#[derive(Parser)]它使用 derive 属性来自动为 Cli 结构体实现 Parser trait。这意味着 Cli 结构体将获得解析命令行参数的功能,而无需手动实现 Parser trait。
图片
#[command(version, about)]用于配置命令行应用程序的元数据。
- version: 设置应用程序的版本信息。
- about: 设置应用程序的简短描述。这里的信息就是我们在Cargo.toml中配置的description的信息。
最后,我们可以通过cargo run -- --help来查看对应的信息。
图片
总的来说,这段代码使用 clap 库定义了一个命令行应用程序,它接受一个名为 name 的字符串参数。当运行这个应用程序时,它会打印出 "Hello, {name}"。#[derive(Parser)] 和 #[command(...)] 这两个属性分别用于自动实现 Parser trait 和配置应用程序的元数据。
当我们加载程序并使用 Cli::parse() 时,它将从 std::env::args 中获取参数(这个概念我们之前在环境变量:熟悉的陌生人有过介绍)。
- 如果你尝试运行 cargo run front789,它应该会打印出 Hello, front789!
- 但如果尝试不添加任何额外值运行它,它将打印出帮助菜单。Clap 在默认特性中包含了一个帮助功能,当输入的命令无效时会自动显示帮助菜单。
当然,如果想让我们的程序更加健壮,我们可以给name设定一个默认值,这样在没有提供参数的情况下,也能合理运行。
#[derive(Parser,Debug)]
#[command(version, about)]
struct Cli {
#[arg(default_value = "front789")]
name: String
}
现在,尝试仅使用 cargo run 而不添加其他任何东西,它应该会打印出 Hello, front789!。
图片
当然,我们也可以像在f_cli中一样为参数添加更多的配置,来增强我们的Cli。
图片
如果想了解更多关于参数配置,可以翻看clap_command-attributes[5]
图片
2. 编写子命令
作为一个功能强大的CLI,我们有时候需要通过定义一些子命令来让我们的目的更加明确。
如果大家用过我们的f_cli,那就心领神会了。
下图是我们f_cli的根据用户提供的参数,默认构建前端项目的命令。
图片
在f_cli的实现中,我们就用到了子命令的操作。
图片
下面我们来简单实现一个拥有子命令的cli。在之前代码的基础上,我们只需要将刚才结构体中再新增一个参数 - command并且其类型为实现sumcommad trait的枚举
use clap::{ Parser, Subcommand };
#[derive(Parser,Debug)]
#[command(version, about)]
struct Cli {
#[arg(default_value = "front789")]
name: String,
#[command(subcommand)]
command: Commands
}
#[derive(Subcommand, Debug, Clone)]
enum Commands {
Create,
Replace,
Update,
Delete
}
fn main() {
let cli = Cli::parse();
println!("Hello, {:?}!", cli);
}
这样,我们就在上面的基础上拥有了一组子命令(CRUD)。这样我们就可以在cli中调用对应的子命令然后执行对应的操作了。
图片
3. 添加命令标志
我们可以继续丰富我们子命令。上面的我们不是通过一个枚举Commands够了一个组件命令(Create/Replace/Update/Delete)吗。
有时候,在某一个子命令下,还需要收集更多的用户选择。那么我们就可以将枚举中的值关联成一个「匿名结构体」。这样,我们就可以针对某个子命令做更深的操作了。
还是举我们之前的f_cli的例子,在我们通过f_cli create xxx构建项目时,我们可以通过-x来像CLI传递Create所用到的必要信息。
图片
use clap::{ Parser, Subcommand };
#[derive(Parser,Debug)]
#[command(version, about)]
struct Cli {
#[arg(default_value = "front789")]
name: String,
#[command(subcommand)]
command: Commands
}
#[derive(Subcommand, Debug, Clone)]
enum Commands {
Create{
#[arg(default_value = "front789")]
name: String,
#[arg(default_value = "山西")]
address: String,
},
Replace,
Update,
Delete
}
这样我们就对Create进一步处理,并且在create的时候,它会从命令行中寻找对应的name/address信息,并且收集到clap实例中。
随后,我们就可以在主函数中通过match来匹配枚举信息,然后执行相对应的操作。
Rust 中的匹配是穷举式的:必须穷举到最后的可能性来使代码有效
为了节约代码量,我们通过_占位符来处理其他的逻辑。
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Create{name,address} => {
println!("我是{},来自:{}", name,address);
},
_=>(),
}
}
当我们运行cargo run create时,由于我们提供了默认值,在控制台就会输出对应的信息。当然,我们也可以通过-- name xx -- address xx来进行操作。
有人会觉得输入较长的子命令不是很友好,我们可以通过short = 'n'来为子命令提供一个别名。同时我们还可以通过help="xxx"设置对应在--help时,提供给用户的帮助信息。
图片
对应的代码如下:
#[derive(Subcommand, Debug, Clone)]
enum Commands {
Create{
#[arg(
short = 'n',
lnotallow="name",
help = "用户信息",
default_value = "front789"
)]
name: String,
#[arg(
short = 'a',
lnotallow="address",
help = "地址信息",
requires = "name",
default_value = "山西"
)]
address: String,
},
Replace,
Update,
Delete
}
4. 交互式cli
在上一节中我们通过对CLI枚举进行改造,让其能够拥有了子命令的功能。其实到这步已经能够获取到cli中用户输入的值,并且能够进行下一步的操作了。
但是呢,你是一个精益求精的人。见多识广的你突然有一个想法,为什么不能像vite/create/next一样。在触发对应的构建和更新操作后,有一个「人机交互」的过程。然后,用户可以根据自己的喜好来选择我们cli的内置功能。这样是不是显的更加友好。
像我们的f_cli就是这种交互流程。用户通过人机交互的方式可以选择内置功能。
图片
f_cli 选择UI库
那我们就再次用一个简单的例子来介绍一下哇。
安装新的包
首先,我们需要安装几个用于交互的包。
cargo add anyhow
cargo add dialoguer
cargo add console
随后,就他们就会自动被注入到Cargo.toml中了。关于anyhow/dialoguer/console我们就不在这里过多介绍了。大家感兴趣可以去对应的官网查找.
- dialoguer[6]
- console[7]
- anyhow[8]
现在,我们需要在src/main.rs中引入相关的功能,同时我们在处理cli变量的时候,用的是枚举值,所以我们需要引入clap中针对这类的操作。
use clap::{
+ builder::EnumValueParser,
Parser,
Subcommand,
+ ValueEnum
};
+use dialoguer::{
+ console::Term,
+ theme::ColorfulTheme,
+ Select
+};
+use console::style;
新增枚举信息
前面说过,我们想通过人机交互的方式,在cli运行过程中让用户自己选择我们内置的功能点。所以,这些内置功能我们可以需要事先设定好。
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Name {
N1,
N2,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Address {
A1,
A2
}
处理结构体中参数的默认值
既然,已经有了对应的默认值,那么我们就需要限制我们cli中的参数必须是这些内置参数中值。
#[derive(Subcommand, Debug, Clone)]
enum Commands {
Create{
#[arg(
short = 'n',
lnotallow="name",
help = "用户信息",
+ value_parser = EnumValueParser::<Name>::new(),
ignore_case = true
)]
+ name: Option<Name>,
#[arg(
short = 'a',
lnotallow="address",
help = "地址信息",
requires = "name",
+ value_parser = EnumValueParser::<Address>::new(),
)]
+ address: Option<Address>,
}
}
上面的配置,见名知意,就是从对应的枚举中解析对应的值。
主函数
其实,这步的操作和之前是差不多的,我们还是利用match对cli.command进行匹配处理。不过我们这里又进一步的做了容错处理。
- 首先判断是否提供子命令
- 在提供子命令的情况下,再判断是否是Craete
因为,在进行操作中我们会有错误抛出,所以我们对main的返回值也做了处理。(anyhow::Result<()>)
fn main() ->anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
// - 如果有子命令,则根据子命令执行相应的逻辑;
Some(command) => {
match command {
Commands::Create {
name,
address,
} =>
operation_params(
name,
address
)?,
}
},
_ => panic!("Fatal: cli为提供参数,退出处理."),
}
Ok(())
}
operation_params
在main中我们通过match是可以获取到cli中参数的,而此时我们还需要根据参数做进一步的处理。我们把这个逻辑提取到了一个函数中了。
fn operation_params (
name: Option<Name>,
address: Option<Address>
) -> anyhow::Result<()> {
let n = match name {
Some(na) => na,
None => {
multiselect_msg("选择一个姓名:");
message("使用上/下箭头进行选择,使用空格或回车键确认。");
let items = vec!["张三", "王五"];
let selection = Select::with_theme(&ColorfulTheme::default())
.items(&items)
.default(0)
.interact_on_opt(&Term::stderr())?;
match selection {
Some(0) => Name::N1,
Some(1) => Name::N2,
_ => panic!("Fatal: 用户信息制定错误."),
}
}
};
let a = match address {
Some(na) => na,
None => {
multiselect_msg("选择一个地址:");
message("使用上/下箭头进行选择,使用空格或回车键确认。");
let items = vec!["太原", "晋中"];
let selection = Select::with_theme(&ColorfulTheme::default())
.items(&items)
.default(0)
.interact_on_opt(&Term::stderr())?;
match selection {
Some(0) => Address::A1,
Some(1) => Address::A2,
_ => panic!("Fatal: 地址信息制定错误."),
}
}
};
println!("name:{:?},地址:{:?}",n,a);
Ok(())
}
其实上面的逻辑也是比较简单明了的。 我们接收cli中的参数name/address。因为他们都是枚举类型,所以我们继续用match进行对应值的匹配。
虽然,我们对两个枚举值都做了处理,但是他们的逻辑都是相同的。
上面的逻辑就是当我们运行子命令时候
- 当提供对应的参数的话,那就原封不动的返回对应的值
- 当没有提供对应的参数的话,我们就调用dialoguer::Select进行我们预设值的选择。
图片
这样,不管我们上面那种情况,我们最后都可以拿到对应的值。这样我们方便我们后期进行其他操作。
5. 其他有用的库
上面我们通过几个例子,讲了很多clap的应用例子,其中我们还配合dialoguer进行人机交互的处理。如果我们想实现功能更加强大的cli我们还可以借助其他的工具。下面我们就来简单介绍几种。
Crossterm
crossterm[9] 是一款跨终端的crate。 它具有各种很酷的功能,如能够更改背景和文本颜色、操作终端本身和光标,以及捕获键盘和其他事件。
图片
comfy-table
comfy-table[10] 是一个设计用于在终端中创建漂亮表格的 crate。
以下是其官网的案例。用仅仅几句话就可以实现一个在终端展示的表格。
use comfy_table::Table;
fn main() {
let mut table = Table::new();
table
.set_header(vec!["Header1", "Header2", "Header3"])
.add_row(vec![
"This is a text",
"This is another text",
"This is the third text",
])
.add_row(vec![
"This is another text",
"Now\nadd some\nmulti line stuff",
"This is awesome",
]);
println!("{table}");
}
执行后的效果如下:
+----------------------+----------------------+------------------------+
| Header1 | Header2 | Header3 |
+======================================================================+
| This is a text | This is another text | This is the third text |
|----------------------+----------------------+------------------------|
| This is another text | Now | This is awesome |
| | add some | |
| | multi line stuff | |
+----------------------+----------------------+------------------------+
inquire
inquire[11] 是一个用于构建终端上交互式提示的 crate。它支持单选、多选、选择日历等功能:
下面的动图是其官网的案例。其中最吸引我的就是那个多选。哈哈。
图片