命令行參數(flag包)
命令行參數可以直接通過 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方法,但是打印的效果又不同,顯示不統一,這樣的做法不夠好。這裏可以看出兩個問題:
- 最初,使用結構體匿名封裝的形式,避免了重復定義 String 方法。這樣就保證了自定義的結構體類型 celsiusFlag 的String方法就是原本的 Celsius 類型的String方法。
- 幫助消息中打印的默認值,實際是打印自定義類型的值。而自定義類型只在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包)