1. 程式人生 > >Go 每日一庫之 flag

Go 每日一庫之 flag

緣起

我一直在想,有什麼方式可以讓人比較輕易地保持每日學習,持續輸出的狀態。寫部落格是一種方式,但不是每天都有想寫的,值得寫的東西。
有時候一個技術比較複雜,寫部落格的時候經常會寫著寫著發現自己的理解有偏差,或者細節還沒有完全掌握,要去查資料,瞭解了之後又繼續寫,如此反覆。
這樣會導致一篇部落格的耗時過長。

我在每天瀏覽思否、掘金和Github的過程中,發現一些比較好的想法,有JS 每日一題,NodeJS 每日一庫,每天一道面試題等等等等。
https://github.com/parro-it/awesome-micro-npm-packages這個倉庫收集 NodeJS 小型庫,一天看一個不是夢!這也是我這個系列的靈感。
我計劃每天學習一個 Go 語言的庫,輸出一篇介紹型的博文。每天一庫當然是理想狀態,我心中的預期是一週 3-5 個。

今天是第一天,我們從一個基礎庫聊起————Go 標準庫中的flag

簡介

flag用於解析命令列選項。有過類 Unix 系統使用經驗的童鞋對命令列選項應該不陌生。例如命令ls -al列出當前目錄下所有檔案和目錄的詳細資訊,其中-al就是命令列選項。

命令列選項在實際開發中很常用,特別是在寫工具的時候。

  • 指定配置檔案的路徑,如redis-server ./redis.conf以當前目錄下的配置檔案redis.conf啟動 Redis 伺服器;
  • 自定義某些引數,如python -m SimpleHTTPServer 8080啟動一個 HTTP 伺服器,監聽 8080 埠。如果不指定,則預設監聽 8000 埠。

快速使用

學習一個庫的第一步當然是使用它。我們先看看flag庫的基本使用:

package main

import (
  "fmt"
  "flag"
)

var (
  intflag int
  boolflag bool
  stringflag string
)

func init() {
  flag.IntVar(&intflag, "intflag", 0, "int flag value")
  flag.BoolVar(&boolflag, "boolflag", false, "bool flag value")
  flag.StringVar(&stringflag, "stringflag", "default", "string flag value")
}

func main() {
  flag.Parse()

  fmt.Println("int flag:", intflag)
  fmt.Println("bool flag:", boolflag)
  fmt.Println("string flag:", stringflag)
}

可以先編譯程式,然後執行(我使用的是 Win10 + Git Bash):

$ go build -o main.exe main.go
$ ./main.exe -intflag 12 -boolflag 1 -stringflag test

輸出:

int flag: 12
bool flag: true
string flag: test

如果不設定某個選項,相應變數會取預設值:

$ ./main.exe -intflag 12 -boolflag 1

輸出:

int flag: 12
bool flag: true
string flag: default

可以看到沒有設定的選項stringflag為預設值default

還可以直接使用go run,這個命令會先編譯程式生成可執行檔案,然後執行該檔案,將命令列中的其它選項傳給這個程式。

$ go run main.go -intflag 12 -boolflag 1

可以使用-h顯示選項幫助資訊:

$ ./main.exe -h
Usage of D:\code\golang\src\github.com\darjun\cmd\flag\main.exe:
  -boolflag
        bool flag value
  -intflag int
        int flag value
  -stringflag string
        string flag value (default "default")

總結一下,使用flag庫的一般步驟:

  • 定義一些全域性變數儲存選項的值,如這裡的intflag/boolflag/stringflag
  • init方法中使用flag.TypeVar方法定義選項,這裡的Type可以為基本型別Int/Uint/Float64/Bool,還可以是時間間隔time.Duration。定義時傳入變數的地址、選項名、預設值和幫助資訊;
  • main方法中呼叫flag.Parseos.Args[1:]中解析選項。因為os.Args[0]為可執行程式路徑,會被剔除。

注意點:

flag.Parse方法必須在所有選項都定義之後呼叫,且flag.Parse呼叫之後不能再定義選項。如果按照前面的步驟,基本不會出現問題。
因為init在所有程式碼之前執行,將選項定義都放在init中,main函式中執行flag.Parse時所有選項都已經定義了。

選項格式

flag庫支援三種命令列選項格式。

-flag
-flag=x
-flag x

---都可以使用,它們的作用是一樣的。有些庫使用-表示短選項,--表示長選項。相對而言,flag使用起來更簡單。

第一種形式只支援布林型別的選項,出現即為true,不出現為預設值。
第三種形式不支援布林型別的選項。因為這種形式的布林選項在類 Unix 系統中可能會出現意想不到的行為。看下面的命令:

cmd -x *

其中,*是 shell 萬用字元。如果有名字為 0、false的檔案,布林選項-x將會取false。反之,布林選項-x將會取true。而且這個選項消耗了一個引數。
如果要顯示設定一個布林選項為false,只能使用-flag=false這種形式。

遇到第一個非選項引數(即不是以---開頭的)或終止符--,解析停止。執行下面程式:

$ ./main.exe noflag -intflag 12

將會輸出:

