Go 标准库 flag 包实战:参数解析、子命令、自定义 Value 类型与默认值
写 Go 命令行工具时,第一反应可能是直接撸 os.Args,自己按位置取参数。工具一简单还行,一旦要支持 -port=8080、-v、--config xxx 这种带名字的选项,手写解析立刻变成一坨 if-else,还要处理 -port 8080 和 -port=8080 两种写法、布尔标志、默认值......
其实标准库的 flag 包这些全包了,不用引第三方。这篇把 flag 从基础用法讲到子命令(git commit 那种 工具 子命令 参数 结构)和自定义类型,把几个新手一定会踩的坑标出来。
别再手撕 os.Args
先看手写解析有多难受。想支持 -port 和 -verbose:
go
// 反面教材:手动解析 os.Args
func main() {
port := "8080"
for i, arg := range os.Args {
if arg == "-port" {
port = os.Args[i+1] // 越界风险、还要处理 -port=8080 写法
}
// -verbose 呢?布尔标志又要另写一套...
}
}
越界、两种写法、类型转换全要自己管。用 flag 三行搞定:
go
package main
import (
"flag"
"fmt"
)
func main() {
port := flag.Int("port", 8080, "监听端口") // 名字、默认值、用法说明
verbose := flag.Bool("verbose", false, "打开详细日志")
name := flag.String("name", "world", "问候对象")
flag.Parse() // 必须调用!不调用上面的值全是默认值
// 注意:flag.Int 返回的是指针,取值要解引用
fmt.Printf("port=%d verbose=%v name=%s\n", *port, *verbose, *name)
}
运行:
bash
$ go run main.go -port=9090 -verbose -name=Go
port=9090 verbose=true name=Go
$ go run main.go -port 9090 # 空格写法也支持
port=9090 verbose=false name=world
第一个坑:flag.Int/String/Bool 返回的是指针 ,用的时候要 *port 解引用。忘了解引用会拿到指针地址而不是值,编译期还未必报错(比如 fmt 里)。
第二个坑:一定要调 flag.Parse()。不调用的话,所有 flag 变量都还是默认值,解析根本没发生------这是新手最常见的「参数怎么没生效」原因。
更常用的写法:flag.XxxVar 绑定到变量
指针写法到处 *port 有点烦。用 flag.IntVar 这类 XxxVar 函数,直接把值绑到一个已有变量上,后面就不用解引用:
go
var (
port int
verbose bool
name string
)
func main() {
flag.IntVar(&port, "port", 8080, "监听端口") // 绑到 port 变量
flag.BoolVar(&verbose, "verbose", false, "详细日志")
flag.StringVar(&name, "name", "world", "问候对象")
flag.Parse()
fmt.Printf("port=%d verbose=%v\n", port, verbose) // 直接用,不用 *
}
如果参数很多,更整洁的做法是定义一个 config struct,把所有 flag 绑到它的字段上:
go
type Config struct {
Port int
Verbose bool
Name string
}
func parseFlags() *Config {
cfg := &Config{}
flag.IntVar(&cfg.Port, "port", 8080, "监听端口")
flag.BoolVar(&cfg.Verbose, "verbose", false, "详细日志")
flag.StringVar(&cfg.Name, "name", "world", "问候对象")
flag.Parse()
return cfg
}
布尔标志的一个特殊坑
布尔 flag 有个和其他类型不一样的行为。-verbose 单独出现就是 true,但你不能 用空格写 -verbose true:
bash
$ go run main.go -verbose true
# -verbose 被置为 true,但 "true" 会被当成「位置参数」,不是 flag 的值!
因为 flag 遇到布尔标志,默认它出现即为真,不吃后面的 token。要显式给布尔 flag 赋值,只能用等号:
bash
$ go run main.go -verbose=false # 正确:显式关闭
$ go run main.go -verbose=true # 正确
记住:布尔 flag 要么单独写 -verbose(表示 true),要么用 -verbose=false 等号形式,永远别用空格。
位置参数:flag.Args()
flag 之后的非选项参数(位置参数)用 flag.Args() 取。一个重要规则:flag 解析在遇到第一个非 flag 参数时就停止:
go
flag.Parse()
fmt.Println("剩余位置参数:", flag.Args()) // []string
fmt.Println("第一个:", flag.Arg(0))
bash
$ go run main.go -port=9090 file1.txt file2.txt
剩余位置参数: [file1.txt file2.txt]
第一个: file1.txt
坑:flag 必须写在位置参数前面。 因为解析遇到第一个非 flag token 就停,写在后面的 flag 不会被解析:
bash
$ go run main.go file1.txt -port=9090
# -port 不生效!解析在 file1.txt 处就停了,-port 被当成位置参数
$ go run main.go -port=9090 file1.txt # 正确:flag 在前
这个行为和很多人习惯的 GNU 风格(flag 可以在任意位置)不一样,是 flag 包最容易让人困惑的地方。
子命令:用 flag.NewFlagSet 实现 git 风格
想做 工具 serve -port=8080、工具 migrate -step=2 这种子命令结构,flag 包本身不直接支持,但用 flag.NewFlagSet 给每个子命令建独立的 FlagSet 就能实现:
go
package main
import (
"flag"
"fmt"
"os"
)
func main() {
// 每个子命令一套独立的 flag
serveCmd := flag.NewFlagSet("serve", flag.ExitOnError)
servePort := serveCmd.Int("port", 8080, "监听端口")
migrateCmd := flag.NewFlagSet("migrate", flag.ExitOnError)
migrateStep := migrateCmd.Int("step", 1, "迁移步数")
if len(os.Args) < 2 {
fmt.Println("用法: tool <serve|migrate> [options]")
os.Exit(1)
}
// os.Args[1] 是子命令名,os.Args[2:] 是该子命令的参数
switch os.Args[1] {
case "serve":
serveCmd.Parse(os.Args[2:]) // 注意从第 2 个参数开始解析
fmt.Printf("启动服务,端口 %d\n", *servePort)
case "migrate":
migrateCmd.Parse(os.Args[2:])
fmt.Printf("执行迁移,步数 %d\n", *migrateStep)
default:
fmt.Printf("未知子命令: %s\n", os.Args[1])
os.Exit(1)
}
}
bash
$ go run main.go serve -port=9090
启动服务,端口 9090
$ go run main.go migrate -step=3
执行迁移,步数 3
关键点:每个子命令 Parse 的是 os.Args[2:] (跳过程序名和子命令名)。flag.ExitOnError 表示解析出错时自动打印用法并 os.Exit(2),省得自己处理错误。
自定义类型:实现 flag.Value 接口
内置的 Int/String/Bool 之外,遇到「用逗号分隔的列表」「时间段」这种需求,可以实现 flag.Value 接口(两个方法:String() 和 Set(string) error),让 flag 支持任意类型。
以「-tags=a,b,c 解析成字符串切片」为例:
go
// 自定义类型:逗号分隔的字符串列表
type stringList []string
func (s *stringList) String() string {
return strings.Join(*s, ",")
}
// Set 会在每次遇到该 flag 时被调用,传入用户输入的字符串
func (s *stringList) Set(value string) error {
*s = strings.Split(value, ",")
return nil
}
func main() {
var tags stringList
flag.Var(&tags, "tags", "逗号分隔的标签,如 -tags=go,web,cli")
flag.Parse()
fmt.Println("标签:", []string(tags))
}
bash
$ go run main.go -tags=go,web,cli
标签: [go web cli]
Set 方法在解析时被调用,返回 error 就能做校验(比如格式不对直接报错)。这是给 flag 加「时长 -timeout=30s」(其实标准库有 flag.Duration)、「枚举值校验」等能力的通用手段。
顺带一提,-timeout=30s 这种时长参数标准库已经内置了 flag.Duration,不用自己实现:
go
timeout := flag.Duration("timeout", 5*time.Second, "超时时间,如 30s、2m")
flag.Parse()
fmt.Println(*timeout) // 传 -timeout=90s 就是 1m30s
自定义帮助信息
默认 -h/--help 会自动打印所有 flag 的用法。想加个整体说明,重写 flag.Usage:
go
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "用法: %s [options] <file>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "把文件按选项处理并输出。\n\n选项:\n")
flag.PrintDefaults() // 打印所有已注册 flag 的默认用法
}
flag.PrintDefaults() 会自动列出每个 flag 的名字、默认值和说明,自己拼说明时调它就行。
小结
- 别手撕
os.Args,flag包内置搞定命名参数、默认值、类型转换,不用第三方库。 flag.Int/String/Bool返回指针 要解引用;更清爽的是flag.IntVar系列直接绑到变量(常绑到一个 config struct 上)。必须调flag.Parse(),否则解析不发生。- 布尔 flag 只能
-verbose(true)或-verbose=false(等号),别用空格-verbose true。 - 位置参数用
flag.Args();解析遇到第一个非 flag token 就停,所以 flag 必须写在位置参数前面。 - 子命令用
flag.NewFlagSet给每个命令建独立 FlagSet,各自Parse(os.Args[2:]);ExitOnError自动处理错误。 - 自定义类型实现
flag.Value的String()+Set()两个方法,用flag.Var注册;时长参数直接用内置的flag.Duration。
一句话记忆:flag 三件事记牢------值是指针记得解引用、一定要 Parse()、flag 放位置参数前面;要子命令就上 NewFlagSet,要新类型就实现 Value 接口。