close

一 概述

命令行解析是幾乎每個後端程序員都會用到的技術,但相比業務邏輯來說,這些細枝末節顯得並不緊要,如果僅僅追求滿足簡單需求,命令行的處理會比較簡單,任何一個後端程序員都可以信手拈來。Go 標準庫提供了flag庫以供大家使用。

然而,當我們稍微想讓我們的命令行功能豐富一些,問題開始變得複雜起來,比如,我們要考慮如何處理可選項和必選項,對於可選項,如何設置其默認值,如何處理子命令,以及子命令的子命令,如何處理子命令的參數等等。

目前,Go 語言中使用最廣泛功能最強大的命令行解析庫是 cobra,但豐富的功能讓 cobra 相比標準庫的 flag 而言,變得異常複雜,為了減少使用的複雜度,cobra 甚至提供了代碼生成的功能,可以自動生成命令行的骨架。然而,自動生成在節省了開發時間的同時,也讓代碼變得不夠直觀。

本文通過打破大家對命令行的固有印象,對命令行的概念解構後重新梳理,開發出一種功能強大但使用極為簡單的命令行解析方法。這種方法支持任意多的子命令,支持可選和必選參數,對可選參數可提供默認值,支持配置文件,環境變量及命令行參數同時使用,配置文件,環境變量,命令行參數生效優先級依次提高,這種設計可以更符合 12 factor的原則。

二 現有的命令行解析方法
Go標準庫flag提供了非常簡單的命令行解析方法,定義好命令行參數後,只需要調用flag.Parse方法即可。

// demo.govar limit intflag.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 100the limit is 100

可以看到,flag庫使用非常簡單,定要好命令行參數後,只需要調用flag.Parse就可以實現參數的解析。在定義命令行參數時,可以指定默認值以及對這個參數的使用說明。

如果要處理子命令,flag 就無能為力了,這時候可以選擇自己解析子命令,但更多的是直接使用 cobra 這個庫。

這裡用cobra官方給出的例子,演示一下這個庫的使用方法

package mainimport ( "fmt" "strings" "github.com/spf13/cobra")func main() { var echoTimes int var 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 providinga 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 3Echo: helloEcho: helloEcho: hello

cobra 功能強大,邏輯清晰,因此得到大家廣泛的認可,然而,這裡卻有兩個問題讓我無法滿意,雖然問題不大,但時時縈懷於心,讓人鬱郁。

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作為子命令:kubectlgetpods...kubectlgetdeploy...,而對於action因不同resource而差別很大時,則往往選擇resource作為子命令,比如阿里雲的命令行工具:aliyunecs...aliyunram...

在實際開發過程中,一開始我們可能無法確定action 和 resource 哪個作為子命令會更好,在有多級子命令的情況下這個選擇可能會更困難。

在不使用任何庫的時候,開發者可能會選擇在父命令中初始化相關資源,在子命令中執行代碼邏輯,這樣父命令和子命令相互調換變得非常困難。這其實是一種錯誤的邏輯,調用子命令並不意味着一定要調用父命令,對於命令行工具來說,命令執行完進程就會退出,父命令初始化後的資源,並不會在子命令中重複使用。

cobra 的設計可以讓大家規避這個錯誤邏輯,其子命令需要提供一個 Run 函數,在這個函數,應該實現初始化資源,執行業務邏輯,銷毀資源的整個生命周期。然而,cobra 仍然需要定義父命令,即必須定義 echo 命令,才能定義 echo times 這個子命令。實際上,在很多場景下,父命令是沒有執行邏輯的,特別是以 resource 作為父命令的場景,父命令的唯一作用就是打印這個命令的用法。

cobra 讓子命令和父命令的定義非常簡單,但父子調換仍然需要修改其間的鏈接關係,是否有方法讓這個過程更簡單一點呢?

三 重新認識命令行
關於命令行的術語有很多,比如參數(argument),標識(flag)和選項(option)等,cobra的設計是基於以下概念的定義
Commandsrepresentactions,ArgsarethingsandFlagsaremodifiersforthoseactions.

另外,又基於這些定義延伸出更多的概念,比如persistentflags代表適用於所有子命令的flag,localflags代表只用於當前子命令的flag,requiredflags代表必選 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):以橫線或雙橫線開頭的參數,標識又由標識名和標識參數組成

--flagnameflagarg

非標識參數

子命令(subcommand),子命令也會有子命令,標識和非標識參數

$ command --flag flagarg subcommand subcmdarg --subcmdfag subcmdflagarg

四 啟發式命令行解析
我們來重新審視一下第一個需求,即我們期望任何一個子命令的實現,都跟使用標準庫的 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 簡單解析流程
定義echo子命令關聯到函數echo,echotimes子命令關聯到函數echoTimes

解析字符串echotimeshello--times3

