Introduction to Go: Command Line
Command line apps are a fundamental part of software development
Go has a built in Commandline parser package. The package can be found here
We simply have to import the flag
package:
import (
"flag"
)
In part 1, we covered the fundamentals of writing basic Go
In part 2, we've learn how to use basic data structures like json
so we can send\receive data.
Part 3 was about exposing data via a Web server.
We'll be combining these techniques so we can serve our videos
data over a commandline application.
As always, let's start with our dockerfile
, main.go
and videos.go
we created in Part 2
Dev Environment
The same as Part 1+2+3, we start with a dockerfile where we declare our version of go
.
cd golang\introduction\part-4.commandline
docker build --target dev . -t go
docker run -it -v ${PWD}:/work go sh
go version
Create our App
Create a new directory that holds defines our repository
and holds our module
mkdir videos
cd videos
- Define a module path
# create a go module file
go mod init videos
Create our base code
We start out all our applications with a main.go
defining our package
, declaring our import
dependencies
and our entrypoint main()
function
package main
import "fmt"
func main() {
fmt.Println("Hello, world.")
}
Create our Videos app
Firstly, we create a seperate code file videos.go
that deals with our YouTube videos
The videos.go
file defines what a video struct
looks like, a getVideos()
function to retrieve
videos list as a slice and a saveVideos()
function to save videos to a file locally.
Let's copy the following content from Part 2 and create videos.go
:
We want videos.go
to be part of package main:
package main
We import 2 packages, 1 for reading and writing files, and another for dealing with json
import (
"io/ioutil"
"encoding/json"
)
Then we define what a video struct
looks like:
type video struct {
Id string
Title string
Description string
Imageurl string
Url string
}
We have a function for retrieving video
objects as a list of type slice
:
func getVideos()(videos []video){
fileBytes, err := ioutil.ReadFile("./videos.json")
if err != nil {
panic(err)
}
err = json.Unmarshal(fileBytes, &videos)
if err != nil {
panic(err)
}
return videos
}
We also need to copy our videos.json
file which contains our video data.
And finally, we have a function that accepts a list of type slice
and stores the videos to a local file
func saveVideos(videos []video)(){
videoBytes, err := json.Marshal(videos)
if err != nil {
panic(err)
}
err = ioutil.WriteFile("./videos.json", videoBytes, 0644)
if err != nil {
panic(err)
}
}
Flag Package
Package flag implements command-line flag parsing. So we can run our videos app and pass it inputs like any other CLI.
Let's build a CLI tool that users our getVideos()
and saveVideos
functions.
To get all videos, perhaps we'd like a command
# get all videos
videos get --all
# get video by ID
videos get -id <video-id>
# add a video to our list
videos add -id -title -url -imageurl -desc
To start, we import package flag
import (
"flag"
"fmt"
)
Let's define our subcommands in main.go
:
//'videos get' subcommand
getCmd := flag.NewFlagSet("get", flag.ExitOnError)
videos get
command will need two inputs, --all
if the user wants to return all videos, or --id
if the user only wants a specific video
// inputs for `videos get` command
getAll := getCmd.Bool("all", false, "Get all videos")
getID := getCmd.String("id", "", "YouTube video ID")
videos add
command will take a bit more inputs to be able to add a video to our storage.
addCmd := flag.NewFlagSet("add", flag.ExitOnError)
addID := addCmd.String("id", "", "YouTube video ID")
addTitle := addCmd.String("title", "", "YouTube video Title")
addUrl := addCmd.String("url", "", "YouTube video URL")
addImageUrl := addCmd.String("imageurl", "", "YouTube video Image URL")
addDesc := addCmd.String("desc", "", "YouTube video description")
When a user runs our videos CLI tool, we may need to validate that our application receives the right subcommands. So lets ensure a simple validation to check if the user has passed a subcommand
To check the arguments passed to our CLI, we use the "os" package. Check the Args variable, it holds usefull information passed to our application including its name.
var Args []string
We can do a simple check by ensuring the length of os.Args
is atleast 2.
We firstly need to add os
to our import section
Followed by our check:
if len(os.Args) < 2 {
fmt.Println("expected 'get' or 'add' subcommands")
os.Exit(1)
}
Handling our subcommands
So to handle each sub command like get
and add
, we add a simple
switch
statement that can branch into different pathways of execution,
based on a variables content.
Let's take a look at this simple switch
statement:
//look at the 2nd argument's value
switch os.Args[1] {
case "get": // if its the 'get' command
//hande get here
case "add": // if its the 'add' command
//hande add here
default: // if we don't understand the input
}
Let's create seperate handler functions for each sub command to keep our code tidy:
func HandleGet(getCmd *flag.FlagSet, all *bool, id *string){
}
func HandleAdd(addCmd *flag.FlagSet,id *string, title *string, url *string, imageUrl *string, description *string ){
}
Now that we have seperate functions for each subcommand, we can take appropriate actions in each. Let's firstly parse the command flags for each subcommand:
This allows us to parse everything after the videos <subcommand>
arguments:
getCmd.Parse(os.Args[2:])
Input Validation
For our HandleGet
function, let's validate input to ensure its correct.
if *all == false && *id == "" {
fmt.Print("id is required or specify --all for all videos")
getCmd.PrintDefaults()
os.Exit(1)
}
Let's handle the scenario if user passing --all
flag:
if *all {
//return all videos
videos := getVideos()
fmt.Printf("ID \t Title \t URL \t ImageURL \t Description \n")
for _, video := range videos {
fmt.Printf("%v \t %v \t %v \t %v \t %v \n",video.Id, video.Title, video.Url, video.Imageurl,video.Description)
}
return
}
Let's handle when user is searching for a video by ID
if *id != "" {
videos := getVideos()
id := *id
for _, video := range videos {
if id == video.Id {
fmt.Printf("ID \t Title \t URL \t ImageURL \t Description \n")
fmt.Printf("%v \t %v \t %v \t %v \t %v \n",video.Id, video.Title, video.Url, video.Imageurl,video.Description)
}
}
}
Parsing multiple fields
For our HandleAdd
function, we need to validate multiple inputs, create a video
struct, append it to the existing video list and save it back to file
Let's create a ValidateVideo()
function with similar inputs to our HandleAdd()
:
func ValidateVideo(addCmd *flag.FlagSet,id *string, title *string, url *string, imageUrl *string, description *string ){
}
Let's simply validate all fields since they are all required:
if *id == "" || *title == "" || *url == "" || *imageUrl == "" || *description == "" {
fmt.Print("all fields are required for adding a video")
addCmd.PrintDefaults()
os.Exit(1)
}
And we can now call this function in our add function:
ValidateVideo(addCmd, id,title,url, imageUrl, description)
Adding our video
Now that we have some basic validation, not perfect, but good enough to get started, let's add our video to the existing file.
Define a video struct with the CLI input:
video := video{
Id: *id,
Title: *title,
Description: *description,
Imageurl: *imageUrl,
Url: *url,
}
Get the existing videos:
videos := getVideos()
Append our video to the list:
videos = append(videos,video)
Save the new updated video list:
saveVideos(videos)
Build our Docker container
Let's uncomment all the build lines in the dockerfile
Full dockerfile
:
FROM golang:1.15-alpine as dev
WORKDIR /work
FROM golang:1.15-alpine as build
WORKDIR /videos
COPY ./videos/* /videos/
RUN go build -o videos
FROM alpine as runtime
COPY --from=build /videos/videos /usr/local/bin/videos
COPY ./videos/videos.json /
COPY run.sh /
RUN chmod +x /run.sh
ENTRYPOINT [ "./run.sh" ]
For our entrypoint, we need to create a shell script to accept all the arguments:
Let's create a script called run.sh
#!/bin/sh
videos $@
Build :
cd golang\introduction\part-4.commandline
docker build . -t videos
Run :
docker run -it videos get --help