在 Go 里面如何使用 Flag 包

简介

命令行工具很少在没有额外配置的情况下开箱即用。好的默认值固然很重要,但有用的工具需要接受用户的配置。在大多数平台上,命令行工具通过接收标志来指定命令的执行。标志是以键值分隔的字符串,加在命令的名称后面。Go 让你通过使用标准库中的 flag 包来制作接受标志的命令行工具。

在本教程中,你将探索使用 flag 包来建立不同种类的命令行工具的各种方法。你将使用一个标志来控制程序输出,引入位置参数,在这里你将混合标志和其他数据,然后实现子命令。

用 Flag 来改变程序的行为

使用 flag 包包括三个步骤:首先,定义变量以捕获标志值,然后定义你的 Go 应用程序将使用的标志,最后解析执行时提供给应用程序的标志。flag包内的大多数函数都与定义标志和将它们与你定义的变量绑定有关。解析阶段由Parse()函数处理。

为了阐述这一点,你将创建一个程序,定义一个 Boolean标志,改变这个标志将会把信息打印到标准输出上。如果提供一个-color标志,程序会用蓝色来打印消息。如果没有这个标志,则打印消息不会有颜色。

创建一个叫boolean.go的文件:

  1. nano boolean.go

添加如下代码到文件里面来创建程序:

  1. package main
  2. import (
  3. "flag"
  4. "fmt"
  5. )
  6. type Color string
  7. const (
  8. ColorBlack Color = "\u001b[30m"
  9. ColorRed = "\u001b[31m"
  10. ColorGreen = "\u001b[32m"
  11. ColorYellow = "\u001b[33m"
  12. ColorBlue = "\u001b[34m"
  13. ColorReset = "\u001b[0m"
  14. )
  15. func colorize(color Color, message string) {
  16. fmt.Println(string(color), message, string(ColorReset))
  17. }
  18. func main() {
  19. useColor := flag.Bool("color", false, "display colorized output")
  20. flag.Parse()
  21. if *useColor {
  22. colorize(ColorBlue, "Hello, DigitalOcean!")
  23. return
  24. }
  25. fmt.Println("Hello, DigitalOcean!")
  26. }

这个例子使用ANSI 逃逸序列来指示终端显示彩色输出。这些是专门的 character 序列,所以为它们定义一个新的类型是有意义的(L8)。在这个例子中,我们称该类型为color,并将该类型定义为string。然后我们定义了一个调色板,在后面的 const 块中使用。定义在const块之后的colorize函数接受Color常量其中之一和一个string,用于对信息进行着色。然后它指示终端改变颜色,首先打印所要求的颜色的转义序列,然后打印信息,最后要求终端通过打印特殊的颜色重置序列来重置其颜色。

main中,我们使用flag.Bool函数来定义一个名为color的 Boolean 标志。这个函数的第二个参数,false,在没有提供这个标志的情况下,设置这个标志的默认值。与你可能有的期望相反,将其设置为true并不会颠倒行为,如提供一个标志会导致它变成 false。因此,这个参数的值在布尔标志下几乎总是false

最后一个参数是一个可以作为使用信息打印出来的文档 string。从这个函数返回的值是一个指向bool的指针。下一行的flag.Parse函数使用这个指针,然后根据用户传入的标志,设置bool变量。 然后我们就可以通过取消引用这个指针来检查这个bool指针的值。更多关于指针变量的信息可以在指针教程找到。使用这个 Boolean,我们就可以在设置-color标志时调用colorize,而在没有这个标志时调用fmt.Println变量。

保存文件,并在未传入没有任何标志的情况下运行该程序:

  1. go run boolean.go

你将会看到如下输出:

  1. Output
  2. Hello, DigitalOcean!

现在带上-color标志再跑一遍程序:

  1. go run boolean.go -color

输出文本会是一样的,只不过这时候颜色时蓝色的。

标志不是传递给命令的唯一参数。你也能发送文件名或其他数据。

使用位置参数

通常情况下,命令会接受一些参数,这些参数作为命令的重点对象。例如,打印文件第一行的head命令经常被以head example.txt调用。文件example.txt是调用head命令时的一个位置参数。

Parse()函数将一直解析它所遇到的标志,直到它检测到一个非标志参数。flag包通过Args()Arg()函数使这些参数可用。

