虽然现在我们使用的大多数软件都是可视化的,很容易上手,但是这并不代表 CLI(命令行)应用就没有用武之地了,特别是对于开发人员来说,还是会经常和 CLI 应用打交道。而 Golang 就非常适合用来构建 CLI 应用,下面我们就将来介绍如何在 Golang 中构建一个 CLI 应用。Golang 使用 Cobra 创建 CLI 应用_学习

对于开发人员来说平时可能就需要使用到很多 CLI 工具,比如 npm、node、go、python、docker、kubectl 等等,因为这些工具非常小巧、没有依赖性、非常适合系统管理或者一些自动化任务等等。

我们这里选择使用 Golang 里面非常有名的 Cobra 库来进行 CLI 工具的开发。Cobra 是一个功能强大的现代化 CLI 应用程序库,有很多知名的 Go 项目使用 Cobra 进行构建,比如:Kubernetes、Docker、Hugo 等等

概念

Cobra 是构建在命令、参数和标识符之上的:

  • Commands 表示执行动作

  • Args 就是执行参数

  • Flags 是这些动作的标识符

基本的执行命令如下所示:

  1. $ APPNAME Command Args --Flags

  2. # 或者

  3. $ APPNAME Command --Flags Args

比如我们平时使用的一些命令行工具:

  • git clone URL -bare

  • go get -u URL

  • npm install package --save

  • kubectl get pods -n kube-system -l app=cobra

示例

下面我们来看下 Cobra 的使用,这里我们使用的 go1.13.3 版本,使用 Go Modules 来进行包管理,如果对这部分知识点不熟悉的,可以查看前面我们的文章 Go Modules 基本使用(视频) 了解。

新建一个名为 my-calc 的目录作为项目目录,然后初始化 modules:

  1. $ mkdir my-calc && cd my-calc

  2. # 如果 go modules 默认没有开启,需要执行 export GO111MODULE=on 开启

  3. $ go mod init my-calc

  4. go: creating new go.mod: module my-calc

初始化完成后可以看到项目根目录下面多了一个 go.mod 的文件,现在我们还没有安装 cobra 库,执行下面的命令进行安装:

  1. # 强烈推荐配置该环境变量

  2. $ export GOPROXY=https://goproxy.cn

  3. $ go get -u github.com/spf13/cobra/cobra

安装成功后,现在我们可以使用 cobra init 命令来初始化 CLI 应用的脚手架:

  1. $ cobra init --pkg-name my-calc

  2. Your Cobra applicaton is ready at

  3. /Users/ych/devs/workspace/youdianzhishi/course/my-calc

需要注意的是新版本的 cobra 库需要提供一个 --pkg-name 参数来进行初始化,也就是指定上面我们初始化的模块名称即可。上面的 init 命令就会创建出一个最基本的 CLI 应用项目:

  1. $ tree .

  2. .

  3. ├── LICENSE

  4. ├── cmd

  5. │   └── root.go

  6. ├── go.mod

  7. ├── go.sum

  8. └── main.go

  9.  

  10. 1 directory, 5 files

其中 main.go 是 CLI 应用的入口,在 main.go 里面调用好了 cmd/root.go 下面的 Execute 函数:

  1. // main.go

  2. package main

  3.  

  4. import "my-calc/cmd"

  5.  

  6. func main() {

  7. cmd.Execute()

  8. }

然后我们再来看下 cmd/root.go 文件。

rootCmd

root(根)命令是 CLI 工具的最基本的命令,比如对于我们前面使用的 gogetURL,其中 go 就是 root 命令,而 get 就是 go 这个根命令的子命令,而在 root.go 中就直接使用了 cobra 命令来初始化 rootCmd 结构,CLI 中的其他所有命令都将是 rootCmd 这个根命令的子命令了。

这里我们将 cmd/root.go 里面的 rootCmd 变量内部的注释去掉,并在 Run 函数里面加上一句 fmt.Println("Hello Cobra CLI"):

  1. var rootCmd = &cobra.Command{

  2. Use: "my-calc",

  3. Short: "A brief description of your application",

  4. Long: `A longer description that spans multiple lines and likely contains

  5. examples and usage of using your application. For example:

  6.  

  7. Cobra is a CLI library for Go that empowers applications.

  8. This application is a tool to generate the needed files

  9. to quickly create a Cobra application.`,

  10. Run: func(cmd *cobra.Command, args []string) {

  11. fmt.Println("Hello Cobra CLI")

  12. },

  13. }

