DEV Community

loading...
Cover image for Learning Go by examples: part 3 - Create a CLI app in Go

Learning Go by examples: part 3 - Create a CLI app in Go

Aurélie Vache
DevRel - Google Developer Expert on Cloud - Docker captain - CKAD - Speaker - Sketchnoter - Technical writer - Conferences organizer - "Duchess France" women in tech association Leader - Mentor
Updated on ・7 min read

In the first article we setted up our environment, then we created an HTTP REST API server in the second article and today, we will create our first CLI (Command Line Interface) application in Go.

Initialization

We created our Git repository in the previous article, so now we just have to retrieve it locally:

$ git clone https://github.com/scraly/learning-go-by-examples.git
$ cd learning-go-by-examples
Enter fullscreen mode Exit fullscreen mode

We will create a folder go-gopher-cli for our CLI application and go into it:

$ mkdir go-gopher-cli
$ cd go-gopher-cli
Enter fullscreen mode Exit fullscreen mode

Now, we have to initialize Go modules (dependency management):

$ go mod init github.com/scraly/learning-go-by-examples/go-gopher-cli
go: creating new go.mod: module github.com/scraly/learning-go-by-examples/go-gopher-cli
Enter fullscreen mode Exit fullscreen mode

This will create a go.mod file like this:

module github.com/scraly/learning-go-by-examples/go-gopher-cli

go 1.16
Enter fullscreen mode Exit fullscreen mode

Before to start our super CLI application, as good practices, we will create a simple code organization.

Create the following folders organization:

.
├── README.md
├── bin
├── go.mod
Enter fullscreen mode Exit fullscreen mode

That's it? Yes, the rest of our code organization will be created shortly ;-).

Cobra

Cobra

Cobra is both a library for creating powerful modern CLI applications and a program for generating applications and batch files.

Using Cobra is easy. First, you can use the go get command to download the latest version. This command will install the cobra generator executable with the library and its dependencies:

$ go get -u github.com/spf13/cobra/cobra
Enter fullscreen mode Exit fullscreen mode

The cobra binary is now in the bin/ directory of your $GOPATH, which is itself in your PATH, so you can use it directly.

We will start by generating our CLI application with the cobra init command followed by the package. The command will generate the application with the correct file structure and imports:

$ cobra init --pkg-name github.com/scraly/learning-go-by-examples/go-gopher-cli
Your Cobra application is ready at
/Users/aurelievache/git/github.com/scraly/learning-go-by-examples/go-gopher-cli
Enter fullscreen mode Exit fullscreen mode

Our application is initialized, a main.go file and a cmd/ folder has been created, our code organization is now like this:

.
├── LICENSE
├── bin
├── cmd
│   └── root.go
├── go.mod
├── go.sum
└── main.go
Enter fullscreen mode Exit fullscreen mode

Let's create our CLI

Gopher 5th element

What do we want?

Personally, what I like is that when I use a CLI, I want someone to explain to me the goal of the CLI and how to use it.

So, first of all, at the execution of our CLI, we want to display:

  • a short description
  • a long description
  • using our app

In order to do this, we have to modify the cmd/root.go file:

// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
    Use:   "go-gopher-cli",
    Short: "Gopher CLI in Go",
    Long:  `Gopher CLI application written in Go.`,
    // Uncomment the following line if your bare application
    // has an action associated with it:
    // Run: func(cmd *cobra.Command, args []string) { },
}
Enter fullscreen mode Exit fullscreen mode

In the root.go file we have two external imports:

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"

    "github.com/spf13/viper"
)
Enter fullscreen mode Exit fullscreen mode

We already get cobra so you need now to get viper dependency:

$ go get github.com/spf13/viper@v1.8.1
Enter fullscreen mode Exit fullscreen mode

Our go.mod file should be like this one:

module github.com/scraly/learning-go-by-examples/go-gopher-cli

go 1.16

require (
    github.com/spf13/cobra v1.2.1 // indirect
    github.com/spf13/viper v1.8.1 // indirect
    golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
    golang.org/x/text v0.3.6 // indirect
)
Enter fullscreen mode Exit fullscreen mode

