As we have already seen, Golang can be used for several of type of applications, but you know, I love creating CLI apps & tools, I love DevOps philosophy and I love Gophers. So in this article we'll create a little tool, with few Go best practices, and automatically generate cross-platform executable binaries and create GitHub releases through GitHub actions.
Ready?
Initialization
First, create our new repository in GitHub (in order to share and open-source it).
For that, I logged in GitHub website, clicked on the repositories link, click on "New" green button and then I created a new repository called gophersay.
Now, in your local computer, git clone this new repository where you want:
$ git clone https://github.com/scraly/gophersay.git
$ cd gophersay
Now, we have to initialize Go modules (dependency management):
$ go mod init github.com/scraly/gophersay
go: creating new go.mod: module github.com/scraly/gophersay
This will create a go.mod file like this:
module github.com/scraly/gophersay
go 1.16
Before starting to code our Desktop application, as good practices, we will create a simple code organization.
Create the following folders organization:
.
├── bin
├── README.md
└── go.mod
That's it? Yes, the rest of our code organization will be created shortly ;-).
Let's create our app
What do we want?
Do you know "cowsay" application? It's a simple app that display your text said by a cow.
I love cow, but I love more Gophers, so we want to create a "cowsay" version with a Gopher instead of a cow.
The program will display a text written by the user and a Gopher, in ASCII, that say the text.
This app may seem useless but you will see, it will allow us to see some good practices ;-).
The first thing to do is to retrieve Gophers in ASCII format I created and put them in a new gophers folder. You need to have a folder like this:
// Hey, I want to embed "gophers" folder in the executable binary// Use embed go 1.16 new feature (for embed gophers static files)//go:embed gophersvarembedGopherFilesembed.FS
Thanks to awesome embed feature included since Go 1.16 version, we tell that we embed gophers/ folder in the variable embedGopherFiles.
Let's create the main() function:
funcmain(){// Display usage/help messageiflen(os.Args)==1||(len(os.Args)==2&&os.Args[1]=="-h")||(len(os.Args)==2&&os.Args[1]=="--help"){usage:="GopherSay is inspired by Cowsay program.\nGopherSay allow you to display a message said by a cute random Gopher.\n\nUsage:\n gophersay MESSAGE\n\nExample:\n gophersay hello Gopher lovers"fmt.Println(usage)return}elseiflen(os.Args)>1{message:=strings.Join(os.Args[1:]," ")nbChar:=len(message)line:=" "fori:=0;i<=nbChar;i++{line+="-"}fmt.Println(line)fmt.Println("< "+message+" >")fmt.Println(line)fmt.Println(" \\")fmt.Println(" \\")// Generate a random integer depending on get the number of ascii filesrand.Seed(time.Now().UnixNano())randInt:=rand.Intn(getNbOfGopherFiles()-1)// Display random gopher ASCII embed filesfileData,err:=embedGopherFiles.ReadFile("gophers/gopher"+strconv.Itoa(randInt)+".txt")iferr!=nil{log.Fatal("Error during read gopher ascii file",err)}fmt.Println(string(fileData))}}
It's time to explain the main() function step by step.
First, if the user execute our app/tool without argument, or with "-h" option and or "--help" option, we display an usage/a help message:
// Display usage/help messageiflen(os.Args)==1||(len(os.Args)==2&&os.Args[1]=="-h")||(len(os.Args)==2&&os.Args[1]=="--help"){usage:="GopherSay is inspired by Cowsay program.\nGopherSay allow you to display a message said by a cute random Gopher.\n\nUsage:\n gophersay MESSAGE\n\nExample:\n gophersay hello Gopher lovers"fmt.Println(usage)return}
Then, if user execute the gophersay app with an argument, a text, we define a variable message that retrieve all arguments and a variable with the number of characters of the message.
We print out this message surrounded by "bubble", like "cowsay" program does:
After that, we generate a random integer between 0 and the number of gopher files we have -1 (4-1 at this time but I plan to add more):
// Generate a random integer depending on get the number of ascii filesrand.Seed(time.Now().UnixNano())randInt:=rand.Intn(getNbOfGopherFiles()-1)
Wait... why do we execute rand.Seed() function?
rand.Intn(int) returns a non negative pseudo-random number in [0,n]. It's cool, but … it produces a deterministic sequence of values!
So the solution, in order to have "real" random number is to use rand.Seed() in order to initialize the default source.
Let's go back to our code, we then want to display our cute ASCII Gopher:
// Display random gopher ASCII embed filesfileData,err:=embedGopherFiles.ReadFile("gophers/gopher"+strconv.Itoa(randInt)+".txt")iferr!=nil{log.Fatal("Error during read gopher ascii file",err)}fmt.Println(string(fileData))
And finally, create the function that return the number of ASCII Gopher image files:
funcgetNbOfGopherFiles()int{files,err:=embedGopherFiles.ReadDir("gophers")iferr!=nil{log.Fatal("Error during reading gophers folder",err)}nbOfFiles:=0for_,_=rangefiles{nbOfFiles++}returnnbOfFiles}
OK, but what is this famous embed??
If we package only our main.go file in an executable binary, when we will execute it, we'll have a problem because "gophers/" folder not exists in your computer.
Before Go version 1.16, there were several solutions but not as easy as the new embed package.
The new embed package provides access to files embedded in the program during compilation using the new //go:embed directive.
The new //go:embed directive allow to embed static files and folders into application binary at compile-time without using an external tool.
In order to use it, first we have to declare a variable for the embedded content. In our example we embed our gophers/ folder:
/!\ If the embed pattern names a folder, all files are embedded (recursively), except the files with names beginning with "." or "_".
If you want to embed them, you need to specify the folder like this: myfolder/*
Awesome!
Test it!
After code explanation, it's time to test our little app!
$ go run main.go
GopherSay is inspired by Cowsay program.
GopherSay allow you to display a message said by a cute random Gopher.
Usage:
gophersay MESSAGE
Example:
gophersay hello Gopher lovers
$ go run main.go --help
GopherSay is inspired by Cowsay program.
GopherSay allow you to display a message said by a cute random Gopher.
Usage:
gophersay MESSAGE
Example:
gophersay hello Gopher lovers
Cool, we have our usage message.
$ go run main.go Hello Gopher lovers!
Yeah! Our text is said by one of our cute ASCII Gophers!
Build it!
Your application is now ready, you can build it.
In previous articles, we used Taskfile in order to automate our common tasks.
I created a Taskfile.yaml file:
version:"3"tasks:run:desc:Run the appcmds:-GOFLAGS=-mod=mod go run main.gobuild:desc:Build the appcmds:-GOFLAGS=-mod=mod go build -o bin/gophersay main.goclean:desc:Build the appcmds:-rm -rf dist
But... the executable binary is only for our environment, our OS, our platform, and I want to share my gophersay worldwide so it's time to find a way to easily cross-compile our app!
Run the init command to create a .goreleaser.yml configuration file:
$ goreleaser init
• Generating .goreleaser.yml file
• config created; please edit accordingly to your needs file=.goreleaser.yml
Let's watch this new generated file:
# This is an example .goreleaser.yml file with some sane defaults.# Make sure to check the documentation at http://goreleaser.combefore:hooks:# You may remove this if you don't use go modules.-go mod tidy# you may remove this if you don't need go generate-go generate ./...builds:-env:-CGO_ENABLED=0goos:-linux-windows-darwinarchives:-replacements:darwin:Darwinlinux:Linuxwindows:Windows386:i386amd64:x86_64checksum:name_template:'checksums.txt'snapshot:name_template:"{{incpatch.Version}}-next"changelog:sort:ascfilters:exclude:-'^docs:'-'^test:'
It's pretty cool. Because we don't use go generate in our application, we can remove the - go generate ./... line ;-).
Let's run a "local-only" release to generate a release of our Go app locally:
/!\ Don't forget to call this goreleaser release command with --rm-dist option or you can execute task clean target in order to remove dist/ folder. If not, you'll have an issue because this folder need to be empty ^^.
If we take a look into new dist/ generated folder, we can see that GoReleaser generate, for us, cross-platform executable binaries, and checksum:
And what about generate a new release automatically?
Now, the next step is to execute GoReleaser, and publish a new associated Release in GitHub everytime we tag a new version of our application in our Git repository.
Let's do this!
Our Git repository is hosted in GitHub so we will use GitHub Actions for our CI (Continuous Integration) pipeline.
Create our workflow:
$ mkdir .github/workflows
$ cd .github/workflows
Inside it, create goreleaser.yml file with this content:
name:goreleaseron:push:tags:-'*'jobs:goreleaser:runs-on:ubuntu-lateststeps:-name:Checkoutuses:actions/checkout@v2with:fetch-depth:0-name:Set up Gouses:actions/setup-go@v2with:go-version:1.16-name:Run GoReleaseruses:goreleaser/goreleaser-action@v2with:distribution:goreleaserversion:latestargs:release --rm-distenv:GITHUB_TOKEN:${{ secrets.GITHUB_TOKEN }}
This workflow contains one job that we'll checkout the repository, package our app with GoReleaser and generate a GitHub release.
/!\ In order to release to GitHub, GoReleaser need a valid GitHub token with the repo scope. Fortunately, GitHub automatically creates a GITHUB_TOKEN secret to use in a workflow.
After pushed your modification in the Git repository, now we can create a Git tag and push it:
$ git tag -a v1.0.0 -m "First release"
$ git push --tags
Énumération des objets: 1, fait.
Décompte des objets: 100% (1/1), fait.
Écriture des objets: 100% (1/1), 157 octets | 157.00 Kio/s, fait.
Total 1 (delta 0), réutilisés 0 (delta 0), réutilisés du pack 0
To https://github.com/scraly/gophersay.git
* [new tag] v1.0.0 -> v1.0.0
Let's go in our GitHub repository, and click on "Actions" tab in order to watch running, failed and successfull workflows:
Perfect, our workflow successfully runned.
A new GitHub release have been automatically created:
So now, each time I will update my app and create a Git tag and push it, automatically a new (GitHub) GH release will be created with cross-platform binaries :-).
Thanks
If you like this article/tutorial and the cute gophersay app, don't hesitate to add a star on GitHub :-)
As you have seen in this article and previous articles, it's possible to create multiple different applications in Go... and to automatize build and cross-compilation.
This time we didn't use Cobra, Viper and other frameworks because I wanted to show you we ca do a very small CLI without it :-).