这个时候我们在项目根目录下面执行如下命令进行构建:

  1. $ go build -o my-calc

该命令会在项目根目录下生成一个名为 my-calc 的二进制文件,直接执行这个二进制文件可以看到如下所示的输出信息:

  1. $ ./my-calc

  2. Hello Cobra CLI

init

我们知道 init 函数是 Golang 中初始化包的时候第一个调用的函数。在 cmd/root.go 中我们可以看到 init 函数中调用了 cobra.OnInitialize(initConfig),也就是每当执行或者调用命令的时候,它都会先执行 init 函数中的所有函数,然后再执行 execute 方法。该初始化可用于加载配置文件或用于构造函数等等,这完全依赖于我们应用的实际情况。

在初始化函数里面 cobra.OnInitialize(initConfig) 调用了 initConfig 这个函数,所有,当 rootCmd 的执行方法 RUN:func 运行的时候, rootCmd 根命令就会首先运行 initConfig 函数,当所有的初始化函数执行完成后,才会执行 rootCmd 的 RUN:func 执行函数。

我们可以在 initConfig 函数里面添加一些 Debug 信息:

  1. func initConfig() {

  2. fmt.Println("I'm inside initConfig function in cmd/root.go")

  3. ...

  4. }

然后同样重新构建一次再执行:

  1. $ go build -o my-calc

  2. $ ./my-calc

  3. I'm inside initConfig function in cmd/root.go

  4. Hello Cobra CLI

可以看到是首先运行的是 initConfig 函数里面的信息,然后才是真正的执行函数里面的内容。

为了搞清楚整个 CLI 执行的流程,我们在 main.go 里面也添加一些 Debug 信息:

  1. // cmd/root.go

  2. func init() {

  3. fmt.Println("I'm inside init function in cmd/root.go")

  4. cobra.OnInitialize(initConfig)

  5. ...

  6. }

  7.  

  8. func initConfig() {

  9. fmt.Println("I'm inside initConfig function in cmd/root.go")

  10. ...

  11. }

  12.  

  13. // main.go

  14. func main() {

  15. fmt.Println("I'm inside main function in main.go")

  16. cmd.Execute()

  17. }

然后同样重新构建一次再执行:

  1. $ go build -o my-calc

  2. $ ./my-calc

  3. I'm inside init function in cmd/root.go

  4. I'm inside main function in main.go

  5. I'm inside initConfig function in cmd/root.go

  6. Hello Cobra CLI

根据上面的日志信息我们就可以了解到 CLI 命令的流程了。

init 函数最后处理的就是 flags 了, Flags 就类似于命令的标识符,我们可以把他们看成是某种条件操作,在 Cobra 中提供了两种类型的标识符:PersistentFlags 和 LocalFlags。

  • PersistentFlags: 该标志可用于为其分配的命令以及该命令的所有子命令。

  • LocalFlags: 该标志只能用于分配给它的命令。

initConfig

该函数主要用于在 home 目录下面设置一个名为 .my-calc 的配置文件,如果该文件存在则会使用这个配置文件。

  1. // cmd/root.go

  2. // initConfig 读取配置文件和环境变量

  3. func initConfig() {

  4. if cfgFile != "" {

  5. // 使用 flag 标志中传递的配置文件

  6. viper.SetConfigFile(cfgFile)

  7. } else {

  8. // 获取 Home 目录

  9. home, err := homedir.Dir()

  10. if err != nil {

  11. fmt.Println(err)

  12. os.Exit(1)

  13. }

  14. // 在 Home 目录下面查找名为 ".my-calc" 的配置文件

  15. viper.AddConfigPath(home)

  16. viper.SetConfigName(".my-calc")

  17. }

  18. // 读取匹配的环境变量

  19. viper.AutomaticEnv()

  20. // 如果有配置文件,则读取它

  21. if err := viper.ReadInConfig(); err == nil {

  22. fmt.Println("Using config file:", viper.ConfigFileUsed())

  23. }

  24. }