Now, we want to add a get command in our CLI application. For that, we will use the cobra add command of the cobra CLI:

$ cobra add get
get created at /Users/aurelievache/git/github.com/scraly/learning-go-by-examples/go-gopher-cli
Enter fullscreen mode Exit fullscreen mode

Warning: command names must be in camelCase format

This command add a new get.go file. Now our application structure should be like this:

.
├── LICENSE
├── bin
├── cmd
│   ├── get.go
│   └── root.go
├── go.mod
├── go.sum
└── main.go
Enter fullscreen mode Exit fullscreen mode

It's time to execute our application:

$ go run main.go
Gopher CLI application written in Go.

Usage:
  go-gopher-cli [command]

Available Commands:
  completion  generate the autocompletion script for the specified shell
  get         A brief description of your command
  help        Help about any command

Flags:
      --config string   config file (default is $HOME/.go-gopher-cli.yaml)
  -h, --help            help for go-gopher-cli
  -t, --toggle          Help message for toggle

Use "go-gopher-cli [command] --help" for more information about a command.
Enter fullscreen mode Exit fullscreen mode

By default, an usage message is displayed, perfect!

$ go run main.go get
get called
Enter fullscreen mode Exit fullscreen mode

OK, the get command answered too.

Let's implement our get command

What do we want?
Yes, good question, we want a gopher CLI which will retrieve our favorite Gophers by name!

We will now modify the cmd/get.go file like this:

var getCmd = &cobra.Command{
    Use:   "get",
    Short: "This command will get the desired Gopher",
    Long:  `This get command will call GitHub respository in order to return the desired Gopher.`,
    Run: func(cmd *cobra.Command, args []string) {
        var gopherName = "dr-who.png"

        if len(args) >= 1 && args[0] != "" {
            gopherName = args[0]
        }

        URL := "https://github.com/scraly/gophers/raw/main/" + gopherName + ".png"

        fmt.Println("Try to get '" + gopherName + "' Gopher...")

        // Get the data
        response, err := http.Get(URL)
        if err != nil {
            fmt.Println(err)
        }
        defer response.Body.Close()

        if response.StatusCode == 200 {
            // Create the file
            out, err := os.Create(gopherName + ".png")
            if err != nil {
                fmt.Println(err)
            }
            defer out.Close()

            // Writer the body to file
            _, err = io.Copy(out, response.Body)
            if err != nil {
                fmt.Println(err)
            }

            fmt.Println("Perfect! Just saved in " + out.Name() + "!")
        } else {
            fmt.Println("Error: " + gopherName + " not exists! :-(")
        }
    },
}
Enter fullscreen mode Exit fullscreen mode

Let's explain this block code step by step

When get command is called:

  • we first initialize a variable gopherName with our default Gopher name
  • then we retrieve the gopher name passed in parameter
  • we initialize the URL of the gopher
  • and finally we log it
        var gopherName = "dr-who"

        if len(args) >= 1 && args[0] != "" {
            gopherName = args[0]
        }

        URL := "https://github.com/scraly/gophers/raw/main/" + gopherName + ".png"

        fmt.Println("Try to get '" + gopherName + "' Gopher...")
Enter fullscreen mode Exit fullscreen mode

Then, we:

  • try to retrieve the gopher thanks to net/http package
  • if gopher is retrieved, we create a file and put the image content into it
  • else, we log an error message
        // Get the data
        response, err := http.Get(URL)
        if err != nil {
            fmt.Println(err)
        }
        defer response.Body.Close()

        if response.StatusCode == 200 {
            // Create the file
            out, err := os.Create(gopherName + ".png")
            if err != nil {
                fmt.Println(err)
            }
            defer out.Close()

            // Writer the body to file
            _, err = io.Copy(out, response.Body)
            if err != nil {
                fmt.Println(err)
            }

            fmt.Println("Perfect! Just saved in " + out.Name() + "!")
        } else {
            fmt.Println("Error: " + gopherName + " not exists! :-(")
        }
