Although most of the software we use today is visual and easy to use, that doesn’t mean that CLI (command line) applications are useless, especially for developers who deal with CLI applications on a regular basis. Golang is perfect for building CLI applications, and we will introduce how to build a CLI application in Golang in the following.

For developers, there are a lot of CLI tools like npm, node, go, python, docker, kubectl, etc. which are very small, dependency-free, ideal for system management or automation tasks, etc.

We chose to use the famous Cobra library in Golang for our CLI tool development. Cobra is a powerful and modern CLI application library, and there are many well-known Go projects that use Cobra for building, such as Kubernetes, Docker, Hugo, etc.

Concepts

Cobra is built on top of commands, parameters and identifiers: the

  • Commands means execute actions
  • Args are the execution parameters
  • Flags are the identifiers for these actions

The basic execution commands are shown below.

1
2
3
$ APPNAME Command Args --Flags 
# or
$ APPNAME Command --Flags Args

For example, some of the command-line tools we normally use are

  • git clone URL -bare
  • go get -u URL
  • npm install package -save
  • kubectl get pods -n kube-system -l app=cobra

Example

Let’s take a look at using Cobra, here we are using go1.13.3 and using Go Modules for package management.

Create a new directory called my-calc as the project directory, and initialize modules.

1
2
3
4
$ mkdir my-calc && cd my-calc
# 如果 go modules 默认没有开启,需要执行 export GO111MODULE=on 开启
$ go mod init my-calc
go: creating new go.mod: module my-calc

After initialization, you can see that there is a go.mod file under the project root. Now that we don’t have the cobra library installed, run the following command to install it.

1
2
3
# 强烈推荐配置该环境变量
$ export GOPROXY=https://goproxy.cn
$ go get -u github.com/spf13/cobra/cobra

After successful installation, we can now initialize the scaffolding of the CLI application using the cobra init command.

1
2
3
$ cobra init --pkg-name my-calc
Your Cobra applicaton is ready at
/Users/ych/devs/workspace/youdianzhishi/course/my-calc

Note that newer versions of the cobra library require a -pkg-name argument for initialization, which specifies the name of the module we initialized above. The init command above will create a minimal CLI application project.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ tree .
.
├── LICENSE
├── cmd
│   └── root.go
├── go.mod
├── go.sum
└── main.go

1 directory, 5 files

The main.go is the entry point for the CLI application, and the Execute function under cmd/root.go is called in main.go.

1
2
3
4
5
6
7
8
// main.go
package main

import "my-calc/cmd"

func main() {
	cmd.Execute()
}

Then we’ll look at the cmd/root.go file.

rootCmd

The root command is the most basic command of the CLI tools, for example, for the go get URL we used earlier, where go is the root command, and get is a subcommand of the go root command, and the cobra command is used directly in root.go to initialize the rootCmd structure, all other commands in the CLI will be subcommands of the rootCmd root command. All other commands in the CLI will be subcommands of the root command rootCmd.

Here we remove the comment inside the rootCmd variable in cmd/root.go and add the phrase fmt.Println("Hello Cobra CLI") to the Run function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
var rootCmd = &cobra.Command{
	Use:   "my-calc",
	Short: "A brief description of your application",
	Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
	Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("Hello Cobra CLI")
    },
}

At this point we execute the following command under the root of the project to build.

1
$ go build -o my-calc

This command generates a binary file named my-calc in the root of the project, which can be executed directly to see the following output.

1
2
$ ./my-calc
Hello Cobra CLI

init

We know that the init function is the first function called when initializing a package in Golang. In cmd/root.go we can see that the init function calls cobra.OnInitialize(initConfig), which means that whenever a command is executed or invoked, it executes all the functions in the init function before executing the execute method. This initialization can be used for loading configuration files or for constructors and so on, depending entirely on the real situation of our application.

