22 Application Building Three Musketeers Pflag Viper Cobra Core Functionality Introduction

22 Application Building Three Musketeers Pflag Viper Cobra Core Functionality Introduction #

Hello, I’m Kong Lingfei. In this lesson, let’s talk about the commonly used Go packages for building applications.

Because the IAM project uses the Pflag, Viper, and Cobra packages to build the IAM application framework, to make it easier for you to learn later, let’s briefly introduce the core functions and usage of these three packages. Actually, if we talk about each package separately, there are still many functions to discuss. However, the purpose of this lesson is to reduce the difficulty of learning the IAM source code, so I will mainly introduce the functions related to IAM.

Before formally introducing these three packages, let’s first look at how to build the framework of an application.

How to Build an Application Framework #

To understand how to build an application framework, first you need to understand what components are involved in an application framework. In my opinion, an application framework should include the following three parts:

  • Command-line Argument Parsing: This is used to parse command-line arguments that can affect the execution of a command.
  • Configuration File Parsing: A large-scale application usually has many parameters. To manage and configure these parameters effectively, they are often placed in a configuration file to be read and parsed by the program.
  • Command-line Framework for the Application: The application is ultimately launched through commands. There are three requirements here: firstly, commands should have a help function to guide users on how to use them; secondly, commands should be able to parse command-line arguments and configuration files; and lastly, commands should be able to initialize the business logic and ultimately start the business process. In other words, our commands need to have the capability to manage these three parts.

You can develop these three parts on your own or use mature implementations already available in the industry. As mentioned before, I do not recommend developing them on your own. Instead, I suggest using the popular and mature implementations available in the industry. Pflag can be used to parse command-line arguments, Viper can be used to parse configuration files, and Cobra can be used to implement the command-line framework for the application. These three packages are currently the most popular and they are not separate, but rather interconnected. We can combine these three packages in an organic way to create a very powerful and excellent application command-line framework.

Next, let’s take a detailed look at how these three packages are used in Go project development.

Command Line Argument Parsing Tool: Introduction to Pflag #

In Go service development, it is often necessary to add various startup parameters to configure the service process and affect the behavior of the service components. For example, kube-apiserver has more than 200 startup parameters, and these parameters have different types (e.g., string, int, IP, etc.) and usage styles (e.g., supporting -- long options, - short options, etc.). Therefore, we need a powerful command line argument parsing tool.

Although the Go source code provides a standard Flag package for parsing command line arguments, another package called Pflag is more widely used in large projects. Pflag provides many powerful features and is very suitable for building large projects. Some well-known open source projects, such as Kubernetes, Istio, Helm, Docker, Etcd, etc., use Pflag for command line argument parsing.

Next, let’s see how to use Pflag. Pflag is mainly used through the creation of Flag and FlagSet. Let’s start with Flag.

Pflag Package Flag Definition #

Pflag can handle command line arguments, and a command line argument in the Pflag package is parsed into a variable of type Flag. Flag is a structure defined as follows:

type Flag struct {
    Name                string // the name of the long option for the flag
    Shorthand           string // the name of the short option for the flag, an abbreviated character
    Usage               string // the usage text for the flag
    Value               Value  // the value of the flag
    DefValue            string // the default value of the flag
    Changed             bool // records if the value of the flag has been set
    NoOptDefVal         string // the default value when the flag appears in the command line but no option value is specified
    Deprecated          string // records if the flag has been deprecated
    Hidden              bool // if true, hides the flag from help/usage output
    ShorthandDeprecated string // if the short option of the flag is deprecated, this message will be printed when the flag is used with the short option
    Annotations         map[string][]string // sets annotations for the flag
}

The value of Flag is an interface of type Value. Value is defined as follows:

type Value interface {
    String() string // converts the value of the flag type to a string type and returns the string contents
    Set(string) error // converts the string type value to the flag type value, returns an error if the conversion fails
    Type() string // returns the type of the flag, such as string, int, IP, etc.
}

By abstracting the value of Flag into an interface, we can define our own types for Flag. Any struct that implements the Value interface becomes a new type.

