1

cmdr 是另一个命令行参数处理器(Golang)。

Golang 自己带有 flags 进行命令行参数处理,算是便利的,然而和 Google 一贯的做法相同,非常独,非常反人类。

在计算机人机交互界面的历史上,命令行的交互方式只有一种是贯穿始终,得到传承和延续的,那就是 getopt 以及 getopt_long。说起 getopt 来也可以讲述一个怪长的故事,然而本文不做此打算。无论如何,你需要知道的就是,getopt及其交互界面已经是POSIX的一部分,一个卓有成效的程序员、开发者、科学家,或者计算机从业者,对于这个界面都已经是训练有素,无需成本了。你可能在用着它,但你或许只是没有意识到它的存在而已。GNU的大部分命令行小刀都采用了这样的界面,所以,例如tar, gwk, gzip, ls, rm, …,以及无法列举的那些工具都是这样的界面。

所以,自行其是,自己搞一套,并非不可以。但我可以不买账。

那么,这并非我独自一人的自赏。我们只需要知道,在 Golang 的开源圈子里,已经有了数十种 getopt-like 的复刻本,用以为 Golang 开发的应用程序提供更好的命令行界面。这里面不乏 viper/cobra, cli 那样的巨作,也有一些小巧精干的实现。

cmdr 也是这么一个 getopt-like 的实现。和已有的其它实现不同之处在于,cmdr基本上原样复制了 getopt 的表现。也就是说,一个典型的 Unix/Linux 应用程序,例如 cp,mv 等等,是怎么做的,那么基于 cmdr 的应用程序也就是怎么做的。这里讲的当然是关于命令行参数怎么被解释的问题,而非应用程序的具体逻辑。

让我们来看看都有哪些具体方面。

POSIX 约定

POSIX 表示可移植操作系统接口(英语:Portable Operating System Interface,缩写为POSIX)是 IEEE(电气和电子工程师协会,Institute of Electrical and Electronics Engineers)为要在各种UNIX操作系统上运行软件,而定义API的一系列互相关联的标准的总称,其正式称呼为IEEE Std 1003,而国际标准名称为ISO/IEC 9945。此标准源于一个大约开始于1985年的项目。POSIX这个名称是由 理查德·斯托曼(RMS)应IEEE的要求而提议的一个易于记忆的名称。它基本上是Portable Operating System Interface(可移植操作系统接口)的缩写,而X则表明其对Unix API的传承。 电气和电子工程师协会(Institute of Electrical and Electronics Engineers,IEEE)最初开发 POSIX 标准,是为了提高 UNIX 环境下应用程序的可移植性。然而,POSIX 并不局限于 UNIX。许多其它的操作系统,例如 DEC OpenVMS 和 Microsoft Windows NT,都支持 POSIX 标准。

