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
2
3
$ mkdir cli
$ cd cli
$ go mod init

编辑 go.mod 文件,将模块名称改为 cli_demo,然后安装 urfave/cli:

1
$ go get github.com/urfave/cli/v2

新建 main.go 文件作为程序的入口,编写代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main  
  
import (  
   "fmt"   
   "github.com/urfave/cli/v2"   
   "os"
)
  
func main() {  
   cliApp := cli.NewApp()  
   cliApp.Name = "demo-cli"  
   cliApp.Usage = "cli usage demo"  
   cliApp.Version = "0.0.1"  
   
   err := cliApp.Run(os.Args) // app退出不会调用 os.Exit,所以默认退出代码都是0,可以通过 cli.Exit方法指定退出信息和退出码  
   if err != nil {  
      fmt.Printf("demo-cli execute error: %v\n", err)  
      os.Exit(-1)  
   }  
}

首先,我们使用 cli.NewApp() 创建 *cli.App 实例,然后分别设置了程序的名称、使用说明、版本,最后使用 cliApp.Run(os.Args) 方法运行程序,传入系统参数并处理错误信息。

这样,一个CLI程序就创建完成。但是这只是一个空程序,运行它除了输出一些帮助信息之外什么也干不了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ go run main.go
NAME:
   demo-cli - cli usage demo

USAGE:
   demo-cli [global options] command [command options] [arguments...]

VERSION:
   0.0.1

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --help, -h     show help
   --version, -v  print the version

urfave/cli 默认的帮助信息已经足够清楚的输出了程序的完整信息,只是目前 COMMANDS 下只有一个 help 命令,编译程序并执行该命令将得到与上边相同的输出:

1
2
$ go build -o cli_demo .
$ ./cli_demo help

GLOBAL OPTIONS 表示程序的全局选项,默认已经包含了 --help, -h 用于显示帮助信息,--version, -v 用于查看程序版本。

添加全局Flag

添加全局选项也很简单,在运行程序之前添加代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var Verbose bool  
  
func main() {
	// ...
	// 全局参数  
	cliApp.Flags = append(cliApp.Flags, []cli.Flag{  
	   &cli.BoolFlag{Name: "i", Usage: "show verbose info", Required: false, Destination: &Verbose}, // destination 可以将设置的参数绑定到变量,后续可以直接使用  
	}...)
	// ...
}

这里我们添加了一个 -i 的全局 bool 选项,用来表示是否输出详细信息。Required 表示是否是必须选项,Destination 这用于将选项值绑定到指定的变量上,这样通过变量即可获得该选项的值。

选项 Flag 有多种类型,包括 StringFlagBoolFlagIntFlag 等等,它们都实现了顶层接口 Flag

再次运行程序,可以通过帮助信息看到全局选项已经添加成功。

1
2
3
4
GLOBAL OPTIONS:
   -i             show verbose info (default: false)
   --help, -h     show help
   --version, -v  print the version

添加命令

没有命令的程序毫无用处,现在,我们来添加一个命令,实现问好的功能。

cli.App 添加命令是通过 cliApp.Commands 属性实现的,需要向其指定一个 []*Command,我们编写一个方法返回 []*Command,将其返回值赋值给 cliApp,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {  
   // ...
   // 系统命令  
   cliApp.Commands = []*cli.Command{sayHelloCmd()}
   // ...
}

func sayHelloCmd() *cli.Command {  
   return &cli.Command{  
      Name:    "hello",        // 命令名称,执行时需要指定  
      Aliases: []string{"ho"}, // 命令别名,简化名称  
      Usage:   "向您问好,-h 查看更多帮助信息", 
      Flags: []cli.Flag{  
        &cli.StringFlag{Name: "n", Aliases: []string{"name"}, Usage: "您的姓名`NAME`", Required: true},  
	  }, 
      Action: func(ctx *cli.Context) error { // 具体命令的执行逻辑  
		name := ctx.String("n")  
		fmt.Println("hello,", name, "!")  
		return nil  
	  },
	}
}

这里定义了一个非常简单的命令,cli.Command 结构代表了一个命令:

  • Name 属性表示命令的名称,执行命令时需要输入该名称或者其别名
  • Aliases 定义了命了的别名,可以简化输入,例如上边的命令输入 helloho 是等价的
  • Flags 定义命令的选项,是一个 []cli.Flag 类型,可以定义多个选项,这里我们定义了一个 -n 的选项,类型为 cli.StringFlag 表示字符串Flag,用来输入被问候者的名称
  • Action 定义命令的执行逻辑,是一个 ActionFunc 类型,底层其实是一个 func(*Context) error 函数,*cli.Context 参数表示CLI程序上下文,可以通过它来获取应用和命令的信息

当然,可以使用 cli.Commands 来简化命令集合的定义,它是一个 []*cli.Command 类型表示多个命令的集合。

现在,我们编译代码:

1
go build -o cli_demo .

然后执行命令:

1
./cli_demo -h

可以看到显示了定义的命令:

1
2
3
COMMANDS:
   hello, ho  向您问好,-h 查看更多帮助信息
   help, h    Shows a list of commands or help for one command

键入 ./cli_demo hello -h 可以查看当前命令的帮助,此时会显示当前命令的子命令、选项等信息。要执行 hello 命令,键入:

1
2
$ ./cli_demo hello -n hank
hello, hank !

添加子命令

有时候,命令下还可能会有很多子命令,来实现不同的子功能,此时,我们需要用到子命令。