viper 是一个非常优秀的用于解决配置文件的 Golang 库,它可以从 JSON、TOML、YAML、HCL、envfile 以及 Java properties 配置文件中读取信息,功能非常强大,而且不仅仅是读取配置这么简单,了解更多相关信息可以查看 Git 仓库相关介绍:https://github.com/spf13/viper。

现在我们可以去掉前面我们添加的一些打印语句,我们已经创建了一个 my-calc 命令作为 rootCmd 命令,执行该根命令会打印 HelloCobraCLI 信息,接下来为我们的 CLI 应用添加一些其他的命令。

添加数据

在项目根目录下面创建一个名为 add 的命令, Cobra 添加一个新的命令的方式为:cobra add<commandName>,所以我们这里直接这样执行:

  1. $ cobra add add

  2. add created at /Users/ych/devs/workspace/youdianzhishi/course/my-calc

  3. $ tree .

  4. .

  5. ├── LICENSE

  6. ├── cmd

  7. │ ├── add.go

  8. │ └── root.go

  9. ├── go.mod

  10. ├── go.sum

  11. ├── main.go

  12. └── my-calc

  13.  

  14. 1 directory, 7 files

现在我们可以看到 cmd/root.go 文件中新增了一个 add.go 的文件,我们仔细观察可以发现该文件和 cmd/root.go 比较类似。首先是声明了一个名为 addCmd 的结构体变量,类型为 *cobra.Command 指针类型, *cobra.Command 有一个 RUN 函数,带有 *cobra.Command 指针和一个字符串切片参数。

然后在 init 函数中进行初始化,初始化后,将其添加到 rootCmd 根命令中 rootCmd.AddCommand(addCmd),所以我们可以把 addCmd 看成是 rootCmd 的子命令。

同样现在重新构建应用再执行:

  1. $ go build -o my-calc

  2. $ ./my-calc

  3. Hello Cobra CLI

  4. $ ./my-calc add

  5. add called

可以看到 add 命令可以正常运行了,接下来我们来让改命令支持添加一些数字,我们知道在 RUN 函数中是用户字符串 slice 来作为参数的,所以要支持添加数字,我们首先需要将字符串转换为 int 类型,返回返回计算结果。

在 cmd/add.go 文件中添加一个名为 intAdd 的函数,定义如下所示:

  1. // cmd/add.go

  2. func intAdd(args []string) {

  3. var sum int

  4. // 循环 args 参数,循环的第一个值为 args 的索引,这里我们不需要,所以用 _ 忽略掉

  5. for _, ival := range args {

  6. // 将 string 转换成 int 类型

  7. temp, err := strconv.Atoi(ival)

  8. if err != nil {

  9. panic(err)

  10. }

  11. sum = sum + temp

  12. }

  13. fmt.Printf("Addition of numbers %s is %d\n", args, sum)

  14. }

然后在 addCmd 变量中,更新 RUN 函数,移除默认的打印信息,调用上面声明的 addInt 函数:

  1. // addCmd

  2. Run: func(cmd *cobra.Command, args []string) {

  3. intAdd(args)

  4. },

然后重新构建应用执行如下所示的命令:

  1. $ go build -o my-calc

  2. $ ./my-calc

  3. Hello Cobra CLI

  4. # 注意参数之间的空格

  5. $ ./my-calc add 1 2 3

  6. Addition of numbers [1 2 3] is 6

由于 RUN 函数中的 args 参数是一个字符串切片,所以我们可以传递任意数量的参数,但是却有一个缺陷,就是只能进行整数计算,不能计算小数,比如我们执行如下的计算就会直接 panic 了:

  1. $ ./my-calc add 1 2 3.5

  2. panic: strconv.Atoi: parsing "3.5": invalid syntax

  3.  

  4. goroutine 1 [running]:

  5. my-calc/cmd.intAdd(0xc0000a5890, 0x3, 0x3)

  6. ......

因为在 intAdd 函数里面,我们只是将字符串转换成了 int,而不是 float32/64 类型,所以我们可以为 addCmd 命令添加一个 flag 标识符,通过该标识符来帮助 CLI 确定它是 int 计算还是 float 计算。