Enter fullscreen mode Exit fullscreen mode

Defer???

Wait a minute please, what is it?

There is a simple but important rule that is language agnostic: if you open a connection, you must close it! :-)

Close if you open something

Forgetting to close a connection / a response body... can cause resource/memory leaks in a long running programs.

That's why the defer exists. So in our code, we open a connection to the file when we create it and then we defer the execution of out.Close() which will be executed at the end of the function:

// Create the file
out, err := os.Create(gopherName + ".png")
if err != nil {
    fmt.Println(err)
}
defer out.Close()
Enter fullscreen mode Exit fullscreen mode

So the best practice is to add your closing statement with a defer word right after your opening, so you don't forget it.

Test it!

Let's test the help of our get command right now:

$ go run main.go get -h
This get command will call GitHub respository in order to return the desired Gopher.

Usage:
  go-gopher-cli get [flags]

Flags:
  -h, --help   help for get

Global Flags:
      --config string   config file (default is $HOME/.go-gopher-cli.yaml)
Enter fullscreen mode Exit fullscreen mode

And now it's time to test our get command:

$ go run main.go get friends
Try to get 'friends' Gopher...
Perfect! Just saved in friends.png!
Enter fullscreen mode Exit fullscreen mode

We can also test with an unknown Gopher:

$ go run main.go get awesome
Try to get 'awesome' Gopher...
Error: awesome not exists! :-(
Enter fullscreen mode Exit fullscreen mode

Build it!

Your application is now ready, you just have to build it.
For that, like the previous article, we will use Taskfile in order to automate our common tasks.

So, for this app too, I created a Taskfile.yml file with this content:

version: "3"

tasks:
    build:
        desc: Build the app
        cmds:
        - GOFLAGS=-mod=mod go build -o bin/gopher-cli main.go 

    run: 
        desc: Run the app
        cmds:
        - GOFLAGS=-mod=mod go run main.go

    clean:
        desc: Remove all retrieved *.png files
        cmds:
        - rm *.png
Enter fullscreen mode Exit fullscreen mode

Thanks to this, we can build our app easily:

$ task build
task: [build] GOFLAGS=-mod=mod go build -o bin/gopher-cli main.go

$ ll bin/gopher-cli
-rwxr-xr-x  1 aurelievache  staff   9,7M 16 jul 21:10 bin/gopher-cli
Enter fullscreen mode Exit fullscreen mode

Let's test it again with our fresh executable binary:

$ ./bin/gopher-cli get 5th-element
Try to get '5th-element' Gopher...
Perfect! Just saved in 5th-element.png!

$ file 5th-element.png
5th-element.png: PNG image data, 1156 x 882, 8-bit/color RGBA, non-interlaced
Enter fullscreen mode Exit fullscreen mode

Cool! :-)

... And clean it!

Each time you will execute the CLI app, an image file will be created locally, so if you want to clean your folder after gophers retrieving, I created a clean task :-)

$ task clean
task: [clean] rm *.png
Enter fullscreen mode Exit fullscreen mode

Goodbye Gophers!

Conclusion

As we have seen in this article, it's possible to create a simple CLI application in few minutes, thanks to cobra and viper, two awesome Go libraries.

All the code is available in: https://github.com/scraly/learning-go-by-examples/tree/main/go-gopher-cli

In the following articles we will create others kind/types of applications in Go.

Hope you'll like it.

Discussion (3)

Collapse
dimitri_acosta profile image
Dimitri Acosta

This is pretty cool but I have a question here, how do I pass in arguments when using the task command? I know I can use the executable file in the bin directory instead but how do I do the same thing with the run task?

If that is not possible, then what is the reason for that task?

Collapse
aurelievache profile image
Aurélie Vache Author

Hi, yes it's possible to define variable for example: taskfile.dev/#/usage?id=dynamic-va...

Collapse
dimitri_acosta profile image
Dimitri Acosta

Thank you, I guess I should have looked at the documentation first, my bad.