Azer Koçulu Aug 09, 2018

A Good Makefile for Go

I occasionally tweak my Makefiles to speed up my development process, this morning was one of those times and I decided to share the result with others.

To summarize, I use Go for building web servers and my expectation from a Makefile is as following:

And here is the typical directory layout I prefer:

.env
Makefile
main.go
bin/
src/
vendor/

Typing make command in this file structure gives following output:

 $  make

 Choose a command run in my-web-server:

 install   Install missing dependencies. Runs `go get` internally.
 start     Start in development mode. Auto-starts when code changes.
 stop      Stop development mode.
 compile   Compile the binary.
 watch     Run given command when code changes. e.g; make watch run="go test ./..."
 exec      Run given command, wrapped with custom GOPATH. e.g; make exec run="go test ./..."
 clean     Clean build files. Runs `go clean` internally.

1. Step by step

Environment Variables

The very first thing we want from our Makefile to include the environment variables we define for our project. So, here is our line #1:

include .env

On top of the project-specific environment variables; we'll define some more; project name, Go folders/files, pid filepaths...

PROJECTNAME=$(shell basename "$(PWD)")

# Go related variables.
GOBASE=$(shell pwd)
GOPATH=$(GOBASE)/vendor:$(GOBASE):/home/azer/code/golang # You can remove or change the path after last colon.
GOBIN=$(GOBASE)/bin
GOFILES=$(wildcard *.go)

# Redirect error output to a file, so we can show it in development mode.
STDERR=/tmp/.$(PROJECTNAME)-stderr.txt

# PID file will store the server process id when it's running on development mode
PID=/tmp/.$(PROJECTNAME)-api-server.pid

# Make is verbose in Linux. Make it silent.
MAKEFLAGS += --silent

In the rest of the Makefile, we'll be using especially GOPATH variable heavily. All our commands should be wrapped with the project specific GOPATH, otherwise they won't work. This provides a clear isolation between our Go projects, and brings some complexity. To make things easier, we can add an exec command that takes executes any given command with custom GOPATH defined above.

## exec: Run given command, wrapped with custom GOPATH. e.g; make exec run="go test ./..."
exec:
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) $(run)

This is not high-level enough though. We should cover some common cases with simple commands, and only use exec if we're doing something not covered by the makefile.

Development-mode

Development-mode should;

It sounds simple, but gets complicated quickly, as we'll run server and file watcher at same time. We need to make sure stopping properly before starting a new process, and also not to break common command-line behavior like stopping when Control-C or Control-D is pressed.

start:
	bash -c "trap 'make stop' EXIT; $(MAKE) compile start-server watch run='make compile start-server'"

stop: stop-server

Here is the problems above code solves:

In the below sections I'll explain these commands in detail.

Compiling

compile command does more than just calling go compile in the background; it cleans up the error output and prints the simplified version.

Here is how making a breaking change looks like in the command-line:

compile:
	@-touch $(STDERR)
	@-rm $(STDERR)
	@-$(MAKE) -s go-compile 2> $(STDERR)
	@cat $(STDERR) | sed -e '1s/.*/\nError:\n/'  | sed 's/make\[.*/ /' | sed "/^/s/^/     /" 1>&2

Starting/stopping Server

start-server basically runs the binary it compiled in background, saving its PID to a temporary file. stop-server reads the PID and kills the process when needed.

start-server:
	@echo "  >  $(PROJECTNAME) is available at $(ADDR)"
	@-$(GOBIN)/$(PROJECTNAME) 2>&1 & echo $$! > $(PID)
	@cat $(PID) | sed "/^/s/^/  \>  PID: /"

stop-server:
	@-touch $(PID)
	@-kill `cat $(PID)` 2> /dev/null || true
	@-rm $(PID)

restart-server: stop-server start-server

Watching for Changes

We need a file watcher for watching for changes. I tried many of them and didn't feel satisfied, so ended up creating my own file watcher tool, yolo. Install it in your system by;

$  go get github.com/azer/yolo

Once it's installed, we can basically start watching changes in the project directory, excluding folders like vendor and bin. Here it is;

## watch: Run given command when code changes. e.g; make watch run="echo 'hey'"
watch:
	@yolo -i . -e vendor -e bin -c $(run)

Now we got a watch command that watches for changes recursively in the project directory excluding vendor directory. We can simply pass any run command we want. For example, start command basically calls make compile start-server when code changes:

make watch run="make compile start-server"

We can use it for running tests, or checking if there is any race condition automatically. Environment variables will be set for the execution, so you don't have to worry about GOPATH at all:

make watch run="go test ./..."

A nice thing about Yolo is its web interface. If you enable it, you can see the output of your command in a web interface instantly. All you need is to pass -a option to enable it:

yolo -i . -e vendor -e bin -c "go run foobar.go" -a localhost:9001

Then you can open localhost:9001 in your browser and start seeing results in your browser instantly:

Installing Dependencies

As we make changes in the code, we'd like missing dependencies to be downloaded before compiling. install command will do that job for us;

install: go-get

