從源碼的角度看Go語言flag庫如何解析命令行參數(shù)!
!我上周五喝酒喝到晚上3點多,確實有點罩不住啊,整個周末都在休息和睡覺,文章鴿了幾天,想不到就有兩個人跑了。
不得不感嘆一下,自媒體的太殘酷了,時效就那么幾天,斷更就沒人愛。你們說好了愛我的,愛呢?哼

昨晚就在寫這篇文章了,沒想到晚上又遇到發(fā)版本,確實不容易,且看且珍惜。
標準庫 flag
flag的簡寫方式
從源碼來看flag如何解析參數(shù)
從源碼想到的拓展用法
小結(jié)
引用
往期精彩回顧
標準庫 flag
命令行程序應該能打印出幫助信息,傳遞其他命令行參數(shù),比如-h就是flag庫的默認幫助參數(shù)。
./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程序,上面所示的四個參數(shù),是我自定義的。
按提示的方法,可以像這樣使用參數(shù)。
./goapi -debug -ip 192.168.1.1
./goapi -port 8080
./goapi -version
像上面-version這樣的參數(shù)是bool類型的,只要指定了就會設置為true,不指定時為默認值,假如默認值是true,想指定為false要像下面這樣顯式的指定(因為源碼里是這樣寫的)。
./goapi -version=false
下面這幾種格式都是兼容的
-isbool #同于 -isbool=true
-age=x #-和等號
-age x #-和空格
--age=x #2個-和等號
--age x #2個-和空格
flag庫綁定參數(shù)的過程很簡單,格式為
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和端口等功能。
綁定完參數(shù)還沒完,還得調(diào)用解析函數(shù)flag.Parse(),注意一定要在使用參數(shù)前調(diào)用哦,使用過程像下面這樣:
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函數(shù)里,不太雅觀,建議把這些單獨放到一個包里,或者放在main函數(shù)的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如何解析參數(shù)
其實我們把之前的綁定方式打開來看,在源碼里就是調(diào)用了xxVar函數(shù),以Bool類型為例。
func (f *FlagSet) Bool(name string, value bool, usage string) *bool {
p := new(bool)
f.BoolVar(p, name, value, usage)
return p
}
上面的代碼用到了BoolVal函數(shù),它的功能是把需要綁定的變量設置為默認值,并調(diào)用f.Var進一步處理,這里p是一個指針,所以只要改變指向的內(nèi)容,就可以影響到外部綁定所用的變量:
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函數(shù)可以得到一個boolValue類型,它是bool類型重命名的。在此包中所有可作為參數(shù)的類型都有這樣的定義。在 flag包的設計中有兩個重要的類型,Flag和FlagSet分別表示某個特定的參數(shù),和一個無重復的參數(shù)集合。
f.Var函數(shù)的作用就是把參數(shù)封裝成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結(jié)構(gòu)體中起作用的是formal map[string]*Flag類型,所以說,flag把程序中需要綁定的變量包裝成一個字典,后面解析的時候再一一賦值。
我們已經(jīng)知道了,在調(diào)用Parse的時候,會對參數(shù)解析并為變量賦值,使用時就可以得到真實值。展開看看它的代碼
func Parse() {
// Ignore errors; CommandLine is set for ExitOnError.
// 調(diào)用了FlagSet.Parse
CommandLine.Parse(os.Args[1:])
}
// 返回一個FlagSet
var CommandLine = NewFlagSet(os.Args[0], ExitOnError)
Parse的代碼里用到了一個,CommandLine共享變量,這就是內(nèi)部庫維護的FlagSet,所有的參數(shù)都會插到里面的變量地址向地址的指向賦值綁定。
上面提到FlagSet綁定的Parse函數(shù),看看它的內(nèi)容:
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
}
上面的函數(shù)內(nèi)容太長了,我收縮了一下。 可看到解析的過程實際上是多次調(diào)用了 parseOne(),它的作用是逐個遍歷命令行參數(shù),綁定到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
// ...如果不存在,或者需要輸出幫助信息,則返回
// ...設置真實值調(diào)用到 flag.Value.Set(value)
if f.actual == nil {
f.actual = make(map[string]*Flag)
}
f.actual[name] = flag
return true, nil
}
parseOne內(nèi)部會解析一個輸入?yún)?shù),判斷輸入?yún)?shù)格式,獲取參數(shù)值。解析過程就是逐個取出程序參數(shù),判斷 -、=取參數(shù)與參數(shù)值解析后查找之前提到的 formal map中有沒有存在此參數(shù),并設置真實值。把設置完畢真實值的參數(shù)放到 f.actual map中,以供它用。一些錯誤處理和細節(jié)的代碼我省略掉了,感興趣可以自行看源碼。 實際上就是逐個參數(shù)解析并設置到對應的指針變量的指向上,讓返回值出現(xiàn)變化。
flag.Value.Set(value) 這里是設置數(shù)據(jù)真實值的代碼,Value長這樣
type Value interface {
String() string
Set(string) error
}
它被設計成一個接口,不同的數(shù)據(jù)類型自己實現(xiàn)這個接口,返回給用戶的地址就是這個接口的實例數(shù)據(jù),解析過程中,可以通過 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這個結(jié)構(gòu)體,它是最核心的解析類。
在庫內(nèi)部提供了一個 *FlagSet 的實例對象 CommandLine,它通過NewFlagSet方法創(chuàng)建。并且對它的所有方法封裝了一下直接對外。
官方的意思很明確了,說明我們可以用到它做些更高級的事情。先看看官方怎么用的。
var CommandLine = NewFlagSet(os.Args[0], ExitOnError)
可以看到調(diào)用的時候是傳入命令行第一個參數(shù),第二個參數(shù)表示報錯時應該呈現(xiàn)怎樣的錯誤。
那就意味著我們可以根據(jù)命令行第一個參數(shù)不同而呈現(xiàn)不同的表現(xiàn)!
我定義了兩個參數(shù)foo或者bar,代表兩個不同的指令集合,每個指令集匹配不同的命令參數(shù),效果如下:
$ ./subcommands
expected 'foo' or 'bar' subcommands
$ ./subcommands foo -h
Usage of foo:
-enable
enable
$./subcommands foo -enable
subcommand 'foo'
enable: true
tail: []
這是怎么實現(xiàn)的呢?其實就是用NewFlagSet方法創(chuàng)建多個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參數(shù)。綁定錯誤時退出。 分別為每個 FlagSet綁定要解析的變量。如果判斷命令行輸入?yún)?shù)少于2個時退出(因為第0個參數(shù)是程序名本身)。
然后根據(jù)第一個參數(shù),判斷應該匹配到哪個指令集:
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來切換命令行參數(shù),綁定不同的變量。對應不同變量輸出不同表現(xiàn)。 x.Args()可以打印未匹配到的其他參數(shù)。
補充:使用NewFlagSet時,flag 提供三種錯誤處理的方式:
ContinueOnError: 通過Parse的返回值返回錯誤ExitOnError: 調(diào)用os.Exit(2)直接退出程序,這是默認的處理方式PanicOnError: 調(diào)用panic拋出錯誤
小結(jié)
通過本節(jié)我們了解到了標準庫flag的使用方法,參數(shù)變量綁定的兩種方式,還通過源碼解析了內(nèi)部實現(xiàn)是如何的巧妙。
我們還使用源碼暴露出來的函數(shù),接收不同參數(shù)匹配不同指令集,這種方式可以讓應用呈現(xiàn)完成不同的功能;
我想到的是用來通過環(huán)境變量改變命令用法、或者讓程序復用大段邏輯呈現(xiàn)不同作用時使用。
但現(xiàn)在微服務那么流行,大多功能集成在一個服務里是不科學的,如果有重復代碼應該提煉成共同模塊才是王道。
你還想到能哪些使用場景呢?
引用
源碼包 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
往期精彩回顧
PS:本來還想寫一下kingpin、cobra再拓展到配置解析的,沒想到加上源碼解讀內(nèi)容實在太多了,繼續(xù)關注我下次走起。
如有收獲,點個在看,誠摯感謝

