1. 程式人生 > >命令行參數(flag包)

命令行參數(flag包)

flag 使用 time 實現 net 狀態碼 new ati lsi

命令行參數

命令行參數可以直接通過 os.Args 獲取,另外標準庫的 flag 包專門用於接收和解除命令行參數

os.Args

簡單的只是從命令行獲取一個或一組參數,可以直接使用 os.Args。下面的這種寫法,無需進行判斷,無論是否提供了命令行參數,或者提供了多個,都可以處理:

// 把命令行參數,依次打印,每行一個
func main() {
    for _, s := range os.Args[1:] {
        fmt.Println(s)
    }
}

flag 基本使用

下面的例子使用了兩種形式的調用方法:

package main

import (
    "flag"
    "fmt"
)

var name string

func init() {
    flag.StringVar(&name, "name", "Adam", "名字")
}

var ageP = flag.Int("age", 18, "年齡")

func main() {
    flag.Parse()
    fmt.Printf("%T %[1]v\n", name)
    fmt.Printf("%T %[1]v\n", ageP)
    fmt.Printf("%T %[1]v\n", *ageP)
}

第一種是直接把變量的指針傳遞給函數作為第一個參數,函數內部會對該變量進行賦值。這種形式必須寫在一個函數體的內部。
第二種是函數會把數據的指針作為函數的返回值返回,這種形式就是給變量賦值,不需要現在函數體內,不過拿到的返回值是指針。

解析時間

時間長度類的命令行標誌應用廣泛,這個功能內置到了 flag 包中。
先看看源碼中的示例,之後在自定義命令行標誌的時候也能有個參考。下面的示例,實現了暫停指定時間的功能:

var period = flag.Duration("period", 1*time.Second, "sleep period")

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

默認是1秒,但是可以通過參數來控制。flag.Duration函數創建了一個 *time.Duration 類型的標誌變量,並且允許用戶用一種友好的方式來指定時長。就是用 String 方法對應的記錄方法。這種對稱的設計提供了一個良好的用戶接口。

PS H:\Go\src\gopl\ch7\sleep> go run main.go -period 3s
Sleeping for 3s...
PS H:\Go\src\gopl\ch7\sleep> go run main.go -period 1m
Sleeping for 1m0s...
PS H:\Go\src\gopl\ch7\sleep> go run main.go -period 1.5h
Sleeping for 1h30m0s...

自定義類型

更多的情況下,是需要自己實現接口來進行自定義的。

接口說明

支持自定義類型,需要定義一個滿足 flag.Value 接口的類型:

package flag

// Value 接口代表了存儲在標誌內的值
type Value interface {
    String() string
    Set(string) error
}

String 方法用於格式化標誌對應的值,可用於輸出命令行幫助消息。
Set 方法解析了傳入的字符串參數並更新標誌值。可以認為 Set 方法是 String 方法的逆操作,這兩個方法使用同樣的記法規格是一個很好的實踐。

自定義溫度解析

下面定義 celsiusFlag 類型來允許在參數中使用攝氏溫度或華氏溫度。因為 Celsius 類型原本就已經實現了 String 方法,這裏把 Celsius 內嵌到了 celsiusFlag 結構體中,這樣結構體有就有了 String 方法(外圍結構體類型不僅獲取了匿名成員的內部變量,還有相關方法)。所以為了滿足接口,只須再定一個 Set 方法:

type Celsius float64
type Fahrenheit float64

func CToF(c Celsius) Fahrenheit { return Fahrenheit(c*9.0/5.0 + 32.0) }
func FToC(f Fahrenheit) Celsius { return Celsius((f - 32.0) * 5.0 / 9.0) }

func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
// 上面這些都是之前在別處定義過的內容,是可以作為包引出過來的
// 為了說明清楚,就單獨把需要用到的部分復制過來

// *celsiusFlag 滿足 flag.Vulue 接口
type celsiusFlag struct{ Celsius }

func (f *celsiusFlag) Set(s string) error {
    var unit string
    var value float64
    fmt.Sscanf(s, "%f%s", &value, &unit) // 無須檢查錯誤
    switch unit {
    case "C", "°C":
        f.Celsius = Celsius(value)
        return nil
    case "F", "°F":
        f.Celsius = FToC(Fahrenheit(value))
        return nil
    }
    return fmt.Errorf("invalid temperature %q", s)
}