Pflag Package FlagSet Definition #

In addition to supporting individual Flags, Pflag also supports FlagSets. FlagSet is a collection of predefined Flags, and almost all Pflag operations need to be done using the methods provided by FlagSet. In actual development, we can use two methods to get and use FlagSets:

  • Method 1: Call NewFlagSet to create a FlagSet.
  • Method 2: Use the global FlagSet defined by the Pflag package: CommandLine. In reality, CommandLine is also created by the NewFlagSet function.

Let’s first look at the first method, custom FlagSet. Here is an example of a custom FlagSet:

var version bool
flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError)
flagSet.BoolVar(&version, "version", true, "Print version information and quit.")

We can define the Flags for commands and their subcommands by defining a new FlagSet.

Now let’s look at the second method, using the global FlagSet. Here is an example of using the global FlagSet:

import (
    "github.com/spf13/pflag"
)

pflag.BoolVarP(&version, "version", "v", true, "Print version information and quit.")

In this example, the pflag.BoolVarP function is defined as follows:

func BoolVarP(p *bool, name, shorthand string, value bool, usage string) {
    flag := CommandLine.VarPF(newBoolValue(value, p), name, shorthand, usage)
    flag.NoOptDefVal = "true"
}

As you can see, pflag.BoolVarP ultimately calls CommandLine, which is a package-level variable defined as follows:

// CommandLine is the default set of command-line flags, parsed from os.Args.
var CommandLine = NewFlagSet(os.Args[0], ExitOnError)

In command line tools that do not need to define subcommands, we can directly use the global FlagSet, which is simpler and more convenient.

Using Pflag #

Above, we introduced the two core structs of using the Pflag package. Now, I will provide a detailed introduction to the common usage methods of Pflag. Pflag has many powerful features, but here I will introduce 7 common usage methods.

  1. Support multiple command line argument definition styles.

Pflag supports the following 4 styles for defining command line arguments:

  • Supports long options, default values, and usage text, and stores the value of the flag in a pointer.
var name = pflag.String("name", "colin", "Input Your Name")
  • Supports long options, short options, default values, and usage text, and stores the value of the flag in a pointer.
var name = pflag.StringP("name", "n", "colin", "Input Your Name")
  • Supports long options, default values, and usage text, and binds the value of the flag to a variable.
var name string
pflag.StringVar(&name, "name", "colin", "Input Your Name")
  • Support long options, short options, default values, and usage text, and bind the value of the flag to a variable.
var name string
pflag.StringVarP(&name, "name", "n","colin", "Input Your Name")

The function name above follows the following rules:

  • Functions with Var in their name indicate that the value of the flag is bound to a variable, otherwise, the value of the flag is stored in a pointer.
  • Functions with P in their name indicate that short options are supported, otherwise, short options are not supported.
  1. Use Get<Type> to retrieve the value of a flag.

You can use Get<Type> to retrieve the value of a flag, where <Type> represents the type that Pflag supports. For example, if you have a pflag.FlagSet with an int flag named flagname, you can use GetInt() to retrieve its int value. Note that flagname must exist and must be of type int. For example:

i, err := flagset.GetInt("flagname")
  1. Retrieve non-optional arguments.

The code example is as follows:

package main

import (
    "fmt"
    "github.com/spf13/pflag"
)

var (
    flagvar = pflag.Int("flagname", 1234, "help message for flagname")
)

func main() {
    pflag.Parse()

    fmt.Printf("argument number is: %v\n", pflag.NArg())
    fmt.Printf("argument list is: %v\n", pflag.Args())
    fmt.Printf("the first argument is: %v\n", pflag.Arg(0))
}

When executing the above code, the output is as follows:

$ go run example1.go arg1 arg2
argument number is: 2
argument list is: [arg1 arg2]
the first argument is: arg1

After defining the flags, you can call pflag.Parse() to parse the defined flags. After parsing, you can use pflag.Args() to return all the non-optional arguments and pflag.Arg(i) to return the i-th non-optional argument, with indices ranging from 0 to pflag.NArg() - 1.

  1. Handling default values when an option is specified but no value is provided.

