跳转到主要内容

介绍


如果没有额外的配置,命令行实用程序很少开箱即用。好的默认值很重要,但有用的实用程序需要接受用户的配置。在大多数平台上,命令行实用程序接受标志来自定义命令的执行。标志是在命令名称之后添加的键值分隔字符串。 Go 允许您使用标准库中的标志包来制作接受标志的命令行实用程序。

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

使用标志(flag )来改变程序的行为


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

为了说明,您将创建一个程序,该程序定义一个布尔标志,该标志更改将打印到标准输出的消息。如果提供了 -color 标志,程序将以蓝色打印一条消息。如果没有提供标志,则消息将不带任何颜色打印。

创建一个名为 boolean.go 的新文件:

nano boolean.go


将以下代码添加到文件中以创建程序:

boolean.go

package main

import (
    "flag"
    "fmt"
)

type Color string

const (
    ColorBlack  Color = "\u001b[30m"
    ColorRed          = "\u001b[31m"
    ColorGreen        = "\u001b[32m"
    ColorYellow       = "\u001b[33m"
    ColorBlue         = "\u001b[34m"
    ColorReset        = "\u001b[0m"
)

func colorize(color Color, message string) {
    fmt.Println(string(color), message, string(ColorReset))
}

func main() {
    useColor := flag.Bool("color", false, "display colorized output")
    flag.Parse()

    if *useColor {
        colorize(ColorBlue, "Hello, DigitalOcean!")
        return
    }
    fmt.Println("Hello, DigitalOcean!")
}

此示例使用 ANSI 转义序列来指示终端显示彩色输出。这些是特殊的字符序列,因此为它们定义新类型是有意义的。在本例中,我们将该类型称为 Color,并将该类型定义为字符串。然后,我们定义一个调色板以在后面的 const 块中使用。在 const 块之后定义的 colorize 函数接受这些 Color 常量之一和一个字符串变量,以便消息着色。然后它通过首先打印请求颜色的转义序列来指示终端更改颜色,然后打印消息,最后通过打印特殊颜色重置序列请求终端重置其颜色。

在 main 中,我们使用 flag.Bool 函数来定义一个名为 color 的布尔标志。此函数的第二个参数 false 设置此标志的默认值(如果未提供)。与您的预期相反,将其设置为 true 不会反转行为,因此提供标志将导致其变为 false。因此,这个参数的值几乎总是带有布尔标志的假。

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

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

go run boolean.go


您将看到以下输出:

Output
Hello, DigitalOcean!


现在使用 -color 标志再次运行该程序:

go run boolean.go -color


输出将是相同的文本,但这次是蓝色。

标志不是传递给命令的唯一值。您还可以发送文件名或其他数据。

使用位置参数


通常,命令将采用许多参数作为命令焦点的主题。例如,打印文件第一行的 head 命令通常作为 head example.txt 调用。文件 example.txt 是调用 head 命令时的位置参数。

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

为了说明这一点,您将构建 head 命令的简化重新实现,该命令显示给定文件的前几行:

创建一个名为 head.go 的新文件并添加以下代码:

head.go

package main

import (
    "bufio"
    "flag"
    "fmt"
    "io"
    "os"
)

func main() {
    var count int
    flag.IntVar(&count, "n", 5, "number of lines to read from the file")
    flag.Parse()

    var in io.Reader
    if filename := flag.Arg(0); filename != "" {
        f, err := os.Open(filename)
        if err != nil {
            fmt.Println("error opening file: err:", err)
            os.Exit(1)
        }
        defer f.Close()

        in = f
    } else {
        in = os.Stdin
    }

    buf := bufio.NewScanner(in)

    for i := 0; i < count; i++ {
        if !buf.Scan() {
            break
        }
        fmt.Println(buf.Text())
    }

    if err := buf.Err(); err != nil {
        fmt.Fprintln(os.Stderr, "error reading: err:", err)
    }
}

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

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

最后一节使用由 bufio.NewScanner 创建的 *bufio.Scanner 从 io.Reader 变量中读取行。我们使用 for 循环迭代到 count 的值,如果使用 buf.Scan 扫描行产生一个false 值,表示行数小于用户请求的行数。

