What are these things?
Both package is used for command line interface applications to parse arguments. Cobra package parsing arguments based on flags. Cobra support the POSIX compliant parameters. Viper is a configuration solution for Go. With this you can read configuration from multiple resources like:
- configuration file, supported files: YAML, TOML, JSON, HCL, INI, env file, Java Properties file
- from environment variables
- it can also be binded to Cobra flags too
What is explained in this article?
This article is not an official reference, but I have found Cobra and Viper documentation not enough good for me. So after a few tinkering when I used both, I decided to make an article with a sample command.
The command itself is just a dummy thing (print out things). This article focuses on the Cobra and Viper handling instead of making a real functional application.
Make CLI with Cobra
The whole source code of this article is stored in this repository. In this part, only Cobra functions will be used to make a test CLI application. The structure of the commands is represented in the following block.
test-cobra-cli
+-- file
+-- list --path
+-- create --path
+-- mv --path --new-path
+-- rm --path
+-- dir
+-- list --path
+-- create --path
+-- mv --path --new-path
+-- rm --path
Create and application with go mod init command. Cobra definitions are
verbose, but they have a CLI that can help with that a bit.
To install it use go install github.com/spf13/cobra-cli@latest command.
Then install Cobra and Viper packages as well: go get github.com/spf13/cobra,
go get github.com/spf13/viper
Execute the cobra-cli init command. If you take a look for the help, it has
multiple parameters, you can set authors or license as well.
This generates a few file:
$ cobra-cli init -a 'Attila Molnár'
Your Cobra application is ready at
/home/ati/tmp/test_cobra_cli
$ tree .
.
├── cmd
│ └── root.go #### root command
├── go.mod
├── go.sum
├── LICENSE
└── main.go #### It calls the root command in cmd/root.go
2 directories, 5 files
Add some commands
Now I will add a file and a dir subcommand using the following commands:
$ cobra-cli add file
file created at /home/ati/tmp/test_cobra_cli
$ cobra-cli add dir
dir created at /home/ati/tmp/test_cobra_cli
$ tree .
.
├── cmd
│ ├── dir.go
│ ├── file.go
│ └── root.go
├── go.mod
├── go.sum
├── LICENSE
└── main.go
Cobra CLI generates a basic file, it has a lot of parameter. For this article, I will keep number of parameters low for better understanding. During this article, I will cover only the file commands, but directory is very similar just replace the “file” words with “directory”.
So from the generated cmd/file.go file, I have removed things and I just kept
the following:
package cmd
import (
"github.com/spf13/cobra"
)
var fileCmd = &cobra.Command{
Use: "file",
Short: "Perform actions with files",
}
func init() {
rootCmd.AddCommand(fileCmd)
fileCmd.PersistentFlags().StringP("path", "p", "", "Path for to the file")
fileCmd.MarkPersistentFlagRequired("path")
}
What is the difference between persistent flag and normal flag? Persistent flags
can be used with any subcommand.
You can see in the plan, that the --path flag is there at every subcommand
of file.
So instead define this flag multiple times, I just do it once at the parent
command definition.
We can also define short parameter using TypeP functions within Flags() or
PersistentFlags().
The path parameter is marked are required.
Let’s see how the help looks now.
$ go run . -h
This is a test application to use cobra CLI
Usage:
test-cobra-cli [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
dir Perform actions with files
file Perform actions with directories
help Help about any command
Flags:
-h, --help help for test-cobra-cli
Use "test-cobra-cli [command] --help" for more information about a command.
Add more subcommand
Next step is to add the list/delete/move/create subcommands for file and
directory too.
It can be done very similarly like we did before but we have to specify the
parent command where this subcommand belongs.
The default is the rootCmd. But where is the problem? Let’s see and example!
$ cobra-cli add -p fileCmd ls
ls created at /home/ati/tmp/test_cobra_cli
$ cobra-cli add -p dirCmd ls
ls created at /home/ati/tmp/test_cobra_cli
So where is the problem? They both created the cmd/ls.go file with lsCmd
command.
And the second one override the first one!
How it can be solved? Delete this file and try it from another way!
❯ cobra-cli add -p fileCmd fileList #### generates: cmd/fileList.go
fileList created at /home/ati/tmp/test_cobra_cli
❯ cobra-cli add -p dirCmd dirList #### generates: cmd/dirList.go
dirList created at /home/ati/tmp/test_cobra_cli
Rest of the subcommands can also be generated on this way.
The generated file names are different, but at what cost?
Let see the help of the file subcommand now.
$ go run . file -h
A longer description that spans multiple lines and likely contains examples
and usage of using your command. 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.
Usage:
test-cobra-cli file [flags]
test-cobra-cli file [command]
Available Commands:
fileCreate A brief description of your command
fileDelete A brief description of your command
fileList A brief description of your command
fileMove A brief description of your command
Flags:
-h, --help help for file
Use "test-cobra-cli file [command] --help" for more information about a command.
It is not comfortable to repeat ourselves when we type command like
test-cobra-cli file fileList command.
The key is to open a file (e.g.: cmd/fileList.go) and modify the Use field.
The subcommand name is get from there.
On this way, we will have comfortable subcommands for file and directory too,
but they are in separated files with separate command structure.
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var fileListCmd = &cobra.Command{
Use: "ls",
Short: "List files in directory",
RunE: func(cmd *cobra.Command, args []string) error {
path, _ := cmd.Flags().GetString("path")
fmt.Println("List files in", path)
return nil
},
}
func init() {
fileCmd.AddCommand(fileListCmd)
}
Take a look for the help and we will see more comfortable commands.
$ go run . file -h
Perform actions with files
Usage:
test-cobra-cli file [command]
Available Commands:
create Create new file
ls List files in directory
mv Move file
rm Dekele file
Flags:
-h, --help help for file
-p, --path string Path for to the file
Use "test-cobra-cli file [command] --help" for more information about a command.
$ go run . dir -h
Do things with directory
Usage:
test-cobra-cli dir [command]
Available Commands:
create Create new directory
ls List directories
mv Move directory
rm Remove a directory
Flags:
-h, --help help for dir
-p, --path string Path to directory
Use "test-cobra-cli dir [command] --help" for more information about a command.
Read parameters
Before explanation, let’s see an example from cmd/fileMove.go file.
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var fileMoveCmd = &cobra.Command{
Use: "mv",
Short: "Move file",
RunE: func(cmd *cobra.Command, args []string) error {
path, _ := cmd.Flags().GetString("path")
newPath, _ := cmd.Flags().GetString("new-path")
fmt.Println("Rename file", path, newPath)
return nil
},
}
func init() {
fileCmd.AddCommand(fileMoveCmd)
fileMoveCmd.Flags().String("new-path", "", "New name of the file")
fileMoveCmd.MarkFlagRequired("new-path")
}
What are different then the basic generated one?
- I used
RunEinstead ofRun. UsingRunE, does the same, but it returns with an error. So if the command would fail, it would be reflected in the return code of the command. I like using this as default because from scripting purpose it gives some ease. - I added a flag here called
new-path. This is not persistent, so this flag only assigned for this subcommand.
Reading parameter can be done with cmd.Flags()GetType("paramater-name") command.
If we run the command, it will just works.
$ go run . file mv -p test.txt --new-path new_test.txt
Rename file test.txt new_test.txt
Create a configuration
Using Viper, we can manager our configuration. On this exercise, I create a
serve subcommand which accept parameters: server-port, server-hostname
and server-cors-policy.
I really like those applications when I have freedom to provide configuration on multiple way. I create a sample for that:
- Specify options via parameter:
--server-port,--server-hostname,--server-cors-policy - Specify options via environment variables:
TESTCLI__SERVER_PORT,TESTCLI__SERVER__HOSTNAME,TESTCLI__SERVER__CORS_POLICY- It has a prefix for better environment variable handling
- Sections are separated by
__(double underscore)
- Read it from configuration file
This is the content of cmd/serve.go file:
package cmd
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
// serveCmd represents the serve command
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start server",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("serve called")
// Use viper.GetType instead of cmd.GetType
hostname := viper.Get("server.hostname")
port := viper.GetInt("server.port")
cors := viper.Get("server.cors_policy")
fmt.Println("Server listen on", hostname, port)
fmt.Println("CORS policy is", cors)
return nil
},
}
func init() {
// Run this command during initialization, within this Viper reads configuration
cobra.OnInitialize(initConfig)
// Default is not set here but in the initConfig function
rootCmd.AddCommand(serveCmd)
serveCmd.Flags().StringVar(&cfgFile, "config", "", "config file (default is $(pwd)/settings.yaml)")
// Setup flags for the parameters
serveCmd.Flags().String("server-hostname", "", "Hostname for the server (env: TESTCLI__SERVER__HOSTNAME)")
viper.BindPFlag("server.hostname", serveCmd.Flags().Lookup("server-hostname"))
serveCmd.Flags().Int("server-port", 0, "Port number for the server (env: TESTCLI__SERVER__PORT)")
viper.BindPFlag("server.port", serveCmd.Flags().Lookup("server-port"))
serveCmd.Flags().String("server-cors-policy", "permissive", "Port number for the server (env: TESTCLI__SERVER__CORS_POLICY)")
viper.BindPFlag("server.cors_policy", serveCmd.Flags().Lookup("server-cors-policy"))
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Find current working directory.
cwd, err := os.Getwd()
cobra.CheckErr(err)
viper.AddConfigPath(cwd)
viper.SetConfigType("yaml")
viper.SetConfigName("settings")
}
// Give prefix for environment variables and read them automatically
viper.SetEnvPrefix("TESTCLI_")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "__"))
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
Let’s try to understand what we see
So, what is new here? We use Viper to get the value of parameters instead of
Cobra.
You can use Cobra, but Viper does not update values of Cobra flags.
Instead, Cobra updates the configuration in Viper from value of flags.
With viper.BindPFlag, Viper can be bind for a Cobra flag. Configuration value
in Viper, is updated with the value of flag (if provided).
The next part is initConfig function that is run when Cobra initialize.
This makes that before the flags would be parsed, Viper read and parse the
provided configuration files.
Viper generates the environment variables with the following patter: prefix + '_' + parameter and make it upper case.
The ‘.’ characters are also replaced with ‘__’ (double underscore).
With and example server.hostname parameter can be specified with
TESTCLI__SERVER__HOSTNAME environment variable.
Or server.cors_policy can be set with TESTCLI__SERVER__CORS_POLICY
environment variable.
They can also set via configuration file like:
server:
hostname: localhost
port: 5000
Precedence is the following for this setup:
- Value read from Cobra flag
- Value read from environment variable
- Value is read from configuration file
Let see some example from command line.
$ go run . serve
Using config file: /home/ati/tmp/test_cobra_cli/settings.yaml
serve called
Server listen on localhost 5000
CORS policy is permissive
$ go run . serve --server-cors-policy strict
Using config file: /home/ati/tmp/test_cobra_cli/settings.yaml
serve called
Server listen on localhost 5000
CORS policy is strict
$ TESTCLI__SERVER__CORS_POLICY=strict go run . serve
Using config file: /home/ati/tmp/test_cobra_cli/settings.yaml
serve called
Server listen on localhost 5000
CORS policy is strict
$ TESTCLI__SERVER__CORS_POLICY=strict TESTCLI__SERVER__PORT=6969 go run . serve
Using config file: /home/ati/tmp/test_cobra_cli/settings.yaml
serve called
Server listen on localhost 6969
CORS policy is strict
$ TESTCLI__SERVER__CORS_POLICY=strict TESTCLI__SERVER__PORT=6969 go run . serve --server-port 420
Using config file: /home/ati/tmp/test_cobra_cli/settings.yaml
serve called
Server listen on localhost 420
CORS policy is strict
Final words
Cobra and Viper seems verbose at the first time and kong might be a better solution. But after I spent some time to learn Cobra and Viper, it has very nice configuration options. For smaller CLI kong is better for me, but for bigger applications, Cobra+Viper seems fine for me.