fmt.Sscanf 函數用於從輸入 s 解析一個浮點值和一個字符串。通常是需要檢查錯誤的,但是這裏如果出錯,後面的 switch 裏的條件也是無法滿足的,是可以通過switch之後的錯誤處理來一並進行處理的。
這裏還需要寫一個 CelsiusFlag 函數來封裝上面的邏輯。這個函數返回了一個 Celsius 的指針,它指向嵌入在 celsiusFlag 變量 f 中的一個字段。Celsius 字段在標誌處理過程中會發生變化(經由Set
方法)。調用 Var 方法可以把這個標誌加入到程序的命令行標記集合中,即全局變量 flag.CommandLine。如果一個程序有非常復雜的命令行接口,那麽單個全局變量就不夠用了,需要多個類似的變量來支撐。最後一節“創建私有命令參數容器”會做簡單的展開,不過也沒有實現到這個程度。
調用 Var 方法是會把 *celsiusFlag 實參賦給 flag.Value 形參,編譯器會在此時檢查 *celsiusFlag 類型是否有 flag.Value 所必需的方法:

func CelsiusFlag(name string, value Celsius, usage string) *Celsius {
    f := celsiusFlag{value}
    flag.CommandLine.Var(&f, name, usage)
    return &f.Celsius
}

現在就可以在程序中使用這個標誌了,使用代碼如下:

var temp = CelsiusFlag("temp", 20.0, "溫度")

func main() {
    flag.Parse()
    fmt.Println(*temp)
}

接下來還可以把上面的例子簡單改一下,不用結構體了,而是換成變量的別名,這樣就需要額外再實現一個String方法,完整的代碼如下:

package main

import (
    "flag"
    "fmt"
)

type Celsius float64
type Fahrenheit float64

func CToF(c Celsius) Fahrenheit { return Fahrenheit(c*9.0/5.0 + 32.0) }
func FToC(f Fahrenheit) Celsius { return Celsius((f - 32.0) * 5.0 / 9.0) }

func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }

// 上面這些都是之前定義過的內容,是可以作為包引出過來的
// 為了說明清楚,就單獨把需要用到的部分復制過來

// *celsiusValue 滿足 flag.Vulue 接口
// 同一個包不必這麽麻煩,直接定義 Celsius 類型即可。這裏假設是從別的包引入的類型
type celsiusValue Celsius

func (c *celsiusValue) String() string { return fmt.Sprintf("%.2f°C", *c) }
// func (c *celsiusValue) String() string { return (*Celsius)(c).String() }

func (c *celsiusValue) Set(s string) error {
    var unit string
    var value float64
    fmt.Sscanf(s, "%f%s", &value, &unit) // 無須檢查錯誤
    switch unit {
    case "C", "°C":
        *c = celsiusValue(value)
        return nil
    case "F", "°F":
        *c = celsiusValue(FToC(Fahrenheit(value)))
        return nil
    }
    return fmt.Errorf("invalid temperature %q", s)
}

func CelsiusFlag(name string, value Celsius, usage string) *Celsius {
    p := new(Celsius) // value 是傳值進來的,取不到地址,new一個內存空間,存放value的值
    *p = value
    flag.CommandLine.Var((*celsiusValue)(p), name, usage)
    return p
}

func main() {
    tempP := CelsiusFlag("temp", 36.7, "溫度")
    flag.Parse()
    fmt.Printf("%T, %[1]v\n", tempP)
}

打印默認值

使用上面最後一個例子,打印幫助,查看默認值的提示:

PS G:\Steed\Documents\Go\src\gopl\output\flag\tempconv2> go run main.go -h
Usage of C:\Users\Steed\AppData\Local\Temp\go-build840446178\b001\exe\main.exe:
  -temp value
        溫度 (default 36.70°C)
exit status 2
PS G:\Steed\Documents\Go\src\gopl\output\flag\tempconv2> go run main.go -temp 36.7C
*main.Celsius, 36.7°C
PS G:\Steed\Documents\Go\src\gopl\output\flag\tempconv2>

默認值打印的格式和打印只的格式是有區別的,這是因為類型不同,調用了不同的 String 方法。
這裏默認值顯示的格式是根據接口類型的String方法定義的,在這裏就是 *celsiusValue 類型的String方法。而後面打印的是 Celsius 類型,使用的是 Celsius 類型的 String 方法。這裏定義了兩個String方法,但是打印的效果又不同,顯示不統一,這樣的做法不夠好。這裏可以看出兩個問題:

  1. 最初,使用結構體匿名封裝的形式,避免了重復定義 String 方法。這樣就保證了自定義的結構體類型 celsiusFlag 的String方法就是原本的 Celsius 類型的String方法。
  2. 幫助消息中打印的默認值,實際是打印自定義類型的值。而自定義類型只在flag包中有用,解析完成後使用的都是原本的類型,這裏就是 Celsius 類型。這兩個類型的String方法最好能保持一致。