After creating a Flag, you can set pflag.NoOptDefVal for this Flag. If a Flag has NoOptDefVal and the Flag value is not set on the command line, the flag will be set to the value specified by NoOptDefVal. For example:

var ip = pflag.IntP("flagname", "f", 1234, "help message")
pflag.Lookup("flagname").NoOptDefVal = "4321"

The above code will produce the following result, which you can refer to the table below:

Image

  1. Deprecating a flag or a flag shorthand.

Pflag allows flags or flag shorthands to be deprecated. Deprecated flags or flag shorthands will be hidden in the help text and the correct usage will be printed when using deprecated flags or shorthands. For example, deprecating a flag named logmode and informing users which flag to use instead:

// Deprecate a flag by specifying its name and a usage message
pflag.CommandLine.MarkDeprecated("logmode", "please use --log-mode instead")

This hides logmode in the help text and prints Flag --logmode has been deprecated, please use --log-mode instead when logmode is used.

  1. Keeping a flag named port but deprecating its shorthand.
pflag.IntVarP(&port, "port", "P", 3306, "MySQL service host port.")

// Deprecate a flag shorthand by specifying its flag name and a usage message
pflag.CommandLine.MarkShorthandDeprecated("port", "please use --port only")

This hides the shorthand P in the help text and prints Flag shorthand -P has been deprecated, please use --port only when the shorthand P is used. The usage message is necessary here and should not be empty.

  1. Hiding a flag.

You can mark a Flag as hidden, which means it will still function normally, but it will not be displayed in the usage/help text. For example, hiding a flag named secretFlag that is only used internally and should not be displayed in the help or usage text:

// Hide a flag by specifying its name
pflag.CommandLine.MarkHidden("secretFlag")

So far, we have introduced the important usage of the Pflag package. Next, let’s take a look at how to parse configuration files.

Configuration Parsing Artifact: Introduction to Viper #

Almost all backend services require some configuration options to configure our services. For small projects with few configurations, we can choose to pass the configurations through command-line parameters. However, for large projects with many configurations, passing them through command-line parameters becomes cumbersome and difficult to maintain. The standard solution is to store these configuration information in a configuration file that is loaded and parsed by the program at startup. In the Go ecosystem, there are many packages that can load and parse configuration files, and currently the most popular one is the Viper package.

Viper is a modern and complete solution for Go applications that can handle configurations in different formats, allowing us to build modern applications without worrying about the format of the configuration file. Viper can also meet various requirements for application configuration.

Viper can read configurations from different locations, and configurations from different locations have different priorities. Configurations with higher priorities will override configurations with the same key and lower priorities. The priorities are arranged from high to low as follows:

  1. Configurations explicitly set through the viper.Set function
  2. Command-line parameters
  3. Environment variables
  4. Configuration file
  5. Key/Value store
  6. Default values

It is important to note that Viper configuration keys are case-insensitive.

Viper has many features, and the two most important categories of features are loading configurations and retrieving configurations. Viper provides different methods to achieve these two categories of features. Next, we will provide a detailed introduction on how Viper loads configurations and retrieves configurations.

Loading Configurations #

Loading configurations means reading configurations into Viper. The following are the methods for loading configurations:

  • Setting the default configuration file name.
  • Reading the configuration file.
  • Watching and re-reading the configuration file.
  • Reading configurations from io.Reader.
  • Reading configurations from environment variables.
  • Reading configurations from command-line flags.
  • Reading configurations from remote Key/Value stores.

You can see the specific loading methods for these methods in the demonstration below.

  1. Setting default values.

A good configuration system should support default values. Viper supports setting default values for keys. Setting default values is usually useful when a key is not set through a configuration file, environment variable, remote configuration, or command-line flag. This allows the program to run normally even when configurations are not explicitly specified. For example:

viper.SetDefault("ContentDir", "content")
viper.SetDefault("LayoutDir", "layouts")
viper.SetDefault("Taxonomies", map[string]string{"tag": "tags", "category": "categories"})
  1. Reading the configuration file.

