Alternativas ao make escritas em Go
Começando do começo: o que é o make
? Presente em todas as distribuições Linux e derivados do Unix como o macOS, o manual da ferramenta a descreve como:
O objetivo do utilitário make é determinar automaticamente quais partes de um programa grande precisam ser recompiladas, e emitir os comandos para recompilá-los.
Para se preparar para usar o make, você deve escrever um arquivo chamado makefile que descreve os relacionamentos entre os arquivos em seu programa e indica os comandos para atualizar cada arquivo.
Antes que me atirem pedras, eu gosto muito do make
e praticamente todo projeto que eu construo tem um Makefile
com automações para facilitar o meu trabalho.
Mas então porque procurar alternativas a algo que existe e funciona há décadas? Acredito que aprender novas ferramentas faz parte do nosso trabalho como devs, além de nos manter atualizados de novas formas de automação. Além disso, para começar a usar o make
é preciso aprender a sintaxe do Makefile
e se pudermos usar algo que já conhecemos pode diminuir a carga cognitiva de novos profissionais.
Dito isso, vamos ver aqui duas alternativas, ambas escritas em Go.
Taskfile
A primeira ferramenta que vamos testar chama-se Taskfile
e pode ser encontrada no site https://taskfile.dev/. A ideia da ferramenta é executar tarefas descritas em um arquivo chamado Taskfile.yaml
e, como o nome sugere, em formato yaml
.
O primeiro passo é realizar a instalação do executável task
, que vamos utilizar. Para isso a documentação oficial mostra algumas alternativas, mas como estou usando macOS eu usei o comando:
❯ brew install go-task
Vamos agora descrever as nossas tarefas dentro de um novo arquivo chamado Taskfile.yaml
. Para demonstrar um caso real, vamos reescrever o Makefile
de um projeto do meu Github.
Este é o conteúdo original:
.PHONY: all
all: build
FORCE: ;
.PHONY: build
build:
go build -o bin/api-o11y-gcp cmd/api/main.go
build-linux:
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -tags "netgo" -installsuffix netgo -o bin/api-o11y-gcp cmd/api/main.go
build-docker:
docker build -t api-o11y-gcp -f Dockerfile .
generate-mocks:
@mockery --output user/mocks --dir user --all
@mockery --output internal/telemetry/mocks --dir internal/telemetry --all
clean:
@rm -rf user/mocks/*
@rm -rf internal/telemetry/mocks/mocks/*
test: generate-mocks
go test ./...
run-docker: build-docker
docker run -d -p 8080:8080 api-o11y-gcp
O conteúdo do Taskfile.yaml
ficou desta forma:
version: "3"
tasks:
install-deps:
cmds:
- go mod tidy
default:
desc: "Build the app"
deps: [install-deps]
cmds:
- go build -o bin/api-o11y-gcp cmd/api/main.go
build-linux:
deps: [install-deps]
desc: "Build for Linux"
cmds:
- go build -a -installsuffix cgo -tags "netgo" -installsuffix netgo -o bin/api-o11y-gcp cmd/api/main.go
env:
CGO_ENABLED: 0
GOOS: linux
build-docker:
desc: "Build a docker image"
cmds:
- docker build -t api-o11y-gcp -f Dockerfile .
generate-mocks:
desc: "Generate mocks"
cmds:
- go install github.com/vektra/mockery/v2@v2.43.1
- mockery --output user/mocks --dir user --all
- mockery --output internal/telemetry/mocks --dir internal/telemetry --all
test:
deps:
- install-deps
- generate-mocks
desc: "Run tests"
cmds:
- go test ./...
clean:
desc: "Clean up"
prompt: This is a dangerous command... Do you want to continue?
cmds:
- rm -f bin/*
- rm -rf user/mocks/*
- rm -rf internal/telemetry/mocks/mocks/*
run-docker:
desc: "Run the docker image"
deps: [build-docker]
cmds:
- docker run -d -p 8080:8080 api-o11y-gcp
Podemos agora usar o comando task
para listar as tarefas disponíveis:
❯ task -l
task: Available tasks for this project:
* build-docker: Build a docker image
* build-linux: Build for Linux
* clean: Clean up
* default: Build the app
* generate-mocks: Generate mocks
* run-docker: Run the docker image
* test: Run tests
Ao executar o comando task
a tarefa default
vai ser executada:
❯ task
task: [install-deps] go mod tidy
task: [default] go build -o bin/api-o11y-gcp cmd/api/main.go
É possível ver que a tarefa executou primeiro a sua dependência, o install-deps
, conforme descrito no Taskfile.yaml
.
E podemos executar outras tarefas adicionando o seu nome ao final do comando:
❯ task build-linux
task: [install-deps] go mod tidy
task: [build-linux] go build -a -installsuffix cgo -tags "netgo" -installsuffix netgo -o bin/api-o11y-gcp cmd/api/main.go
No comando build-linux
é possível também ver a utilização de env vars
para configurar o ambiente no momento da compilação.
Na documentação é possível ver outros exemplos mais avançados, além de um guia de estilo para escrever bons Taskfile.yml
.
A principal vantagem em usar o Taskfile
é que a grande maioria dos times atualmente tem experiência em escrever e usar arquivos no formato YAML
, pois ele tornou-se o formato mais usado para arquivos de configuração (apesar de eu achar que o formato TOML é bem mais legal).
Mage
A segunda alternativa que quero demonstrar é o projeto Mage que se descreve como
uma ferramenta de construção semelhante a make/rake usando Go
O interessante desta ferramenta é que as tarefas são construídas em arquivos Go, com todo o poder que a linguagem nos fornece.
O primeiro passo necessário é a instalação do executável mage
. Para isso usei o comando a seguir no macOS, mas no site oficial é possível visualizar as opções para outros sistemas operacionais.
❯ brew install mage
Vamos novamente reescrever as tarefas do Makefile
neste novo formato. Para isso podemos criar um arquivo chamado magefile.go
na raiz do projeto e adicionar a lógica dentro dele. Mas eu achei mais interessante outra opção documentada, a de criarmos um diretório chamado magefiles
e dentro dele armazenar os arquivos. Achei que desta forma o projeto fica mais organizado. Para isso executei os comandos:
❯ mkdir magefiles
❯ mage -init -d magefiles
O segundo comando inicializa um arquivo magefile.go
com um exemplo inicial para começarmos a descrever as tarefas:
//go:build mage
// +build mage
package main
import (
"fmt"
"os"
"os/exec"
"github.com/magefile/mage/mg" // mg contains helpful utility functions, like Deps
)
// Default target to run when none is specified
// If not set, running mage will list available targets
// var Default = Build
// A build step that requires additional params, or platform specific steps for example
func Build() error {
mg.Deps(InstallDeps)
fmt.Println("Building...")
cmd := exec.Command("go", "build", "-o", "MyApp", ".")
return cmd.Run()
}
// A custom install step if you need your bin someplace other than go/bin
func Install() error {
mg.Deps(Build)
fmt.Println("Installing...")
return os.Rename("./MyApp", "/usr/bin/MyApp")
}
// Manage your deps, or running package managers.
func InstallDeps() error {
fmt.Println("Installing Deps...")
cmd := exec.Command("go", "get", "github.com/stretchr/piglatin")
return cmd.Run()
}
// Clean up after yourself
func Clean() {
fmt.Println("Cleaning...")
os.RemoveAll("MyApp")
}
Como as tarefas são descritas na forma de um script Go é necessário baixar a dependência do Mage
usando o comando:
❯ go get github.com/magefile/mage/mg
Agora é possível listarmos as tarefas disponíveis, que o Mage
chama de targets
:
❯ mage -l
Targets:
build A build step that requires additional params, or platform specific steps for example
clean up after yourself
install A custom install step if you need your bin someplace other than go/bin
installDeps Manage your deps, or running package managers.
A linha de comentário de cada função torna-se a documentação do target
como é possível visualizarmos na saída do comando mage
.
Vamos agora converter o conteúdo do Makefile
em um script no formato do mage
:
//go:build mage
// +build mage
package main
import (
"log"
"os"
"os/exec"
"path/filepath"
"github.com/magefile/mage/mg" // mg contains helpful utility functions, like Deps
)
// Default target to run when none is specified
// If not set, running mage will list available targets
var Default = Build
// A build step that requires additional params, or platform specific steps for example
func Build() error {
mg.Deps(InstallDeps)
log.Println("Building...")
cmd := exec.Command("go", "build", "-o", "bin/api-o11y-gcp", "cmd/api/main.go")
return cmd.Run()
}
// Build for Linux
func BuildLinux() error {
mg.Deps(InstallDeps)
log.Println("Generating Linux binary...")
os.Setenv("CGO_ENABLED", "0")
os.Setenv("GOOS", "linux")
cmd := exec.Command("go", "build", "-a", "-installsuffix", "cgo", "-tags", `"netgo"`, "-installsuffix", "netgo", "-o", "bin/api-o11y-gcp", "cmd/api/main.go")
return cmd.Run()
}
// Build a docker image
func BuildDocker() error {
log.Println("Building...")
cmd := exec.Command("docker", "build", "-t", "api-o11y-gcp", "-f", "Dockerfile", ".")
return cmd.Run()
}
// Generate mocks
func GenerateMocks() error {
log.Println("Installing mockery...")
cmd := exec.Command("go", "install", "github.com/vektra/mockery/v2@v2.43.1")
err := cmd.Run()
if err != nil {
return err
}
log.Println("Generating user mocks...")
cmd = exec.Command("mockery", "--output", "user/mocks", "--dir", "user", "--all")
err = cmd.Run()
if err != nil {
return err
}
log.Println("Generating telemetry mocks...")
cmd = exec.Command("mockery", "--output", "internal/telemetry/mocks", "--dir", "internal/telemetry", "--all")
return cmd.Run()
}
// Manage your deps, or running package managers.
func InstallDeps() error {
log.Println("Installing Deps...")
cmd := exec.Command("go", "mod", "tidy")
return cmd.Run()
}
// Run tests
func Test() error {
mg.Deps(GenerateMocks)
cmd := exec.Command("go", "test", "./...")
return cmd.Run()
}
// Run the docker image
func RunDocker() error {
mg.Deps(BuildDocker)
cmd := exec.Command("docker", "run", "-p", "8080:8080", "api-o11y-gcp")
return cmd.Run()
}
// Clean up after yourself
func Clean() error {
log.Println("Cleaning...")
err := removeGlob("user/mocks/*")
if err != nil {
return err
}
err = removeGlob("internal/telemetry/mocks/*")
if err != nil {
return err
}
return os.RemoveAll("bin/api-o11y-gcp")
}
func removeGlob(path string) (err error) {
contents, err := filepath.Glob(path)
if err != nil {
return
}
for _, item := range contents {
err = os.RemoveAll(item)
if err != nil {
return
}
}
return
}
Neste arquivo é possível ver o uso das dependências, como no exemplo: mg.Deps(BuildDocker)
. Também é possível ver o uso de lógica de programação Go, como na função removeGlob(path string)
. Esta função poderia, por exemplo, estar em um pacote separado e ser utilizado por diversos arquivos dentro do diretório magefiles
, fazendo uso das boas práticas da linguagem.
Podemos agora visualizar todos os targets
disponíveis:
❯ mage -l
Targets:
build* A build step that requires additional params, or platform specific steps for example
buildDocker Build a docker image
buildLinux Build for Linux
clean up after yourself
generateMocks Generate mocks
installDeps Manage your deps, or running package managers.
runDocker Run the docker image
test Run tests
* default target
Ao executar o comando mage
a função indicada como Default
vai ser executada, neste caso a build
:
❯ mage
❯ mage -v
Running dependency: InstallDeps
Installing Deps...
Building...
Na segunda execução, ao adicionarmos o flag -v
o resultado é mais detalhado pois são apresentados os logs do target
.
Vejo duas vantagens ao usar o mage
em um projeto. O primeiro é que se o projeto é escrito em Go não torna-se necessário que o time aprenda uma nova linguagem para descrever as tarefas automatizadas. O segundo benefício é termos a disposição uma linguagem de programação completa e não apenas comandos descritos em um arquivo Makefile
ou Taskfile.yaml
. Isso permite a execução de lógicas complexas de maneira muito mais fácil (já vi arquivos Makefile
gigantes, com uma sintaxe pouco amigável para contornar essa necessidade).
Conclusões
O make
é uma ferramenta madura e usada por todos os principais projetos Open Sorce do mundo, e isso não deve mudar tão facilmente. Por isso continuo achando muito válido que o conhecimento desta ferramenta seja incentivado entre devs. Mas adicionar alternativas como as apresentadas aqui pode ser um passo bem importante para facilitar a criação de tarefas e automações graças as vantagens que comentei no texto.
Conhece outras alternativas? Não concorda com a adoção de algo diferente do make
? Compartilhei suas opiniões e experiências nos comentários.