Skip to content

Project Anni 之旅 01 - 从 clap-builder 到 derive

Published: at 21:59

Project Anni 的第一笔 commit 是从去年(2020年)12月20号开始的,而 clap 则是在当天的第三个 commit 中引入的。当时引入的是 2.3.3,也就是 clap 2 的最后一个版本,这个版本中还没有 derive 的身影。随着 clap 3.0.0-beta 版本的发布,带着尝鲜的心情,anniclap 版本也悄然升级。但依然只是使用了传统的 builder 方式,并没有关注 clap_derive

256 commit 留念
256 commit 留念

但随着 anni-cli 功能的不断增多与复杂化,传统的方式开始越来越不能满足需要了。以字符串形式约定的 value_of,层层传递的 matches,手动进行的参数类型转换,这一切都令人心生退意。随着参数的增加,情况只会越来越糟,在现有的模式下,基本看不到变好的可能……

于是,从 builderderive 的迁移之旅就开始了。

ToC

回顾:builder 模式

回头望去,不难发现 builder 模式其实是很符合人类直觉的一种方式。在默认的基础上进行改动,并最终拼凑成形。我们以现在 Readme 为例:

1
let matches = App::new("My Super Program")
2
.version("1.0")
3
.author("Kevin K. <[email protected]>")
4
.about("Does awesome things")
5
.arg(Arg::new("config")
6
.short('c')
7
.long("config")
8
.value_name("FILE")
9
.about("Sets a custom config file")
10
.takes_value(true))
11
.arg(Arg::new("INPUT")
12
.about("Sets the input file to use")
13
.required(true)
14
.index(1))
15
.arg(Arg::new("v")
16
.short('v')
17
.multiple_occurrences(true)
18
.takes_value(true)
19
.about("Sets the level of verbosity"))
20
.subcommand(App::new("test")
21
.about("controls testing features")
22
.version("1.3")
23
.author("Someone E. <[email protected]>")
24
.arg(Arg::new("debug")
25
.short('d')
26
.about("print debug information verbosely")))
27
.get_matches();
28
29
// You can check the value provided by positional arguments, or option arguments
30
if let Some(i) = matches.value_of("INPUT") {
31
println!("Value for input: {}", i);
32
}
33
34
if let Some(c) = matches.value_of("config") {
35
println!("Value for config: {}", c);
36
}
37
38
// You can see how many times a particular flag or argument occurred
39
// Note, only flags can have multiple occurrences
40
match matches.occurrences_of("v") {
41
0 => println!("Verbose mode is off"),
42
1 => println!("Verbose mode is kind of on"),
43
2 => println!("Verbose mode is on"),
44
_ => println!("Don't be crazy"),
45
}
46
47
// You can check for the existence of subcommands, and if found use their
48
// matches just as you would the top level app
49
if let Some(ref matches) = matches.subcommand_matches("test") {
50
// "$ myapp test" was run
51
if matches.is_present("debug") {
52
// "$ myapp test -d" was run
53
println!("Printing debug info...");
54
} else {
55
println!("Printing normally...");
56
}
57
}

这种模式看起来很灵活,实际使用时也确实很灵活,但这种灵活性也是有代价的。

九 评 b u i l d e r

肉眼可见的就是代码长度。可以看到这种模式下,我们并没有太多的功能,但却用了足足 57 行代码来描述这些参数和功能。

其次是参数的类型。通过 value_ofvalues_of 获得的内容都是字符串(数组),这意味着当我们需要的参数类型并非字符串时,势必需要在某个地方对它们的类型进行转换。而这样的类型转换又会带来新的问题:是否要持久化转换后的参数?如果不持久化,那么在下次使用同一参数时又需要再重复一次反序列化的操作;但如果持久化,那我们又必须建立持久化对应的结构体,并手动创建它们;又或是在某一个较高层级对参数的类型进行调整,然后一级一级送下去?然后你就得到了一幅以 matches 为底,高层参数内容组成的结构体为边的等腰直角三角形世 界 名 画……咳咳,扯远了。

从 builder 到 derive

builderderive 的思想非常简单:将命令替换成 struct,子命令替换成 enum。以 anni repo 子命令为例,替换后的代码如下:

1
// 顶层命令
2
// 包含了 root 属性和不同的 subcommand
3
#[derive(Clap, Debug)]
4
pub struct RepoSubcommand {
5
#[clap(long, env = "ANNI_REPO")]
6
root: PathBuf,
7
8
#[clap(subcommand)]
9
action: RepoAction,
10
}
11
12
// 第二层的 Subcommand
13
// 包含各个子命令的声明方式
14
#[derive(Clap, Debug)]
15
pub enum RepoAction {
16
Add(RepoAddAction),
17
Edit(RepoEditAction),
18
Apply(RepoApplyAction),
19
Validate(RepoValidateAction),
20
Print(RepoPrintAction),
21
}
22
23
// 第三层的命令
24
// 实际执行的操作
25
#[derive(Clap, Debug)]
26
pub struct RepoAddAction {
27
#[clap(short = 'e', long)]
28
open_editor: bool,
29
30
#[clap(required = true)]
31
directories: Vec<PathBuf>,
32
}

首先我们需要 #[derive(Clap)],可以选择 Debug 以在解析结束之后输出解析结果,方便调试;然后我们需要通过 #[clap(xxx)] 表示每个参数的详细属性。比如 shortlong。这些参数可以简单地和 builder 中的同名函数对应,这里就不再展开了。

处理 Subcommand

——命令解析成功,我们得到了一个完整的 struct,代表了用户的具体输入。下一步就是处理了。

