Go语言编译后的程序本身就是一个可用于命令行的可执行文件,而且Go天生支持CLI程序(command line interface),这得益于Go精简的语法以及自身支持Flag来解析命令行的选项参数等。但是,基于Go原始能力开发CLI程序仍然非常繁琐,如解析参数就是一个非常麻烦的工作。幸好,有许多非常强大的库可以用来简化我们的工作:
- cobra: 一个非常强大的用于构建CLI程序的库,官方地址见这儿
- urfave/cli: 另一个使用广泛的CLI开发库,同样足够强大且简单易上手,官方地址见这儿
- survey: 一个强大的构建交互式命令行程序的库,详情见这里
在开发CLI之前,你可以阅读Go官方的构建CLI程序指南。本文介绍如何使用 urfave/cli 库开发完整的CLI程序。
CLI程序
命令行界面(CLI,command line interface) 是一种通过用户或客户端发出的命令以及设备或程序以文本行形式做出的响应与设备或计算机程序进行交互的方式。
以上是维基百科的解释,简单而言就是控制台程序,我们需要通过控台执行程序并输入程序内置支持的选项、参数等完成与程序的交互以实现功能。
一般而言,CLI都具备这些功能:
- 命令:一个CLI程序应该至少支持一个命令,才能用来实现功能,大多CLI都支持多个命令,而且命令下还支持多个的子命令,用来将功能细分
- 选项:选项分为全局选项和命令选项,全局选项表示对所有命令都可以使用的选项,而命令选项这仅对特定命令有效
- 参数:CLI支持用户通过控制台传入参数告诉其特定信息,一般情况会通过选项指定参数来区分不同的用途,也可以直接传递给命令
- 帮助:展示给用户如何使用当前程序的帮助信息
- 输出:程序处理完成后展示给用户的结果信息
- 别名:命令和选项都应该支持别名,当命令和选项太长时用来简化输入
当然,CLI还包括程序退出码、错误等信息,不再一一列举。
cli框架简介
urfave/cli 是一个简单、快速且有趣的包,用于在 Go 中构建命令行应用程序。目标是使开发人员能够以富有表现力的方式编写快速且可分发的命令行应用程序。
目前最新支持的版本是 v2,这也是目前使用最广泛、功能强大的版本。
接下来,我们将创建一个CLI应用并逐步完善它。
创建应用
创建一个cli目录,然后初始化go模块:
1$ mkdir cli
2$ cd cli
3$ go mod init
编辑 go.mod 文件,将模块名称改为 cli_demo,然后安装 urfave/cli:
1$ go get github.com/urfave/cli/v2
新建 main.go 文件作为程序的入口,编写代码如下:
1package main
2
3import (
4 "fmt"
5 "github.com/urfave/cli/v2"
6 "os"
7)
8
9func main() {
10 cliApp := cli.NewApp()
11 cliApp.Name = "demo-cli"
12 cliApp.Usage = "cli usage demo"
13 cliApp.Version = "0.0.1"
14
15 err := cliApp.Run(os.Args) // app退出不会调用 os.Exit,所以默认退出代码都是0,可以通过 cli.Exit方法指定退出信息和退出码
16 if err != nil {
17 fmt.Printf("demo-cli execute error: %v\n", err)
18 os.Exit(-1)
19 }
20}
首先,我们使用 cli.NewApp() 创建 *cli.App 实例,然后分别设置了程序的名称、使用说明、版本,最后使用 cliApp.Run(os.Args) 方法运行程序,传入系统参数并处理错误信息。
这样,一个CLI程序就创建完成。但是这只是一个空程序,运行它除了输出一些帮助信息之外什么也干不了:
1$ go run main.go
2NAME:
3 demo-cli - cli usage demo
4
5USAGE:
6 demo-cli [global options] command [command options] [arguments...]
7
8VERSION:
9 0.0.1
10
11COMMANDS:
12 help, h Shows a list of commands or help for one command
13
14GLOBAL OPTIONS:
15 --help, -h show help
16 --version, -v print the version
urfave/cli 默认的帮助信息已经足够清楚的输出了程序的完整信息,只是目前 COMMANDS 下只有一个 help 命令,编译程序并执行该命令将得到与上边相同的输出:
1$ go build -o cli_demo .
2$ ./cli_demo help
GLOBAL OPTIONS 表示程序的全局选项,默认已经包含了 --help, -h 用于显示帮助信息,--version, -v 用于查看程序版本。
添加全局Flag
添加全局选项也很简单,在运行程序之前添加代码:
1var Verbose bool
2
3func main() {
4 // ...
5 // 全局参数
6 cliApp.Flags = append(cliApp.Flags, []cli.Flag{
7 &cli.BoolFlag{Name: "i", Usage: "show verbose info", Required: false, Destination: &Verbose}, // destination 可以将设置的参数绑定到变量,后续可以直接使用
8 }...)
9 // ...
10}
这里我们添加了一个 -i 的全局 bool 选项,用来表示是否输出详细信息。Required 表示是否是必须选项,Destination 这用于将选项值绑定到指定的变量上,这样通过变量即可获得该选项的值。
选项 Flag 有多种类型,包括 StringFlag、BoolFlag、IntFlag 等等,它们都实现了顶层接口 Flag。
再次运行程序,可以通过帮助信息看到全局选项已经添加成功。
1GLOBAL OPTIONS:
2 -i show verbose info (default: false)
3 --help, -h show help
4 --version, -v print the version
添加命令
没有命令的程序毫无用处,现在,我们来添加一个命令,实现问好的功能。
给 cli.App 添加命令是通过 cliApp.Commands 属性实现的,需要向其指定一个 []*Command,我们编写一个方法返回 []*Command,将其返回值赋值给 cliApp,代码如下:
1func main() {
2 // ...
3 // 系统命令
4 cliApp.Commands = []*cli.Command{sayHelloCmd()}
5 // ...
6}
7
8func sayHelloCmd() *cli.Command {
9 return &cli.Command{
10 Name: "hello", // 命令名称,执行时需要指定
11 Aliases: []string{"ho"}, // 命令别名,简化名称
12 Usage: "向您问好,-h 查看更多帮助信息",
13 Flags: []cli.Flag{
14 &cli.StringFlag{Name: "n", Aliases: []string{"name"}, Usage: "您的姓名`NAME`", Required: true},
15 },
16 Action: func(ctx *cli.Context) error { // 具体命令的执行逻辑
17 name := ctx.String("n")
18 fmt.Println("hello,", name, "!")
19 return nil
20 },
21 }
22}
这里定义了一个非常简单的命令,cli.Command 结构代表了一个命令:
Name属性表示命令的名称,执行命令时需要输入该名称或者其别名Aliases定义了命了的别名,可以简化输入,例如上边的命令输入hello与ho是等价的Flags定义命令的选项,是一个[]cli.Flag类型,可以定义多个选项,这里我们定义了一个-n的选项,类型为cli.StringFlag表示字符串Flag,用来输入被问候者的名称Action定义命令的执行逻辑,是一个ActionFunc类型,底层其实是一个func(*Context) error函数,*cli.Context参数表示CLI程序上下文,可以通过它来获取应用和命令的信息
当然,可以使用 cli.Commands 来简化命令集合的定义,它是一个 []*cli.Command 类型表示多个命令的集合。
现在,我们编译代码:
1go build -o cli_demo .
然后执行命令:
1./cli_demo -h
可以看到显示了定义的命令:
1COMMANDS:
2 hello, ho 向您问好,-h 查看更多帮助信息
3 help, h Shows a list of commands or help for one command
键入 ./cli_demo hello -h 可以查看当前命令的帮助,此时会显示当前命令的子命令、选项等信息。要执行 hello 命令,键入:
1$ ./cli_demo hello -n hank
2hello, hank !
添加子命令
有时候,命令下还可能会有很多子命令,来实现不同的子功能,此时,我们需要用到子命令。
命令支持层层嵌套,cli.Command 类型支持 Subcommands []*Command 属性来嵌套子命令。这里,我们定一个 weather 子命令来问好并报告天气情况,代码如下:
1var weathers = []string{"sunny", "windy", "cloudy", "rainy"}
2
3func main() {
4 // ...
5}
6
7func sayHelloCmd() *cli.Command {
8 return &cli.Command{
9 Name: "hello", // 命令名称,执行时需要指定
10 Aliases: []string{"ho"}, // 命令别名,简化名称
11 Usage: "向您问好,-h 查看更多帮助信息",
12 Flags: []cli.Flag{
13 &cli.StringFlag{Name: "n", Aliases: []string{"name"}, Usage: "您的姓名 `NAME`", Required: true},
14 },
15 Subcommands: cli.Commands{
16 &cli.Command{
17 Name: "weather", // 命令名称,执行时需要指定
18 Aliases: []string{"w"}, // 命令别名,简化名称
19 Usage: "报告天气情况,-h 查看更多帮助信息",
20 Flags: []cli.Flag{},
21 Action: func(ctx *cli.Context) error {
22 name := ctx.String("n")
23 rd := rand.New(rand.NewSource(time.Now().UnixNano()))
24 weatherCmd := weathers[rd.Intn(len(weathers))]
25 fmt.Printf("hello %s, today is a %s day!\n", name, weatherCmd)
26 return nil
27 },
28 },
29 }, // 子命令
30 Action: func(ctx *cli.Context) error { // 具体命令的执行逻辑
31 name := ctx.String("n")
32 fmt.Println("hello,", name, "!")
33 return nil
34 },
35 }
36}
与前边一节的区别是,这里添加了 SubCommands 属性,并定义了 Name 为 weather 的子命令。
编译运行命令帮助:
1$ ./cli_demo hello -h
2NAME:
3 demo-cli hello - 向您问好,-h 查看更多帮助信息
4
5USAGE:
6 demo-cli hello command [command options] [arguments...]
7
8COMMANDS:
9 weather, w 报告天气情况,-h 查看更多帮助信息
10 help, h Shows a list of commands or help for one command
11
12OPTIONS:
13 -n NAME, --name NAME 您的姓名 NAME
14 --help, -h show help
可以看到此时 COMMANDS 显示的是子命令,运行 weather:
1$ ./cli_demo hello -n hank w
2hello hank, today is a cloudy day!
命令分组
命令或者子命令太多,不便于阅读,可以通过 cli.Command 的 Category 来指定分组名称,这样可以在帮助信息中归类展示。
现在,我们在添加一个子命令,并将命令分组,代码如下:
1// ...
2Subcommands: cli.Commands{
3 &cli.Command{
4 Name: "weather", // 命令名称,执行时需要指定
5 Aliases: []string{"w"}, // 命令别名,简化名称
6 Usage: "报告天气情况,-h 查看更多帮助信息",
7 Before: func(context *cli.Context) error {
8 fmt.Println("sayHello weatherCmd 子命令 Before...")
9 return nil
10 },
11 Flags: []cli.Flag{},
12 Action: func(ctx *cli.Context) error {
13 name := ctx.String("n")
14 rd := rand.New(rand.NewSource(time.Now().UnixNano()))
15 weatherCmd := weathers[rd.Intn(len(weathers))]
16 fmt.Printf("hello %s, today is a %s day!\n", name, weatherCmd)
17 return nil
18 },
19 Category: "weather", // 命令分组
20 },
21 &cli.Command{
22 Name: "complain-weather", // 命令名称,执行时需要指定
23 Aliases: []string{"cw"}, // 命令别名,简化名称
24 Usage: "Complains the weather today",
25 Before: func(ctx *cli.Context) error {
26 return nil
27 },
28 Flags: []cli.Flag{},
29 Action: func(ctx *cli.Context) error {
30 return nil
31 },
32 Category: "weather", // 命令分组
33 },
34 // ...
运行帮助,此时命令将分组显示:
1COMMANDS:
2 help, h Shows a list of commands or help for one command
3 weather:
4 weather, w 报告天气情况,-h 查看更多帮助信息
5 complain-weather, cw Complains the weather today
生命周期方法
不论是 cli.App 还是 cli.Command 都支持三个生命周期方法:
Before BeforeFunc:对于cli.App,在cli.Context准备就绪而且任何命令执行前调用;对于cli.Command,在cli.Context准备就绪而且当前命令和子命令执行前调用After AfterFunc:对于cli.App,命令运行后会执行,即使Action方法 panic;对于cli.Command,命令执行完成后调用,即使Action方法 panic 也会执行Action ActionFunc:对于cli.App,如果没有定义任何命令会执行Action方法;对于cli.Command,当前命令执行时调用
通常,Before 方法用来初始化环境,After 方法可以用于清理资源等。
一个简单的 Before 方法示例如下:
1cliApp.Before = func(ctx *cli.Context) error {
2 fmt.Println("Before app run ...")
3 return nil
4}
Context
cli.Context 表示程序运行的上下文,其定义如下:
1type Context struct {
2 context.Context
3 App *App
4 Command *Command
5 shellComplete bool
6 flagSet *flag.FlagSet
7 parentContext *Context
8}
可见,其内部包含 App、Command 属性,可以获取 cli.App、cli.Command 的信息,flagSet 属性定义了 cli.Flag 的集合,会在运行时解析并装载当前命令和全局定义的 Flag,由于需要区分不同的 Flag,因此,一个 flagSet 中的选项必须唯一,否则会 panic。也就是说,对于某一个具体命令,app的全局 Flag 不能与其 Flag 重名,而不同的命令间(包括子命令)可以重复。
一般会通过 cli.Context 获取 Flag 的值,比如前边的 hello 命令,要获取 -n 选项传入的名称,可以这样:
1Action: func(ctx *cli.Context) error {
2 name := ctx.String("n")
3 // ...
4 return nil
5},
直接通过 ctx.String("n") 获取 StringFlag 类型的值,其参数为 Flag 的 Name 而不是别名 Aliases。
更多关于 cli 库的用法请参阅官方文档。