Viper can read a configuration file to parse the configurations. It supports the following formats for configuration files: JSON, TOML, YAML, YML, Properties, Props, Prop, HCL, Dotenv, and Env. Viper supports searching multiple paths and by default does not configure any search paths, leaving the default decision to the application.

Here is an example of how to use Viper to search and read configuration files:

package main

import (
	"fmt"

	"github.com/spf13/pflag"
	"github.com/spf13/viper"
)

var (
	cfg  = pflag.StringP("config", "c", "", "Configuration file.")
	help = pflag.BoolP("help", "h", false, "Show this help message.")
)

func main() {
	pflag.Parse()
	if *help {
		pflag.Usage()
		return
	}

	// Read configurations from the configuration file
	if *cfg != "" {
		viper.SetConfigFile(*cfg)   // Specify the configuration file name
		viper.SetConfigType("yaml") // If the configuration file name does not have a file extension, you need to specify the format of the configuration file to tell Viper how to parse the file
	} else {
		// ...
	}
```go
viper.AddConfigPath(".")          // Add the current directory to the search path of the configuration file
viper.AddConfigPath("$HOME/.iam") // Configuration file search path, multiple search paths can be set
viper.SetConfigName("config")     // Configuration file name (without file extension)
}

if err := viper.ReadInConfig(); err != nil { // Read the configuration file. If the configuration file name is specified, use the specified configuration file; otherwise, search in the registered search paths
    panic(fmt.Errorf("Fatal error config file: %s \n", err))
}

fmt.Printf("Used configuration file is: %s\n", viper.ConfigFileUsed())
}
```

Viper supports setting multiple configuration file search paths, and you need to pay attention to the order in which the search paths are added. Viper will search for the configuration file based on the added paths, and stop when it finds one. If you call SetConfigFile to directly specify the configuration file name and the name does not have a file extension, you need to explicitly specify the format of the configuration file so that Viper can parse it correctly.

1. Listening and reloading configuration files.

Viper supports real-time reading of configuration files during runtime, also known as hot-reloading configurations. You can use the WatchConfig function to hot-reload configurations. Before calling the WatchConfig function, make sure that the search paths of the configuration files have been added. In addition, you can provide a callback function to Viper to run whenever a change occurs. Here is an example:

```go
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
    // Callback function to be called after the configuration file changes
    fmt.Println("Config file changed:", e.Name)
})
```

I do not recommend using the hot-reloading feature in actual development because even if the configuration is reloaded, the code in the program may not be reloaded. For example, if the service port is modified but the service is not restarted, the service will still listen on the old port, which may cause inconsistencies.

1. Setting configuration values.

We can explicitly set configurations using the viper.Set() function:

```go
viper.Set("user.username", "colin")
```

1. Using environment variables.

Viper also supports environment variables through the following 5 functions:

- AutomaticEnv()
- BindEnv(input ...string) error
- SetEnvPrefix(in string)
- SetEnvKeyReplacer(r *strings.Replacer)
- AllowEmptyEnv(allowEmptyEnv bool)

It is important to note that Viper reads environment variables in a case-sensitive manner. Viper provides a mechanism to ensure that Env variables are unique. By using SetEnvPrefix, you can tell Viper to use the prefix when reading environment variables. Both BindEnv and AutomaticEnv will use this prefix. For example, if we set viper.SetEnvPrefix("VIPER"), when we use viper.Get("apiversion"), the actual environment variable read is `VIPER_APIVERSION`.

BindEnv requires one or two parameters. The first parameter is the key name, and the second is the name of the environment variable, which is case-sensitive. If the environment variable name is not provided, Viper will assume that the environment variable name is: `environment variable prefix_key name in uppercase`. For example, if the prefix is VIPER and the key is username, the environment variable name is `VIPER_USERNAME`. When an environment variable name (the second parameter) is explicitly provided, it will not automatically add the prefix. For example, if the second parameter is ID, Viper will look for the environment variable ID.

When using Env variables, one important thing to note is that it will be read every time the value is accessed. Viper does not fix this value when the BindEnv function is called.

There is also a magical function SetEnvKeyReplacer, which allows you to use a strings.Replacer object to rewrite Env keys. If you want to use `-` or `.` in the Get() call, but want your environment variables to use `_` as the separator, you can use SetEnvKeyReplacer to achieve this. For example, if we set the environment variables `USER_SECRET_KEY=bVix2WBv0VPfrDrvlLWrhEdzjLpPCNYb`, but we want to use `viper.Get("user.secret-key")`, we can call the function:

```go
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
```

In the above code, when the viper.Get() function is called, `_` will be used to replace `.` and `-`. By default, an empty environment variable is considered unset and will be returned to the next configuration source. To treat an empty environment variable as set, you can use the AllowEmptyEnv method. An example of using environment variables is as follows:

```go
// Using environment variables
os.Setenv("VIPER_USER_SECRET_ID", "QLdywI2MrmDVjSSv6e95weNRvmteRjfKAuNV")
os.Setenv("VIPER_USER_SECRET_KEY", "bVix2WBv0VPfrDrvlLWrhEdzjLpPCNYb")

viper.AutomaticEnv()                                             // Read environment variables
viper.SetEnvPrefix("VIPER")                                      // Set environment variable prefix: VIPER_, if it is viper, it will automatically convert to uppercase.
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) // Replace '.' and '-' with '_' in the strings of viper.Get(key)
viper.BindEnv("user.secret-key")
viper.BindEnv("user.secret-id", "USER_SECRET_ID") // Bind environment variable names to key
```

1. Using flags.

Viper supports the Pflag package, which can bind keys to flags. Similar to BindEnv, when calling the binding method, the value will not be set, but it will be set when accessed. For individual flags, you can use BindPFlag() to bind them:

viper.BindPFlag("token", pflag.Lookup("token")) // Bind a single flag

You can also bind a group of existing Pflags (pflag.FlagSet):

viper.BindPFlags(pflag.CommandLine) // Bind a set of flags

Reading Configuration #

Viper provides the following methods for reading configuration:

  • Get(key string) interface{}
  • Get(key string)
  • AllSettings() map[string]interface{}
  • IsSet(key string) bool

Each Get method returns a zero value if the value is not found. To check if a given key exists, you can use the IsSet() method. can be any type supported by Viper, with the first letter capitalized: Bool, Float64, Int, IntSlice, String, StringMap, StringMapString, StringSlice, Time, Duration. For example, GetInt().

There are several common methods for reading configurations:

  1. Accessing nested keys.

For example, with the following JSON file loaded:

{
    "host": {
        "address": "localhost",
        "port": 5799
    },
    "datastore": {
        "metric": {
            "host": "127.0.0.1",
            "port": 3099
        },
        "warehouse": {
            "host": "198.0.0.1",
            "port": 2112
        }
    }
}

Viper can access nested fields by passing a path separated by dots:

viper.GetString("datastore.metric.host") // Returns "127.0.0.1"

If datastore.metric is directly assigned a value (e.g. through flags, environment variables, the set() method, etc.), all its child keys will become undefined as they are overridden by higher priority configuration levels.

If there is a key path that matches the separated key path, its value is directly returned. For example:

{
    "datastore.metric.host": "0.0.0.0",
    "host": {
        "address": "localhost",
        "port": 5799
    },
    "datastore": {
        "metric": {
            "host": "127.0.0.1",
            "port": 3099
        },
        "warehouse": {
            "host": "198.0.0.1",
            "port": 2112
        }
    }
}

You can get the value using viper.GetString:

viper.GetString("datastore.metric.host") // Returns "0.0.0.0"
  1. Deserialization.

Viper can support parsing all or specific values into structs, maps, etc. This can be done through two functions:

  • Unmarshal(rawVal interface{}) error
  • UnmarshalKey(key string, rawVal interface{}) error

Here’s an example:

type config struct {
    Port    int
    Name    string
    PathMap string `mapstructure:"path_map"`
}

var C config

err := viper.Unmarshal(&C)
if err != nil {
    t.Fatalf("unable to decode into struct, %v", err)
}

If you want to parse configurations where the keys themselves contain the default key delimiter (.), you need to change the delimiter:

v := viper.NewWithOptions(viper.KeyDelimiter("::"))

v.SetDefault("chart::values", map[string]interface{}{
    "ingress": map[string]interface{}{
        "annotations": map[string]interface{}{
            "traefik.frontend.rule.type":                 "PathPrefix",
            "traefik.ingress.kubernetes.io/ssl-redirect": "true",
        },
    },
})

type config struct {
    Chart struct {
        Values map[string]interface{}
    }
}

var C config

v.Unmarshal(&C)

Viper uses github.com/mitchellh/mapstructure in the background to parse values, which by default uses mapstructure tags. When we want to deserialize the configurations read by Viper into the struct variables we defined, we must use mapstructure tags.

  1. Serialization to string.

Sometimes we need to serialize all the settings saved in Viper into a string instead of writing them to a file. Here’s an example:

import (
    yaml "gopkg.in/yaml.v2"
    // ...
)

func yamlStringSettings() string {
    c := viper.AllSettings()
    bs, err := yaml.Marshal(c)
    if err != nil {
        log.Fatalf("unable to marshal config to YAML: %v", err)
    }
    return string(bs)
}

Modern Command-line Framework: A Comprehensive Guide to Cobra #

Cobra is both a library for creating powerful modern CLI applications and a program for generating application and command files. Many large projects use Cobra to build their applications, including Kubernetes, Docker, etcd, Rkt, and Hugo.

Cobra is built on the concepts of commands, arguments, and flags. Commands represent actions, arguments represent non-option parameters, and flags represent option parameters. A good application should be easy to understand, and users should have a clear understanding of how to use it. Applications typically follow the pattern: APPNAME VERB NOUN --ADJECTIVE or APPNAME COMMAND ARG --FLAG. For example:

git clone URL --bare # clone is a command, URL is a non-option parameter, and bare is an option parameter

Here, VERB represents a verb, NOUN represents a noun, and ADJECTIVE represents an adjective.

Cobra provides two ways to create commands: Cobra commands and Cobra libraries. Cobra commands generate a Cobra command template, and the command template is built using the Cobra library. Here, I will directly introduce how to create commands using the Cobra library.

Creating Commands Using the Cobra Library #

To implement an application using the Cobra library, you need to first create an empty main.go file and a rootCmd file, and then add other commands as needed. The specific steps are as follows:

  1. Create the rootCmd.

    $ mkdir -p newApp2 && cd newApp2
    

    Typically, we put the rootCmd in the file cmd/root.go.

    var rootCmd = &cobra.Command{
      Use:   "hugo",
      Short: "Hugo is a very fast static site generator",
      Long: `A Fast and Flexible Static Site Generator built with
              love by spf13 and friends in Go.
              Complete documentation is available at http://hugo.spf13.com`,
      Run: func(cmd *cobra.Command, args []string) {
        // Do Stuff Here
      },
    }
    
    func Execute() {
      if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
    }
    

    You can also define flags and handle configuration in the init() function in cmd/root.go.

    import (
      "fmt"
      "os"
    
      homedir "github.com/mitchellh/go-homedir"
      "github.com/spf13/cobra"
      "github.com/spf13/viper"
    )
    
    var (
        cfgFile     string
        projectBase string
        userLicense string
    )
    
    func init() {
      cobra.OnInitialize(initConfig)
      rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cobra.yaml)")
      rootCmd.PersistentFlags().StringVarP(&projectBase, "projectbase", "b", "", "base project directory eg. github.com/spf13/")
      rootCmd.PersistentFlags().StringP("author", "a", "YOUR NAME", "Author name for copyright attribution")
      rootCmd.PersistentFlags().StringVarP(&userLicense, "license", "l", "", "Name of license for the project (can provide `licensetext` in config)")
      rootCmd.PersistentFlags().Bool("viper", true, "Use Viper for configuration")
      viper.BindPFlag("author", rootCmd.PersistentFlags().Lookup("author"))
      viper.BindPFlag("projectbase", rootCmd.PersistentFlags().Lookup("projectbase"))
      viper.BindPFlag("useViper", rootCmd.PersistentFlags().Lookup("viper"))
      viper.SetDefault("author", "NAME HERE <EMAIL ADDRESS>")
      viper.SetDefault("license", "apache")
    }
    
    func initConfig() {
      // Don't forget to read config either from cfgFile or from home directory!
      if cfgFile != "" {
        // Use config file from the flag.
        viper.SetConfigFile(cfgFile)
      } else {
        // Find home directory.
        home, err := homedir.Dir()
        if err != nil {
          fmt.Println(err)
          os.Exit(1)
        }
    
        // Search config in home directory with name ".cobra" (without extension).
        viper.AddConfigPath(home)
        viper.SetConfigName(".cobra")
      }
    
      if err := viper.ReadInConfig(); err != nil {
        fmt.Println("Can't read config:", err)
        os.Exit(1)
      }
    }
    
  2. Create main.go.

    We also need a main function to call rootCmd. Typically, we create a main.go file and call rootCmd.Execute() to execute the command:

    package main
    
    import (
      "{pathToYourApp}/cmd"
    )
    
    func main() {
      cmd.Execute()
    }
    

    Note that main.go is not recommended for placing a lot of code. Typically, calling cmd.Execute() is sufficient.

  3. Add commands.

    In addition to rootCmd, we can call AddCommand to add other commands. In most cases, we place the source code files of other commands in the cmd/ directory. For example, if we want to add a version command, we can create a cmd/version.go file with the following content:

    package cmd
    
    import (
      "fmt"
    
      "github.com/spf13/cobra"
    )
    
    func init() {
      rootCmd.AddCommand(versionCmd)
    }
    
    var versionCmd = &cobra.Command{
      Use:   "version",
      Short: "Print the version number of Hugo",
      Long:  `All software has versions. This is Hugo's`,
      Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("Hugo Static Site Generator v0.9 -- HEAD")
      },
    }
    

