一種命令列解析的新思路(Go語言描述)

一  概述

命令列解析是幾乎每個後端程式設計師都會用到的技術,但相比業務邏輯來說,這些細枝末節顯得並不緊要,如果僅僅追求滿足簡單需求,命令列的處理會比較簡單,任何一個後端程式設計師都可以信手拈來。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.gothe 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")funcmain() {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 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 echotimes hello --times 3Echo: helloEcho: helloEcho: hello
cobra 功能強大,邏輯清晰,因此得到大家廣泛的認可,然而,這裡卻有兩個問題讓我無法滿意,雖然問題不大,但時時縈懷於心,讓人鬱郁。

1  引數定義跟命令邏輯分離

從上面 –times的定義可以看到,引數的定義跟命令邏輯的定義(即這裡的 Run)是分離的,當我們有大量子命令的時候,我們更傾向把命令的定義放到不同的檔案甚至目錄,這就會出現命令的定義是分散的,而所有命令的引數定義卻集中在一起的情況。
當然,這個問題用 cobra 也很好解決,只要把引數定義從 main函式移動到 init函式,並將 init 函式分散到跟子命令的定義一起即可。比如子命令 times 定義在 times.go檔案中,同時在檔案中定義 init函式,函式中定義了 times 的引數。然而,這樣導致當引數比較多時需要定義大量的全域性變數,這對於追求程式碼清晰簡潔無副作用的人來說如芒刺背。
為什麼不能像 flag庫一樣,把引數定義放到命令函式的裡面呢?這樣程式碼更緊湊,邏輯更直觀。
// 為什麼我不能寫成下面這樣呢?functimes(){ 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),子命令也會有子命令,標識和非標識引數
$command --flag flagarg subcommand subcmdarg --subcmdfag subcmdflagarg

四  啟發式命令列解析

我們來重新審視一下第一個需求,即我們期望任何一個子命令的實現,都跟使用標準庫的 flag 一樣簡單。這也就意味著,只有在執行這個函式的時候,才開始解析其命令列引數。如果我們能把子命令和其他引數區分開來,那麼就可以先執行子命令對應的函式,後解析這個子命令的引數。
flag 之所以在 main中呼叫 Parse, 是因為 shell 已經知道字串的第一個項是命令本身,後面所有項都是引數,同樣的,如果我們能識別出子命令來,那麼也可以讓以下程式碼變為可能:
funccommand(){// 定義 flags// 呼叫 Parse 函式}
問題的關鍵是如何將子命令跟其他引數區分開來,其中標識名以橫線或雙橫線開頭,可以顯而易見的區別開來,其他則需要區分子命令、子命令引數以及標識引數。仔細思考可以發現,我們雖然期望引數無需預先定義,但子命令是可以預先定義的,透過把非標識名的引數,跟預先定義的子命令比對,則可以識別出子命令來。
為了演示如何識別出子命令,我們以上面 cobra 的程式碼為例,假設 cobra.go 程式碼編譯為程式 app,那麼其命令列可以執行
$ app echotimes hello --times 3
按 cobra 的概念, times 是 echo 的子命令,而 echo 又是 app 的子命令。我們則把 echo times整體作為 app 的子命令。

1  簡單解析流程

  1. 定義echo子命令關聯到函式echo, echo times子命令關聯到函式 echoTimes
  2. 解析字串 echo times hello –times 3
  3. 解析第一個引數,透過 echo匹配到我們預定義的 echo子命令,同時發現這也是 echo times命令的字首部分,此時,只有知道後一個引數是什麼,我們才能確定使用者呼叫的是 echo還是 echo times
  4. 解析第二個引數,透過 times我們匹配到 echo times子命令,並且其不再是任何子命令的字首。此時確定子命令為 echo times,其他所有引數皆為這個子命令的引數。
  5. 如果解析第二個引數為 hello,那麼其只能匹配到 echo這個子命令,那麼會呼叫 echo函式而不是 echoTimes函式。

2  啟發式探測流程

上面的解析比較簡單,但現實情況下,我們往往期望允許標識可以出現在命令列的任意位置,比如,我們期望新加一個控制列印顏色的選項 –color red,從邏輯上講,顏色選項更多的是對 echo的描述,而非對 times的描述,因此我們期望可以支援如下的命令列:
$ app echo --color red times hello --times 3
此時,我們期望呼叫的子命令仍然是 echo times,然而中間的引數讓情況變得複雜起來,因為這裡的引數 red可能是 —color的標識引數(red),可能是子命令的一部分,也可能是子命令的引數。更有甚者,使用者還可能把引數錯誤的寫為 –color times
所謂啟發式的探測,是指當解析到 red引數時,我們並不知道 red到底是子命令(或者子命令的字首部分),還是子命令的引數,因此我們可以將其假定為子命令的字首進行匹配,如果匹配不到,則將其當做子命令引數處理。
  1. 解析到 red時,用 echo red搜尋預定義的子命令,若搜尋不到,則將 red視為引數
  2. 解析 times時,用 echo times搜尋預定義的子命令,此時可搜尋到 echo times子命令
可以看到 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")funcprint() { 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, " "))}funcecho() { 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, " "))}funcechoTimes() { 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, " ")) }}funcmain() { 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:printprint anything to the screenechoecho anything to the screenechotimesecho 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, --helphelpfor the command# echo 任意內容$ ./app echo hello world hello world# echo 任意次數$ ./app echotimes 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命令是相同的,我們其實可以透過別名的方式將兩者關聯
// 定義 printecho 命令的別名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:echotimesecho anything to the screen more timesUsage: echo [texts...] -h, --helphelpfor the command
別名的機制非常靈活,可以為任意命令和引數設定別名,比如我們期望實現 three這個子命令,列印任意字串 3 次。可以直接透過別名的方式實現:
cortana.Alias("three", "echo times --times 3")
# three 是 echotimes --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:echotimesecho anything to the screen more timesUsage: echo [texts...] -u, --usage helpfor the command
Cortana 預設並沒有提供 help子命令,但利用別名的機制,我們自己很容易實現 help命令。
cortana.Alias("help", "--help")
// 透過別名,實現 help 命令,用於列印任意子命令的幫助資訊$ ./app helpechotimesEcho anything to the screen more timesecho things multiple times back to the user by providing a count and a string.Usage: echotimes [options] [texts...] -t, --times <times> times to echo the input. (default=1) -h, --helphelpfor the command

4  配置檔案與環境變數

除了透過命令列引數實現變數的繫結外,Cortana 還支援使用者自定義繫結配置檔案和環境變數,Cortana 並不負責配置檔案或環境變數的解析,使用者可以藉助第三方庫來實現這個需求。Cortana 在這裡的主要作用是根據優先順序合併不同來源的值。其遵循的優先順序順序如下:
預設值 < 配置檔案 < 環境變數 < 引數
Cortana 設計為便於使用者使用任意格式的配置,使用者只需要實現 Unmarshaler 介面即可,比如,使用 JSON 作為配置檔案:
cortana.AddConfig("app.json", cortana.UnmarshalFunc(json.Unmarshal))
Cortana 將配置檔案或環境變數的解析完全交給第三方庫,使用者可以自由定義如何將配置檔案繫結到變數,比如使用 jsonTag。

5  沒有子命令?

Cortana 的設計將命令查詢和引數解析解耦,因此兩者可以分別獨立使用,比如在沒有子命令的場景下,直接在main函式中實現引數解析:
funcmain(){ 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、索引、檢視、鎖等概念,本節課帶你走進資料庫。


相關文章