在 cmd/add.go 文件的 init 函数内部,我们创建一个 Bool 类型的本地标识符,命名成 float,简写成 f,默认值为 false。这个默认值是非常重要的,意思就是即使没有在命令行中调用 flag 标识符,该标识符的值就将为 false。

  1. // cmd/add.go

  2. func init() {

  3. rootCmd.AddCommand(addCmd)

  4. addCmd.Flags().BoolP("float", "f", false, "Add Floating Numbers")

  5. }

然后创建一个 floatAdd 的函数:

  1. func floatAdd(args []string) {

  2. var sum float64

  3. for _, fval := range args {

  4. // 将字符串转换成 float64 类型

  5. temp, err := strconv.ParseFloat(fval, 64)

  6. if err != nil {

  7. panic(err)

  8. }

  9. sum = sum + temp

  10. }

  11. fmt.Printf("Sum of floating numbers %s is %f\n", args, sum)

  12. }

该函数和上面的 intAdd 函数几乎是相同的,除了是将字符串转换成 float64 类型。然后在 addCmd 的 RUN 函数中,我们根据传入的标识符来判断到底应该是调用 intAdd 还是 floatAdd,如果传递了 --float 或者 -f 标志,就将会调用 floatAdd 函数。

  1. // cmd/add.go

  2. // addCmd

  3. Run: func(cmd *cobra.Command, args []string) {

  4. // 获取 float 标识符的值,默认为 false

  5. fstatus, _ := cmd.Flags().GetBool("float")

  6. if fstatus { // 如果为 true,则调用 floatAdd 函数

  7. floatAdd(args)

  8. } else {

  9. intAdd(args)

  10. }

  11. },

现在重新编译构建 CLI 应用,按照如下方式执行:

  1. $ go build -o my-calc

  2. $ ./my-calc add 1 2 3

  3. Addition of numbers [1 2 3] is 6

  4. $ ./my-calc add 1 2 3.5 -f

  5. Sum of floating numbers [1 2 3.5] is 6.500000

  6. $./my-calc add 1 2 3.5 --float

  7. Sum of floating numbers [1 2 3.5] is 6.500000

然后接下来我们在给 addCmd 添加一些子命令来扩展它。

添加偶数

同样在项目根目录下执行如下命令添加一个名为 even 的命令:

  1. $ cobra add even

  2. even created at /Users/ych/devs/workspace/youdianzhishi/course/my-calc

和上面一样会在 root 目录下面新增一个名为 even.go 的文件,修改该文件中的 init 函数,将 rootCmd 修改为 addCmd,因为我们是为 addCmd 添加子命令:

  1. // cmd/even.go

  2. func init() {

  3. addCmd.AddCommand(evenCmd)

  4. }

然后更新 evenCmd 结构体参数的 RUN 函数:

  1. // cmd/even.go

  2. Run: func(cmd *cobra.Command, args []string) {

  3. var evenSum int

  4. for _, ival := range args {

  5. temp, _ := strconv.Atoi(ival)

  6. if temp%2 == 0 {

  7. evenSum = evenSum + temp

  8. }

  9. }

  10. fmt.Printf("The even addition of %s is %d\n", args, evenSum)

  11. },

首先将字符串转换成整数,然后判断如果是偶数才进行累加。然后重新编译构建应用:

  1. $ go build -o my-calc

  2. $ ./my-calc add even 1 2 3 4 5 6

  3. The even addition of [1 2 3 4 5 6] is 12

my-calc 是我们的根命令, add 是 rootCmd 的子命令, even 又是 addCmd 的子命令,所以按照上面的方式调用。可以用同样的方式再去添加一个奇数相加的子命令。

到这里我们就在 Golang 里面使用 Cobra 创建了一个简单的 CLI 应用。本文的内容虽然比较简单,但是是我们了解学习 Cobra 基础的一个很好的入门方式,后续我们也可以尝试添加一些更加复杂的使用案例。

参考资料

  • https://github.com/spf13/cobra

  • https://github.com/spf13/viper

  • https://schadokar.dev/posts/how-to-create-a-cli-in-golang-with-cobra/