所以,使用結構體封裝應該是一種不錯的實現方式。不過flag包中的 time.Duration 類型用的就是類型別名來實現的:

type durationValue time.Duration

func (d *durationValue) Set(s string) error {
    v, err := time.ParseDuration(s)
    *d = durationValue(v)
    return err
}

func (d *durationValue) String() string { return (*time.Duration)(d).String() }

上面是源碼中的部分代碼,可以看出這裏保持一致的方法是進行類型轉換後,調用原來類型的String方法。可能原本定義的是值類型的String方法,也可能直接就是定義了指針類型的String方法,不過指針類型的方法包括了所有值類型的方法,所以這裏不必關系原本類型的方法具體是指針方法還是值方法。
所以最後一個示例中的String方法也可以做同樣的修改:

func (c *celsiusValue) String() string { return (*Celsius)(f).String() }

自定義切片

以字符串切片為例,這裏有兩種實現的思路。一種是直接提供一個字符串,然後做分隔得到切片:

package main

import (
    "flag"
    "fmt"
    "strings"
)

type fullName []string

func (v *fullName) String() string {
    r := []string{}
    for _, s := range *v {
        r = append(r, fmt.Sprintf("%q", s))
    }
    return strings.Join(r, " ")
}

func (v *fullName) Set(s string) error {
    *v = nil
    // strings.Fields 可以區分連續的空格
    for _, str := range strings.Fields(s) {
        *v = append(*v, str)
    }
    return nil
}

func FullName(name string, value []string, usage string) *[]string {
    p := new([]string) // value 是傳值進來的,取不到地址,new一個內存空間,存放value的值
    *p = value
    flag.CommandLine.Var((*fullName)(p), name, usage)
    return p
}

func main() {
    s := FullName("name", []string{"Karl", "Lichter", "Von", "Randoll"}, "全名")
    flag.Parse()
    fmt.Printf("% q\n", *s)
}

這裏就不管 String 方法和 Set 方法展示規格的一致了,String方法采用 %q 的輸出形式可以更好的把每一個元素清楚的展示出來。

還有一種方式是,可以多次調用同一個參數,每一次調用,就添加一個元素:

package main

import (
    "flag"
    "fmt"
    "strings"
)

type urls []string

func (v *urls) String() string {
    r := []string{}
    for _, s := range *v {
        r = append(r, fmt.Sprintf("%q", s))
    }
    return strings.Join(r, ", ")
}

func (v *urls) Set(s string) error {
    // *v = nil // 不能再清空原有的記錄了
    // strings.Fields 可以區分連續的空格
    *v = append(*v, s)
    return nil
}

func Urls(name string, value []string, usage string) *[]string {
    p := new([]string) // value 是傳值進來的,取不到地址,new一個內存空間,存放value的值
    *p = value
    flag.CommandLine.Var((*urls)(p), name, usage)
    return p
}

func main() {
    s := Urls("url", []string{"baidu.com"}, "域名")
    flag.Parse()
    fmt.Printf("% q\n", *s)
}

由於每出現一個參數,都會調用一次 Set 方法,所以只要在 Set 裏對切片進行append就可以了。不過這也帶來一個問題,就是默認值無法被覆蓋掉:

PS G:\Steed\Documents\Go\src\gopl\output\flag\urls> go run main.go -h
Usage of C:\Users\Steed\AppData\Local\Temp\go-build727433198\b001\exe\main.exe:
  -url value
        域名 (default "baidu.com")
exit status 2
PS G:\Steed\Documents\Go\src\gopl\output\flag\urls> go run main.go
["baidu.com"]
PS G:\Steed\Documents\Go\src\gopl\output\flag\urls> go run main.go -url shuxun.net -url 51cto.com
["baidu.com" "shuxun.net" "51cto.com"]
PS G:\Steed\Documents\Go\src\gopl\output\flag\urls>

下面這個版本的Set方法引入了一個全局變量,可以改進上面的問題:

var isNew bool
func (v *urls) Set(s string) error {
    if !isNew {
        *v = nil
        isNew = true
    }
    *v = append(*v, s)
    return nil
}

這裏是一個方法,無法改成閉包。最好的做法就是將這個變量和原本的字符串切片封裝為一個結構體:

type urls struct {
    data  []string
    isNew bool
}

剩下的修改,參考之前自定義溫度解析的實現就差不多了。

簡易的自定義版本