OnInitialize(initConfig)calls theinitConfigfunction inside the initialization function, so that when therootCmdexecution methodRUN: funcis run, therootCmdroot command will first run theinitConfigfunction, and when all the TheRUN: funcexecution function ofrootCmd` will be executed only after all the initialization functions have been executed.

We can add some Debug information to the initConfig function.

1
2
3
4
func initConfig() {
    fmt.Println("I'm inside initConfig function in cmd/root.go")
    ...
}

Then, again, reconstruct and execute.

1
2
3
4
$ go build -o my-calc
$ ./my-calc 
I'm inside initConfig function in cmd/root.go
Hello Cobra CLI

You can see that the information inside the initConfig function is run first, and then the actual execution of the function is done.

In order to understand the whole CLI execution flow, we add some debug information to main.go as well.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// cmd/root.go
func init() {
    fmt.Println("I'm inside init function in cmd/root.go")
    cobra.OnInitialize(initConfig)
    ...
}

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

// main.go
func main() {
     fmt.Println("I'm inside main function in main.go")
     cmd.Execute()
}

Then, again, reconstruct and execute.

1
2
3
4
5
6
$ go build -o my-calc
$ ./my-calc 
I'm inside init function in cmd/root.go
I'm inside main function in main.go
I'm inside initConfig function in cmd/root.go
Hello Cobra CLI

Based on the above log information, we can understand the flow of CLI commands.

The last thing the init function deals with is flags. Flags are similar to command identifiers, we can think of them as conditional operations of some kind, two types of identifiers are provided in Cobra: Persistent Flags and Local Flags.

  • Persistent Flags: This flag can be used for the command to which it is assigned and for all subcommands of that command.
  • Local Flags: This flag can only be used for the command to which it is assigned.

initConfig

This function is used to set a configuration file named .my-calc in the home directory, which will be used if the file exists.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// cmd/root.go
// initConfig 读取配置文件和环境变量
func initConfig() {
	if cfgFile != "" {
        // 使用 flag 标志中传递的配置文件
		viper.SetConfigFile(cfgFile)
	} else {
		// 获取 Home 目录
		home, err := homedir.Dir()
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}
		// 在 Home 目录下面查找名为 ".my-calc" 的配置文件
		viper.AddConfigPath(home)
		viper.SetConfigName(".my-calc")
	}
    // 读取匹配的环境变量
	viper.AutomaticEnv()
	// 如果有配置文件,则读取它
	if err := viper.ReadInConfig(); err == nil {
		fmt.Println("Using config file:", viper.ConfigFileUsed())
	}
}

viper is an excellent Golang library for solving configuration files. It can read information from JSON, TOML, YAML, HCL, envfile, and Java properties configuration files, which is very powerful and goes beyond reading configuration. Introduction: https://github.com/spf13/viper.

Now that we can remove some of the print statements we added earlier, we’ve created a my-calc command as a rootCmd command that will print the Hello Cobra CLI message when we execute the root command, so let’s add some other commands to our CLI application.

Adding Data

Create a command named add in the root of the project. The way Cobra adds a new command is: cobra add <commandName>, so we’ll do it here directly like this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ cobra add add
add created at /Users/ych/devs/workspace/youdianzhishi/course/my-calc
$ tree .
.
├── LICENSE
├── cmd
│   ├── add.go
│   └── root.go
├── go.mod
├── go.sum
├── main.go
└── my-calc

1 directory, 7 files

Now we can see that a new add.go file has been added to the cmd/root.go file, and if we look closely we can see that the file is relatively similar to cmd/root.go. Commandhas aRUNfunction with a*cobra.Command` pointer and a string slicing parameter.

Then it is initialized in the init function, and after initialization, it is added to the rootCmd root command rootCmd.AddCommand(addCmd), so we can think of addCmd as a subcommand of rootCmd.

Again, now rebuild the application and execute :

1
2
3
4
5
$ go build -o my-calc
$ ./my-calc 
Hello Cobra CLI
$ ./my-calc add
add called

We know that the RUN function takes the user string slice as an argument, so to support adding numbers, we first need to convert the string to int type and return the calculation result.

Add a function named intAdd to the cmd/add.go file, defined as follows

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// cmd/add.go
func intAdd(args []string) {
	var sum int
	// 循环 args 参数,循环的第一个值为 args 的索引,这里我们不需要,所以用 _ 忽略掉
	for _, ival := range args {
		// 将 string 转换成 int 类型
		temp, err := strconv.Atoi(ival)
		if err != nil {
			panic(err)
		}
		sum = sum + temp
	}
	fmt.Printf("Addition of numbers %s is %d\n", args, sum)
}

Then, in the addCmd variable, update the RUN function to remove the default print message and call the addInt function declared above.