In this example, we added a versionCmd command to rootCmd by calling rootCmd.AddCommand(versionCmd).

  1. Compile and run.

Replace {pathToYourApp} in main.go with the corresponding path. For example, in this example, the pathToYourApp is github.com/marmotedu/gopractise-demo/cobra/newApp2.

$ go mod init github.com/marmotedu/gopractise-demo/cobra/newApp2
$ go build -v .
$ ./newApp2 -h
A Fast and Flexible Static Site Generator built with
love by spf13 and friends in Go.
Complete documentation is available at http://hugo.spf13.com

Usage:
hugo [flags]
hugo [command]

Available Commands:
help Help about any command
version Print the version number of Hugo

Flags:
-a, --author string Author name for copyright attribution (default "YOUR NAME")
--config string config file (default is $HOME/.cobra.yaml)
-h, --help help for hugo
-l, --license licensetext Name of license for the project (can provide licensetext in config)
-b, --projectbase string base project directory eg. github.com/spf13/
--viper Use Viper for configuration (default true)

Use "hugo [command] --help" for more information about a command.

By following steps 1, 2, and 3, we have successfully created and added a Cobra application and its command.

Next, let me explain the core features of Cobra in more detail.

Using Flags #

Cobra can be combined with Pflag to achieve powerful flag functionality. The steps are as follows:

  1. Use persistent flags.

