cmdr 02 - 复刻一个 wget

cmdr 02 – Covered for wget

基于
cmdr v0.2.11

Getting Start 之后,我们来介绍如何用 cmdr 复刻一个 wget 的命令行界面,并具体介绍 CommandFlag 的各个细节以及 cmdr 能够做到哪些别人做不到的事。

此外,我们也声明一下,Getting Start (‘另一个go命令行参数处理器 – cmdr‘) 的内容有了一些轻微的变化,因为这两周来,我们已经不停地增加了很多特性来完善 cmdr 的能力,期间有一些不恰当的策略、衍生的命名、采用的算法都有所调整,虽然尽力避免变化,但它是不可免的。我们是期望给你的编程界面越来越完美,让整个编写的流程流畅化,自然化。

wget 的参数

wget 本身是一个 GNU 应用程序。它的命令行参数有长有短,短参数可能有两个字符,此外参数被分为若干个分组。请看一部分截取:

《cmdr 02 - 复刻一个 wget》

这将是我们复刻的基准。

cmdr 都能做到些什么 – First

我们曾经做过多个应用,不同的开发语言,不同的目标,有的是练练手,有的是眼前有个事情有点烦、不好处理、一怒之下就干,有的是有特定的目的例如一个RESTful服务,等等。

所以,要想满足那么多的情况下命令行参数的组织和设定都能被很好地表示,不夸张地说,迄今数十年来,我们没有找到一个命令参数解释器能够完成这个任务。把时间限定在最近几年,把开发语言限定在 Golang,C++,Python 等几种之内,依然没有谁真的能这么称呼自己。现有的命令行参数解释器都有这样那样的不如意:

  • 短参数不能重复,哪怕是在多级命令结构下也必须全局唯一;
  • 不能分组;
  • 分组后顺序随机或者字母序,开发者无法干预,无法按照自己的意愿提供最好的顺序;
  • 短参数需要两个字母、或者三个字母的缩略语,更能表达参数原意时,基本上大多数现有的命令行参数解释器都废了;
  • 想要长参数显示为“--progress=TYPE”的式样,其中的 TYPE 还可以被复用;
  • 想要 git -m 的效果,结果费尽了力,终于实现了一个,然而受制于既有命令行解释器的结构,实现的坑坑洼洼的,自己都难以满意;
  • 想要和配置文件挂钩,没错挂钩了,然而需要写很多代码来安排;
  • 想要 /etc/program 加载配置文件,结果累了;想要 /etc/nginx/sites.avaliable 那样的效果,自己 watch 了,却合并不了新的配置到已经加载和构建好的配置中,也无法有效地通知应用的业务层按需取用新的配置条目;
  • 还有很多

遇到这些情况时,多数时候只能忍了,毕竟没有太多精力专门去搞参数问题,还有大把的业务需要去完成的对吧。

cmdr 选择和实现 wget-demo 也是为了展示自己大体上能够解决命令行参数处理的多数问题。不过和其它命令行参数的策略不同地在于:别人通常会对参数值的类型做很多文章,例如支持 string/int/slice/map 的多种式样,或者提供 validator,或者采用 Golang 结构 Tag 方式来挂钩参数类型处理器等等。但是 cmdr 在参数类型方面只能说有且够,整体的重心并不在这些方面。

cmdr 具有一个精悍短小的关键处理器 InternalExecFor(),它负责处理组合短参数的各种情况。

例如:对于 -1acg -t3 来说,cmdr 能够正确地识别到 -1 -c -c -g -t=3 的参数集合。

进一步地,对于 -4nva 来说,cmdr 能够正确识别到 -4 - nv -a 的参数集合。

此外,-mmsg -m msg -m=msg -m'what msg' -m"msg" '-mmsg' "-mWhat msg" 都是对的。在这里,cmdr处理了多数变形形态,有的形态则不必处理,因为 Shell 会负责处理其中一部分引号问题。

cmdr 也关注短参数的字母重复问题,在不同层级的子命令之间,你可以同时使用 -a 这样的短参数,当然,-a 仍然不能在子命令内重复,也不能和子命令的上层命令的参数相冲突。长参数以及别名都有同样的处理逻辑。

wget-demo 的实现细节

按照上一小节 cmdr 都能做到些什么 – First 提到的 cmdr 的专注点的说法,wget-demo 已经可以被很好地实现出来了。实际上,wget-demo 的代码非常简单(并不短),这也是 cmdr 想要给予开发者的方便。

这里 查阅 wget-demo 的目录。

这里 查阅 wget-demo 的单一代码文件。

main()