运行此程序并使用 head.go 作为文件参数显示您刚刚编写的文件的内容:

go run head.go -- head.go


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

Output
package main

import (
        "bufio"
        "flag"

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

go run head.go -n 1 head.go


这仅输出包语句:

Output
package main


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

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


你会看到输出:

Output
fish
lobsters
sharks


到目前为止,您看到的标志函数的行为仅限于检查整个命令调用。您并不总是想要这种行为,尤其是当您正在编写支持子命令的命令行工具时。

使用 FlagSet 实现子命令


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

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

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

package main

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

func NewGreetCommand() *GreetCommand {
    gc := &GreetCommand{
        fs: flag.NewFlagSet("greet", flag.ContinueOnError),
    }

    gc.fs.StringVar(&gc.name, "name", "World", "name of the person to be greeted")

    return gc
}

type GreetCommand struct {
    fs *flag.FlagSet

    name string
}

func (g *GreetCommand) Name() string {
    return g.fs.Name()
}

func (g *GreetCommand) Init(args []string) error {
    return g.fs.Parse(args)
}

func (g *GreetCommand) Run() error {
    fmt.Println("Hello", g.name, "!")
    return nil
}

type Runner interface {
    Init([]string) error
    Run() error
    Name() string
}

func root(args []string) error {
    if len(args) < 1 {
        return errors.New("You must pass a sub-command")
    }

    cmds := []Runner{
        NewGreetCommand(),
    }

    subcommand := os.Args[1]

    for _, cmd := range cmds {
        if cmd.Name() == subcommand {
            cmd.Init(os.Args[2:])
            return cmd.Run()
        }
    }

    return fmt.Errorf("Unknown subcommand: %s", subcommand)
}

func main() {
    if err := root(os.Args[1:]); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

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

根函数定义 []Runner,所有子命令都将在其中定义。 Runner 是子命令的接口,它允许 root 使用 Name() 检索子命令的名称并将其与内容子命令变量进行比较。一旦在遍历 cmds 变量后找到正确的子命令,我们就使用其余参数初始化子命令并调用该命令的 Run() 方法。

我们只定义了一个子命令,尽管这个框架很容易让我们创建其他的。 GreetCommand 使用 NewGreetCommand 实例化,我们使用 flag.NewFlagSet 创建一个新的 *flag.FlagSet。 flag.NewFlagSet 有两个参数:标志集的名称和报告解析错误的策略。 *flag.FlagSet 的名称可以使用 flag.(*FlagSet).Name 方法访问。我们在 (*GreetCommand).Name() 方法中使用它,因此子命令的名称与我们给 *flag.FlagSet 的名称相匹配。 NewGreetCommand 也以与前面的示例类似的方式定义了 -name 标志,但它改为将其作为方法调用 *GreetCommand、gc.fs 的 *flag.FlagSet 字段。当 root 调用 *GreetCommand 的 Init() 方法时,我们将提供的参数传递给 *flag.FlagSet 字段的 Parse 方法。

如果您构建此程序然后运行它,将更容易查看子命令。构建程序:

go build subcommand.go


现在运行不带参数的程序:

./subcommand


你会看到这个输出:

Output
You must pass a sub-command


现在使用 greet 子命令运行程序:

./subcommand greet


这会产生以下输出:

Output
Hello World !


现在使用带有 greet 的 -name 标志来指定名称:

./subcommand greet -name Sammy


您将看到程序的以下输出:

Output
Hello Sammy !


这个例子说明了如何在 Go 中构建更大的命令行应用程序背后的一些原则。 FlagSet 旨在让开发人员更好地控制标志解析逻辑在何处以及如何处理标志。

结论


标志使您的应用程序在更多上下文中更有用,因为它们使您的用户可以控制程序的执行方式。为用户提供有用的默认值很重要,但您应该让他们有机会覆盖对他们的情况不起作用的设置。您已经看到 flag 包提供了灵活的选择来向您的用户展示配置选项。您可以选择一些简单的标志,或者构建一个可扩展的子命令套件。在任何一种情况下,使用 flag 包都将帮助您以灵活和可编写脚本的命令行工具的悠久历史的风格构建实用程序。

文章链接