简介: 本文通过突破大家对命令行的固有印象,对命令行的概念解构后从新梳理,开发出一种功能强大但应用极为简略的命令行解析办法。这种办法反对任意多的子命令,反对可选和必选参数,对可选参数可提供默认值,反对配置文件,环境变量及命令行参数同时应用,配置文件,环境变量,命令行参数失效优先级顺次进步,这种设计能够更合乎 12 factor的准则。
作者 | 克识
起源 | 阿里技术公众号
一 概述
命令行解析是简直每个后端程序员都会用到的技术,但相比业务逻辑来说,这些细枝末节显得并不紧要,如果仅仅谋求满足简略需要,命令行的解决会比较简单,任何一个后端程序员都能够信手拈来。Go 规范库提供了 flag 库以供大家应用。
然而,当咱们略微想让咱们的命令行功能丰富一些,问题开始变得复杂起来,比方,咱们要思考如何解决可选项和必选项,对于可选项,如何设置其默认值,如何解决子命令,以及子命令的子命令,如何解决子命令的参数等等。
目前,Go 语言中应用最宽泛性能最弱小的命令行解析库是 cobra,但丰盛的性能让 cobra 相比规范库的 flag 而言,变得异样简单,为了缩小应用的复杂度,cobra 甚至提供了代码生成的性能,能够主动生成命令行的骨架。然而,主动生成在节俭了开发工夫的同时,也让代码变得不够直观。
本文通过突破大家对命令行的固有印象,对命令行的概念解构后从新梳理,开发出一种功能强大但应用极为简略的命令行解析办法。这种办法反对任意多的子命令,反对可选和必选参数,对可选参数可提供默认值,反对配置文件,环境变量及命令行参数同时应用,配置文件,环境变量,命令行参数失效优先级顺次进步,这种设计能够更合乎 12 factor的准则。
二 现有的命令行解析办法
Go 规范库 flag提供了非常简单的命令行解析办法,定义好命令行参数后,只须要调用 flag.Parse办法即可。
// demo.govar limit intflag.IntVar(&limit, "limit", 10, "the max number of results")flag.Parse()fmt.Println("the limit is", limit)// 执行后果$ go run demo.go the limit is 10$ go run demo.go -limit 100the limit is 100
能够看到, flag 库应用非常简单,定要好命令行参数后,只须要调用 flag.Parse就能够实现参数的解析。在定义命令行参数时,能够指定默认值以及对这个参数的应用阐明。
如果要解决子命令,flag 就无能为力了,这时候能够抉择本人解析子命令,但更多的是间接应用 cobra 这个库。
这里用 cobra 官网给出的例子,演示一下这个库的应用办法
package mainimport ( "fmt" "strings" "github.com/spf13/cobra")func main() { var echoTimes int var cmdPrint = &cobra.Command{ Use: "print [string to print]", Short: "Print anything to the screen", Long: `print is for printing anything back to the screen.For many years people have printed back to the screen.`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { fmt.Println("Print: " + strings.Join(args, " ")) }, } var cmdEcho = &cobra.Command{ Use: "echo [string to echo]", Short: "Echo anything to the screen", Long: `echo is for echoing anything back.Echo works a lot like print, except it has a child command.`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { fmt.Println("Echo: " + strings.Join(args, " ")) }, } var cmdTimes = &cobra.Command{ Use: "times [string to echo]", Short: "Echo anything to the screen more times", Long: `echo things multiple times back to the user by providinga count and a string.`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { for i := 0; i < echoTimes; i++ { fmt.Println("Echo: " + strings.Join(args, " ")) } }, } cmdTimes.Flags().IntVarP(&echoTimes, "times", "t", 1, "times to echo the input") var rootCmd = &cobra.Command{Use: "app"} rootCmd.AddCommand(cmdPrint, cmdEcho) cmdEcho.AddCommand(cmdTimes) rootCmd.Execute()}
能够看到子命令的退出让代码变得略微简单,但逻辑依然是清晰的,并且子命令和跟命令遵循雷同的定义模板,子命令还能够定义本人子命令。
$ go run cobra.go echo times hello --times 3Echo: helloEcho: helloEcho: hello
cobra 功能强大,逻辑清晰,因而失去大家宽泛的认可,然而,这里却有两个问题让我无奈称心,尽管问题不大,但时时萦怀于心,让人郁郁。
1 参数定义跟命令逻辑拆散
从下面 --times的定义能够看到,参数的定义跟命令逻辑的定义(即这里的 Run)是拆散的,当咱们有大量子命令的时候,咱们更偏向把命令的定义放到不同的文件甚至目录,这就会呈现命令的定义是扩散的,而所有命令的参数定义却集中在一起的状况。
当然,这个问题用 cobra 也很好解决,只有把参数定义从 main函数挪动到 init函数,并将 init 函数扩散到跟子命令的定义一起即可。比方子命令 times 定义在 times.go文件中,同时在文件中定义 init函数,函数中定义了 times 的参数。然而,这样导致当参数比拟多时须要定义大量的全局变量,这对于谋求代码清晰简洁无副作用的人来说如芒刺背。
为什么不能像 flag库一样,把参数定义放到命令函数的外面呢?这样代码更紧凑,逻辑更直观。
// 为什么我不能写成上面这样呢?func times(){ cobra.IntVarP(&echoTimes, "times", "t", 1, "times to echo the input") cobra.Parse()}
置信大家稍加思考就会明确,times函数只有解析完命令行参数能力调用,这就要求命令行参数要当时定义好,如果把参数定义放到 times,这就意味着只有调用 times函数时才会解析相干参数,这就跟让手机依据外壳色彩变换主题一样无理取闹,可是,真的是这样吗?
2 子命令与父命令的程序定义不够灵便
在开发有子命令甚至多级子命令的工具时,咱们常常面临到底是抉择 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 的外围设计起源,要想解决我下面提到的两个问题,咱们须要从新扫视这些定义。为此,咱们从头开始一步步剖析何为一个命令行。
1 命令行只是一个可被 shell 解析执行的字符串
$ cmd arg1 arg2 arg3
命令行及其参数,实质上就是一个字符串而已。字符串的含意是由 shell来解释的,对于 shell来说,一个命令行由命令和参数组成,命令和参数以及参数和参数之间是由空白符宰割。
还有别的吗? 没了,没有什么父命令、子命令,也没有什么长久参数、本地参数,一个参数是双横线(--) 、单横线(-)还是其余字符结尾,都没有关系,这只是字符串而已,这些字符串由 shell 传递给你要执行的程序,并放到 os.Args (Go 语言)这个数组里。
2 参数、标识与选项
从下面的形容可知,参数(argument)是对命令行前面那一串空白符分隔的字符串的称说,而一个参数,在命令行中又能够赋予不同的含意。
以横线或双横线结尾的参数看起来有些非凡,联合代码来看,这种类型的参数有其独特的作用,就是将某个值跟代码中的某个变量关联起来,这种类型的参数,咱们叫做标识(flag)。回忆一下,os.Args 这个数组里的参数有很多,这些参数跟命令中的变量是没有间接关系的,而 flag 提供的实质上是一个键值对,咱们的代码中,通过把键跟某个变量关联起来,从而实现了对这个变量赋值的性能。
flag.IntVar(&limit, "limit", 10, "the max number of results")// 变量绑定,当在命令行中指定 -limit 100 的时候,这意味着咱们是把 100 这个值,赋予变量 limit
标识(flag)赋予了咱们通过命令行间接给代码中某个变量赋值的能力。那么一个新的问题是,如果我没有给这个变量赋值呢,程序还能持续运行上来吗?如果不能持续运行,则这个参数(flag 只是一种非凡的参数)就是必选的,否则就是可选的。还有一种可能,命令行定义了多个变量,任意一个变量有值,程序都能够执行上来,也即是说只有这多个标识中轻易指定一个,程序就能够执行,那么这些标识或参数从这个角度讲又能够叫做选项(option)。
通过下面的剖析,咱们发现参数、标识、选项的概念彼此交错,既有区别又有相近的含意。标识是以横线结尾的参数,标识名前面的参数(如果有的话),是标识的值。这些参数可能是必选或可选,或多个选项中的一个,因而这些参数又能够称为选项。
3 子命令
通过下面的剖析,咱们能够很简略的得出结论,子命令只是一种非凡的参数,这种参数外观上跟其余参数没有任何区别(不像标识用横线结尾),然而这个参数会引发非凡的动作或函数(任意动作都能够封装为一个函数)。
比照标识和子命令咱们会意外的发现其中的关联:标识关联变量而子命令关联函数!他们具备雷同的目标,标识前面的参数,是变量的值,那么子命令前面的所有参数,就是这个函数的参数(并非指语言层面的函数参数)。
更乏味的问题是,为什么标识须要以横线结尾?如果没有横线,是否能达成关联变量的目标?这显然能够的,因为子命令就没有横线,对变量的关联和对函数的关联并没有什么区别。实质上,这个关联是通过标识或子命令的名字实现的,那横线起到什么作用呢?
是跟变量关联还是函数关联,依然是由参数的名字决定的,这是在代码中事后定义的,没有横线一样能够区别标识和子命令,一样能够实现变量或参数的关联。
比方:
// 不带有横线的参数也能够实现关联变量或函数for _, arg := range os.Args{ switch arg{ case "limit": // 设置 limit 变量 case "scan": // 调用 scan 函数 }}
由此可见,标识在外围性能实现上,并没有非凡的作用,横线的作用次要是用来加强可读性。然而须要留神的是,尽管实质上咱们能够不须要标识,但一旦有了标识,咱们就能够利用其个性实现额定的功能,比方 netstat -lnt这里的 -lnt就是 -l -n -t的语法糖。
4 命令行的形成
通过下面的剖析,咱们能够把命令行的参数赋予不同的概念
- 标识(flag):以横线或双横线结尾的参数,标识又由标识名和标识参数组成
- --flagname flagarg
- 非标识参数
- 子命令(subcommand),子命令也会有子命令,标识和非标识参数
$ command --flag flagarg subcommand subcmdarg --subcmdfag subcmdflagarg
四 启发式命令行解析
咱们来从新扫视一下第一个需要,即咱们冀望任何一个子命令的实现,都跟应用规范库的 flag 一样简略。这也就意味着,只有在执行这个函数的时候,才开始解析其命令行参数。如果咱们能把子命令和其余参数辨别开来,那么就能够先执行子命令对应的函数,后解析这个子命令的参数。
flag 之所以在 main中调用 Parse, 是因为 shell 曾经晓得字符串的第一个项是命令自身,前面所有项都是参数,同样的,如果咱们能辨认出子命令来,那么也能够让以下代码变为可能:
func command(){ // 定义 flags // 调用 Parse 函数}
问题的要害是如何将子命令跟其余参数辨别开来,其中标识名以横线或双横线结尾,能够不言而喻的区别开来,其余则须要区分子命令、子命令参数以及标识参数。认真思考能够发现,咱们尽管冀望参数无需事后定义,但子命令是能够事后定义的,通过把非标识名的参数,跟事后定义的子命令比对,则能够辨认出子命令来。
为了演示如何辨认出子命令,咱们以下面 cobra 的代码为例,假如 cobra.go 代码编译为程序 app,那么其命令行能够执行
$ app echo times hello --times 3
按 cobra 的概念, times 是 echo 的子命令,而 echo 又是 app 的子命令。咱们则把 echo times整体作为 app 的子命令。
1 简略解析流程
- 定义echo子命令关联到函数echo, echo times子命令关联到函数 echoTimes
- 解析字符串 echo times hello --times 3
- 解析第一个参数,通过 echo匹配到咱们预约义的 echo子命令,同时发现这也是 echo times命令的前缀局部,此时,只有晓得后一个参数是什么,咱们能力确定用户调用的是 echo还是 echo times
- 解析第二个参数,通过 times咱们匹配到 echo times子命令,并且其不再是任何子命令的前缀。此时确定子命令为 echo times,其余所有参数皆为这个子命令的参数。
- 如果解析第二个参数为 hello,那么其只能匹配到 echo这个子命令,那么会调用 echo函数而不是 echoTimes函数。
2 启发式探测流程
下面的解析比较简单,但现实情况下,咱们往往冀望容许标识能够呈现在命令行的任意地位,比方,咱们冀望新加一个管制打印色彩的选项 --color red,从逻辑上讲,色彩选项更多的是对 echo的形容,而非对 times的形容,因而咱们冀望能够反对如下的命令行:
$ app echo --color red times hello --times 3
此时,咱们冀望调用的子命令依然是 echo times,然而两头的参数让状况变得复杂起来,因为这里的参数 red可能是 --color的标识参数(red),可能是子命令的一部分,也可能是子命令的参数。更有甚者,用户还可能把参数谬误的写为 --color times
所谓启发式的探测,是指当解析到 red参数时,咱们并不知道 red到底是子命令(或者子命令的前缀局部),还是子命令的参数,因而咱们能够将其假设为子命令的前缀进行匹配,如果匹配不到,则将其当做子命令参数解决。
- 解析到 red时,用 echo red搜寻预约义的子命令,若搜寻不到,则将 red视为参数
- 解析 times时,用 echo times搜寻预约义的子命令,此时可搜寻到 echo times子命令
能够看到 red不需辨别是 --color的标识参数,还是子命令的非标识参数,只有其匹配不到任何子命令,则能够确认,其肯定是子命令的参数。
3 子命令任意书写程序
子命令实质上就是一个字符串,咱们下面的启发式解析曾经实现将任意子命令字符串辨认进去,前提是事后对这个字符串进行定义。也就是将这个字符串关联到某个函数。这样的设计使得父命令、子命令只是逻辑上的概念,而跟具体的代码实现毫无关联,咱们须要做的就是调整映射而已。
保护映射关系
# 关联到 echoTimes 函数"echo times" => echoTimes# 调整子命令只是改一下这个映射而已"times echo" => echoTimes
五 Cortana: 基于启发式命令行解析的实现
为了实现上述思路,我开发了 Cortana这个我的项目。Cortana 引入 Btree 建设子命令与函数之间的映射关系,得益于其前缀搜寻的能力,用户输出任意子命令前缀,程序都会主动列出所有可用的子命令。启发式命令行解析机制,能够在解析具体的标识或子命令参数前,先解析出子命令,从而搜寻到子命令所映射的函数,在映射的函数中,去真正的解析子命令的参数,实现变量的绑定。另外,Cortana 充分利用了 Go 语言 Struct Tag 的个性,简化了变量绑定的流程。
咱们用 cortana 从新实现 cobra 代码的性能
package mainimport ( "fmt" "strings" "github.com/shafreeck/cortana")func print() { cortana.Title("Print anything to the screen") cortana.Description(`print is for printing anything back to the screen.For many years people have printed back to the screen.`) args := struct { Texts []string `cortana:"texts"` }{} cortana.Parse(&args) fmt.Println(strings.Join(args.Texts, " "))}func echo() { cortana.Title("Echo anything to the screen") cortana.Description(`echo is for echoing anything back. Echo works a lot like print, except it has a child command.`) args := struct { Texts []string `cortana:"texts"` }{} cortana.Parse(&args) fmt.Println(strings.Join(args.Texts, " "))}func echoTimes() { cortana.Title("Echo anything to the screen more times") cortana.Description(`echo things multiple times back to the user by providing a count and a string.`) args := struct { Times int `cortana:"--times, -t, 1, times to echo the input"` Texts []string `cortana:"texts"` }{} cortana.Parse(&args) for i := 0; i < args.Times; i++ { fmt.Println(strings.Join(args.Texts, " ")) }}func main() { cortana.AddCommand("print", print, "print anything to the screen") cortana.AddCommand("echo", echo, "echo anything to the screen") cortana.AddCommand("echo times", echoTimes, "echo anything to the screen more times") cortana.Launch()}
命令用法跟 cobra 齐全一样,只是主动生成的帮忙信息有一些区别
# 不加任何子命令,输入主动生成的帮忙信息$ ./appAvailable commands:print print anything to the screenecho echo anything to the screenecho times echo anything to the screen more times# 默认启用 -h, --help 选项,开发者无需做任何事件$ ./app print -hPrint anything to the screenprint is for printing anything back to the screen.For many years people have printed back to the screen.Usage: print [texts...] -h, --help help for the command # echo 任意内容$ ./app echo hello world hello world # echo 任意次数$ ./app echo times hello world --times 3 hello world hello world hello world# --times 参数能够在任意地位$ ./app echo --times 3 times hello world hello world hello world hello world
1 选项与默认值
args := struct { Times int `cortana:"--times, -t, 1, times to echo the input"` Texts []string `cortana:"texts"`}{}
能够看到, echo times 命令有一个 --times 标识,另外,则是要回显的内容,内容实质上也是命令行参数,并且可能因为内容中有空格,而被宰割为多个参数。
咱们下面提到,标识实质上是将某个值绑定到某个变量,标识的名字,比方这里的 --times,跟变量 args.Times 关联,那么对于非标识的其余参数呢,这些参数是没有名字的,因而咱们对立绑定到一个 Slice,也就是 args.Texts
Cortana 定义了属于本人的 Struct Tag,别离用来指定其长标识名、短标识名,默认值和这个选项的形容信息。其格局为: cortana:"long, short, default, description"
- 长标识名(long): --flagname, 任意标识都反对长标识名的格局,如果不写,则默认用字段名
- 短标识名(short): -f,能够省略
- 默认值(default):能够为任意跟字段类型匹配的值,如果省略,则默认为空值,如果为单个横线 "-",则标识用户必须提供一个值
- 形容(description):这个选项的形容信息,用于生成帮忙信息,形容中能够蕴含任意可打印字符(包含逗号和空格)
为了便于记忆,cortana这个 Tag 名字也能够写为 lsdd,即上述四局部的英文首字母。
2 子命令与别名
AddCommond 能够增加任意子命令,其本质上是建设子命令与其处理函数的映射关系。
cortana.AddCommand("echo", echo, "echo anything to the screen")
在这个例子里,print命令和 echo命令是雷同的,咱们其实能够通过别名的形式将两者关联
// 定义 print 为 echo 命令的别名cortana.Alias("print", "echo")
执行 print 命令实际上执行的是 echo
$ ./app print -hEcho anything to the screenecho is for echoing anything back. Echo works a lot like print, except it has a child command.Available commands:echo times echo anything to the screen more timesUsage: echo [texts...] -h, --help help for the command
别名的机制非常灵活,能够为任意命令和参数设置别名,比方咱们冀望实现 three这个子命令,打印任意字符串 3 次。能够间接通过别名的形式实现:
cortana.Alias("three", "echo times --times 3")
# three 是 echo times --times 3 的别名$ ./app three hello world hello world hello world hello world
3 help 标识和命令
Cortana 主动为任意命令生成帮忙信息,这个行为也能够通过 cortana.DisableHelpFlag禁用,也能够通过 cortana.HelpFlag来设定本人喜爱的标识名。
cortana.Use(cortana.HelpFlag("--usage", "-u"))
# 自定义 --usage 来打印帮忙信息$ ./app echo --usageEcho anything to the screenecho is for echoing anything back. Echo works a lot like print, except it has a child command.Available commands:echo times echo anything to the screen more timesUsage: echo [texts...] -u, --usage help for the command
Cortana 默认并没有提供 help子命令,但利用别名的机制,咱们本人很容易实现 help命令。
cortana.Alias("help", "--help")
// 通过别名,实现 help 命令,用于打印任意子命令的帮忙信息$ ./app help echo timesEcho anything to the screen more timesecho things multiple times back to the user by providing a count and a string.Usage: echo times [options] [texts...] -t, --times <times> times to echo the input. (default=1) -h, --help help for the command
4 配置文件与环境变量
除了通过命令行参数实现变量的绑定外,Cortana 还反对用户自定义绑定配置文件和环境变量,Cortana 并不负责配置文件或环境变量的解析,用户能够借助第三方库来实现这个需要。Cortana 在这里的次要作用是依据优先级合并不同起源的值。其遵循的优先级程序如下:
默认值 < 配置文件 < 环境变量 < 参数
Cortana 设计为便于用户应用任意格局的配置,用户只须要实现 Unmarshaler 接口即可,比方,应用 JSON 作为配置文件:
cortana.AddConfig("app.json", cortana.UnmarshalFunc(json.Unmarshal))
Cortana 将配置文件或环境变量的解析齐全交给第三方库,用户能够自在定义如何将配置文件绑定到变量,比方应用 jsonTag。
5 没有子命令?
Cortana 的设计将命令查找和参数解析解耦,因而两者能够分别独立应用,比方在没有子命令的场景下,间接在main函数中实现参数解析:
func main(){ args := struct { Version bool `cortana:"--version, -v, , print the command version"` }{} cortana.Parse(&args) if args.Version { fmt.Println("v0.1.1") return } // ...}$ ./app --versionv0.1.1
六 总结
命令行解析是一个大家都会用到,但并不是特地重要的性能,除非是专一于命令行应用的工具,个别程序咱们都不须要过多关注命令行的解析,所以对于对这篇文章的主题感兴趣,并能读到文章最初的读者,我示意由衷的感激。
flag库简略易用,cobra 功能丰富,这两个库曾经简直能够满足咱们所有的需要。然而,我在编写命令行程序的过程中,总感到现有的库美中不足,flag库只解决标识解析的问题,cobra库尽管反对子命令和参数的解析,但把子命令和参数的解析耦合在一起,导致参数定义跟函数拆散。Cortana的外围诉求是将命令查找和参数解析解耦,我通过从新回归命令行参数的实质,创造了启发式解析的办法,最终实现了上述指标。这种解耦使得 Cortana即具备 cobra一样的丰盛性能,又有像 flag一样的应用体验。这种通过精美设计而用非常简单的机制实现弱小性能体验让我感到十分舒服,心愿通过这篇文章,能够跟大家分享我的高兴。
原文链接
本文为阿里云原创内容,未经容许不得转载。