一种命令行解析的新思路(Go 语言描述)
簡介:?本文通過打破大家對命令行的固有印象,對命令行的概念解構后重新梳理,開發出一種功能強大但使用極為簡單的命令行解析方法。這種方法支持任意多的子命令,支持可選和必選參數,對可選參數可提供默認值,支持配置文件,環境變量及命令行參數同時使用,配置文件,環境變量,命令行參數生效優先級依次提高,這種設計可以更符合 12 factor的原則。
作者 | 克識
來源 | 阿里技術公眾號
一 概述
命令行解析是幾乎每個后端程序員都會用到的技術,但相比業務邏輯來說,這些細枝末節顯得并不緊要,如果僅僅追求滿足簡單需求,命令行的處理會比較簡單,任何一個后端程序員都可以信手拈來。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 mainimport ("fmt""strings""github.com/spf13/cobra" )func main() {var echoTimes intvar 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: hellocobra 功能強大,邏輯清晰,因此得到大家廣泛的認可,然而,這里卻有兩個問題讓我無法滿意,雖然問題不大,但時時縈懷于心,讓人郁郁。
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),子命令也會有子命令,標識和非標識參數
四 啟發式命令行解析
我們來重新審視一下第一個需求,即我們期望任何一個子命令的實現,都跟使用標準庫的 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 簡單解析流程
2 啟發式探測流程
上面的解析比較簡單,但現實情況下,我們往往期望允許標識可以出現在命令行的任意位置,比如,我們期望新加一個控制打印顏色的選項 --color red,從邏輯上講,顏色選項更多的是對 echo的描述,而非對 times的描述,因此我們期望可以支持如下的命令行:
$ app echo --color red times hello --times 3此時,我們期望調用的子命令仍然是 echo times,然而中間的參數讓情況變得復雜起來,因為這里的參數 red可能是 --color的標識參數(red),可能是子命令的一部分,也可能是子命令的參數。更有甚者,用戶還可能把參數錯誤的寫為 --color times
所謂啟發式的探測,是指當解析到 red參數時,我們并不知道 red到底是子命令(或者子命令的前綴部分),還是子命令的參數,因此我們可以將其假定為子命令的前綴進行匹配,如果匹配不到,則將其當做子命令參數處理。
可以看到 red不需區分是 --color的標識參數,還是子命令的非標識參數,只要其匹配不到任何子命令,則可以確認,其一定是子命令的參數。
3 子命令任意書寫順序
子命令本質上就是一個字符串,我們上面的啟發式解析已經實現將任意子命令字符串識別出來,前提是預先對這個字符串進行定義。也就是將這個字符串關聯到某個函數。這樣的設計使得父命令、子命令只是邏輯上的概念,而跟具體的代碼實現毫無關聯,我們需要做的就是調整映射而已。
維護映射關系
# 關聯到 echoTimes 函數 "echo times" => echoTimes# 調整子命令只是改一下這個映射而已 "times echo" => echoTimes五 Cortana: 基于啟發式命令行解析的實現
為了實現上述思路,我開發了 Cortana這個項目。Cortana 引入 Btree 建立子命令與函數之間的映射關系,得益于其前綴搜索的能力,用戶輸入任意子命令前綴,程序都會自動列出所有可用的子命令。啟發式命令行解析機制,可以在解析具體的標識或子命令參數前,先解析出子命令,從而搜索到子命令所映射的函數,在映射的函數中,去真正的解析子命令的參數,實現變量的綁定。另外,Cortana 充分利用了 Go 語言 Struct Tag 的特性,簡化了變量綁定的流程。
我們用 cortana 重新實現 cobra 代碼的功能
package mainimport ("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 providinga 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 screenprint 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 worldhello world# echo 任意次數 $ ./app echo times hello world --times 3hello worldhello worldhello world# --times 參數可以在任意位置 $ ./app echo --times 3 times hello worldhello worldhello worldhello world1 選項與默認值
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 screenecho 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 timesUsage: 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 worldhello worldhello worldhello world3 help 標識和命令
Cortana 自動為任意命令生成幫助信息,這個行為也可以通過 cortana.DisableHelpFlag禁用,也可以通過 cortana.HelpFlag來設定自己喜歡的標識名。
cortana.Use(cortana.HelpFlag("--usage", "-u")) # 自定義 --usage 來打印幫助信息 $ ./app echo --usage Echo anything to the screenecho 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 timesUsage: echo [texts...]-u, --usage help for the commandCortana 默認并沒有提供 help子命令,但利用別名的機制,我們自己很容易實現 help命令。
cortana.Alias("help", "--help") // 通過別名,實現 help 命令,用于打印任意子命令的幫助信息 $ ./app help echo times Echo anything to the screen more timesecho things multiple times back to the user by providinga count and a string.Usage: echo times [options] [texts...]-t, --times <times> times to echo the input. (default=1)-h, --help help for the command4 配置文件與環境變量
除了通過命令行參數實現變量的綁定外,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一樣的使用體驗。這種通過精巧設計而用非常簡單的機制實現強大功能體驗讓我感到非常舒適,希望通過這篇文章,可以跟大家分享我的快樂。
原文鏈接
本文為阿里云原創內容,未經允許不得轉載。?
總結
以上是生活随笔為你收集整理的一种命令行解析的新思路(Go 语言描述)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 7招,实现安全高效的流水线管理
- 下一篇: 历经7年双11实战,阿里巴巴是如何定义云