首先看 main:

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

    // To disable internal commands and flags, uncomment the following codes
    cmdr.EnableVersionCommands = false
    cmdr.EnableVerboseCommands = false
    cmdr.EnableHelpCommands = false
    cmdr.EnableGenerateCommands = false
    cmdr.EnableCmdrCommands = false

    if err := cmdr.Exec(rootCmd); err != nil {
        logrus.Errorf("Error: %v", err)
    }
}

line 2,3可以被忽略,那是便于 cmdr 开发阶段的内容。发布后的 cmdr 也依赖于 logrus,但实际上这是因为 cmdr 的 examples 的原因,而 cmdr 自身是不做此依赖的,所以你还是可以自己选择 logger。logger 问题以后或许会被 cmdr 慎重考虑,彻底去除对任何 logger 的依赖。

line 5-10 是为了 wget-demo 专用的。因为 wget 没有命令和子命令,只有参数,因此 cmdr 内置的几个命令(组)被禁用了。

真正的代码,只有 line 12-14。无需解释。

rootCmd

所以你需要做的只是编排 rootCmd 结构。

var (
    rootCmd = &cmdr.RootCommand{
        Command: cmdr.Command{
            BaseOpt: cmdr.BaseOpt{
                Name: "wget",
                Flags: append(
                    startupFlags,
                    append(loggerFlags,
                        downloadFlags...)...,
                ),
            },
            SubCommands: []*cmdr.Command{},
        },

        AppName:    "wget-demo",
        Version:    wgetVersion,
        VersionInt: 0x011400,
        Header: `GNU Wget 1.20, a non-interactive network retriever.

Usage: wget [OPTION]... [URL]...

Mandatory arguments to long options are mandatory for short options too.`,
    }
)

rootCmd 包含一个 Command 嵌入结构。然后 rootCmd 包含 AppName, Version, Header 等等顶级宣告。看看 RootCommand 的定义:

type(
    // RootCommand holds some application information
    RootCommand struct {
        Command

        AppName    string
        Version    string
        VersionInt uint32

        Copyright string
        Author    string
        Header    string // using `Header` for header and ignore built with `Copyright` and `Author`, and no usage lines too.

        ow   *bufio.Writer
        oerr *bufio.Writer
    }
)

你可以编写自己的 CopyrightAuthor 字段,由 cmdr 为你构造 app 的 header 部分。你也可以单纯指定 Header 字段让 cmdr 原样输出。

为了复刻的更像一点,wget-demo 定制了 Header 字段。

此外,wget 的分组的参数选项,我们选择实现了前三组,因此你能看到 line 6-9 使用了一个 append 嵌套组合这三组参数集定义。

Command

rootCmd 包含一个 Command 嵌入结构,其定义为:

type(
    // BaseOpt is base of `Command`, `Flag`
    BaseOpt struct {
        Name string
        // single char. example for flag: "a" -> "-a"
        // Short rune.
        Short string
        // word string. example for flag: "addr" -> "--addr"
        Full string
        // more synonyms
        Aliases []string
        // group name
        Group string
        // to-do: Toggle Group
        ToggleGroup string

        owner  *Command
        strHit string

        Flags []*Flag

        Description             string
        LongDescription         string
        Examples                string
        Hidden                  bool
        DefaultValuePlaceholder string

        // Deprecated is a version string just like '0.5.9', that means this command/flag was/will be deprecated since `v0.5.9`.
        Deprecated string

        // Action is callback for the last recognized command/sub-command.
        // return: ErrShouldBeStopException will break the following flow and exit right now
        // cmd 是 flag 被识别时已经得到的子命令
        Action func(cmd *Command, args []string) (err error)
    }
    
    // Command holds the structure of commands and subcommands
    Command struct {
        BaseOpt
        SubCommands []*Command
        // return: ErrShouldBeStopException will break the following flow and exit right now
        PreAction func(cmd *Command, args []string) (err error)
        // PostAction will be run after Action() invoked.
        PostAction func(cmd *Command, args []string)
        // be shown at tail of command usages line. Such as for TailPlaceHolder="<host-fqdn> <ipv4/6>":
        // austr dns add <host-fqdn> <ipv4/6> [Options] [Parent/Global Options]
        TailPlaceHolder string

        root            *RootCommand
        allCmds         map[string]map[string]*Command // key1: Commnad.Group, key2: Command.Full
        allFlags        map[string]map[string]*Flag    // key1: Command.Flags[#].Group, key2: Command.Flags[#].Full
        plainCmds       map[string]*Command
        plainShortFlags map[string]*Flag
        plainLongFlags  map[string]*Flag
    }
)
Name

Name 暂时没有什么用处,目前你总是可以忽略它。将来,它可能被更好地用在文档输出方面。

Short, Full, Aliases

Short, Full, Aliases 无需再特别说明了,只是再强调一次,在上级命令的所有子命令中,它们不能重复。在多级子命令结构的不同层级中,没有这个限制,你可以比较宽泛地定义自己的命令和子命令集合。