int flag: 0
bool flag: false
string flag: default

因為解析遇到noflag就停止了,後面的選項-intflag沒有被解析到。所以所有選項都取的預設值。

執行下面的程式:

$ ./main.exe -intflag 12 -- -boolflag=true

將會輸出:

int flag: 12
bool flag: false
string flag: default

首先解析了選項intflag,設定其值為 12。遇到--後解析終止了,後面的--boolflag=true沒有被解析到,所以boolflag選項取預設值false

解析終止之後如果還有命令列引數,flag庫會儲存下來,通過flag.Args方法返回這些引數的切片。
可以通過flag.NArg方法獲取未解析的引數數量,flag.Arg(i)訪問位置i(從 0 開始)上的引數。
選項個數也可以通過呼叫flag.NFlag方法獲取。

稍稍修改一下上面的程式:

func main() {
  flag.Parse()
    
  fmt.Println(flag.Args())
  fmt.Println("Non-Flag Argument Count:", flag.NArg())
  for i := 0; i < flag.NArg(); i++ {
    fmt.Printf("Argument %d: %s\n", i, flag.Arg(i))
  }
  
  fmt.Println("Flag Count:", flag.NFlag())
}

編譯執行該程式:

$ go build -o main.exe main.go
$ ./main.exe -intflag 12 -- -stringflag test

輸出:

[-stringflag test]
Non-Flag Argument Count: 2
Argument 0: -stringflag
Argument 1: test

解析遇到--終止後,剩餘引數-stringflag test儲存在flag中,可以通過Args/NArg/Arg等方法訪問。

整數選項值可以接受 1234(十進位制)、0664(八進位制)和 0x1234(十六進位制)的形式,並且可以是負數。實際上flag在內部使用strconv.ParseInt方法將字串解析成int
所以理論上,ParseInt接受的格式都可以。

布林型別的選項值可以為:

  • 取值為true的:1、t、T、true、TRUE、True;
  • 取值為false的:0、f、F、false、FALSE、False。

另一種定義選項的方式

上面我們介紹了使用flag.TypeVar定義選項,這種方式需要我們先定義變數,然後變數的地址。
還有一種方式,呼叫flag.Type(其中Type可以為Int/Uint/Bool/Float64/String/Duration等)會自動為我們分配變數,返回該變數的地址。用法與前一種方式類似:

package main

import (
  "fmt"
  "flag"
)

var (
  intflag *int
  boolflag *bool
  stringflag *string
)

func init() {
  intflag = flag.Int("intflag", 0, "int flag value")
  boolflag = flag.Bool("boolflag", false, "bool flag value")
  stringflag = flag.String("stringflag", "default", "string flag value")
}

func main() {
  flag.Parse()
    
  fmt.Println("int flag:", *intflag)
  fmt.Println("bool flag:", *boolflag)
  fmt.Println("string flag:", *stringflag)
}

編譯並執行程式:

$ go build -o main.exe main.go
$ ./main.exe -intflag 12

將輸出:

int flag: 12
bool flag: false
string flag: default

除了使用時需要解引用,其它與前一種方式基本相同。

高階用法

定義短選項

flag庫並沒有顯示支援短選項,但是可以通過給某個相同的變數設定不同的選項來實現。即兩個選項共享同一個變數。
由於初始化順序不確定,必須保證它們擁有相同的預設值。否則不傳該選項時,行為是不確定的。

package main

import (
  "fmt"
  "flag"
)

var logLevel string

func init() {
  const (
    defaultLogLevel = "DEBUG"
    usage = "set log level value"
  )
  
  flag.StringVar(&logLevel, "log_type", defaultLogLevel, usage)
  flag.StringVar(&logLevel, "l", defaultLogLevel, usage + "(shorthand)")
}

func main() {
  flag.Parse()

  fmt.Println("log level:", logLevel)
}

編譯、執行程式:

$ go build -o main.exe main.go
$ ./main.exe -log_type WARNING
$ ./main.exe -l WARNING

使用長、短選項均輸出:

log level: WARNING

不傳入該選項,輸出預設值:

$ ./main.exe
log level: DEBUG

解析時間間隔

除了能使用基本型別作為選項,flag庫還支援time.Duration型別,即時間間隔。時間間隔支援的格式非常之多,例如"300ms"、"-1.5h"、"2h45m"等等等等。
時間單位可以是 ns/us/ms/s/m/h/day 等。實際上flag內部會呼叫time.ParseDuration。具體支援的格式可以參見time(需fq)庫的文件。

package main

import (
  "flag"
  "fmt"
  "time"
)

var (
  period time.Duration
)

func init() {
  flag.DurationVar(&period, "period", 1*time.Second, "sleep period")
}

func main() {
  flag.Parse()
  fmt.Printf("Sleeping for %v...", period)
  time.Sleep(period)
  fmt.Println()
}

根據傳入的命令列選項period,程式睡眠相應的時間,預設 1 秒。編譯、執行程式:

$ go build -o main.exe main.go
$ ./main.exe
Sleeping for 1s...

$ ./main.exe -period 1m30s
Sleeping for 1m30s...

自定義選項

