Etter å ha slåss mot og med maven, gradle, grunt og diverse moderne byggeverktøy så er det deilig å se at den gamle traveren make
ofte er et bedre og enklere alternativ.
GNU Make er et byggesystem som ble laget i 1976. Make er ikke knyttet til å gjøre bygg for ett spesifikt programmeringsspråk og det er bygget opp av enkle konsepter som gjør det robust og fleksibelt slik at det enkelt kan tilpasses nye språk.
I denne bloggposten får du med deg det viktigste du trenger for å lage en effektiv Makefile.
¶Det vi trenger for inkrementell bygging
De viktigste konseptene i en makefile er target og dependencies.
Vi tar utgangspunkt i en veldig enkel Makefile:
mybinary: main.go
@echo "Building a binary"
go build -o mybinary
Her har vi et target, mybinary
, som kompilerer en applikasjon av kildekodefilen main.go
som ligger i samme mappe som makefilen. Resultatet er en executable, mybinary
, spesifisert av optionet -o mybinary
. Targetet her heter mybinary
og det er et poeng at det har samme navn som binær-filen det produserer. Dette targetet har en avhengighet til kildekoden main.go
i samme mappe som makefilen, spesifisert etter kolonet. Etter spesifikasjon av navn på target og avhengigheter så kjøres de kommandoene vi vil for å produsere artifaktet vårt.
Hvis avhengighetene til et target ikke endrer seg så vil ikke make kjøre targetet, men fortelle deg at target er “up to date”. Hvis det ikke finnes noen fil med samme navn som target, eller hvis noen av avhengighetene endrer seg så vil target også kjøres. make
har altså innebygd støtte for inkrementell bygging!
Venstre side av :
er navnet på target, høyre side er avhengighetene til target som kan være navn på filer eller navn til andre target. Make lager en graf over alle targets som må kjøres og sjekker også om det finnes sykliske avhengigheter mellom targets før den kjører.
For å kjøre dette targetet bruker vi kommandoen
$ make mybinary
Hvis vi kjører denne kommandoen to ganger uten å endre kildekoden får vi beskjed om at target er oppdatert og make vil derfor ikke kjøre dette targetet:
$ make: 'mybinary' is up to date
Siden mybinary
er det første targetet i Makefilen vår kan vi også bygge ved å kjøre make
uten argumenter:
$ make
make: 'mybinary' is up to date
For at dette skal virke så må resultatet av targetet være en fil av samme navn som targetet. Hvis vi skulle komme til å ha et annet filnavn enn targetnavn vil make alltid kjøre targetet fordi den ikke finner en fil med samme navn som targetet. F.eks med en makefile som dette
myapp: *.go
go build -o mybinary
vil make alltid kompilere programmet på nytt siden ingen fil med navn myapp
blir laget.
Et target består av et vilkårlig antall kommandoer som kjøres i sekvens. Hvis en av disse kommandoene feiler med exit-status != 0
så vil make feile bygget og stoppe etter den feilede kommandoen.
¶Flere targets, faktisk inkrementell bygging
Et byggescript har som regel flere targets som er avhengig av hverandre. Vi ønsker f.eks. å kjøre testene før vi bygger en applikasjon, gjøre linting, generere kode for binære schema eller annen galskap.
Vi lager nye targets i Makefila vår og setter avhengighetene mellom targetene:
mybinary: test main.go
# bygge applikasjonen
go build -o mybinary
test: *test.go events
go test
touch test # lage en fil "test" som en markør-fil
# slik at make vet at test-targetet har blitt
# kjørt ved gjentatte kjøringer av dette targetet
events: *.proto
# generer go-kode for å håndtere serialisering til og fra protobuf
mkdir -p events && protoc *.proto --go-out=events
clean:
# rydd opp etter oss
rm -f events test mybinary
Her er test
-targetet satt opp med avhengighet til alle filene som slutter på test.go
og til events
-targetet, dvs både main.go og alle andre filer i samme mappe. Det oppdaterte targetet mybinary
har nå avhengighet til både test
og events
og vil kjøres på nytt hver gang disse targetene blir oppdatert. Test-targetet vil kun kjøre om noen av test.go-filene er oppdatert eller filen test ikke finnes fra før. events
vil kun bli oppdatert når en .proto
fil endrer seg.
For hygienens skyld lager vi et clean
target som sletter filene som blir produsert av de andre targetene. Test-targetet lager en tom fil for å markere at testene er up to date.
Hvis vi vil kjøre flere targets etter hverandre gjør vi det ved å liste opp targetene i den rekkefølgen vi vil de skal kjøres:
make clean events mybinary
Vi vil at clean-targetet alltid skal kjøres selv om det skulle finnes en fil som heter clean. Dette kan vi si fra til make om ved å bruke det innebygde .PHONY
targetet i toppen av fila vår som markerer at dette targetet alltid skal kjøres:
.PHONY: clean
¶What the $() $@ $< ?
Hvis du har sett noen makefiler før så har du sikkert sett en del ukjent syntaks som kan virke litt skremmende. Ikke la deg skremme! Disse kan være veldig nyttige og det er lurt å lære seg et minimum av hva disse kråketegnene betyr. En god start er automatic variables og custom variables.
En variabel kan assignes med VARIABEL_NAVN=verdi
og aksesseres med dollartegn og paranteser $(VARIABEL_NAVN)
. Variabler opprettes oftest på toppnivå i makefila for å kunne gjenbrukes i flere targets.
Den innebygde variabelen $@
har alltid navn på target som verdi. Den er praktisk å bruke som parameter til output for kompilatoren vår slik at vi sikrer oss at output heter det samme som target. En annen innebygd variabel er $<
, som angir navnet på første avhenginghet til targetet Med ett blir makefilen litt mer kryptisk når vi tar i bruk variabler, så det er en avveining om man vil bruke dette i den første makefilen man lager.
BUILD_COMMAND=go build
mybinary: main.go
$(BUILD_COMMAND) -o $@ $<
For å kunne se hvordan make ekspanderer variablene kan vi bruke --just-print
som option til make:
$ make mybinary --just-print
> go build -o mybinary main.go
¶En smak av funksjoner
Det finnes en rekke innebygde funksjoner i make i tillegg til at man kan definere nye funksjoner. En av de funksjonene jeg bruker ofte er if
for å sjekke om ting er på stell i utviklermiljøet før bygget går videre.
For å sjekke at f.eks docker er installert kan jeg kjøre funksjonen if
i kombinasjon med funksjonen shell
begynnelsen av en target som en guard:
dockerbuild: mybinary
$(if $(shell which docker),@echo "Found docker on path",@echo "Docker not installed"; exit 1)
## flere kommandoer for å bygge docker-imaget ditt
touch $@
Som sagt så stopper make ved første kommando som feiler med exit-status != 0. Hvis docker ikke er installert her så feiler byggescriptet med den brukervennlige feilmeldingen “Docker not installed”.
Siden docker ikke produserer en fil som make kan bruke så lager jeg her en tom fil med samme navn som target for å si til make at dette targetet har kjørt.
Andre nyttige funksjoner er f.eks subst
og andre string-funksjoner, foreach
og masse annen moro.
¶Wrap up
Det er ikke mange triks som skal til for å lage en effektiv makefile. Når du har fått kontroll over target/dependencies så har du fått inkrementell bygging. Med bruk av noen variabler og litt funksjoner så er du i stand til å sette sammen en badass makefile for prosjektet ditt!
Eksempel på en komplett Makefile med triksene brukt ovenfor finner du i denne gisten.