Project Anni
的第一笔 commit
是从去年(2020年)12月20号开始的,而 clap
则是在当天的第三个 commit
中引入的。当时引入的是 2.3.3
,也就是 clap 2
的最后一个版本,这个版本中还没有 derive
的身影。随着 clap 3.0.0-beta
版本的发布,带着尝鲜的心情,anni
的 clap
版本也悄然升级。但依然只是使用了传统的 builder
方式,并没有关注 clap_derive
。
但随着 anni-cli
功能的不断增多与复杂化,传统的方式开始越来越不能满足需要了。以字符串形式约定的 value_of
,层层传递的 matches
,手动进行的参数类型转换,这一切都令人心生退意。随着参数的增加,情况只会越来越糟,在现有的模式下,基本看不到变好的可能……
于是,从 builder
到 derive
的迁移之旅就开始了。
ToC
回顾:builder 模式
回头望去,不难发现 builder
模式其实是很符合人类直觉的一种方式。在默认的基础上进行改动,并最终拼凑成形。我们以现在 Readme
为例:
这种模式看起来很灵活,实际使用时也确实很灵活,但这种灵活性也是有代价的。
肉眼可见的就是代码长度。可以看到这种模式下,我们并没有太多的功能,但却用了足足 57
行代码来描述这些参数和功能。
其次是参数的类型。通过 value_of
和 values_of
获得的内容都是字符串(数组),这意味着当我们需要的参数类型并非字符串时,势必需要在某个地方对它们的类型进行转换。而这样的类型转换又会带来新的问题:是否要持久化转换后的参数?如果不持久化,那么在下次使用同一参数时又需要再重复一次反序列化的操作;但如果持久化,那我们又必须建立持久化对应的结构体,并手动创建它们;又或是在某一个较高层级对参数的类型进行调整,然后一级一级送下去?然后你就得到了一幅以 matches
为底,高层参数内容组成的结构体为边的等腰直角三角形
从 builder 到 derive
从 builder
到 derive
的思想非常简单:将命令替换成 struct
,子命令替换成 enum
。以 anni repo
子命令为例,替换后的代码如下:
首先我们需要 #[derive(Clap)]
,可以选择 Debug
以在解析结束之后输出解析结果,方便调试;然后我们需要通过 #[clap(xxx)]
表示每个参数的详细属性。比如 short
和 long
。这些参数可以简单地和 builder
中的同名函数对应,这里就不再展开了。
处理 Subcommand
——命令解析成功,我们得到了一个完整的 struct
,代表了用户的具体输入。下一步就是处理了。
我们知道,一个 struct
代表了一种命令,而一个 enum
则代表了一个子命令。 struct
与 enum
交替嵌套,形成了整个工具。如果想要实现简单的处理逻辑,就要从顶层的 command
开始,逐层匹配是否命中对应的 subcommand
。
举一个简单的例子吧,只有一层子命令的情况:
可以看到,最外层的命令只有一个,是 SimpleCommand
;在这个命令中有两个 Subcommand
,对应了两个命令各自的结构体。简单的层级关系如下图所示:
图中,我们将 SimpleCommand
称为根命令,SubcommandA
和 SubcommandB
称为子命令。特别地,在只有一层子命令嵌套的关系下,我们还可以把这两个子命令称为叶子命令。
那想要解析 SubcommandA
,我们需要做什么呢?
不难发现,想要到达 SubcommandA
,我们需要经过 SimpleCommand
和 SimpleSubcommand
两层。根据从上到下的解析顺序,只要我们给每一个层级增加一个 handle
函数,再对每一个 struct
和 enum
都实现这个方法,最终就可以通过调用根命令的 handle
来处理整个树的执行了。
对于 SimpleCommand
,由于其拥有子命令,因此结构体中必定存在一个 #[clap(subcommand)]
的字段表示所属的子命令;而对于 SimpleSubcommand
,其 enum
中每一个 variant
都实际对应了一个子命令。因此这两部分都能简单地通过过程宏实现。
RE:thonk
好,回答一个问题:这样就够了吗?
考虑这样一种情形。顶层的 Command
读取配置并构造了一个 ABCManager
,而子命令则需要根据 ABCManager
的 A
、B
、C
三种不同方法进行处理,同时需要读入各自不同的参数。
很自然地,我们将 A
、B
、C
归为三个(叶)子命令。但这样的话,问题就出现了。我们的叶子命令 A
需要依赖上一层命令解析得到的结果 ABCManager
,而这在现有的体系下是无法做到的。我们的 trait Handle
只支持了无参数的 handle
方法,怎样才能把参数传递下去呢?
最简单的想法就是直接把参数传递下去,这也是目前 Anni
使用的方案。这种方案简单粗暴,但也存在着很大的问题。最大的问题就是可扩展性太差了,很难处理复杂的依赖情况,但对目前的 Anni
而言也还算足够,于是就这样先用着了。
但实际上,对于实际执行到叶子命令,我们还可以提供更多的依赖信息。观察命令解析的树,不难发现,子命令的解析实际上就是在寻找一条到对应 Subcommand
的路径。而在这条路径中,同级的其他子命令是不会被执行的。也就是说,对于这个寻找的过程而言,其完全可以把所有权交给下一级的处理者——也就是 self
而非 &self
。对于实际执行的叶子命令,其完全可以获得一个 Owned
的,能够逐级反推最后得到根命令 struct
的参数。
结语
从 builder
到 derive
,Anni
的命令解析终于进入了可维护的轨道,但这也只是刚刚开始。正如 RE:thonk
部分所描述的,目前使用的方案还是最简单粗暴的。并且由于个人对宏的了解尚浅,现在的实现可以说是把头都绕没了,还有很大的改进空间。
Anni
的旅程也远不止 clap
那么简单。恰恰相反,clap
只是整个工具的第一步。从 Project Anni
的编写过程中所学到的,就静侯后续的其他记录吧。