要實現自定義類型,只需要實現接口就可以了。不過上面的例子中都額外寫了一個函數,用於返回自定義類型的指針,並且還設置了默認值。這個方法內部也是調用 Var 方法。這裏可以直接使用 flag 包裏的 Var 函數調用全局的Var方法:

package main

import (
    "flag"
    "fmt"
    "strings"
)

type urls []string

func (v *urls) String() string {
    // *v = []string{"baidu.com"} // 通過指針改變初始值
    r := []string{}
    for _, s := range *v {
        r = append(r, fmt.Sprintf("%q", s))
    }
    return strings.Join(r, ", ")
}

var isNew bool
func (v *urls) Set(s string) error {
    if !isNew {
        *v = nil
        isNew = true
    }
    *v = append(*v, s)
    return nil
}

func main() {
    var value urls
    // value = append(value, "baidu.com") // 傳遞給Var函數前就設定好初始值
    flag.Var(&value, "url", "域名")
    flag.Parse()
    fmt.Printf("%T % [1]q\n", value)
    s := []string(value)
    fmt.Printf("%T % [1]q\n", s)
}

這裏提供了兩個設置初始值的方法,示例中都註釋掉了。
String 方法由於內部是獲得指針的,所以可以對變量進行修改。並且該方法調用的時機是在解析開始時只調用一次。所以在 String 方法裏設置默認值是可行的。不過無法在打印幫助的時候把默認值打印出來。不需要這麽做,但是正好可以對String方法有進一步的了解,還有就是這裏利用指針修改參數原值的思路。
另外,由於 Var 函數需要接收一個變量,所以在定義變量的時候,就可以賦一個初始值。並且在打印幫助的時候是可以把這個初始值打印出來的。
不過簡易版本最大的問題就是 Var 函數接收和返回的值都是 Value 接口類型。所以在使用之前,需要對返回值做一次類型轉換。而設置初始值也是對 Value 接口類型的值進行設置。主要問題就是對外暴露了 Value 類型。現在調用者必須知道並且使用 Value 類型,對 Value 類型進行處理,這樣就不是很友好。而之前的示例中,調用方(就是main函數中的那些代碼)是完全可以忽略 Value 的存在的。
小結:這一小段主要是為了說明,之前示例中額外定義的函數是非常好的做法,封裝了 flag 內部接口的細節。經過這個函數封裝後再提供給用戶使用,用戶就可以完全忽略 flag.Value 這個接口而直接操作真正需要的類型了。這個函數的作用就是封裝接口的所有細節,調用者只需要關註真正需要的操作的類型。

自定義命令參數容器

接下來就是通過包提供的方法行進一步的自定制。以下3小節是一層一層更加接近底層的調用,做更加深入的定制。

定制 Usage

回到最基本的使用,打印一下幫助消息可以得到以下的內容:

PS H:\Go\src\gopl\output\flag\beginning> go run main.go -h
Usage of C:\Users\Steed\AppData\Local\Temp\go-build926710106\b001\exe\main.exe:
  -age int
        年齡 (default 18)
  -name string
        名字 (default "Adam")
exit status 2
PS H:\Go\src\gopl\output\flag\beginning>

這裏關註第一行,在 Usage of 後面是一長串的路徑,這個是go run命令在構建上述命令源碼文件時臨時生成的可執行文件的完整路徑。如果是編譯之後再執行,就是可執行文件的相對路徑,就沒那麽難看了。
這一行的內容也是可以自定制的,但是首先來看看源碼裏的實現:

var Usage = func() {
    fmt.Fprintf(CommandLine.Output(), "Usage of %s:\n", os.Args[0])
    PrintDefaults()
}

func (f *FlagSet) Output() io.Writer {
    if f.output == nil {
        return os.Stderr
    }
    return f.output
}

看上面的代碼就清楚了,輸出的內容就是執行的命令本身 os.Args[0]。就會輸出的位置默認就是標準錯誤 os.Stderr。
這個 Usage 是可導出的變量,值是一個匿名函數,只要重新為 Usage 賦一個新值就可以完成內容的自定制:

var name string

func init() {
    flag.StringVar(&name, "name", "Adam", "名字")
    flag.Usage = func() {
        fmt.Fprintln(os.Stderr, "請指定名字和年齡:")
        flag.PrintDefaults()
    }
}

var ageP = flag.Int("age", 18, "年齡")

func main() {
    flag.Parse()
    fmt.Printf("%T %[1]v\n", name)
    fmt.Printf("%T %[1]v\n", ageP)
    fmt.Printf("%T %[1]v\n", *ageP)
}

