从源码的角度看Go语言flag库如何解析命令行参数!
共 10580字,需浏览 22分钟
·
2021-08-15 12:43
我上周五喝酒喝到晚上3点多,确实有点罩不住啊,整个周末都在休息和睡觉,文章鸽了几天,想不到就有两个人跑了。
不得不感叹一下,自媒体的太残酷了,时效就那么几天,断更就没人爱。你们说好了爱我的,爱呢?哼
昨晚就在写这篇文章了,没想到晚上又遇到发版本,确实不容易,且看且珍惜。
标准库 flag
flag的简写方式
从源码来看flag如何解析参数
从源码想到的拓展用法
小结
引用
往期精彩回顾
标准库 flag
命令行程序应该能打印出帮助信息,传递其他命令行参数,比如-h
就是flag
库的默认帮助参数。
./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
程序,上面所示的四个参数,是我自定义的。
按提示的方法,可以像这样使用参数。
./goapi -debug -ip 192.168.1.1
./goapi -port 8080
./goapi -version
像上面-version
这样的参数是bool
类型的,只要指定了就会设置为true
,不指定时为默认值,假如默认值是true
,想指定为false
要像下面这样显式的指定(因为源码里是这样写的)。
./goapi -version=false
下面这几种格式都是兼容的
-isbool #同于 -isbool=true
-age=x #-和等号
-age x #-和空格
--age=x #2个-和等号
--age x #2个-和空格
flag
库绑定参数的过程很简单,格式为
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
和端口等功能。
绑定完参数还没完,还得调用解析函数flag.Parse()
,注意一定要在使用参数前调用哦,使用过程像下面这样:
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
函数里,不太雅观,建议把这些单独放到一个包里,或者放在main
函数的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如何解析参数
其实我们把之前的绑定方式打开来看,在源码里就是调用了xxVar
函数,以Bool
类型为例。
func (f *FlagSet) Bool(name string, value bool, usage string) *bool {
p := new(bool)
f.BoolVar(p, name, value, usage)
return p
}
上面的代码用到了BoolVal
函数,它的功能是把需要绑定的变量设置为默认值,并调用f.Var
进一步处理,这里p
是一个指针,所以只要改变指向的内容,就可以影响到外部绑定所用的变量:
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
函数可以得到一个boolValue
类型,它是bool
类型重命名的。在此包中所有可作为参数的类型都有这样的定义。在 flag
包的设计中有两个重要的类型,Flag
和FlagSet
分别表示某个特定的参数,和一个无重复的参数集合。
f.Var
函数的作用就是把参数封装成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
结构体中起作用的是formal map[string]*Flag
类型,所以说,flag
把程序中需要绑定的变量包装成一个字典,后面解析的时候再一一赋值。
我们已经知道了,在调用Parse
的时候,会对参数解析并为变量赋值,使用时就可以得到真实值。展开看看它的代码
func Parse() {
// Ignore errors; CommandLine is set for ExitOnError.
// 调用了FlagSet.Parse
CommandLine.Parse(os.Args[1:])
}
// 返回一个FlagSet
var CommandLine = NewFlagSet(os.Args[0], ExitOnError)
Parse
的代码里用到了一个,CommandLine
共享变量,这就是内部库维护的FlagSet
,所有的参数都会插到里面的变量地址向地址的指向赋值绑定。
上面提到FlagSet
绑定的Parse
函数,看看它的内容:
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
}
上面的函数内容太长了,我收缩了一下。 可看到解析的过程实际上是多次调用了 parseOne()
,它的作用是逐个遍历命令行参数,绑定到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
// ...如果不存在,或者需要输出帮助信息,则返回
// ...设置真实值调用到 flag.Value.Set(value)
if f.actual == nil {
f.actual = make(map[string]*Flag)
}
f.actual[name] = flag
return true, nil
}
parseOne
内部会解析一个输入参数,判断输入参数格式,获取参数值。解析过程就是逐个取出程序参数,判断 -
、=
取参数与参数值解析后查找之前提到的 formal map
中有没有存在此参数,并设置真实值。把设置完毕真实值的参数放到 f.actual map
中,以供它用。一些错误处理和细节的代码我省略掉了,感兴趣可以自行看源码。 实际上就是逐个参数解析并设置到对应的指针变量的指向上,让返回值出现变化。
flag.Value.Set(value)
这里是设置数据真实值的代码,Value
长这样
type Value interface {
String() string
Set(string) error
}
它被设计成一个接口,不同的数据类型自己实现这个接口,返回给用户的地址就是这个接口的实例数据,解析过程中,可以通过 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
这个结构体,它是最核心的解析类。
在库内部提供了一个 *FlagSet
的实例对象 CommandLine
,它通过NewFlagSet
方法创建。并且对它的所有方法封装了一下直接对外。
官方的意思很明确了,说明我们可以用到它做些更高级的事情。先看看官方怎么用的。
var CommandLine = NewFlagSet(os.Args[0], ExitOnError)
可以看到调用的时候是传入命令行第一个参数,第二个参数表示报错时应该呈现怎样的错误。
那就意味着我们可以根据命令行第一个参数不同而呈现不同的表现!
我定义了两个参数foo
或者bar
,代表两个不同的指令集合,每个指令集匹配不同的命令参数,效果如下:
$ ./subcommands
expected 'foo' or 'bar' subcommands
$ ./subcommands foo -h
Usage of foo:
-enable
enable
$./subcommands foo -enable
subcommand 'foo'
enable: true
tail: []
这是怎么实现的呢?其实就是用NewFlagSet
方法创建多个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
参数。绑定错误时退出。 分别为每个 FlagSet
绑定要解析的变量。如果判断命令行输入参数少于2个时退出(因为第0个参数是程序名本身)。
然后根据第一个参数,判断应该匹配到哪个指令集:
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
来切换命令行参数,绑定不同的变量。对应不同变量输出不同表现。 x.Args()
可以打印未匹配到的其他参数。
补充:使用NewFlagSet
时,flag
提供三种错误处理的方式:
ContinueOnError
: 通过Parse
的返回值返回错误ExitOnError
: 调用os.Exit(2)
直接退出程序,这是默认的处理方式PanicOnError
: 调用panic
抛出错误
小结
通过本节我们了解到了标准库flag
的使用方法,参数变量绑定的两种方式,还通过源码解析了内部实现是如何的巧妙。
我们还使用源码暴露出来的函数,接收不同参数匹配不同指令集,这种方式可以让应用呈现完成不同的功能;
我想到的是用来通过环境变量改变命令用法、或者让程序复用大段逻辑呈现不同作用时使用。
但现在微服务那么流行,大多功能集成在一个服务里是不科学的,如果有重复代码应该提炼成共同模块才是王道。
你还想到能哪些使用场景呢?
引用
源码包 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
再拓展到配置解析的,没想到加上源码解读内容实在太多了,继续关注我下次走起。