In previous articles we created an HTTP REST API server, a CLI, a Bot for Discord, a gRPC app ... and even a game for Nintendo Game Boy Advance.
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:
├── gophers
│ ├── gopher0.txt
│ ├── gopher1.txt
│ ├── gopher2.txt
│ └── gopher3.txt
Create a main.go
file.
First, we initialize the package, called main
, and all dependencies/librairies we need to import:
package main
import (
"fmt"
"log"
"math/rand"
"os"
"strconv"
"strings"
"time"
"embed"
)
Then, we initialize our variable:
// 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 gophers
var embedGopherFiles embed.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:
func main() {
// Display usage/help message
if len(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
} else if len(os.Args) > 1 {
message := strings.Join(os.Args[1:], " ")
nbChar := len(message)
line := " "
for i := 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 files
rand.Seed(time.Now().UnixNano())
randInt := rand.Intn(getNbOfGopherFiles() - 1)
// Display random gopher ASCII embed files
fileData, err := embedGopherFiles.ReadFile("gophers/gopher" + strconv.Itoa(randInt) + ".txt")
if err != 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 message
if len(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:
else if len(os.Args) > 1 {
message := strings.Join(os.Args[1:], " ")
nbChar := len(message)
line := " "
for i := 0; i <= nbChar; i++ {
line += "-"
}
fmt.Println(line)
fmt.Println("< " + message + " >")
fmt.Println(line)
fmt.Println(" \\")
fmt.Println(" \\")
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 files
rand.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 files
fileData, err := embedGopherFiles.ReadFile("gophers/gopher" + strconv.Itoa(randInt) + ".txt")
if err != 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:
func getNbOfGopherFiles() int {
files, err := embedGopherFiles.ReadDir("gophers")
if err != nil {
log.Fatal("Error during reading gophers folder", err)
}
nbOfFiles := 0
for _, _ = range files {
nbOfFiles++
}
return nbOfFiles
}
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:
//go:embed gophers
var embedGopherFiles embed.FS
Then, we can read one file in this folder:
fileData, err := embedGopherFiles.ReadFile("gophers/gopher" + strconv.Itoa(randInt) + ".txt")
And retrieve a list of the files in this folder:
files, err := embedGopherFiles.ReadDir("gophers")
You can also embed a file directly:
//go:embed gophers/gopher0.txt
var myFile string
/!\ 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 app
cmds:
- GOFLAGS=-mod=mod go run main.go
build:
desc: Build the app
cmds:
- GOFLAGS=-mod=mod go build -o bin/gophersay main.go
clean:
desc: Build the app
cmds:
- rm -rf dist
So, now we can build our app:
$ task build
task: [build] GOFLAGS=-mod=mod go build -o bin/gophersay main.go
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!
GoReleaser
With GoReleaser, it's possible to:
- Cross-compile Go project
- Release to GitHub, GitLab and Gitea
- Create Docker images and manifests
- Create Linux packages and Homebrew taps
- ...
Oh, come on, it's exactly what do we want!
First, we need to install GoReleaser.
For MacOS:
$ brew install goreleaser/tap/goreleaser
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.com
before:
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=0
goos:
- linux
- windows
- darwin
archives:
- replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: i386
amd64: x86_64
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
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:
$ goreleaser release --snapshot --skip-publish --rm-dist
/!\ 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:
dist
├── checksums.txt
├── config.yaml
├── gophersay_0.0.1-next_Darwin_arm64.tar.gz
├── gophersay_0.0.1-next_Darwin_x86_64.tar.gz
├── gophersay_0.0.1-next_Linux_arm64.tar.gz
├── gophersay_0.0.1-next_Linux_i386.tar.gz
├── gophersay_0.0.1-next_Linux_x86_64.tar.gz
├── gophersay_0.0.1-next_Windows_i386.tar.gz
├── gophersay_0.0.1-next_Windows_x86_64.tar.gz
├── gophersay_darwin_amd64
│ └── gophersay
├── gophersay_darwin_arm64
│ └── gophersay
├── gophersay_linux_386
│ └── gophersay
├── gophersay_linux_amd64
│ └── gophersay
├── gophersay_linux_arm64
│ └── gophersay
├── gophersay_windows_386
│ └── gophersay.exe
└── gophersay_windows_amd64
└── gophersay.exe
It's perfect!
When GitHub Action meet GoReleaser...
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: goreleaser
on:
push:
tags:
- '*'
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.16
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
distribution: goreleaser
version: latest
args: release --rm-dist
env:
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 :-)
GopherSay
About
Welcome in GopherSay!
GopherSay is inspired by Cowsay program.
GopherSay allow you to display a message said by a cute random Gopher.
Installation
For MacOS:
brew tap scraly/tools
brew install gophersay
Pre-requisites
Install Go in 1.16 version minimum.
Build the app
$ go build -o bin/gophersay main.go
or
$ task build
Run the app
$ ./bin/gophersay
or
$ task run
Test the app
$ ./bin/gophersay Hello Gopher lovers
---------------------
< Hello Gopher lovers! >
---------------------
\
\
,
(%..**,,,,,.& .,,.**
,%,*..,,,. .** **.,*,,**./
./*,,.. .**,.,..,.,**.**..(.
. (@.,*%.**.,,**,.,,%*..**,*,/(
,..../../&&%................%///#.,***.....
/....,..........................@*@%...,.,
.....................................,
&* #@................................
& ...............................
# .........................% @
/@ @, ........................* *
*., @.......................@ /@@
/....% ..........................&
/........%@@..............................% %
,....................................................* * .%
.........................@,,,,,,,@...................* @ *****#
*........................@,,,,,,/&(................... /. ****,**
........................@,,,,,,,,,,,, ................/ #/////( ,*,
//.....................@,,,,,,,,,,,,#................., #//////////
...........................,@@@ / @................../....**//////(
...,.........................@@ @.......................///*//*/(
...........................&@@@@@@.................. @///////////
,..................................................* @///////&* *
/.................................................. @/@..,(@.& (
,................................................. @ @/ .(# .
................................................, @ ,.%(#,**
.............................................../ @ . @////*/
…Conclusion
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 :-).
All the code of our GopherSay app in Go is available in: https://github.com/scraly/gophersay
In the following articles we will create others kind/types of applications in Go.
Hope you'll like it.
Top comments (4)
thank you very much. this is a great course
Thanks Alexandr
congrats to this really great series and this new article.
Here you Go:
twitter.com/golangch/status/143396...
Thanks for posting!