命令支持层层嵌套,cli.Command 类型支持 Subcommands []*Command 属性来嵌套子命令。这里,我们定一个 weather 子命令来问好并报告天气情况,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var weathers = []string{"sunny", "windy", "cloudy", "rainy"}

func main() {
	// ...
}

func sayHelloCmd() *cli.Command {  
   return &cli.Command{  
      Name:    "hello",        // 命令名称,执行时需要指定  
      Aliases: []string{"ho"}, // 命令别名,简化名称  
      Usage:   "向您问好,-h 查看更多帮助信息",  
      Flags: []cli.Flag{  
         &cli.StringFlag{Name: "n", Aliases: []string{"name"}, Usage: "您的姓名 `NAME`", Required: true},  
      },  
      Subcommands: cli.Commands{  
         &cli.Command{  
            Name:    "weather",     // 命令名称,执行时需要指定  
            Aliases: []string{"w"}, // 命令别名,简化名称  
            Usage:   "报告天气情况,-h 查看更多帮助信息",  
            Flags: []cli.Flag{},  
            Action: func(ctx *cli.Context) error {  
               name := ctx.String("n")  
               rd := rand.New(rand.NewSource(time.Now().UnixNano()))  
               weatherCmd := weathers[rd.Intn(len(weathers))]  
               fmt.Printf("hello %s, today is a %s day!\n", name, weatherCmd)  
               return nil  
            },  
         },  
      }, // 子命令  
      Action: func(ctx *cli.Context) error { // 具体命令的执行逻辑  
         name := ctx.String("n")  
         fmt.Println("hello,", name, "!")  
         return nil  
      },  
   }  
}

与前边一节的区别是,这里添加了 SubCommands 属性,并定义了 Nameweather 的子命令。

编译运行命令帮助:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ ./cli_demo hello -h   
NAME:
   demo-cli hello - 向您问好,-h 查看更多帮助信息

USAGE:
   demo-cli hello command [command options] [arguments...]

COMMANDS:
   weather, w  报告天气情况,-h 查看更多帮助信息
   help, h     Shows a list of commands or help for one command

OPTIONS:
   -n NAME, --name NAME  您的姓名 NAME
   --help, -h            show help

可以看到此时 COMMANDS 显示的是子命令,运行 weather:

1
2
$ ./cli_demo hello -n hank w
hello hank, today is a cloudy day!

命令分组

命令或者子命令太多,不便于阅读,可以通过 cli.CommandCategory 来指定分组名称,这样可以在帮助信息中归类展示。

现在,我们在添加一个子命令,并将命令分组,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ...
Subcommands: cli.Commands{  
   &cli.Command{  
      Name:    "weather",     // 命令名称,执行时需要指定  
      Aliases: []string{"w"}, // 命令别名,简化名称  
      Usage:   "报告天气情况,-h 查看更多帮助信息",  
      Before: func(context *cli.Context) error {  
         fmt.Println("sayHello weatherCmd 子命令 Before...")  
         return nil  
      },  
      Flags: []cli.Flag{},  
      Action: func(ctx *cli.Context) error {  
         name := ctx.String("n")  
         rd := rand.New(rand.NewSource(time.Now().UnixNano()))  
         weatherCmd := weathers[rd.Intn(len(weathers))]  
         fmt.Printf("hello %s, today is a %s day!\n", name, weatherCmd)  
         return nil  
      },  
      Category: "weather",  // 命令分组
   },  
   &cli.Command{  
      Name:    "complain-weather", // 命令名称,执行时需要指定  
      Aliases: []string{"cw"},     // 命令别名,简化名称  
      Usage:   "Complains the weather today",  
      Before: func(ctx *cli.Context) error {  
         return nil  
      },  
      Flags: []cli.Flag{},  
      Action: func(ctx *cli.Context) error {  
         return nil  
      },  
      Category: "weather",  // 命令分组
   },
   // ...

运行帮助,此时命令将分组显示:

1
2
3
4
5
COMMANDS:
   help, h  Shows a list of commands or help for one command
   weather:
     weather, w            报告天气情况,-h 查看更多帮助信息
     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 方法示例如下:

1
2
3
4
cliApp.Before = func(ctx *cli.Context) error {  
   fmt.Println("Before app run ...")  
   return nil  
}

Context

cli.Context 表示程序运行的上下文,其定义如下:

1
2
3
4
5
6
7
8
type Context struct {  
   context.Context  
   App           *App  
   Command       *Command  
   shellComplete bool  
   flagSet       *flag.FlagSet  
   parentContext *Context  
}

可见,其内部包含 AppCommand 属性,可以获取 cli.Appcli.Command 的信息,flagSet 属性定义了 cli.Flag 的集合,会在运行时解析并装载当前命令和全局定义的 Flag,由于需要区分不同的 Flag,因此,一个 flagSet 中的选项必须唯一,否则会 panic。也就是说,对于某一个具体命令,app的全局 Flag 不能与其 Flag 重名,而不同的命令间(包括子命令)可以重复。

一般会通过 cli.Context 获取 Flag 的值,比如前边的 hello 命令,要获取 -n 选项传入的名称,可以这样:

1
2
3
4
5
Action: func(ctx *cli.Context) error {  
   name := ctx.String("n")  
   // ...
   return nil  
},

直接通过 ctx.String("n") 获取 StringFlag 类型的值,其参数为 FlagName 而不是别名 Aliases

更多关于 cli 库的用法请参阅官方文档


相关阅读