PreAction, Action, PostAction

当命令被识别出来时,PreAction 被立即执行,此时,cmd.GetHitStr() 可以获得被命中的命令行参数中的命令字符串。你可以在这里建立 PreAction 逻辑,当特定条件不满足时,你的逻辑可以返回 cmdr.ErrShouldBeStopException 来通知立即退出。

ActionPostAction 的用法应该很明确,这里就不展开了。你对命令的实现逻辑通常应该总是利用 Action 字段来完成。

Command 的函数

Command 也包含一些类似于 GetHitStr() 的函数:

  • PrintHelp(justFlags bool):输出帮助屏。
  • PrintVersion():输出版本信息屏。
  • GetRoot() 直接访问到 rootCmd;如果想逐级回溯,通过 Owner 字段就可以了。
  • IsRoot() 帮助你测试是否到达了顶级命令。
  • HasParent() 帮助你测试是否还有 Owner/Parent。
Group

Group 字段被用于命令分组。相同的字符串会被组织为一个命令组,显示的效果像这样:

《cmdr 02 - 复刻一个 wget》

如果你不指定Group,那么它们会被自动归属于一个名为 cmdr.UnsortedGroup 的特殊组中,图示中的 ms, s, t 都是这样的未指定分组,它们不会有组标题输出,而且总是被作为第一个被输出的分组。

如果你想要归属到 “Misc” 分组,那么你可以指定 Group 字段为 cmdr.SysMgmtGroup,其特殊之处在于总是被最后输出(v0.2.11及前可能存在不同的表现,下一版本会予以确认,但想要最后输出也很容易,稍后描述)。

对于分组谁先谁后,实际上有一个方案:指定你的Group字符串时使用两段结构“a.b”。a被用于排序,你可以使用字母和数字,例如:“001”,“011”,“091”等等。又或者:“A01”,“B01″等等。b被用作分组名并被用于显示。

ToggleGroup

ToggleGroup暂未实现,因为其功能可以暂时使用 PreAction 来代替。

since 0.2.13,ToggleGroup 已被移出 BaseOpt 结构,移入 Flag 中。

since 0.2.15 (待发布),ToggleGroup 已被实现。

Description,LongDescription

DescriptionLongDescription,是命令的描述性文字。你必须提供 Description 字段,在上面的图示中,它被显示在命令的后半段。如果你提供了 LongDescription ,它将会在命令的 --help 屏中被显示,另外,在 man page 或者文档输出中,LongDescription 也会被输出以便更细致地进行描述。

Examples

Examples 是命令的用例。实际上我们限定了用例的格式:

                    Examples:`
$ {{.AppName}} start
                    make program running as a daemon background.
$ {{.AppName}} start --foreground
                    make program running in current tty foreground.
$ {{.AppName}} run
                    make program running in current tty foreground.
$ {{.AppName}} stop
                    stop daemonized program.
$ {{.AppName}} reload
                    send signal to trigger program reload its configurations.
$ {{.AppName}} status
                    display the daemonized program running status.
$ {{.AppName}} install [--systemd]
                    install program as a systemd service.
$ {{.AppName}} uninstall
                    remove the installed systemd service.
`,

你必须按上述格式来提供 Examples 的具体内容。第一行以 $ {{.AppName}} 开头,然后是你的命令,如果是多级下的子命令,请注意补全,例如 $ {{.AppName}} ms tags list。然后第二行为上一行命令的功能性描述,不建议描述太冗长,也不建议描述被切分到多行。如是重复。

这样做的原因是为了在 man page 和文档输出时 cmdr 能够重组 examples 部分的格式令其更视觉化。

《cmdr 02 - 复刻一个 wget》

这是一个 man page 的部分截图,我们可以令其更视觉化,帮助最终使用者。

Hidden

如果你不想命令被显示在帮助屏、man page、文档中,使用 Hidden 字段来隐藏它。

Deprecated

如果你计划在下一某个版本废弃某个命令,可以使用 Deprecated 字段来标识它,你应该提供一个语义化的版本号到 Deprecated 中,至少在 Markdown 的文档输出中,它会被显示为删除线样式。

《cmdr 02 - 复刻一个 wget》

在 Terminal 中,deprecated 的命令显示为暗色。

DefaultValuePlaceholder, DefaultValue

适用于
Flag,不适用于
Command

DefaultValuePlaceholder 字段提供一个字符串 X,X 被连接在长参数之后用于显示目的,例如:--config=FILE。这是为了让参数的用法更具有表义性,也是为了强调参数为带值的。

注意为了提醒 cmdr 你需要一个带值参数,你必须明确设定 DefaultValue 字段为一个特定数据类型的值。你可以使用 string, int, string slice, int slice, duration 作为默认值。

