cmdr
是另一個命令列引數處理器。
Golang 自己帶有 flags
進行命令列引數處理,算是便利的,然而和 Google 一貫的做法相同,非常獨,非常反人類。
在計算機人機互動介面的歷史上,命令列的互動方式只有一種是貫穿始終,得到傳承和延續的,那就是 getopt
以及 getopt_long
。說起 getopt
來也可以講述一個怪長的故事,然而本文不做此打算。無論如何,你需要知道的就是,getopt及其互動介面已經是POSIX的一部分,一個卓有成效的程式設計師、開發者、科學家,或者計算機從業者,對於這個介面都已經是訓練有素,無需成本了。你可能在用著它,但你或許只是沒有意識到它的存在而已。GNU的大部分命令列小刀都採用了這樣的介面,所以,例如tar, gwk, gzip, ls, rm, …,以及無法列舉的那些工具都是這樣的介面。
所以,自行其是,自己搞一套,並非不可以。但我可以不買賬。
那麼,這並非我獨自一人的自賞。我們只需要知道,在 Golang 的開源圈子裡,已經有了數十種 getopt-like 的復刻本,用以為 Golang 開發的應用程式提供更好的命令列介面。這裡面不乏 viper/cobra, cli 那樣的鉅作,也有一些小巧精幹的實現。
cmdr
也是這麼一個 getopt-like 的實現。和已有的其它實現不同之處在於,cmdr基本上原樣複製了 getopt 的表現。也就是說,一個典型的 Unix/Linux 應用程式,例如 cp,mv 等等,是怎麼做的,那麼基於 cmdr
的應用程式也就是怎麼做的。這裡講的當然是關於命令列引數怎麼被解釋的問題,而非應用程式的具體邏輯。
讓我們來看看都有哪些具體方面。
POSIX 約定
POSIX 表示可移植作業系統介面(英語:Portable Operating System Interface,縮寫為POSIX)是 IEEE(電氣和電子工程師協會,Institute of Electrical and Electronics Engineers)為要在各種UNIX作業系統上執行軟體,而定義API的一系列互相關聯的標準的總稱,其正式稱呼為IEEE Std 1003,而國際標準名稱為ISO/IEC 9945。此標準源於一個大約開始於1985年的專案。POSIX這個名稱是由 理查德·斯托曼(RMS)應IEEE的要求而提議的一個易於記憶的名稱。它基本上是Portable Operating System Interface(可移植作業系統介面)的縮寫,而X則表明其對Unix API的傳承。 電氣和電子工程師協會(Institute of Electrical and Electronics Engineers,IEEE)最初開發 POSIX 標準,是為了提高 UNIX 環境下應用程式的可移植性。然而,POSIX 並不侷限於 UNIX。許多其它的作業系統,例如 DEC OpenVMS 和 Microsoft Windows NT,都支援 POSIX 標準。
下面是 POSIX 標準中關於程式名、引數的約定:
- 程式名不宜少於2個字元且不多於9個字元;
- 程式名應只包含小寫字母和阿拉伯數字;
- 選項名應該是單字元活單數字,且以短橫‘-‘為字首;
- 多個不需要選項引數的選項,可以合併。(譬如:
foo -a -b -c ---->foo -abc
) - 選項與其引數之間用空白符隔開;
- 選項引數不可選。
- 若選項引數有多值,要將其併為一個字串傳進來。譬如:
myprog -u "arnold,joe,jane"
。這種情況下,需要自己解決這些引數的分離問題。 - 選項應該在運算元出現之前出現。
- 特殊引數
‘--'
指明所有引數都結束了,其後任何引數都認為是運算元。 - 選項如何排列沒有什麼關係,但對互相排斥的選項,如果一個選項的操作結果覆蓋其他選項的操作結果時,最後一個選項起作用;如果選項重複,則順序處理。
- 允許運算元的順序影響程式行為,但需要作文件說明。
- 讀寫指定檔案的程式應該將單個引數'-'作為有意義的標準輸入或輸出來對待。
GNU長選項約定
- 對於已經遵循POSIX約定的GNU程式,每個短選項都有一個對應的長選項。
- 額外針對GNU的長選項不需要對應的短選項,僅僅推薦要有。
- 長選項可以縮寫成保持惟一性的最短的字串。
- 選項引數與長選項之間或通過空白字元活通過一個'='來分隔。
- 選項引數是可選的(只對短選項有效)。
- 長選項允許以一個短橫線為字首。
getopt 介面
以下對 getopt 以及 getopt_long 提供的介面進行描述,cmdr
具備相同的能力。
在以下的行文中,短引數
和短選項
是等同的概念,其它詞彙也類似如此,不再贅述。
短引數
單個短橫線引導的單個字元的引數,被稱為短引數。例如:-v
,-d
,等等。有的時候,短引數也可能有兩個字元甚至更多個字母。然而,短引數的用意就在於縮略,因此多字元的短引數很少見,且通常被用於組合,更像是典型的單字元短引數字尾以一個取值。例如 rar 的選項中有 -ep, -ep1, -ep3:
ep Exclude paths from names
ep1 Exclude base directory from names
ep3 Expand paths to full including the drive letter
複製程式碼
然而在實現其處理器時,我們可以提供 -ep<n>
的處理器就夠了,所以你仍然可以將其視為 -ep
短引數的變形。
長引數
兩個短橫線引導的多個字元的引數,被稱為長引數。例如:--debug
,--version
等等。
一般來說,長引數更具備描述性,通常使用單詞、片語來構成長引數。例如 docker 的子命令 docker checkpoint create
:
$ docker checkpoint create --help
Usage: docker checkpoint create [OPTIONS] CONTAINER CHECKPOINT
Create a checkpoint from a running container
Options:
--checkpoint-dir string Use a custom checkpoint storage directory
--leave-running Leave the container running after checkpoint
複製程式碼
引數描述
每條命令或引數選項可以被一段文字以描述。
引數重複堆疊
無論長短引數,可以以任意順序出現,也可以任意出現多次。對於多次出現的引數,一般來說是最後一次出現的為準,之前出現過的會被覆蓋。
例如命令列:-1 -a yy -a dd -a cc
,則對於引數a來說,其有效值為 ”cc“,此前出現的都被覆蓋了。
bool型短引數的組合
對於getopt不帶值的引數,例如 "1abc"
,以下的命令列都是有效的:
-1 -a -b -c
-abc1
-ac -1b
- ...
順序是不敏感的,組合是任意的。
必須帶值的引數
getopt的定義是引數後加一個冒號,例如 “1a:b::"
中的引數 a
,對它你需要指定命令列形如 -1 -a xxx
。
可選值的引數
getopt的定義是引數後加兩個冒號,例如 “1a:b::"
中的引數 b
,對它你需要指定命令列形如 -1 -b
或者 -1 -bvalue
。
在 getopt 介面上的增強
命令和子命令
以 docker 的子命令 checkpoint 為例:
graph LR
A[docker] -->|Commands| B(checkpoint)
B --> D[create]
B --> E[ls]
B --> F[rm]
複製程式碼
事實上,命令與子命令是沒有區別的,如果有必要,可以建立任意多級的命令和子命令巢狀層次。不過在實際的 Command-Line UI 設計中,超過4層的子命令巢狀都是極少數,因為這也會給使用工具的人帶來麻煩。
Shell自動完成
在現代的命令列介面中,自動完成(Shell Completion)已經是一個關鍵性特性了。流行的命令列介面例如 Bash、Zsh、Fish 都提供了自動完成的特性。通常一個應用程式需要面向這個Shells 提供配套的自動完成指令碼,從而獲得自動完成能力。
一個已經支援自動完成的應用程式的命令列輸入可能是這樣子的:
Bash 的自動完成
docker 的 自動完成
Zsh 的自動完成
docker 在 zsh 中的自動完成。可以注意到 zsh 的 TAB 按鍵次數更簡練,而且列表選擇介面也更有效和更具有提示性。當然,zsh的自動完成也存在一些bug,例如一級命令列表超出終端螢幕可視行數時列表選擇介面就被破碎掉了。
cmdr 的使用方法
cmdr
的使用方法儘可能簡單化了,接下來我們做一個簡明的介紹。
一個簡單的入口可以這樣:
package main
import (
"fmt"
"github.com/hedzr/cmdr"
)
func main() {
// logrus.SetLevel(logrus.DebugLevel)
// logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true,})
// 可選的四個選項:
cmdr.EnableVersionCommands = true
cmdr.EnableVerboseCommands = true
cmdr.EnableHelpCommands = true
cmdr.EnableGenerateCommands = true
if err := cmdr.Exec(rootCmd); err != nil {
fmt.Printf("Error: %v", err) // or log, logrus
}
}
var(
rootCmd = &cmdr.RootCommand{
Command: cmdr.Command{
BaseOpt: cmdr.BaseOpt{
Name: "short",
Flags: []*cmdr.Flag{
},
},
SubCommands: []*cmdr.Command{
serverCommands,
// msCommands,
},
},
AppName: "short",
Version: cmdr.Version,
VersionInt: cmdr.VersionInt,
Copyright: "austr is an effective devops tool",
Author: "Your Name <yourmail@gmail.com>",
}
serverCommands = &cmdr.Command{
BaseOpt: cmdr.BaseOpt{
Short: "s",
Full: "server",
Aliases: []string{"serve", "svr",},
Description: "server ops: for linux service/daemon.",
Flags: []*cmdr.Flag{
{
BaseOpt: cmdr.BaseOpt{
Short: "f",
Full: "foreground",
Aliases: []string{"fg",},
Description: "running at foreground",
},
},
},
},
SubCommands: []*cmdr.Command{
{
BaseOpt: cmdr.BaseOpt{
Short: "s",
Full: "start",
Aliases: []string{"run", "startup",},
Description: "startup this system service/daemon.",
Action: func(cmd *cmdr.Command, args []string) (err error) {
return
},
},
},
{
BaseOpt: cmdr.BaseOpt{
Short: "t",
Full: "stop",
Aliases: []string{"stp", "halt", "pause",},
Description: "stop this system service/daemon.",
},
},
{
BaseOpt: cmdr.BaseOpt{
Short: "r",
Full: "restart",
Aliases: []string{"reload",},
Description: "restart this system service/daemon.",
},
},
{
BaseOpt: cmdr.BaseOpt{
Full: "status",
Aliases: []string{"st",},
Description: "display its running status as a system service/daemon.",
},
},
{
BaseOpt: cmdr.BaseOpt{
Short: "i",
Full: "install",
Aliases: []string{"setup",},
Description: "install as a system service/daemon.",
},
},
{
BaseOpt: cmdr.BaseOpt{
Short: "u",
Full: "uninstall",
Aliases: []string{"remove",},
Description: "remove from a system service/daemon.",
},
},
},
}
)
複製程式碼
可以看到的是,cmdr.RootCommand 和 cmdr.Command 的區別不大,只是多了應用程式資訊的成員欄位。而 cmdr.Command 和 cmdr.Flag 的區別也不大,它們都有相同的 BaseOpt 嵌入結構。
因此,定義命令和定義選項是很相似的,然後你需要進行正確的結構巢狀。如果感到巢狀結構迷亂了眼睛,則可以抽出一個子命令、或者一組子命令到一個獨立的變數中,然後使用引用的方式嵌入到上級命令的恰當位置。
這種抽出的方式也適合於進行相似結構的共享,但要注意引用和深度拷貝的區別。此處不做進一步討論了,總之,如果感到沒有把握,不妨一級一級地老老實實地完成定義,一般的工具開發也不會有太多的巢狀的吧。
言歸正傳,完成了上面的定義之後,就可以編譯成執行檔案執行了(或者用 go run main.go)。你可以在終端中嘗試使用它:
bin/short
bin/short --help
bin/short --version
bin/short -#
bin/short --debug --verbose server --help
bin/short svr --help --debug -v
bin/short s start -f --help ~~debug
複製程式碼
指定 Action
每條命令(cmdr.Command
)都可以指定 Action
,一個 func 物件。你將要為命令編寫的業務邏輯就在這裡。
如果有必要的話,一個選項(cmdr.Flag
)也可以被提供一個 Action
,如果你需要在選項被掃描到時觸發點其他邏輯的話。
對於命令而言,你可以提供額外的 PreAction
和 PostAction
,它們分別是在命令的 Action
被執行的前後被呼叫的。特別是 PreAction
允許返回一個特別的錯誤值 cmdr.ErrShouldBeStopException
來告訴 cmdr
終止後續處理,所以你可以有機會避免 命令的 Action 部分被執行。
由於 RootCommand
也是一個 Command
,所以定義在 RootCommand
中的 PreAction
,postAction
有著特別的處理邏輯:
RootCommand.PreAction
將會在具體 Command.PreAction
執行之前被執行;RootCommand.PostAction
將會在具體 Command.PostAction
執行之後被執行。
這樣的特別邏輯是為了便於開發者定義自己的前置、退出邏輯。例如一個微服務應該在開始提供服務之前完成註冊中心登記,以及在停止服務時撤銷登記,這些任務適合於在 RootCommand
的 Pre/PostAction
中來做。
~~debug
~~debug
是一個隱藏性的標誌。~~
和 long 引數 是相似的,不過它的不同在於,相應的引數的入口不會被建立在 標準名字空間中,因此你需要在頂級名字空間中抽取它的值。
~~debug
有著一個特別的作用,在除錯階段,這個選項將會使得正常處理邏輯結束後,附加一段除錯性的資訊輸出,其中包含 所有有效的選項及其最終值,還包含這些選項的 yaml 文字形式。
一個式樣是:
你可以通過:
bin/wget-demo ~~debug
複製程式碼
來檢視相似的輸出結果。
我相信這個功能可以幫助你解決很多問題,不必再來猜來猜去的了。
名字空間
所有的選項值都被放在標準名字空間中,cmdr.RxxtPrefix
定義了標準名字空間的層級,其預設值為 app
。為了
這意味著 RootCommand 的 Flags,例如 --version
,可以用 cmdr.GetBool("app.version")
來抽取其值。類似的,--debug
的抽取語句為 cmdr.GetBool("app.debug")
。
前面說過
~~debug
有點特殊,這樣的不加字首的選項的值可以直接抽取:cmdr.GetBool("debug")
。
每一級命令或子命令就會建立一個巢狀的名字空間,其名稱取自命令的 Full
欄位,也就是長引數名。因此 bin/short server start -f
的 -f
的抽取語句為 cmdr.GetBool("app.server.start.foreground")
。
你當然可以執行不同的 cmdr.RxxtPrefix
,例如:
cmdr.RxxtPrefix = []string{"server",}
// 等價於使用 ”server.xxx" 而不是 “app.xxx”
複製程式碼
一個選項的值是可以多種形態的,但總的來說我們支援四種資料型別:
- bool
- int
- string
- string slice
更多的型別,我們暫不直接支援。未來或會予以增強。
環境變數過載
可以使用環境變數過載去覆蓋命令列引數。
所以:
CMDR_APP_SERVER_START_FOREGROUND=1 bin/short server start
等價於 bin/short server start -f
。
如果你希望使用非 ”CMDR_“ 的環境變數字首,你可以設定 cmdr.EnvPrefix
來自行控制字首。例如
cmdr.EnvPrefix = []string{ "Rx", "cd", }
// 等價於使用 RX_CD_ 字首
複製程式碼
當前版本的問題
環境變數的優先順序較低,如果配置檔案或者命令列引數有指定值,則環境變數的設定值就被掩蓋了。
這不符合慣例,我們考慮在下一版本中解決此問題。
我們將會實現的優先順序為:defaultValue -> config-file -> env-var -> command-line opts。
配置檔案的自動載入
預設情況下,cmdr
自動檢視如下檔案:
/etc/<appname>/<appname>.yml
/usr/local/etc/<appname>/<appname>.yml
$HOME/.<appname>/<appname>.yml
cmdr
也會自動裝載相應的 conf.d
子目錄中的所有 yaml 檔案,並依次載入和覆蓋選項的定義值。因此你可以切分大型配置檔案到多個小檔案中,以便於運維部署和管理。
對於開發者來說,cmdr
還會首先檢查專案目錄下的 ./ci/etc/<appname>/<appname>.yml
是否有效並試圖自動載入它及其 conf.d 子目錄。
cmdr
支援 conf.d 資料夾的監視,其中的變化會被傳送給所有註冊的 listeners。關於這個方面的細節,可以檢視:
cmdr.AddOnConfigLoadedListener(c)
cmdr.RemoveOnConfigLoadedListener(c)
cmdr.SetOnConfigLoadedListener(c, enabled)
當前版本的問題
無法定製載入位置、無法忽略載入位置,等等。
其他的配置檔案格式也暫時不支援。
例項 wget-demo
我們已經實現了一個 wget 的命令列介面復刻版本,但是僅提供小部分命令列引數的處理,因為完整的復刻版本基本上只是一個重複的勞作了,作為示例我們已經實現了足夠多的選項,足以說明 cmdr
的能力了。
wget-demo 的幫助屏是這樣的:
和 gnu 的 wget 相比較而言,看起來也算是沒有區別了。
wget-demo 的原始碼可以在這裡找到:
cmdr 的版本規劃方式
semver是符合規範的。
關於 semver 的含義可以檢視如下兩個連結,無需多言:
更多的介紹
cmdr
是在早前若干個非正式實現的基礎上重寫的一個新的實現,其首要目標就是完完全全地 Unix/Linux 命令列介面,而不是 golang 風格的、或者其它的部分實現的風格。
getopt
以及 getopt_long
都有自己的引數定義方式,不過在這個方面,cmdr
不打算實現它們的模擬風格,因為那並不方便也不算直觀。
cmdr
盡力做到的是,命令和引數定義完成之後就完成了一切。除此而外,你無需做別的事就能得到:
- 自動的幫助屏
- 自動的配置檔案載入
- 配置檔案切分到
conf.d
子目錄,且自動監視其變更 - 完全的 Unix/Linux Command-Line UI
- 允許環境變數過載到選項
- 支援 Shell 自動完成特性
- 更多特性...
目前已經實現的是主體的大部分特性,細節尚未打磨完美,還需要繼續投入力量進行改善。然而作為建設的主要目標已經可作為已達成了。
更多 cmdr
用法,今後繼續進行描述。
- Source: github.com/hedzr/cmdr