Refactoring a Go Project Makefile

If you are not familiar with Makefile, I recommend reading Ruanyifeng’s article “Make Command Tutorial[1]“. This article will guide you to write a more elegant Makefile through a refactoring example, so let’s get started!

Assuming there is a project named foo, developed in Golang and deployed on Docker, its Makefile is as follows:

APP = $(shell basename ${CURDIR})
TAG = $(shell git log --pretty=format:"%cd.%h" --date=short -1)

.PHONY: build
build:
	go build -ldflags "-X 'main.version=${TAG}'" -o ./tmp/${APP} .

.PHONY: docker-config
docker-config: env
	TAG=${TAG} docker-compose config

.PHONY: docker-build
docker-build: env
	TAG=${TAG} docker-compose build

.PHONY: docker-push
docker-push: env
	TAG=${TAG} docker-compose push

.PHONY: docker-up
docker-up: env
	TAG=${TAG} docker-compose up

.PHONY: docker-down
docker-down:
	TAG=${TAG} docker-compose down

It looks quite clean, the only thing to note is that when operating docker-compose, an environment variable named TAG is passed, indicating the current tag of the project. Let’s take a look at the corresponding docker-compose.yml file:

version: "3.0"
services:
  server:
    image: docker.domain.com/foo:${TAG}
    build:
      context: .
      dockerfile: build/docker/Dockerfile
    ports:
      - "9090:9090"
      - "6060:6060"

At this point, there is an area for improvement: the ports information is duplicated. Let’s look at the corresponding config.toml file:

[rpc]
port = 9090

[debug]
port = 6060

The rpc port 9090 and debug port 6060 were initially configured in the config.toml file, but were repeated again in the docker-compose.yml file. If a change is needed, it would require modifying multiple places.

At this point, a common solution that comes to mind is to pass the port information through environment variables, just like the TAG variable. To obtain the port information, I even wrote a sub-command config:

package cmd

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
	"github.com/spf13/viper"
)

func NewConfigCmd() *cobra.Command {
	configCmd := &cobra.Command{
		Use: "config ",
		Run: config,
	}
	return configCmd
}

func config(cmd *cobra.Command, args []string) {
	if len(args) != 1 {
		_ = cmd.Usage()
		os.Exit(1)
	}
	key := args[0]
	value := viper.Get(key)
	fmt.Println(value)
}

Having determined the solution, let’s look at the corresponding docker-compose.yml file:

version: "3.0"
services:
  server:
    image: docker.domain.com/${APP}:${TAG}
    build:
      context: .
      dockerfile: build/docker/Dockerfile
    ports:
      - "${RPC_PORT}:${RPC_PORT}"
      - "${DEBUG_PORT}:${DEBUG_PORT}"

At this point, there is no hardcoded configuration information anymore. Now, let’s take another look at the corresponding Makefile:

APP = $(shell basename ${CURDIR})
TAG = $(shell git log --pretty=format:"%cd.%h" --date=short -1)
RPC_PORT   = $(shell ./tmp/${APP} config rpc.port)
DEBUG_PORT = $(shell ./tmp/${APP} config debug.port)

.PHONY: build
build:
	go build -ldflags "-X 'main.version=${TAG}'" -o ./tmp/${APP} .

.PHONY: docker-config
docker-config: env
	APP=${APP} TAG=${TAG} RPC_PORT={RPC_PORT} DEBUG_PORT={DEBUG_PORT} docker-compose config

.PHONY: docker-build
docker-build: env
	APP=${APP} TAG=${TAG} RPC_PORT={RPC_PORT} DEBUG_PORT={DEBUG_PORT} docker-compose build

.PHONY: docker-push
docker-push: env
	APP=${APP} TAG=${TAG} RPC_PORT={RPC_PORT} DEBUG_PORT={DEBUG_PORT} docker-compose push

.PHONY: docker-up
docker-up: env
	APP=${APP} TAG=${TAG} RPC_PORT={RPC_PORT} DEBUG_PORT={DEBUG_PORT} docker-compose up

.PHONY: docker-down
docker-down:
	APP=${APP} TAG=${TAG} RPC_PORT={RPC_PORT} DEBUG_PORT={DEBUG_PORT} docker-compose down

I must say, the long environment variables are quite ugly. Fortunately, docker-compose supports .env files[2], so we can write the environment variables into a .env file and let the docker-compose command read data from it:

APP = $(shell basename ${CURDIR})
TAG = $(shell git log --pretty=format:"%cd.%h" --date=short -1)
RPC_PORT   = $(shell ./tmp/${APP} config rpc.port)
DEBUG_PORT = $(shell ./tmp/${APP} config debug.port)

.PHONY: env
env:
	echo "APP=${APP}" > .env; \
	 echo "TAG=${TAG}" >> .env; \
	 echo "RPC_PORT=${RPC_PORT}" >> .env; \
	 echo "DEBUG_PORT=${DEBUG_PORT}" >> .env

.PHONY: build
build:
	go build -ldflags "-X 'main.version=${TAG}'" -o ./tmp/${APP} .

.PHONY: docker-config
docker-config: env
docker-compose config

.PHONY: docker-build
docker-build: env
docker-compose build

.PHONY: docker-push
docker-push: env
docker-compose push

.PHONY: docker-up
docker-up: env
docker-compose up

.PHONY: docker-down
docker-down:
docker-compose down

In the Makefile, we defined an env operation and made it a prerequisite for all docker-compose operations, finally eliminating the need to write long environment variables. However, remember to add .env to .gitignore!

References

[1]

Make Command Tutorial: https://www.ruanyifeng.com/blog/2015/02/make.html

[2]

docker-compose supports .env files: https://docs.docker.com/compose/environment-variables/

Recommended Reading

  • Go Memory Leak Debugging Practice

Benefits
I have organized a package of Go learning materials from beginner to advanced, including learning suggestions: what to read for beginners and what to read for advanced. Follow the WeChat public account “polarisxu”, reply ebook to get it; you can also reply “Join Group” to communicate and learn with thousands of Gophers.

Refactoring a Go Project Makefile

Leave a Comment