Go CLI

Setting up a decent Command Line Interface for your project in Go

Let’s set up a decent Command Line Interface (CLI) for our Go project. This has been made easy by Cobra, a library that provides a simple interface to create a powerful modern CLI, like you are used to from git, kubectl, etc.

Cobra comes with a very nice set of features, amongst which:

  • Easily add commands and nested commands
  • Support for flags (including short & long versions)
  • Intelligent suggestions (app srver… did you mean app server?)
  • Automatic help generation and support of -h and --help
  • Automatically generated bash autocomplete and man pages

Installation

To get started, we’ll create a new go project and initialize it, in my case:

go mod init github.com/akleinloog/hello

Then, install the latest version of Cobra:

go get github.com/spf13/cobra/cobra

And initialize the new project:

cobra init --pkg-name github.com/akleinloog/hello

This adds a main.go file and a cmd folder with a root.go file. The main.go file is very simple, contains some license info and a simple main function:

package main

import "github.com/akleinloog/hello/cmd"

func main() {
  cmd.Execute()
}

Take a look at the root.go file in the cmd folder, and you will notice a few things. First, it contains a use instruction and a short and a long description. Change the text to something meaningful for your example. In my case:

var rootCmd = &cobra.Command{
  Use:   "hello",
  Short: "Go Hello",
  Long: `A simple HTTP Server written in Go.
It gives out a simple Hello message with a counter, the host name and the requested address.`,
}

It is good practice not to define an action here. In that case, Cobra will ensure to give out a nice set of instructions. See for yourself:

go build

./hello

Another thing to notice is that cobra added an Execute() function, an init() function and an initConfig() function. With that, it added Viper and support for a configuration file. We’ll add another flag later on, and I will discuss Viper and proper configuration in another post.

Adding Commands

Let’s first add a new command that will start our HTTP Server:

cobra add serve

Cobra added a serve.go file in the cmd folder, similar to the root.go we saw earlier. This time it adds a serve command. Let’s update the text again:

var serveCmd = &cobra.Command{
  Use:   "serve",
  Short: "Starts the HTTP Server.",
  Long: `Starts the HTTP Server listening at port 80, where it will return a simple hello on any request.`,
  Run: func(cmd *cobra.Command, args []string) {
    fmt.Println("serve called")
  },
}

In the init() method you see that the command is added to the root command:

func init() {
  rootCmd.AddCommand(serveCmd)
...
}

Rebuild and run again:

go build

./hello

Notice that the output has now changed. Information is provided on the commands and on flags that are supported.

Try the help:

./hello serve -h

Help specific to the serve command is provided.

Now try the actual command:

./hello serve

You should see the simple ‘serve called’ output.

Add the HTTP Server

Let’s implement the HTTP server. We’ll keep it simple. In serve.go add the following functions:

var (
  requestNr int64  = 0
  host      string = "unknown"
)

func listen() {

  currentHost, err := os.Hostname()

  if err != nil {
    log.Println("Could not determine host name:", err)
  } else {
    host = currentHost
  }

  log.Println("Starting Hello Server on " + host)

  http.HandleFunc("/", hello)

  err = http.ListenAndServe(":80", nil)
  if err != nil {
    log.Fatal(err)
  }
}

func hello(w http.ResponseWriter, r *http.Request) {

  requestNr++
  message := fmt.Sprintf("Go Hello %d from %s on %s ./%s\n", requestNr, host, r.Method, r.URL.Path[1:])
  log.Print(message)
  fmt.Fprint(w, message)
}

And change the serve command to:

var serveCmd = &cobra.Command{
  Use:   "serve",
  Short: "Starts the HTTP Server.",
  Long: `Starts the HTTP Server listening at port 80, where it will return a simple hello on any request.`,
  Run: func(cmd *cobra.Command, args []string) {
    listen()
  },
}

Rebuild and run the server:

go build

./hello serve

The HTTP Server will start and you can visit http://localhost to see the result.

Add an HTTP Client

Let’s add another command that will serve as a client for our Server:

cobra add get

Add the following function to get.go:

func get() {
  resp, err := http.Get("http://localhost")
  if err != nil {
    log.Println(err)
  }
  defer resp.Body.Close()

  scanner := bufio.NewScanner(resp.Body)
  for i := 0; scanner.Scan() && i < 5; i++ {
    fmt.Println(scanner.Text())
  }
  if err := scanner.Err(); err != nil {
    log.Println(err)
  }
}

And make sure it is called from the get command:

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

Rebuild and start the server:

go build

./hello serve

Then from another console window:

./hello get

And you will see the hello as output!

Add a Flag

To finish up, let’s add a flag to specify the port number that our server will listen on.

We’ll keep it simple for now, we want:

  • Works for both the client and the server
  • Supports the full –port and the shorthand -p
  • Default port is 80

In get.go_, declare a variable to be used as port number:

var clientPort int

And add the port flag to the init() function:

  getCmd.Flags().IntVarP(&clientPort,"port","p", 80, "port number")

Do the same thing in serve.go, but use serverPort as variable name.

Rebuild and check the help output:

go build

./hello
./hello serve -h
./hello get -h

Let’s adjust the server so it uses the new port flag. In the listen() function in serve.go, replace

  err = http.ListenAndServe(":80", nil)

with

  log.Printf("Listening on port %d\n", serverPort)
  
  err = http.ListenAndServe(fmt.Sprintf(":%d", serverPort), nil)

Now for the client, in the get() function in get.go, replace

  resp, err := http.Get("http://localhost")

with

  resp, err := http.Get(fmt.Sprintf("http://localhost:%d", clientPort))

Rebuild and start the server:

go build

./hello serve -p 10080

The server will now be available on http://localhost:10080

Then from another console window:

./hello get -p 10080

You should see something similar to: Then from another console window:

Go Hello 1 from my-mac.home on GET ./

The code

The code of my simple Hello Server is available on GitHub here. I may add a few features there since I intend to use it for some Kubernetes experiments. Another similar example is my simple HTTP Logger.

comments powered by Disqus