为了阐述这一点,你将重新实现一个简化的head命令,它显示一个给定文件的前几行:

创建一个新的文件称为head.go,然后添加如下代码:

  1. package main
  2. import (
  3. "bufio"
  4. "flag"
  5. "fmt"
  6. "io"
  7. "os"
  8. )
  9. func main() {
  10. var count int
  11. flag.IntVar(&count, "n", 5, "number of lines to read from the file")
  12. flag.Parse()
  13. var in io.Reader
  14. if filename := flag.Arg(0); filename != "" {
  15. f, err := os.Open(filename)
  16. if err != nil {
  17. fmt.Println("error opening file: err:", err)
  18. os.Exit(1)
  19. }
  20. defer f.Close()
  21. in = f
  22. } else {
  23. in = os.Stdin
  24. }
  25. buf := bufio.NewScanner(in)
  26. for i := 0; i < count; i++ {
  27. if !buf.Scan() {
  28. break
  29. }
  30. fmt.Println(buf.Text())
  31. }
  32. if err := buf.Err(); err != nil {
  33. fmt.Fprintln(os.Stderr, "error reading: err:", err)
  34. }
  35. }

首先,我们定义了一个count变量,用来保存程序应该从文件中读取的行数。然后,我们使用flag.IntVar定义-n标志,模拟原始head程序的行为。 这个函数允许我们将自己的pointer传递给一个变量,与没有Var后缀的标志函数相反。除了这个区别之外,flag.IntVar的其他参数与flag.Int对应的参数相同:标志名称、默认值和描述。 和前面的例子一样,我们随后调用flag.Parse()来处理用户的输入。

下一节读取文件。我们首先定义一个io.Reader变量,该变量将被设置为用户请求的文件,或传递给程序的标准输入。在if语句中,我们使用flag.Arg函数来访问所有标志之后的第一个位置参数。如果用户提供了文件名,这个位置参数会被设置。否则,它将为空 string("")。当文件名提供时,我们使用os.Open函数来打开该文件,并将我们之前定义的io.Reader设置为该文件。否则,我们使用os.stdin来读取标准输入。

最后一节使用一个用bufio.NewScanner创建的*bufio.Scannerio.Reader变量in中读取行数据。我们使用forloop遍历到 count 的值,如果用buf.Scan扫描该行结果为false,则调用break,表示行数少于用户要求的数量。

运行这个程序,用head.go作为文件参数,显示你刚才写的文件的内容:

  1. go run head.go -- head.go