Flags can be “persistent,” which means they can be used for the command they are assigned to as well as each subcommand under that command. Persistent flags can be defined on rootCmd:

rootCmd.PersistentFlags().BoolVarP(&Verbose, "verbose", "v", false, "verbose output")
  1. Use local flags.

A local flag can also be assigned, which can only be used on the command it is bound to:

rootCmd.Flags().StringVarP(&Source, "source", "s", "", "Source directory to read from")

The --source flag can only be referenced on rootCmd and not on any subcommands of rootCmd.

  1. Bind flags to Viper.

Flags can be bound to Viper, allowing access to the flag’s value using viper.Get():

var author string

func init() {
  rootCmd.PersistentFlags().StringVar(&author, "author", "YOUR NAME", "Author name for copyright attribution")
  viper.BindPFlag("author", rootCmd.PersistentFlags().Lookup("author"))
}
  1. Set a flag as required.

By default, flags are optional, but they can also be set as required. When a flag is set as required and not provided, Cobra will throw an error:

rootCmd.Flags().StringVarP(&Region, "region", "r", "", "AWS region (required)")
rootCmd.MarkFlagRequired("region")

Non-Option Argument Validation #

During command execution, non-option arguments are often passed and may need to be validated. Cobra provides a mechanism to validate non-option arguments. The Args field of a command can be used to validate non-option arguments. Cobra also includes some built-in validation functions:

  • NoArgs: If any non-option arguments are present, the command will error.
  • ArbitraryArgs: The command will accept any non-option arguments.
  • OnlyValidArgs: If any non-option arguments are not in the ValidArgs field of the command, the command will error.
  • MinimumNArgs(int): If there are fewer than N non-option arguments, the command will error.
  • MaximumNArgs(int): If there are more than N non-option arguments, the command will error.
  • ExactArgs(int): If the number of non-option arguments is not N, the command will error.
  • ExactValidArgs(int): If the number of non-option arguments is not N or if any non-option arguments are not in the ValidArgs field of the command, the command will error.
  • RangeArgs(min, max): If the number of non-option arguments is not between min and max, the command will error.

