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 都能做到些什麼 - 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 字段被用於命令分組。相同的字符串會被組織爲一個命令組,顯示的效果像這樣:

如果你不指定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 部分的格式令其更視覺化。

這是一個 man page 的部分截圖,我們可以令其更視覺化,幫助最終使用者。

Hidden

如果你不想命令被顯示在幫助屏、man page、文檔中,使用 Hidden 字段來隱藏它。

Deprecated

如果你計劃在下一某個版本廢棄某個命令,可以使用 Deprecated 字段來標識它,你應該提供一個語義化的版本號到 Deprecated 中,至少在 Markdown 的文檔輸出中,它會被顯示爲刪除線樣式。

在 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>" 來說,顯示的效果是這樣的:

應該不需要更多解釋了,這個用文字表達我需要首先給出一堆術語釋義才行,就不騙字數了。

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 已經實現了三組參數集,不但能夠被正確識別,顯示的效果也還不錯:

如果希望對命令行參數的解釋和操作有更多便利,歡迎 Issue 到:

https://github.com/hedzr/cmdr

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章