--分隔符是一个被flag包识别的特殊标志,它表示后面没有更多的 flag 参数。当你运行这个命令时,你会收到以下输出:

  1. Output
  2. package main
  3. import (
  4. "bufio"
  5. "flag"

使用你定义的-n标志来调整输出的数量:

  1. go run head.go -n 1 head.go

这只输出包的声明:

  1. Output
  2. package main

最后,当程序检测到没有提供位置参数时,它从标准输入中读取输入,就像head一样。试着运行这个命令:

  1. echo "fish\nlobsters\nsharks\nminnows" | go run head.go -n 3

你将会看到如下输出:

  1. Output
  2. fish
  3. lobsters
  4. sharks

到目前为止,你所看到的flag函数的行为仅限于检查整个命令的调用。你并不总是想要这种行为,特别是当你在编写一个支持子命令的命令行工具时。

用 FlagSet 来实现子命令

现代的命令行应用程序经常实现 “子命令”,将一套工具捆绑在一个命令之下。使用这种模式的最著名的工具是git。 当检查像git init这样的命令时,git是命令,init是 git 的子命令。子命令的一个显著特点是,每个子命令可以有自己的标志集合。

Go 应用程序可以使用flag.(*FlagSet)类型支持具有自己的标志集的子命令。为了阐述这一点,创建一个程序,使用两个具有不同标志的子命令来实现一个命令。

创建一个名为subcommand.go的新文件,并在该文件中添加以下内容:

  1. package main
  2. import (
  3. "errors"
  4. "flag"
  5. "fmt"
  6. "os"
  7. )
  8. func NewGreetCommand() *GreetCommand {
  9. gc := &GreetCommand{
  10. fs: flag.NewFlagSet("greet", flag.ContinueOnError),
  11. }
  12. gc.fs.StringVar(&gc.name, "name", "World", "name of the person to be greeted")
  13. return gc
  14. }
  15. type GreetCommand struct {
  16. fs *flag.FlagSet
  17. name string
  18. }
  19. func (g *GreetCommand) Name() string {
  20. return g.fs.Name()
  21. }
  22. func (g *GreetCommand) Init(args []string) error {
  23. return g.fs.Parse(args)
  24. }
  25. func (g *GreetCommand) Run() error {
  26. fmt.Println("Hello", g.name, "!")
  27. return nil
  28. }
  29. type Runner interface {
  30. Init([]string) error
  31. Run() error
  32. Name() string
  33. }
  34. func root(args []string) error {
  35. if len(args) < 1 {
  36. return errors.New("You must pass a sub-command")
  37. }
  38. cmds := []Runner{
  39. NewGreetCommand(),
  40. }
  41. subcommand := os.Args[1]
  42. for _, cmd := range cmds {
  43. if cmd.Name() == subcommand {
  44. cmd.Init(os.Args[2:])
  45. return cmd.Run()
  46. }
  47. }
  48. return fmt.Errorf("Unknown subcommand: %s", subcommand)
  49. }
  50. func main() {
  51. if err := root(os.Args[1:]); err != nil {
  52. fmt.Println(err)
  53. os.Exit(1)
  54. }
  55. }

这个程序分为几个部分:main函数,root函数,以及实现子命令的各个函数。main函数处理从命令返回的错误。如果任何函数返回错误if语句将捕捉到它,打印出错误,程序将以1的状态码退出,向操作系统的其他部分表明发生了错误。在main中,我们将程序被调用的所有参数传递给root。我们通过先将os.Args切片来删除第一个参数,也就是程序的名称(在前面的例子中是./subcommand)。

root函数定义了[]Runner,所有的子命令都会在这里定义。Runner是一个子命令的 interface ,允许root使用Name()获取子命令的名称,并将其与变量subcommand内容进行比较。一旦在遍历cmds变量后找到了正确的子命令,我们就用其余的参数初始化子命令,并调用该命令的Run()方法。

我们只定义了一个子命令,尽管这个框架很容易让我们创建其他子命令。GreetCommand是使用NewGreetCommand实例化的,在这里我们使用flag.NewFlagSet创建一个新的*flag.FlagSetflag.NewFlagSet需要两个参数:一个标志集的名称,和一个报告解析错误的策略。用flag.(*FlagSet).Name方法获取*flag.FlagSet的名称。我们在(*GreetCommand).Name()方法中使用这个方法,所以子命令的名字与我们给*flag.FlagSet的名字一致。 NewGreetCommand也用了类似于以前的例子的方式定义了一个-name标志,但它改为从*GreetCommand*flag.FlagSet字段中调用这个方法,gc.fs。当root调用*GreetCommandInit()方法时,我们将传入的参数传递给*flag.FlagSet字段的Parse方法。

如果你构建这个程序,然后运行它,就会更容易看到子命令。建立该程序:

  1. go build subcommand.go

现在运行该程序,没有参数:

  1. ./subcommand

你会看到如下输出:

  1. Output
  2. You must pass a sub-command

现在用greet子命令运行该程序。

  1. ./subcommand greet

这会输出如下内容:

  1. Output
  2. Hello World !

现在使用-name标志和greet来指定一个名字:

  1. ./subcommand greet -name Sammy

你会看到程序给出的这个输出:

  1. Output
  2. Hello Sammy !

这个例子说明了在 Go 中如何构建大型命令行应用程序的一些原则。 FlagSets的设计是为了给开发者提供更多的控制权,使其能够通过 flag 解析逻辑,分析flag的位置和处理方式。

总结

标记使你的应用程序在更多情景下更有用,因为它们让你的用户控制程序的执行方式。给用户提供有用的默认值很重要,但你应该让他们有机会覆盖那些不适合他们情况的设置。你已经看到,flag包提供了灵活的选择,向你的用户展示配置选项。你可以选择一些简单的标志,或者建立一套可扩展的子命令。 无论是哪种情况,在过去长久历史沉淀的风格下,使用flag包都可以帮助你按照灵活的、可编写脚本的命令行工具。