Using the predefined validation functions, the following example demonstrates how to use them:

var cmd = &cobra.Command{
  Short: "hello",
  Args: cobra.MinimumNArgs(1),  // Use a built-in validation function
  Run: func(cmd *cobra.Command, args []string) {
    fmt.Println("Hello, World!")
  },
}

Of course, you can also define your own validation function, as shown in the following example:

var cmd = &cobra.Command{
  Short: "hello",
  // Args: cobra.MinimumNArgs(10),  // Use a built-in validation function
  Args: func(cmd *cobra.Command, args []string) error {  // Use a custom validation function
    if len(args) < 1 {
      return errors.New("requires at least one arg")
    }
    if myapp.IsValidColor(args[0]) {
      return nil
    }
    return fmt.Errorf("invalid color specified: %s", args[0])
  },
  Run: func(cmd *cobra.Command, args []string) {
    fmt.Println("Hello, World!")
  },
}

PreRun and PostRun Hooks #

When running the Run function, we can run some hook functions. For example, PersistentPreRun and PreRun functions are executed before the Run function, while PersistentPostRun and PostRun are executed after the Run function. If a subcommand doesn’t specify Persistent*Run functions, it will inherit the Persistent*Run functions from the parent command. The order of function execution is as follows:

  1. PersistentPreRun
  2. PreRun
  3. Run
  4. PostRun
  5. PersistentPostRun