如果是不带值的参数,它们总是具有 bool 类型的隐含值。如果你不指定 DefaultValue,那么 cmdr 认为你需要的是一个 bool 类型的不带值参数。

如果你在提供命令行参数是使用逗号分隔的字符串,而且为 DefaultValue 设定了 string slice, int slice 的话,那么 cmdr 会识别到并切分字符串转义为 Slice。稍后你在 Action 中可以使用 cmdr.GetStringSlice() 等方式直接抽取到数组。

DefaultValue 字段决定了 该参数的值的存储方式。但你可以自由地抽取该参数值到不同的数据类型,你可以通过 Get() 抽出该参数值的内部存储,然后自行转义为想要的类型。

since 0.2.13,DefaultValuePlaceholder 已被移出 BaseOpt 结构,移入 Flag 中。

Flags

since 0.2.13,Flags 已被移出 BaseOpt 结构,移入 Command 中。

命令的参数集被定义于此。

SubCommands

对于命令来说,多级命令能够构成一个结构化的层次,不仅便于用户索引和记忆,也有利于业务逻辑的构建和编写。

嵌套多级的子命令可能会很冗长,因此实际编码过程中,你可以考虑拆分并独立定义子命令,并在父命令中组合它们。

TailPlaceHolder

对于命令来说,在 Usage 行的显示也需要被 meaningful。如果你有这样的需要,那么 TailPlaceHolder 字段可以在 Usage 行的正常输出之外额外嵌入一段文字。

对于 TailPlaceHolder="<host-fqdn> <ipv4/6>" 来说,显示的效果是这样的:

《cmdr 02 - 复刻一个 wget》

应该不需要更多解释了,这个用文字表达我需要首先给出一堆术语释义才行,就不骗字数了。

Flag

参数,选项,都是 Flag 的同义语。cmdr 在代码实现时选用了 Flag 这个单词而已。

除了在 Command 中已经描述过的 术语两者都有的字段之外,这一小节描述其它部分,尤其是 Flag 特有的部分:

ToggleGroup

参考 Command 中有关小节的描述。虽未实现,但这个字段可以干点什么,将来吧。

DefaultValuePlaceholder

参考 Command 中有关小节的描述。自 cmdr v0.2.13 起,经过代码 review,这个字段正式移入 Flag 中,因为这才是正确的逻辑归属点。

DefaultValue

参考 Command 中有关小节的描述。嗯,它本来就设计在 Flag 中,难怪以前写 demo 时感觉怪怪的,DefaultValuePlaceholder 写在一处,DefaultValue 又写在另一处。今后就是一家人了。

ValidArgs

尚未实现。暂时也没考虑。原来的意图是提供枚举文字量。可是大家都是写代码的,不如就 1,2,3 将就了吧先。

Required

未用。实际上 cmdr 没有校验的概念,也没有必须存在这种概念。

因为我们觉得,你不应该要求用户一定要提供一个什么。

比如 consul 集群在哪里呀?consul 集群当然是在 consul.ops.local 那儿啊,要不然你们家云设施架构师设计的不一样,那么它就在 registrar.prod.ashiley.org.local 啊。换句话说,你总是应该给参数一个默认值,甚至给它 nil 或者 ”“ 也可以,你的业务逻辑应该处理一下这些临界场景。

尽管我们设计了 cmdr 以帮助你建立完善的 Command Line UI,但让用户随时随地能省缺就省缺才是正确的。

ExternalTool

这个字段的用途,首先是实现 git commit -m 效果。

为了达到效果,你必须在 ExternalTool 中填写 ”EDITOR“ 字符串,又或者使用 cmdr.ExternalToolEditor 常量。

本质上,cmdrExternalTool 视为环境变量名,试图探查环境变量是不是存在,并取得该值作为执行文件X,然后采用一个临时文件T作为执行文件X的输入参数并就地执行它们,待用户操作完毕并关闭执行文件X之后,临时文件T的内容被当做文本并被作为选项值填入。

所以,git commit -m 就是这么干的,cmdr 复制了这个流程。如果你需要类似的逻辑,那么就可以借助于 ExternalTool 字段。

组织

依据上面各小节的对 RootCommand,Command,Flag的阐述,接下来就是具体的数据集的定义了。

我们已经提到过嵌套结构的烦恼并做出了建议,至于更好的数据集定义方案,继续改善吧,欢迎给我建议。

小结

那么现在,你已经可以构建出你的 Command Line UI 了。wget-demo 已经实现了三组参数集,不但能够被正确识别,显示的效果也还不错:

《cmdr 02 - 复刻一个 wget》

如果希望对命令行参数的解释和操作有更多便利,欢迎 Issue 到:

https://github.com/hedzr/cmdr

REF

    原文作者:hedzr
    原文地址: https://segmentfault.com/a/1190000019344235
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