只要在 flag.Parse() 執行前覆蓋掉 flag.Usage 即可。
下面那行 flag.PrintDefaults() 則是打印幫助信息中其他的內容。完全可以把這行去掉,這裏完全可以自定義打印更多其他內容,甚至是執行其他操作。

定制 CommandLine

在調用flag包中的一些函數(比如StringVar、Parse等等)的時候,實際上是在調用flag.CommandLine變量的對應方法。
flag.CommandLine相當於默認情況下的命令參數容?。通過對flag.CommandLine重新賦值,就可以更深層次地定制當前命令源碼文件的參數使用說明。
flag包提供了NewFlagSet函數用於創建自定制的 CommandLine 。在上一個簡單例子的基礎上,修改一下其中的init函數的內容:

var name string
var age int

func init() {
    flag.CommandLine = flag.NewFlagSet("", flag.ExitOnError)
    flag.StringVar(&name, "name", "Adam", "名字")
    age = *flag.Int("age", 18, "年齡")
    // 和上面兩句效果一樣
    // flag.CommandLine.StringVar(&name, "name", "Adam", "名字")
    // var ageP = flag.CommandLine.Int("age", 18, "年齡")
    flag.CommandLine.Usage = func() {
        fmt.Fprintln(os.Stderr, "請指定名字和年齡:")
        flag.PrintDefaults()
    }
}

其實這裏只加了一行語句。所有flag包的操作都要在flag.NewFlagSet執行之後,否則之前執行的內容會被覆蓋掉。所以這裏把flag.Int的調用移到了包內,否則在全局中的賦值語句會在這之前就運行了,然後被flag.NewFlagSet方法覆蓋掉。
這裏無論是 flag.StringVar 或者是 flag.CommandLine.StringVar,最終都是使用flag.NewFlagSet創建的 *FlagSet 對象的方法來調用的。不過本質上是有差別的:

  • flag.StringVar : 使用默認 CommandLine 的對象調用,但是第一行語句 flag.CommandLine = flag.NewFlagSet("", flag.ExitOnError) 則是把它的值覆蓋為新創建的對象。
  • flag.CommandLine.StringVar : 使用 flag.NewFlagSet 函數創建的對象來調用,所以和上面是一個東西。

第一個方式是專門為默認的容器提供的便捷調用方式。第二個是則是通用的方法,之後創建私有命令參數容器的時候就需要用通用的方式來調用了。
Usage 必須用flag.CommandLine調用。另外不定制的話,包裏也準備了默認的方法可以使用:

func (f *FlagSet) defaultUsage() {
    if f.name == "" {
        fmt.Fprintf(f.Output(), "Usage:\n")
    } else {
        fmt.Fprintf(f.Output(), "Usage of %s:\n", f.name)
    }
    f.PrintDefaults()
}

第一個參數的作用基本就是顯示一個名稱,也可以用空字符串,向上面這樣。而第二個參數可以是下面三種常量:

const (
    ContinueOnError ErrorHandling = iota // Return a descriptive error.
    ExitOnError                          // Call os.Exit(2).
    PanicOnError                         // Call panic with a descriptive error.
)

效果一看就明白了。定義在解析遇到問題後,是執行何種操作。默認的就是ExitOnError,所以在--help執行打印說明後,最後一行會出現“exit status 2”,以狀態碼2退出。這裏可以根據需要定制為拋出Panic。
使用-h參數打印幫助信息也算是解析出錯,如果是Panic則會在打印幫助信息後Panic,如果是Continue則先打印幫助信息然後按照默認值執行。所以如果要使用另外兩種模式,最好修改一下-h參數的行為,就是上面講的定制Usage。使用-h參數之後程序將執行的就是Usage指定的函數。

創建私有命令參數容器

上一個例子依然是使用flag包提供的命令參數容器,只是重新進行了創建和賦值。這裏依然是調用flag.NewFlagSet()函數創建命令參數容器,不過這次賦值給自定義的變量:

package main

import (
    "flag"
    "fmt"
    "os"
)

var cmdLine = flag.NewFlagSet("", flag.ExitOnError)
var name string
var age int

func init() {
    cmdLine.StringVar(&name, "name", "Adam", "名字")
    age = *cmdLine.Int("age", 18, "年齡")
}

func main() {
    cmdLine.Parse(os.Args[1:])
    fmt.Printf("%T %[1]v\n", name)
    fmt.Printf("%T %[1]v\n", age)
}

首先通過 flag.NewFlagSet 函數創建了私有的命令參數容器。然後調用其他方法的接收者都使用這個容器。另外還有很多方法可以調用,可以繼續探索。

命令行參數(flag包)