從源碼的角度看Go語言Flag庫如何解析命令行參數!
我上周五喝酒喝到晚上3點多,確實有點罩不住啊,整個周末都在休息和睡覺,文章鴿了幾天,想不到就有兩個人跑了。
不得不感嘆一下,自媒體的太殘酷了,時效就那么幾天,斷更就沒人愛。你們說好了愛我的,愛呢?哼
昨晚就在寫這篇文章了,沒想到晚上又遇到發版本,確實不容易,且看且珍惜。
- 標準庫 flag
- flag的簡寫方式
- 從源碼來看flag如何解析參數
- 從源碼想到的拓展用法
- 小結
- 引用
標準庫 flag
命令行程序應該能打印出幫助信息,傳遞其他命令行參數,比如-h就是flag庫的默認幫助參數。
- ./goapi -h
- Usage of ./goapi:
- -debug
- is debug
- -ip string
- Input bind address (default "127.0.0.1")
- -port int
- Input bind port (default 80)
- -version
- show version information
goapi是我build出來的一個二進制go程序,上面所示的四個參數,是我自定義的。
按提示的方法,可以像這樣使用參數。
- ./goapi -debug -ip 192.168.1.1
- ./goapi -port 8080
- ./goapi -version
像上面-version這樣的參數是bool類型的,只要指定了就會設置為true,不指定時為默認值,假如默認值是true,想指定為false要像下面這樣顯式的指定(因為源碼里是這樣寫的)。
- ./goapi -version=false
下面這幾種格式都是兼容的
- -isbool #同于 -isbool=true
- -age=x #-和等號
- -age x #-和空格
- --age=x #2個-和等號
- --age x #2個-和空格
flag庫綁定參數的過程很簡單,格式為
- flag.(name string, value bool, usage string) *類型
如下是詳細的綁定方式:
- var (
- showVersion = flag.Bool("version", false, "show version information")
- isDebug = flag.Bool("debug", false, "is debug")
- ip = flag.String("ip", "127.0.0.1", "Input bind address")
- port = flag.Int("port", 80, "Input bind port")
- )
可以定義任意類型的變量,比如可以表示是否debug模式、讓它來輸出版本信息、傳入需要綁定的ip和端口等功能。
綁定完參數還沒完,還得調用解析函數flag.Parse(),注意一定要在使用參數前調用哦,使用過程像下面這樣:
- func main() {
- flag.Parse()
- if *showVersion {
- fmt.Println(version)
- os.Exit(0)
- }
- if *isDebug {
- fmt.Println("set log level: debug")
- }
- fmt.Println(fmt.Sprintf("bind address: %s:%d successfully",*ip,*port))
- }
全部放在main函數里,不太雅觀,建議把這些單獨放到一個包里,或者放在main函數的init()里,看起來不僅舒服,也便于閱讀。
flag的簡寫方式
有時候可能我們要給某個全局配置變量賦值,flag提供了一種簡寫的方式,不用額外定義中間變量。像下面這樣
- var (
- ip string
- port int
- )
- func init() {
- flag.StringVar(&ip, "ip", "127.0.0.1", "Input bind address(default: 127.0.0.1)")
- flag.IntVar(&port, "port", 80, "Input bind port(default: 80)")
- }
- func main() {
- flag.Parse()
- fmt.Println(fmt.Sprintf("bind address: %s:%d successfully", ip, port))
- }
這樣寫可以省掉很多判斷的代碼,也避免了使用指針,命令行的使用方法還是一樣的。
從源碼來看flag如何解析參數
其實我們把之前的綁定方式打開來看,在源碼里就是調用了xxVar函數,以Bool類型為例。
- func (f *FlagSet) Bool(name string, value bool, usage string) *bool {
- p := new(bool)
- f.BoolVar(p, name, value, usage)
- return p
- }
上面的代碼用到了BoolVal函數,它的功能是把需要綁定的變量設置為默認值,并調用f.Var進一步處理,這里p是一個指針,所以只要改變指向的內容,就可以影響到外部綁定所用的變量:
- func (f *FlagSet) BoolVar(p *bool, name string, value bool, usage string) {
- f.Var(newBoolValue(value, p), name, usage)
- }
- type boolValue bool
- func newBoolValue(val bool, p *bool) *boolValue {
- *p = val
- return (*boolValue)(p)
- }
- newBoolValue 函數可以得到一個boolValue類型,它是bool類型重命名的。在此包中所有可作為參數的類型都有這樣的定義。
- 在flag包的設計中有兩個重要的類型,Flag和FlagSet分別表示某個特定的參數,和一個無重復的參數集合。
f.Var函數的作用就是把參數封裝成Flag,并合并到FlagSet中,下面的代碼就是核心過程:
- func (f *FlagSet) Var(value Value, name string, usage string) {
- // Remember the default value as a string; it won't change.
- flag := &Flag{name, usage, value, value.String()}
- _, alreadythere := f.formal[name]
- if alreadythere {
- //...錯誤處理省略
- }
- if f.formal == nil {
- f.formal = make(map[string]*Flag)
- }
- f.formal[name] = flag
- }
FlagSet結構體中起作用的是formal map[string]*Flag類型,所以說,flag把程序中需要綁定的變量包裝成一個字典,后面解析的時候再一一賦值。
我們已經知道了,在調用Parse的時候,會對參數解析并為變量賦值,使用時就可以得到真實值。展開看看它的代碼
- func Parse() {
- // Ignore errors; CommandLine is set for ExitOnError.
- // 調用了FlagSet.Parse
- CommandLine.Parse(os.Args[1:])
- }
- // 返回一個FlagSet
- var CommandLine = NewFlagSet(os.Args[0], ExitOnError)
Parse的代碼里用到了一個,CommandLine共享變量,這就是內部庫維護的FlagSet,所有的參數都會插到里面的變量地址向地址的指向賦值綁定。
上面提到FlagSet綁定的Parse函數,看看它的內容:
- func (f *FlagSet) Parse(arguments []string) error {
- f.parsed = true
- f.args = arguments
- for {
- seen, err := f.parseOne()
- if seen { continue }
- if err == nil {...}
- switch f.errorHandling {
- case ContinueOnError: return err
- case ExitOnError:
- if err == ErrHelp { os.Exit(0) }
- os.Exit(2)
- case PanicOnError: panic(err)
- }
- }
- return nil
- }
- 上面的函數內容太長了,我收縮了一下。
- 可看到解析的過程實際上是多次調用了parseOne(),它的作用是逐個遍歷命令行參數,綁定到Flag,就像翻頁一樣。
- 用switch對應處理錯誤,決定退出碼或直接panic。
parseOne就是解析命令行輸入綁定變量的過程了:
- func (f *FlagSet) parseOne() (bool, error) {
- //...
- s := f.args[0]
- //...
- if s[1] == '-' { ...}
- name := s[numMinuses:]
- if len(name) == 0 || name[0] == '-' || name[0] == '=' {
- return false, f.failf("bad flag syntax: %s", s)
- }
- f.args = f.args[1:]
- //...
- m := f.formal
- flag, alreadythere := m[name] // BUG
- // ...如果不存在,或者需要輸出幫助信息,則返回
- // ...設置真實值調用到 flag.Value.Set(value)
- if f.actual == nil {
- f.actual = make(map[string]*Flag)
- }
- f.actual[name] = flag
- return true, nil
- }
- parseOne 內部會解析一個輸入參數,判斷輸入參數格式,獲取參數值。
- 解析過程就是逐個取出程序參數,判斷-、=取參數與參數值
- 解析后查找之前提到的formal map中有沒有存在此參數,并設置真實值。
- 把設置完畢真實值的參數放到f.actual map中,以供它用。
- 一些錯誤處理和細節的代碼我省略掉了,感興趣可以自行看源碼。
- 實際上就是逐個參數解析并設置到對應的指針變量的指向上,讓返回值出現變化。
flag.Value.Set(value) 這里是設置數據真實值的代碼,Value長這樣
- type Value interface {
- String() string
- Set(string) error
- }
它被設計成一個接口,不同的數據類型自己實現這個接口,返回給用戶的地址就是這個接口的實例數據,解析過程中,可以通過 Set 方法修改它的值,這個設計確實還挺巧妙的。
- func (b *boolValue) String() string {
- return strconv.FormatBool(bool(*b))
- }
- func (b *boolValue) Set(s string) error {
- v, err := strconv.ParseBool(s)
- if err != nil {
- err = errParse
- }
- *b = boolValue(v)
- return err
- }
從源碼想到的拓展用法
flag的常用方法也學會了,基本原理也了解了,我怎么那么厲害。哈哈哈。
有沒有注意到整個過程都圍繞了FlagSet這個結構體,它是最核心的解析類。
在庫內部提供了一個 *FlagSet 的實例對象 CommandLine,它通過NewFlagSet方法創建。并且對它的所有方法封裝了一下直接對外。
官方的意思很明確了,說明我們可以用到它做些更高級的事情。先看看官方怎么用的。
- var CommandLine = NewFlagSet(os.Args[0], ExitOnError)
可以看到調用的時候是傳入命令行第一個參數,第二個參數表示報錯時應該呈現怎樣的錯誤。
那就意味著我們可以根據命令行第一個參數不同而呈現不同的表現!
我定義了兩個參數foo或者bar,代表兩個不同的指令集合,每個指令集匹配不同的命令參數,效果如下:
- $ ./subcommands
- expected 'foo' or 'bar' subcommands
- $ ./subcommands foo -h
- Usage of foo:
- -enable
- enable
- $./subcommands foo -enable
- subcommand 'foo'
- enable: true
- tail: []
這是怎么實現的呢?其實就是用NewFlagSet方法創建多個FlagSet再分別綁定變量,如下:
- fooCmd := flag.NewFlagSet("foo", flag.ExitOnError)
- fooEnable := fooCmd.Bool("enable", false, "enable")
- barCmd := flag.NewFlagSet("bar", flag.ExitOnError)
- barLevel := barCmd.Int("level", 0, "level")
- if len(os.Args) < 2 {
- fmt.Println("expected 'foo' or 'bar' subcommands")
- os.Exit(1)
- }
- 定義兩個不同的FlagSet,接受foo或bar參數。
- 綁定錯誤時退出。
- 分別為每個FlagSet綁定要解析的變量。
- 如果判斷命令行輸入參數少于2個時退出(因為第0個參數是程序名本身)。
然后根據第一個參數,判斷應該匹配到哪個指令集:
- switch os.Args[1] {
- case "foo":
- fooCmd.Parse(os.Args[2:])
- fmt.Println("subcommand 'foo'")
- fmt.Println(" enable:", *fooEnable)
- fmt.Println(" tail:", fooCmd.Args())
- case "bar":
- barCmd.Parse(os.Args[2:])
- fmt.Println("subcommand 'bar'")
- fmt.Println(" level:", *barLevel)
- fmt.Println(" tail:", barCmd.Args())
- default:
- fmt.Println("expected 'foo' or 'bar' subcommands")
- os.Exit(1)
- }
- 使用switch來切換命令行參數,綁定不同的變量。
- 對應不同變量輸出不同表現。
- x.Args()可以打印未匹配到的其他參數。
補充:使用NewFlagSet時,flag 提供三種錯誤處理的方式:
- ContinueOnError: 通過 Parse 的返回值返回錯誤
- ExitOnError: 調用 os.Exit(2) 直接退出程序,這是默認的處理方式
- PanicOnError: 調用 panic 拋出錯誤
小結
通過本節我們了解到了標準庫flag的使用方法,參數變量綁定的兩種方式,還通過源碼解析了內部實現是如何的巧妙。
我們還使用源碼暴露出來的函數,接收不同參數匹配不同指令集,這種方式可以讓應用呈現完成不同的功能;
我想到的是用來通過環境變量改變命令用法、或者讓程序復用大段邏輯呈現不同作用時使用。
但現在微服務那么流行,大多功能集成在一個服務里是不科學的,如果有重復代碼應該提煉成共同模塊才是王道。
你還想到能哪些使用場景呢?
引用
源碼包 https://golang.org/src/flag/flag.go
命令行子命令 https://gobyexample-cn.github.io/command-line-subcommands
命令行解析庫 flag https://segmentfault.com/a/1190000021143456
騰訊云文檔flag https://cloud.tencent.com/developer/section/1141707#stage-100022105
往期精彩回顧
本文轉載自微信公眾號「機智的程序員小熊」,可以通過以下二維碼關注。轉載本文請聯系機智的程序員小熊公眾號。