cmdr 02 - 復刻一個 wget

hedzr發表於2019-05-28

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 到:

github.com/hedzr/cmdr

相關文章