下面是 POSIX 标准中关于程序名、参数的约定:

  • 程序名不宜少于2个字符且不多于9个字符;
  • 程序名应只包含小写字母和阿拉伯数字;
  • 选项名应该是单字符活单数字,且以短横‘-‘为前綴;
  • 多个不需要选项参数的选项,可以合并。(譬如:foo -a -b -c ---->foo -abc
  • 选项与其参数之间用空白符隔开;
  • 选项参数不可选。
  • 若选项参数有多值,要将其并为一个字串传进来。譬如:myprog -u "arnold,joe,jane"。这种情况下,需要自己解决这些参数的分离问题。
  • 选项应该在操作数出现之前出现。
  • 特殊参数 ‘--' 指明所有参数都结束了,其后任何参数都认为是操作数。
  • 选项如何排列没有什么关系,但对互相排斥的选项,如果一个选项的操作结果覆盖其他选项的操作结果时,最后一个选项起作用;如果选项重复,则顺序处理。
  • 允许操作数的顺序影响程序行为,但需要作文档说明。
  • 读写指定文件的程序应该将单个参数'-'作为有意义的标准输入或输出来对待。

GNU长选项约定

  • 对于已经遵循POSIX约定的GNU程序,每个短选项都有一个对应的长选项。
  • 额外针对GNU的长选项不需要对应的短选项,仅仅推荐要有。
  • 长选项可以缩写成保持惟一性的最短的字串。
  • 选项参数与长选项之间或通过空白字符活通过一个'='来分隔。
  • 选项参数是可选的(只对短选项有效)。
  • 长选项允许以一个短横线为前缀。

getopt 界面

以下对 getopt 以及 getopt_long 提供的界面进行描述,cmdr 具备相同的能力。

在以下的行文中,短参数短选项是等同的概念,其它词汇也类似如此,不再赘述。

短参数

单个短横线引导的单个字符的参数,被称为短参数。例如:-v-d,等等。有的时候,短参数也可能有两个字符甚至更多个字母。然而,短参数的用意就在于缩略,因此多字符的短参数很少见,且通常被用于组合,更像是典型的单字符短参数后缀以一个取值。例如 rar 的选项中有 -ep, -ep1, -ep3:

  ep            Exclude paths from names
  ep1           Exclude base directory from names
  ep3           Expand paths to full including the drive letter

然而在实现其处理器时,我们可以提供 -ep<n> 的处理器就够了,所以你仍然可以将其视为 -ep 短参数的变形。

长参数

两个短横线引导的多个字符的参数,被称为长参数。例如:—debug--version 等等。

一般来说,长参数更具备描述性,通常使用单词、词组来构成长参数。例如 docker 的子命令 docker checkpoint create

$ docker checkpoint create --help

Usage:    docker checkpoint create [OPTIONS] CONTAINER CHECKPOINT

Create a checkpoint from a running container

Options:
      --checkpoint-dir string   Use a custom checkpoint storage directory
      --leave-running           Leave the container running after checkpoint

参数描述

每条命令或参数选项可以被一段文件以描述。

参数重复堆叠

无论长短参数,可以以任意顺序出现,也可以任意出现多次。对于多次出现的参数,一般来说是最后一次出现的为准,之前出现过的会被覆盖。

例如命令行:-1 -a yy -a dd -a cc,则对于参数a来说,其有效值为 ”cc“,此前出现的都被覆盖了。

bool型短参数的组合

对于getopt不带值的参数,例如 "1abc" ,以下的命令行都是有效的:

  • -1 -a -b -c
  • -abc1
  • -ac -1b
  • ...

顺序是不敏感的,组合是任意的。

必须带值的参数

getopt的定义是参数后加一个冒号,例如 “1a:b::" 中的参数 a,对它你需要指定命令行形如 -1 -a xxx

可选值的参数

getopt的定义是参数后加两个冒号,例如 “1a​:b:​:" ​中的参数 b,对它你需要指定命令行形如 -1 -b 或者 -1 -bvalue

在 getopt 界面上的增强

命令和子命令

以 docker 的子命令 checkpoint 为例:

clipboard.png

事实上,命令与子命令是没有区别的,如果有必要,可以建立任意多级的命令和子命令嵌套层次。不过在实际的 Command-Line UI 设计中,超过4层的子命令嵌套都是极少数,因为这也会给使用工具的人带来麻烦。

Shell自动完成

在现代的命令行界面中,自动完成(Shell Completion)已经是一个关键性特性了。流行的命令行界面例如 Bash、Zsh、Fish 都提供了自动完成的特性。通常一个应用程序需要面向这个Shells 提供配套的自动完成脚本,从而获得自动完成能力。

一个已经支持自动完成的应用程序的命令行输入可能是这样子的:

Bash 的自动完成

docker 的 自动完成

docker-completion.gif
clipboard.png

Zsh 的自动完成

docker 在 zsh 中的自动完成。可以注意到 zsh 的 TAB 按键次数更简练,而且列表选择界面也更有效和更具有提示性。当然,zsh的自动完成也存在一些bug,例如一级命令列表超出终端屏幕可视行数时列表选择界面就被破碎掉了。

docker-zsh-completion.gif
clipboard.png

cmdr 的使用方法

cmdr 的使用方法尽可能简单化了,接下来我们做一个简明的介绍。

一个简单的入口可以这样:

package main

import (
    "fmt"
    "github.com/hedzr/cmdr"
)

func main() {
    // logrus.SetLevel(logrus.DebugLevel)
    // logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true,})

    // 可选的四个选项:
    cmdr.EnableVersionCommands = true
    cmdr.EnableVerboseCommands = true
    cmdr.EnableHelpCommands = true
    cmdr.EnableGenerateCommands = true
    
    if err := cmdr.Exec(rootCmd); err != nil {
        fmt.Printf("Error: %v", err) // or log, logrus
    }
}

var(

    rootCmd = &cmdr.RootCommand{
        Command: cmdr.Command{
            BaseOpt: cmdr.BaseOpt{
                Name: "short",
                Flags: []*cmdr.Flag{

                },
            },
            SubCommands: []*cmdr.Command{
                serverCommands,
                // msCommands,
            },
        },

        AppName:    "short",
        Version:    cmdr.Version,
        VersionInt: cmdr.VersionInt,
        Copyright:  "austr is an effective devops tool",
        Author:     "Your Name <yourmail@gmail.com>",
    }

    serverCommands = &cmdr.Command{
        BaseOpt: cmdr.BaseOpt{
            Short:       "s",
            Full:        "server",
            Aliases:     []string{"serve", "svr",},
            Description: "server ops: for linux service/daemon.",
            Flags: []*cmdr.Flag{
                {
                    BaseOpt: cmdr.BaseOpt{
                        Short:       "f",
                        Full:        "foreground",
                        Aliases:     []string{"fg",},
                        Description: "running at foreground",
                    },
                },
            },
        },
        SubCommands: []*cmdr.Command{
            {
                BaseOpt: cmdr.BaseOpt{
                    Short:       "s",
                    Full:        "start",
                    Aliases:     []string{"run", "startup",},
                    Description: "startup this system service/daemon.",
                    Action: func(cmd *cmdr.Command, args []string) (err error) {
                        return
                    },
                },
            },
            {
                BaseOpt: cmdr.BaseOpt{
                    Short:       "t",
                    Full:        "stop",
                    Aliases:     []string{"stp", "halt", "pause",},
                    Description: "stop this system service/daemon.",
                },
            },
            {
                BaseOpt: cmdr.BaseOpt{
                    Short:       "r",
                    Full:        "restart",
                    Aliases:     []string{"reload",},
                    Description: "restart this system service/daemon.",
                },
            },
            {
                BaseOpt: cmdr.BaseOpt{
                    Full:        "status",
                    Aliases:     []string{"st",},
                    Description: "display its running status as a system service/daemon.",
                },
            },
            {
                BaseOpt: cmdr.BaseOpt{
                    Short:       "i",
                    Full:        "install",
                    Aliases:     []string{"setup",},
                    Description: "install as a system service/daemon.",
                },
            },
            {
                BaseOpt: cmdr.BaseOpt{
                    Short:       "u",
                    Full:        "uninstall",
                    Aliases:     []string{"remove",},
                    Description: "remove from a system service/daemon.",
                },
            },
        },
    }
)

可以看到的是,cmdr.RootCommand 和 cmdr.Command 的区别不大,只是多了应用程序信息的成员字段。而 cmdr.Command 和 cmdr.Flag 的区别也不大,它们都有相同的 BaseOpt 嵌入结构。

因此,定义命令和定义选项是很相似的,然后你需要进行正确的结构嵌套。如果感到嵌套结构迷乱了眼睛,则可以抽出一个子命令、或者一组子命令到一个独立的变量中,然后使用引用的方式嵌入到上级命令的恰当位置。

这种抽出的方式也适合于进行相似结构的共享,但要注意引用和深度拷贝的区别。此处不做进一步讨论了,总之,如果感到没有把握,不妨一级一级地老老实实地完成定义,一般的工具开发也不会有太多的嵌套的吧。

言归正传,完成了上面的定义之后,就可以编译成执行文件运行了(或者用 go run main.go)。你可以在终端中尝试使用它:

bin/short
bin/short --help
bin/short --version
bin/short -#
bin/short --debug --verbose server --help
bin/short svr --help --debug -v
bin/short s start -f ~~debug

指定 Action

每条命令(cmdr.Command)都可以指定 Action,一个 func 对象。你将要为命令编写的业务逻辑就在这里。

如果有必要的话,一个选项(cmdr.Flag)也可以被提供一个 Action,如果你需要在选项被扫描到时触发点其他逻辑的话。

对于命令而言,你可以提供额外的 PreActionPostAction,它们分别是在命令的 Action 被执行的前后被调用的。特别是 PreAction 允许返回一个特别的错误值 cmdr.ShouldBeStopException 来告诉 cmdr 终止后续处理,所以你可以有机会避免 命令的 Action 部分被执行。

由于 RootCommand 也是一个 Command,所以定义在 RootCommand 中的 PreActionpostAction 有着特别的处理逻辑:

RootCommand.PreAction 将会在具体 Command.PreAction 执行之前被执行;RootCommand.PostAction 将会在具体 Command.PostAction 执行之后被执行。

这样的特别逻辑是为了便于开发者定义自己的前置、退出逻辑。例如一个微服务应该在开始提供服务之前完成注册中心登记,以及在停止服务时撤销登记,这些任务适合于在 RootCommandPre/PostAction 中来做。

~~debug

~~debug 是一个隐藏性的标志。~~ 和 long 参数 是相似的,不过它的不同在于,相应的参数的入口不会被建立在 标准名字空间中,因此你需要在顶级名字空间中抽取它的值。

~~debug 有着一个特别的作用,在调试阶段,这个选项将会使得正常处理逻辑结束后,附加一段调试性的信息输出,其中包含 所有有效的选项及其最终值,还包含这些选项的 yaml 文本形式。

一个式样是:

image-20190514162434050.png
clipboard.png

你可以通过:

bin/wget-demo ~~debug

来查看相似的输出结果。

我相信这个功能可以帮助你解决很多问题,不必再来猜来猜去的了。

名字空间

所有的选项值都被放在标准名字空间中,cmdr.RxxtPrefix 定义了标准名字空间的层级,其默认值为 app

这意味着 RootCommand 的 Flags,例如 --version ,可以用 cmdr.GetBool("app.version") 来抽取其值。类似的,--debug 的抽取语句为 cmdr.GetBool("app.debug")

前面说过 ~~debug 有点特殊,这样的不加前缀的选项的值可以直接抽取:cmdr.GetBool("debug")

每一级命令或子命令就会建立一个嵌套的名字空间,其名称取自命令的 Full 字段,也就是长参数名。因此 bin/short server start -f-f 的抽取语句为 cmdr.GetBool("app.server.start.foreground")

你当然可以执行不同的 cmdr.RxxtPrefix,例如:

cmdr.RxxtPrefix = []string{"server",}
// 等价于使用 ”server.xxx" 而不是 “app.xxx”

一个选项的值是可以多种形态的,但总的来说我们支持四种数据类型:

  • bool
  • int
  • string
  • string slice

更多的类型,我们暂不直接支持。未来或会予以增强。

环境变量重载

可以使用环境变量重载去覆盖命令行参数。

所以:

CMDR_APP_SERVER_START_FOREGROUND=1 bin/short server start 等价于 bin/short server start -f

如果你希望使用非 ”CMDR_“ 的环境变量前缀,你可以设置 cmdr.EnvPrefix 来自行控制前缀。例如

cmdr.EnvPrefix = []string{ "Rx", "cd", }
// 等价于使用 RX_CD_ 前缀
当前版本的问题

环境变量的优先级较低,如果配置文件或者命令行参数有指定值,则环境变量的设定值就被掩盖了。

这不符合惯例,我们考虑在下一版本中解决此问题。

我们将会实现的优先级为:defaultValue -> config-file -> env-var -> command-line opts。

配置文件的自动加载

默认情况下,cmdr 自动查看如下文件:

  • /etc/<appname>/<appname>.yml
  • /usr/local/etc/<appname>/<appname>.yml
  • $HOME/.<appname>/<appname>.yml

cmdr 也会自动装载相应的 conf.d 子目录中的所有 yaml 文件,并依次载入和覆盖选项的定义值。因此你可以切分大型配置文件到多个小文件中,以便于运维部署和管理。

对于开发者来说,cmdr 还会首先检查项目目录下的 ./ci/etc/<appname>/<appname>.yml 是否有效并试图自动加载它及其 conf.d 子目录。

cmdr 支持 conf.d 文件夹的监视,其中的变化会被传送给所有注册的 listeners。关于这个方面的细节,可以查看:

  • cmdr.AddOnConfigLoadedListener(c)
  • cmdr.RemoveOnConfigLoadedListener(c)
  • cmdr.SetOnConfigLoadedListener(c, enabled)
当前版本的问题

无法定制加载位置、无法忽略加载位置,等等。

其他的配置文件格式也暂时不支持。

实例 wget-demo

我们已经实现了一个 wget 的命令行界面复刻版本,但是仅提供小部分命令行参数的处理,因为完整的复刻版本基本上只是一个重复的劳作了,作为示例我们已经实现了足够多的选项,足以说明 cmdr 的能力了。

wget-demo 的帮助屏是这样的:

image-20190514131624710.png
clipboard.png

和 gnu 的 wget 相比较而言,看起来也算是没有区别了。

wget-demo 的源码可以在这里找到:

https://github.com/hedzr/cmdr...

cmdr 的版本规划

semver是符合规范的。

关于 semver 的含义可以查看如下两个链接,无需多言:

更多的介绍

cmdr 是在早前若干个非正式实现的基础上重写的一个新的实现,其首要目标就是完完全全地 Unix/Linux 命令行界面,而不是 golang 风格的、或者其它的部分实现的风格。

getopt 以及 getopt_long 都有自己的参数定义方式,不过在这个方面,cmdr 不打算实现它们的仿真风格,因为那并不方便也不算直观。

cmdr 尽力做到的是,命令和参数定义完成之后就完成了一切。除此而外,你无需做别的事就能得到:

  • 自动的帮助屏
  • 自动的配置文件载入
  • 配置文件切分到 conf.d 子目录,且自动监视其变更
  • 完全的 Unix/Linux Command-Line UI
  • 允许环境变量重载到选项
  • 支持 Shell 自动完成特性
  • 更多特性...

目前已经实现的是主体的大部分特性,细节尚未打磨完美,还需要继续投入力量进行改善。然而作为建设的主要目标已经可作为已达成了。

更多 cmdr 用法,今后继续进行描述。


hedzr
95 声望18 粉丝