我们知道,一个 struct 代表了一种命令,而一个 enum 则代表了一个子命令。 structenum 交替嵌套,形成了整个工具。如果想要实现简单的处理逻辑,就要从顶层的 command 开始,逐层匹配是否命中对应的 subcommand

举一个简单的例子吧,只有一层子命令的情况:

1
#[derive(Clap)]
2
struct SimpleCommand {
3
#[clap(subcommand)]
4
subcommand: SimpleSubcommand;
5
}
6
7
#[clap(Clap)]
8
enum SimpleSubcommand {
9
A(SubcommandA),
10
B(SubcommandB),
11
}
12
13
#[clap(Clap)]
14
struct SubcommandA {
15
#[clap(short, long)]
16
arg1: String,
17
}
18
19
#[clap(Clap)]
20
struct SubcommandB {
21
#[clap(short, long)]
22
arg2: String,
23
}

可以看到,最外层的命令只有一个,是 SimpleCommand;在这个命令中有两个 Subcommand,对应了两个命令各自的结构体。简单的层级关系如下图所示:

图中,我们将 SimpleCommand 称为根命令SubcommandASubcommandB 称为子命令。特别地,在只有一层子命令嵌套的关系下,我们还可以把这两个子命令称为叶子命令

那想要解析 SubcommandA,我们需要做什么呢?

不难发现,想要到达 SubcommandA,我们需要经过 SimpleCommandSimpleSubcommand 两层。根据从上到下的解析顺序,只要我们给每一个层级增加一个 handle 函数,再对每一个 structenum 都实现这个方法,最终就可以通过调用根命令的 handle 来处理整个树的执行了。

对于 SimpleCommand,由于其拥有子命令,因此结构体中必定存在一个 #[clap(subcommand)] 的字段表示所属的子命令;而对于 SimpleSubcommand,其 enum 中每一个 variant 都实际对应了一个子命令。因此这两部分都能简单地通过过程宏实现。

1
pub trait Handle {
2
fn handle(&self) -> anyhow::Result<()>;
3
}
4
5
#[proc_macro_derive(ClapHandler)]
6
pub fn derive_clap_handler(item: TokenStream) -> TokenStream {
7
let input = parse_macro_input!(item as DeriveInput);
8
let name = &input.ident;
9
10
let expanded = match input.data {
11
Data::Struct(DataStruct { ref fields, .. }) => {
12
match fields {
13
Fields::Named(ref fields_name) => {
14
// find struct field which has #[clap(subcommand)]
15
let subcommand_field: Option<syn::Ident> = fields_name
16
.named
17
.iter()
18
.find_map(|field| {
19
for attr in field.attrs.iter() {
20
if attr.path.is_ident("clap") {
21
let ident: syn::Ident = attr.parse_args().ok()?;
22
if ident == "subcommand" {
23
return Some(field.ident.clone().unwrap());
24
}
25
}
26
}
27
None
28
})
29
.expect("Failed to find #[clap(subcommand)] in struct!");
30
31
quote! {
32
impl Handle for #name {
33
fn handle(&self) -> anyhow::Result<()> {
34
self.#subcommand_field.handle()
35
}
36
}
37
}
38
}
39
_ => panic!("ClapHandler is not implemented for unnamed or None struct"),
40
}
41
}
42
Data::Enum(DataEnum { variants, .. }) => {
43
// list enum variants
44
let subcommands: Vec<_> = variants
45
.iter()
46
.map(|v| {
47
let ident = &v.ident;
48
quote! { #name::#ident }
49
})
50
.collect();
51
52
quote! {
53
impl Handler for #name {
54
#[inline(always)]
55
fn handle(&self) -> anyhow::Result<()> {
56
match self {
57
#(#subcommands(s) => s.handle(),)*
58
}
59
}
60
}
61
}
62
}
63
_ => panic!("ClapHandler is not implemented for union type"),
64
};
65
expanded.into()
66
}

RE:thonk

好,回答一个问题:这样就够了吗?

考虑这样一种情形。顶层的 Command 读取配置并构造了一个 ABCManager,而子命令则需要根据 ABCManagerABC 三种不同方法进行处理,同时需要读入各自不同的参数。

很自然地,我们将 ABC 归为三个(叶)子命令。但这样的话,问题就出现了。我们的叶子命令 A 需要依赖上一层命令解析得到的结果 ABCManager,而这在现有的体系下是无法做到的。我们的 trait Handle 只支持了无参数的 handle 方法,怎样才能把参数传递下去呢?

最简单的想法就是直接把参数传递下去,这也是目前 Anni 使用的方案。这种方案简单粗暴,但也存在着很大的问题。最大的问题就是可扩展性太差了,很难处理复杂的依赖情况,但对目前的 Anni 而言也还算足够,于是就这样先用着了。

但实际上,对于实际执行到叶子命令,我们还可以提供更多的依赖信息。观察命令解析的树,不难发现,子命令的解析实际上就是在寻找一条到对应 Subcommand 的路径。而在这条路径中,同级的其他子命令是不会被执行的。也就是说,对于这个寻找的过程而言,其完全可以把所有权交给下一级的处理者——也就是 self 而非 &self。对于实际执行的叶子命令,其完全可以获得一个 Owned 的,能够逐级反推最后得到根命令 struct 的参数。

结语

builderderiveAnni 的命令解析终于进入了可维护的轨道,但这也只是刚刚开始。正如 RE:thonk 部分所描述的,目前使用的方案还是最简单粗暴的。并且由于个人对宏的了解尚浅,现在的实现可以说是把头都绕没了,还有很大的改进空间。

Anni 的旅程也远不止 clap 那么简单。恰恰相反,clap 只是整个工具的第一步。从 Project Anni 的编写过程中所学到的,就静侯后续的其他记录吧。