1
2
3
4
// addCmd
Run: func(cmd *cobra.Command, args []string) {
    intAdd(args)
},

Then rebuild the application by executing the command shown below.

1
2
3
4
5
6
$ go build -o my-calc
$ ./my-calc 
Hello Cobra CLI
# 注意参数之间的空格
$ ./my-calc add 1 2 3
Addition of numbers [1 2 3] is 6

Since the args argument in the RUN function is a string slice, we can pass any number of arguments, but it does have the drawback that it can only compute integers, not decimals. For example, if we perform the following calculation, we will just panic:

1
2
3
4
5
6
$ ./my-calc add 1 2 3.5
panic: strconv.Atoi: parsing "3.5": invalid syntax

goroutine 1 [running]:
my-calc/cmd.intAdd(0xc0000a5890, 0x3, 0x3)
......

Since inside the intAdd function we are only converting strings to int, not float32/64 types, we can add a flag identifier to the addCmd command to help the CLI determine whether it is an int calculation or a float calculation.

Inside the init function in the cmd/add.go file, we create a local identifier of type Bool, named float, abbreviated to f, with a default value of false. this default value is very important, meaning that the value of the flag identifier will be false even if it is not called on the command line.

1
2
3
4
5
// cmd/add.go
func init() {
	rootCmd.AddCommand(addCmd)
	addCmd.Flags().BoolP("float", "f", false, "Add Floating Numbers")
}

Then create a floatAdd function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func floatAdd(args []string) {
	var sum float64
	for _, fval := range args {
		// 将字符串转换成 float64 类型
		temp, err := strconv.ParseFloat(fval, 64)
		if err != nil {
			panic(err)
		}
		sum = sum + temp
	}
	fmt.Printf("Sum of floating numbers %s is %f\n", args, sum)
}

This function is almost identical to the intAdd function above, except that it converts a string to float64. Then in the RUN function of addCmd, we determine whether intAdd or floatAdd should be called based on the passed identifier, and if the -float or -f flags are passed, the floatAdd function will be called.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// cmd/add.go
// addCmd
Run: func(cmd *cobra.Command, args []string) {
    // 获取 float 标识符的值,默认为 false
    fstatus, _ := cmd.Flags().GetBool("float")
    if fstatus { // 如果为 true,则调用 floatAdd 函数
        floatAdd(args)
    } else {
        intAdd(args)
    }
},

Now recompile the build CLI application and execute it as follows.

1
2
3
4
5
6
7
$ go build -o my-calc  
$ ./my-calc add 1 2 3
Addition of numbers [1 2 3] is 6
$ ./my-calc add 1 2 3.5 -f
Sum of floating numbers [1 2 3.5] is 6.500000
$./my-calc add 1 2 3.5 --float
Sum of floating numbers [1 2 3.5] is 6.500000

Then we extend it by adding some subcommands to addCmd.

Adding even numbers

Also add a command named even to the root of the project with the following command.

1
2
$ cobra add even
even created at /Users/ych/devs/workspace/youdianzhishi/course/my-calc

As above, a new file named even.go will be added under the root directory. Modify the init function in this file, and change rootCmd to addCmd, since we are adding a subcommand for addCmd:

1
2
3
4
// cmd/even.go
func init() {
	addCmd.AddCommand(evenCmd)
}

Then update the RUN function of the evenCmd structure parameter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// cmd/even.go
Run: func(cmd *cobra.Command, args []string) {
    var evenSum int
    for _, ival := range args {
        temp, _ := strconv.Atoi(ival)
        if temp%2 == 0 {
            evenSum = evenSum + temp
        }
    }
    fmt.Printf("The even addition of %s is %d\n", args, evenSum)
},

First convert the string to an integer, then determine if it is even before adding it up. Then recompile and build the application:

1
2
3
$ go build -o my-calc
$ ./my-calc add even 1 2 3 4 5 6
The even addition of [1 2 3 4 5 6] is 12

my-calc is our root command, add is a subcommand of rootCmd, and even is a subcommand of addCmd, so call it in the same way as above. You can add another subcommand for adding odd numbers in the same way.

Here we have created a simple CLI application in Golang using Cobra. This article is relatively simple, but it’s a good way to get started learning the basics of Cobra, and we can try to add some more complex use cases later.

References