解析第一個參數,通過echo匹配到我們預定義的echo子命令,同時發現這也是echotimes命令的前綴部分,此時,只有知道後一個參數是什麼,我們才能確定用戶調用的是echo還是echotimes
解析第二個參數,通過times我們匹配到echotimes子命令,並且其不再是任何子命令的前綴。此時確定子命令為echotimes,其他所有參數皆為這個子命令的參數。
如果解析第二個參數為hello,那麼其只能匹配到echo這個子命令,那麼會調用echo函數而不是echoTimes函數。

2 啟發式探測流程
上面的解析比較簡單,但現實情況下,我們往往期望允許標識可以出現在命令行的任意位置,比如,我們期望新加一個控制打印顏色的選項--colorred,從邏輯上講,顏色選項更多的是對echo的描述,而非對times的描述,因此我們期望可以支持如下的命令行:

$ app echo --color red times hello --times 3

此時,我們期望調用的子命令仍然是echotimes,然而中間的參數讓情況變得複雜起來,因為這裡的參數red可能是--color的標識參數(red),可能是子命令的一部分,也可能是子命令的參數。更有甚者,用戶還可能把參數錯誤的寫為--colortimes

所謂啟發式的探測,是指當解析到red參數時,我們並不知道red到底是子命令(或者子命令的前綴部分),還是子命令的參數,因此我們可以將其假定為子命令的前綴進行匹配,如果匹配不到,則將其當做子命令參數處理。

解析到red時,用echored搜索預定義的子命令,若搜索不到,則將red視為參數
解析times時,用echotimes搜索預定義的子命令,此時可搜索到echotimes子命令

可以看到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 providing a 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完全一樣,只是自動生成的幫助信息有一些區別

# 不加任何子命令,輸出自動生成的幫助信息$ ./appAvailable commands:print print anything to the screenecho echo anything to the screenecho times echo anything to the screen more times# 默認啟用 -h, --help 選項,開發者無需做任何事情$ ./app print -hPrint 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 world hello world # echo 任意次數$ ./app echo times hello world --times 3 hello world hello world hello world# --times 參數可以在任意位置$ ./app echo --times 3 times hello world hello world hello world hello world

1 選項與默認值
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 -hEcho 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 world hello world hello world hello world

3 help標識和命令
Cortana 自動為任意命令生成幫助信息,這個行為也可以通過 cortana.DisableHelpFlag禁用,也可以通過 cortana.HelpFlag來設定自己喜歡的標識名。

cortana.Use(cortana.HelpFlag("--usage", "-u"))

# 自定義 --usage 來打印幫助信息$ ./app echo --usageEcho 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 command

Cortana默認並沒有提供help子命令,但利用別名的機制,我們自己很容易實現help命令。

cortana.Alias("help", "--help")

// 通過別名,實現 help 命令,用於打印任意子命令的幫助信息$ ./app help echo timesEcho anything to the screen more timesecho things multiple times back to the user by providing a count and a string.Usage: echo times [options] [texts...] -t, --times <times> times to echo the input. (default=1) -h, --help help for the command

4 配置文件與環境變量
除了通過命令行參數實現變量的綁定外,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 --versionv0.1.1

六 總結
命令行解析是一個大家都會用到,但並不是特別重要的功能,除非是專注於命令行使用的工具,一般程序我們都不需要過多關注命令行的解析,所以對於對這篇文章的主題感興趣,並能讀到文章最後的讀者,我表示由衷的感謝。

flag庫簡單易用,cobra功能豐富,這兩個庫已經幾乎可以滿足我們所有的需求。然而,我在編寫命令行程序的過程中,總感到現有的庫美中不足,flag庫只解決標識解析的問題,cobra庫雖然支持子命令和參數的解析,但把子命令和參數的解析耦合在一起,導致參數定義跟函數分離。Cortana的核心訴求是將命令查找和參數解析解耦,我通過重新回歸命令行參數的本質,發明了啟發式解析的方法,最終實現了上述目標。這種解耦使得Cortana即具備cobra一樣的豐富功能,又有像flag一樣的使用體驗。這種通過精巧設計而用非常簡單的機制實現強大功能體驗讓我感到非常舒適,希望通過這篇文章,可以跟大家分享我的快樂。

項目地址:https://github.com/shafreeck/cortana


數據庫核心概念

數據庫,簡而言之可視為電子化的文件櫃——存儲電子文件的處所,用戶可以對文件中的數據運行新增、截取、更新、刪除等操作。數據庫管理系統(Database Management System,簡稱DBMS)是為管理數據庫而設計的電腦軟件系統,一般具有存儲、截取、安全保障、備份等基礎功能 要想學習數據庫,需要了解SQL、索引、視圖、鎖等概念,本節課帶你走進數據庫。

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()