We'll automate calling install on file change before compiling, so dependencies will get installed automatically. If you'd like to install a dependency manually, you can run;

make install get="github.com/foo/bar"

Internally, this command will be converted to;

$ GOPATH=~/my-web-server GOBIN=~/my-web-server/bin go get github.com/foo/bar

How does it work though ? See the next section where we actually add the Go commands that we use for implementing the higher level commands.

Go Commands

As we want to set the GOPATH to the project directory to simplify dependency management which is still not solved officially in Go ecosystem, we need to wrap all Go commands in the Makefile.

go-compile: go-clean go-get go-build

go-build:
	@echo "  >  Building binary..."
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) go build -o $(GOBIN)/$(PROJECTNAME) $(GOFILES)

go-generate:
	@echo "  >  Generating dependency files..."
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) go generate $(generate)

go-get:
	@echo "  >  Checking if there is any missing dependencies..."
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) go get $(get)

go-install:
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) go install $(GOFILES)

go-clean:
	@echo "  >  Cleaning build cache"
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) go clean

Help

Finally, we need a help command to see the overview available commands. We can auto-generate nicely formatted help output using sed and column commands. See below:

help: Makefile
	@echo " Choose a command run in "$(PROJECTNAME)":"
	@sed -n 's/^##//p' $< | column -t -s ':' |  sed -e 's/^/ /'

The command below basically scans the Makefile for lines starting with ## and outputs them. So, you can simply comment the commands that you defined, and those comments will be used by the help command.

If we add some comments like below:

## install: Install missing dependencies. Runs `go get` internally.
install: go-get

## start: Start in development mode. Auto-starts when code changes.
start:

## stop: Stop development mode.
stop: stop-server

We'll get:

 $  make help

 Choose a command run in my-web-server:

 install   Install missing dependencies. Runs `go get` internally.
 start     Start in development mode. Auto-starts when code changes.
 stop      Stop development mode.

Final Version

Here is the all combined & final version of what I've shared above. It's the exact copy from a new project that I've started this morning:

include .env

PROJECTNAME=$(shell basename "$(PWD)")

# Go related variables.
GOBASE=$(shell pwd)
GOPATH="$(GOBASE)/vendor:$(GOBASE)"
GOBIN=$(GOBASE)/bin
GOFILES=$(wildcard *.go)

# Redirect error output to a file, so we can show it in development mode.
STDERR=/tmp/.$(PROJECTNAME)-stderr.txt

# PID file will keep the process id of the server
PID=/tmp/.$(PROJECTNAME).pid

# Make is verbose in Linux. Make it silent.
MAKEFLAGS += --silent

## install: Install missing dependencies. Runs `go get` internally. e.g; make install get=github.com/foo/bar
install: go-get

## start: Start in development mode. Auto-starts when code changes.
start:
    bash -c "trap 'make stop' EXIT; $(MAKE) compile start-server watch run='make compile start-server'"

## stop: Stop development mode.
stop: stop-server

start-server: stop-server
	@echo "  >  $(PROJECTNAME) is available at $(ADDR)"
	@-$(GOBIN)/$(PROJECTNAME) 2>&1 & echo $$! > $(PID)
	@cat $(PID) | sed "/^/s/^/  \>  PID: /"

stop-server:
	@-touch $(PID)
	@-kill `cat $(PID)` 2> /dev/null || true
	@-rm $(PID)

## watch: Run given command when code changes. e.g; make watch run="echo 'hey'"
watch:
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) yolo -i . -e vendor -e bin -c "$(run)"

restart-server: stop-server start-server

## compile: Compile the binary.
compile:
	@-touch $(STDERR)
	@-rm $(STDERR)
	@-$(MAKE) -s go-compile 2> $(STDERR)
	@cat $(STDERR) | sed -e '1s/.*/\nError:\n/'  | sed 's/make\[.*/ /' | sed "/^/s/^/     /" 1>&2

## exec: Run given command, wrapped with custom GOPATH. e.g; make exec run="go test ./..."
exec:
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) $(run)

## clean: Clean build files. Runs `go clean` internally.
clean:
	@(MAKEFILE) go-clean

go-compile: go-clean go-get go-build

go-build:
	@echo "  >  Building binary..."
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) go build -o $(GOBIN)/$(PROJECTNAME) $(GOFILES)

go-generate:
	@echo "  >  Generating dependency files..."
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) go generate $(generate)

go-get:
	@echo "  >  Checking if there is any missing dependencies..."
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) go get $(get)

go-install:
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) go install $(GOFILES)

go-clean:
	@echo "  >  Cleaning build cache"
	@GOPATH=$(GOPATH) GOBIN=$(GOBIN) go clean

.PHONY: help
all: help
help: Makefile
	@echo
	@echo " Choose a command run in "$(PROJECTNAME)":"
	@echo
	@sed -n 's/^##//p' $< | column -t -s ':' |  sed -e 's/^/ /'
	@echo

That was it! If you have any questions, thoughts or some recommendations to make it better, shoot me e-mail!

Cheers.

Back