一种命令行解析的新思路(Go 语言描述)

简介: 本文通过打破大家对命令行的固有印象,对命令行的概念解构后重新梳理,开发出一种功能强大但使用极为简单的命令行解析方法。这种方法支持任意多的子命令,支持可选和必选参数,对可选参数可提供默认值,支持配置文件,环境变量及命令行参数同时使用,配置文件,环境变量,命令行参数生效优先级依次提高,这种设计可以更符合 12 factor的原则。

image.png

作者 | 克识
来源 | 阿里技术公众号

一 概述

命令行解析是几乎每个后端程序员都会用到的技术,但相比业务逻辑来说,这些细枝末节显得并不紧要,如果仅仅追求满足简单需求,命令行的处理会比较简单,任何一个后端程序员都可以信手拈来。Go 标准库提供了 flag 库以供大家使用。

然而,当我们稍微想让我们的命令行功能丰富一些,问题开始变得复杂起来,比如,我们要考虑如何处理可选项和必选项,对于可选项,如何设置其默认值,如何处理子命令,以及子命令的子命令,如何处理子命令的参数等等。

目前,Go 语言中使用最广泛功能最强大的命令行解析库是 cobra,但丰富的功能让 cobra 相比标准库的 flag 而言,变得异常复杂,为了减少使用的复杂度,cobra 甚至提供了代码生成的功能,可以自动生成命令行的骨架。然而,自动生成在节省了开发时间的同时,也让代码变得不够直观。

本文通过打破大家对命令行的固有印象,对命令行的概念解构后重新梳理,开发出一种功能强大但使用极为简单的命令行解析方法。这种方法支持任意多的子命令,支持可选和必选参数,对可选参数可提供默认值,支持配置文件,环境变量及命令行参数同时使用,配置文件,环境变量,命令行参数生效优先级依次提高,这种设计可以更符合 12 factor的原则。

二 现有的命令行解析方法

Go 标准库 flag提供了非常简单的命令行解析方法,定义好命令行参数后,只需要调用 flag.Parse方法即可。

// demo.go
var limit int
flag.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 100
the limit is 100

可以看到, flag 库使用非常简单,定要好命令行参数后,只需要调用 flag.Parse就可以实现参数的解析。在定义命令行参数时,可以指定默认值以及对这个参数的使用说明。

如果要处理子命令,flag 就无能为力了,这时候可以选择自己解析子命令,但更多的是直接使用 cobra 这个库。

这里用 cobra 官方给出的例子,演示一下这个库的使用方法

package main

import (
  "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 providing
a 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 3
Echo: hello
Echo: hello
Echo: 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 简单解析流程

  1. 定义echo子命令关联到函数echo, echo times子命令关联到函数 echoTimes
  2. 解析字符串 echo times hello --times 3
  3. 解析第一个参数,通过 echo匹配到我们预定义的 echo子命令,同时发现这也是 echo times命令的前缀部分,此时,只有知道后一个参数是什么,我们才能确定用户调用的是 echo还是 echo times
  4. 解析第二个参数,通过 times我们匹配到 echo times子命令,并且其不再是任何子命令的前缀。此时确定子命令为 echo times,其他所有参数皆为这个子命令的参数。
  5. 如果解析第二个参数为 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到底是子命令(或者子命令的前缀部分),还是子命令的参数,因此我们可以将其假定为子命令的前缀进行匹配,如果匹配不到,则将其当做子命令参数处理。

  1. 解析到 red时,用 echo red搜索预定义的子命令,若搜索不到,则将 red视为参数
  2. 解析 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 main

import (
  "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 完全一样,只是自动生成的帮助信息有一些区别

# 不加任何子命令,输出自动生成的帮助信息
$ ./app
Available commands:

print                         print anything to the screen
echo                          echo anything to the screen
echo times                    echo anything to the screen more times

# 默认启用 -h, --help 选项,开发者无需做任何事情
$ ./app print -h
Print anything to the screen

print 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 -h
Echo anything to the screen

echo 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 times


Usage: 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 --usage
Echo anything to the screen

echo 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 times


Usage: echo [texts...]

  -u, --usage                    help for the command

Cortana 默认并没有提供 help子命令,但利用别名的机制,我们自己很容易实现 help命令。

cortana.Alias("help", "--help")
// 通过别名,实现 help 命令,用于打印任意子命令的帮助信息
$ ./app help echo times
Echo anything to the screen more times

echo 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 --version
v0.1.1

六 总结

命令行解析是一个大家都会用到,但并不是特别重要的功能,除非是专注于命令行使用的工具,一般程序我们都不需要过多关注命令行的解析,所以对于对这篇文章的主题感兴趣,并能读到文章最后的读者,我表示由衷的感谢。

flag库简单易用,cobra 功能丰富,这两个库已经几乎可以满足我们所有的需求。然而,我在编写命令行程序的过程中,总感到现有的库美中不足,flag库只解决标识解析的问题,cobra库虽然支持子命令和参数的解析,但把子命令和参数的解析耦合在一起,导致参数定义跟函数分离。Cortana的核心诉求是将命令查找和参数解析解耦,我通过重新回归命令行参数的本质,发明了启发式解析的方法,最终实现了上述目标。这种解耦使得 Cortana即具备 cobra一样的丰富功能,又有像 flag一样的使用体验。这种通过精巧设计而用非常简单的机制实现强大功能体验让我感到非常舒适,希望通过这篇文章,可以跟大家分享我的快乐。

原文链接
本文为阿里云原创内容,未经允许不得转载。 

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>