Please note that the parent’s PreRun is only called when the parent command is run and will not be called for subcommands.

Cobra also supports many other useful features, such as custom Help command, automatic addition of --version flag to output program version information, printing usage information when invalid flags or commands are provided, and generating suggestions based on registered commands when an incorrect command is entered, and more.

Summary #

When developing Go projects, we can use Pflag to parse command-line arguments, Viper to parse configuration files, and Cobra to implement a command-line framework. You can use methods like pflag.String(), pflag.StringP(), pflag.StringVar(), pflag.StringVarP() to set command-line arguments and use Get<Type> to get the value of the arguments.

At the same time, you can also use Viper to read configuration settings from command-line arguments, environment variables, configuration files, and other sources. The most common usage is to read from a configuration file, which can be done by setting the configuration file search path using viper.AddConfigPath, setting the configuration file name using viper.SetConfigFile and viper.SetConfigType, and reading the configuration file using viper.ReadInConfig. After reading the configuration file, you can use Get/Get<Type> in your program to retrieve the values of the configuration settings.

Lastly, you can use Cobra to build a command-line framework, which integrates well with Pflag and Viper.

Exercises #

  1. Study the code of Cobra and see how Cobra integrates with Pflag and Viper.

  2. Think about other excellent packages you have encountered while developing, which handle command-line arguments, configuration files, and command-line framework. Welcome to share in the comments area.

Feel free to discuss and communicate with me in the comments area. See you in the next lecture!