除了使用flag庫提供的選項型別,我們還可以自定義選項型別。我們分析一下標準庫中提供的案例:

package main

import (
  "errors"
  "flag"
  "fmt"
  "strings"
  "time"
)

type interval []time.Duration

func (i *interval) String() string {
  return fmt.Sprint(*i)
}

func (i *interval) Set(value string) error {
  if len(*i) > 0 {
    return errors.New("interval flag already set")
  }
  for _, dt := range strings.Split(value, ",") {
    duration, err := time.ParseDuration(dt)
    if err != nil {
      return err
    }
    *i = append(*i, duration)
  }
  return nil
}

var (
  intervalFlag interval
)

func init() {
  flag.Var(&intervalFlag, "deltaT", "comma-seperated list of intervals to use between events")
}

func main() {
  flag.Parse()

  fmt.Println(intervalFlag)
}

首先定義一個新型別,這裡定義型別interval

新型別必須實現flag.Value介面:

// src/flag/flag.go
type Value interface {
  String() string
  Set(string) error
}

其中String方法格式化該型別的值,flag.Parse方法在執行時遇到自定義型別的選項會將選項值作為引數呼叫該型別變數的Set方法。
這裡將以,分隔的時間間隔解析出來存入一個切片中。

自定義型別選項的定義必須使用flag.Var方法。

編譯、執行程式:

$ go build -o main.exe main.go
$ ./main.exe -deltaT 30s
[30s]
$ ./main.exe -deltaT 30s,1m,1m30s
[30s 1m0s 1m30s]

如果指定的選項值非法,Set方法返回一個error型別的值,Parse執行終止,列印錯誤和使用幫助。

$ ./main.exe -deltaT 30x
invalid value "30x" for flag -deltaT: time: unknown unit x in duration 30x
Usage of D:\code\golang\src\github.com\darjun\go-daily-lib\flag\self-defined\main.exe:
  -deltaT value
        comma-seperated list of intervals to use between events

解析程式中的字串

有時候選項並不是通過命令列傳遞的。例如,從配置表中讀取或程式生成的。這時候可以使用flag.FlagSet結構的相關方法來解析這些選項。

實際上,我們前面呼叫的flag庫的方法,都會間接呼叫FlagSet結構的方法。flag庫中定義了一個FlagSet型別的全域性變數CommandLine專門用於解析命令列選項。
前面呼叫的flag庫的方法只是為了提供便利,它們內部都是呼叫的CommandLine的相應方法:

// src/flag/flag.go
var CommandLine = NewFlagSet(os.Args[0], ExitOnError)

func Parse() {
  CommandLine.Parse(os.Args[1:])
}

func IntVar(p *int, name string, value int, usage string) {
  CommandLine.Var(newIntValue(value, p), name, usage)
}

func Int(name string, value int, usage string) *int {
  return CommandLine.Int(name, value, usage)
}

func NFlag() int { return len(CommandLine.actual) }

func Arg(i int) string {
  return CommandLine.Arg(i)
}

func NArg() int { return len(CommandLine.args) }

同樣的,我們也可以自己建立FlagSet型別變數來解析選項。

package main

import (
  "flag"
  "fmt"
)

func main() {
  args := []string{"-intflag", "12", "-stringflag", "test"}

  var intflag int
  var boolflag bool
  var stringflag string

  fs := flag.NewFlagSet("MyFlagSet", flag.ContinueOnError)
  fs.IntVar(&intflag, "intflag", 0, "int flag value")
  fs.BoolVar(&boolflag, "boolflag", false, "bool flag value")
  fs.StringVar(&stringflag, "stringflag", "default", "string flag value")

  fs.Parse(args)
  
  fmt.Println("int flag:", intflag)
  fmt.Println("bool flag:", boolflag)
  fmt.Println("string flag:", stringflag)
}

NewFlagSet方法有兩個引數,第一個引數是程式名稱,輸出幫助或出錯時會顯示該資訊。第二個引數是解析出錯時如何處理,有幾個選項:

  • ContinueOnError:發生錯誤後繼續解析,CommandLine就是使用這個選項;
  • ExitOnError:出錯時呼叫os.Exit(2)退出程式;
  • PanicOnError:出錯時產生 panic。

隨便看一眼flag庫中的相關程式碼:

// src/flag/flag.go
func (f *FlagSet) Parse(arguments []string) error {
  f.parsed = true
  f.args = arguments
  for {
    seen, err := f.parseOne()
    if seen {
      continue
    }
    if err == nil {
      break
    }
    switch f.errorHandling {
    case ContinueOnError:
      return err
    case ExitOnError:
      os.Exit(2)
    case PanicOnError:
      panic(err)
    }
  }
  return nil
}

與直接使用flag庫的方法有一點不同,FlagSet呼叫Parse方法時需要顯示傳入字串切片作為引數。因為flag.Parse在內部呼叫了CommandLine.Parse(os.Args[1:])
示例程式碼都放在GitHub上了。

參考

  1. flag庫文件

我的部落格

歡迎關注我的微信公眾號【GoUpUp】,共同學習,一起進步~

本文由部落格一文多發平臺 OpenWrite 釋出!