命令行解析是几乎每个后端程序员都会用到的技术,但相比业务逻辑来说,这些细枝末节显得并不紧要,如果仅仅追求满足简单需求,命令行的处理会比较简单,任何一个后端程序员都可以信手拈来。Go 标准库提供了 flag 库以供大家使用。
然而,当我们稍微想让我们的命令行功能丰富一些,问题开始变得复杂起来,比如,我们要考虑如何处理可选项和必选项,对于可选项,如何设置其默认值,如何处理子命令,以及子命令的子命令,如何处理子命令的参数等等。
目前,Go 语言中使用最广泛功能最强大的命令行解析库是 cobra,但丰富的功能让 cobra 相比标准库的 flag 而言,变得异常复杂,为了减少使用的复杂度,cobra 甚至提供了代码生成的功能,可以自动生成命令行的骨架。然而,自动生成在节省了开发时间的同时,也让代码变得不够直观。
本文通过打破大家对命令行的固有印象,对命令行的概念解构后重新梳理,开发出一种功能强大但使用极为简单的命令行解析方法。这种方法支持任意多的子命令,支持可选和必选参数,对可选参数可提供默认值,支持配置文件,环境变量及命令行参数同时使用,配置文件,环境变量,命令行参数生效优先级依次提高,这种设计可以更符合 factor的原则。
Go 标准库 flag提供了非常简单的命令行解析方法,定义好命令行参数后,只需要调用 flag.Parse方法即可。
可以看到, flag 库使用非常简单,定要好命令行参数后,只需要调用 flag.Parse就可以实现参数的解析。在定义命令行参数时,可以指定默认值以及对这个参数的使用说明。
如果要处理子命令,flag 就无能为力了,这时候可以选择自己解析子命令,但更多的是直接使用 cobra 这个库。
这里用 cobra 官方给出的例子,演示一下这个库的使用方法
可以看到子命令的加入让代码变得稍微复杂,但逻辑仍然是清晰的,并且子命令和跟命令遵循相同的定义模板,子命令还可以定义自己子命令。
cobra 功能强大,逻辑清晰,因此得到大家广泛的认可,然而,这里却有两个问题让我无法满意,虽然问题不大,但时时萦怀于心,让人郁郁。
参数定义跟命令逻辑分离
从上面 times的定义可以看到,参数的定义跟命令逻辑的定义即这里的 Run是分离的,当我们有大量子命令的时候,我们更倾向把命令的定义放到不同的文件甚至目录,这就会出现命令的定义是分散的,而所有命令的参数定义却集中在一起的情况。
当然,这个问题用 cobra 也很好解决,只要把参数定义从 main函数移动到 init函数,并将 init 函数分散到跟子命令的定义一起即可。比如子命令 times 定义在 times.go文件中,同时在文件中定义 init函数,函数中定义了 times 的参数。然而,这样导致当参数比较多时需要定义大量的全局变量,这对于追求代码清晰简洁无副作用的人来说如芒刺背。
为什么不能像 flag库一样,把参数定义放到命令函数的里面呢?这样代码更紧凑,逻辑更直观。
相信大家稍加思考就会明白,times函数只有解析完命令行参数才能调用,这就要求命令行参数要事先定义好,如果把参数定义放到 times,这就意味着只有调用 times函数时才会解析相关参数,这就跟让手机根据外壳颜色变换主题一样无理取闹,可是,真的是这样吗?
子命令与父命令的顺序定义不够灵活
在开发有子命令甚至多级子命令的工具时,我们经常面临到底是选择 cmd resource action还是 cmd action resource的问题,也就是 resource 和 action 谁是子命令谁是参数的问题,比如 Kubernetes 的设计,就是 action 作为子命令kubectl get pods ... kubectl get deploy ...,而对于 action 因不同 resource 而差别很大时,则往往选择 resource 作为子命令, 比如阿里云的命令行工具 aliyun ecs ... aliyun ram ...
在实际开发过程中,一开始我们可能无法确定action 和 resource 哪个作为子命令会更好,在有多级子命令的情况下这个选择可能会更困难。
在不使用任何库的时候,开发者可能会选择在父命令中初始化相关资源,在子命令中执行代码逻辑,这样父命令和子命令相互调换变得非常困难。 这其实是一种错误的逻辑,调用子命令并不意味着一定要调用父命令,对于命令行工具来说,命令执行完进程就会退出,父命令初始化后的资源,并不会在子命令中重复使用。
cobra 的设计可以让大家规避这个错误逻辑,其子命令需要提供一个 Run 函数,在这个函数,应该实现初始化资源,执行业务逻辑,销毁资源的整个生命周期。然而,cobra 仍然需要定义父命令,即必须定义 echo 命令,才能定义 echo times 这个子命令。实际上,在很多场景下,父命令是没有执行逻辑的,特别是以 resource 作为父命令的场景,父命令的唯一作用就是打印这个命令的用法。
cobra 让子命令和父命令的定义非常简单,但父子调换仍然需要修改其间的链接关系,是否有方法让这个过程更简单一点呢?
关于命令行的术语有很多,比如参数argument,标识flag和选项option等,cobra 的设计是基于以下概念的定义
Commands represent actions, Args are things and Flags are modifiers for those actions.
另外,又基于这些定义延伸出更多的概念,比如 persistent flags代表适用于所有子命令的 flag,local flags 代表只用于当前子命令的 flag, required flags代表必选 flag 等等。
这些定义是 cobra 的核心设计