О Makefile

2017-08-06

Вы делали когда-нибудь «заклинание» ./configure && make && make install? Им собирается 80% юниксовых/линуксовых программ из исходников. configure — это Autotools, про них я не буду рассказывать. make — это GNU make.

Learn Makefile

Оригинальный make появился, если верить Википедии, в 1977 году, в недрах Bell Labs. Сейчас имеется два мейка: BSD make и GNU make. Чем они отличаются — это отдельный религиозный вопрос. Но если у вас Linux, у вас по-любому будет GNU make.

Make в качестве параметра принимает имя некоторого правила. А правила задаются в файле Makefile в текущем каталоге.

Зачем явисту может понадобится make и Makefile? Ведь у нас есть великолепнейшие системы сборки: Ant (кто-нибудь им ещё пользуется?), Maven, Gradle. Они действительно могут многое: и собрать, и прогнать тесты, и запустить (сервер локально), и задеплоить (в удалённый контейнер приложений/сервлетов или собранный артефакт в репозиторий). Но, вы всегда помните, что именно надо написать после mvn, чтобы пересобрать супер-jar, задеплоить его в Artifactory, и при этом вам зачем-то хочется пропустить запуск тестов?

А ведь ещё есть более посторонние штуки. Как запустить Docker контейнеры вашей системы локально? docker-compose up или лучше docker-compose up -d? А в каком каталоге? А надо ли перед этим ещё что-то собрать?

А вы деплоите с помощью Ansible? А вы используете роли? А в каком каталоге проекта это всё у вас лежит? А у вас отдельные inventory файлы для разных окружений? Вариантов может быть очень много, а от этого зависит команда, которую нужно выполнять для деплоя. У нас на проектах получается что-то вроде: cd ansible && ansible-playbook -i production deploy.yml. И это далеко не самый каноничный вариант.

Выходов из необходимости запоминать длинные, но необходимые команды есть несколько.

Самые ленивые команды оставляют всё как есть. В результате новичку приходится надоедать старичкам в попытке вытянуть крупицы тайных знаний о том, как собирать и работать с проектом.

Самые упорные команды пишут документацию. Да, в каком-нибудь README.md рядышком, или в вики проекта, или, упаси боже, в корпоративной вики. Описывают всё. Что куда как пойти, и какие команды выполнить. Новичку приходится долго читать, тщательно копировать команды. И молиться, что документация не устарела, а команды не содержат ошибок.

Самые продвинутые команды, которые работают в Linux и слышали, что такое shell script... Хотя ладно, батники в Windows тоже никто не отменял... Они пишут эти самые скрипты. Помещая туда самые сложные команды или последовательности команд. Беда в том, что ворох скриптов не добавляет понимания, даже наоборот. И скрипты приходится документировать.

GNU Make O'Reilly book

Maven и Gradle всегда имеют свой жизненный цикл. И вы лишь можете выбрать, какой вариант этого цикла и с какого места запустить. Дополнительные плугины, особенно в Gradle, изрядно усложняют и дополняют этот жизненный цикл. Количество возможных «слов», которые можно приписать после mvn или gradlew, просто чудовищно.

Ant работал по-другому. Там надо было явно прописывать targetы, со своими, нежно выдуманными, именами, и зависимостями между ними. И вот эти таргеты и выполнялись.

В этом смысле make подобен antу. Можно считать, что в Makefile вы пишите отдельные именованные правила сборки, тоже с зависимостями между ними, а потом запускаете. Только в make одним шагом сборки может быть любая команда или последовательность команд, доступных для выполнения в данной ОС, а не команды ограниченного набора плугинов. Make работает на том же уровне, что shell скрипты. Он может объединить систему сборки с инструментами деплоя и всем прочим.

all: build

build: jar docker

jar:
    mvn package

docker:
    docker-compose build

run:
    docker-compose up

clean:
    mvn clean
    docker-compose down

Синтаксис объявления правила такой.

имя-правила: зависимость-1 зависимость-2
    команда-1
    команда-2

Тут ахтунг. Makefile — это единственный известный мне синтаксис, чувствительный к табуляции. Команды в правиле выделены не просто отступом, а именно что знаком табуляции. Который ASCII 0x09, "\t", 	, U+0009.

Большинство программистских редакторов уже давным давно при нажатии кнопки Tab ↹ делают именно отступ, а не вставляют табуляцию. Поэтому .editorconfig вам в помощь.

[Makefile]
indent_style = tab

На самом деле, в терминах самого make, это нифига не имя правила, зависимости и команды. Это цели, пререквизиты и рецепты.

targets : prerequisites ; recipe
    recipe
    …

Цель — это не абстрактное имя, выбранное вами, а имя файла, который должен появиться в результате выполнения рецепта. Целевой файл правила.

Пререквизиты — это тоже имена (или маски) файлов, которые при этом должны быть новее (смотрится дата последней модификации), чем цель. И эти файлы нужны рецепту, чтобы создать цель.

Рецепт — это рецепт. Команды.

Всё это создавалось для компиляции программ на C. Тут принято, что результат компиляции, это файл с тем же именем в том же каталоге, но с другим расширением.

foo.o : foo.c defs.h
    cc -c -g foo.c

Более того, много правил именно для C уже неявно присутствуют в GNU make. В Makefile достаточно лишь указать название итоговой программы, и из каких модулей она должна быть собрана.

При большом желании можно повторить подобное и для Явы. Ведь Maven, в отличие от Gradle, не умеет не пересобирать проект, когда ничего не менялось. Придётся только немного извратиться, ибо в мире Ява принято рассовывать исходники и результат компиляции в разные каталоги, и к тому же сами исходники щедро раскиданы по развесистому дереву каталогов.

# правило сборки jar: для jar нужен jar в target
jar: main-project/target/*.jar

# http://stackoverflow.com/questions/4036191/sources-from-subdirectories-in-makefile
# Make does not offer a recursive wildcard function, so here's one:
rwildcard=$(wildcard $1$2) $(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2))

# определения длинных списков файлов
ALL_MAIN_JAVA := $(call rwildcard,main-project/src/,*.java)
ALL_MAIN_RESOURCES := $(call rwildcard,main-project/src/main/resources/,*)
ALL_LIB_JAVA := $(call rwildcard,lib-project/src/,*.java)
ALL_LIB_RESOURCES := $(call rwildcard,lib-project/src/main/resources/,*)

# правило сборки jar, ему нужны все эти файлы
main-project/target/*.jar: $(ALL_MAIN_JAVA) $(ALL_MAIN_RESOURCES) $(ALL_LIB_JAVA) $(ALL_LIB_RESOURCES)
        mvn package

Целями и пререквизитами могут быть маски файлов. А в данном случае с помощью грязного хака получается маска с рекурсивным поиском по подкаталогам.

Так как цели — это файлы, но некоторые цели — выдуманные имена, то может случиться конфуз, если файл или каталог с именем «jar» или «clean», или, что уже вполне вероятно, «build», окажется в текущем каталоге. Make тогда может посчитать, что сборка уже имела место, и не выполнит рецепт.

Чтобы этого избежать, цели с выдуманными именами надо явно помечать как «.PHONY».

.PHONY: jar
jar:
    mvn package

.PHONY: clean
clean:
    mvn clean
    docker-compose down

.PHONY — это тоже цель, но со специальным искусственным значением.

Как вы уже наверное догадались, если запустить make без параметров, то будет выполнена первая цель.

В Makefile можно определять переменные. А можно и переопределять переменные. Можно определить дефолтные значения в самом Makefile, а переопределить в командной строке или через переменные окружения.

VAR ?= makefile variable

print:
    echo $(VAR)
$ make
echo makefile variable
makefile variable
$ make VAR="overridden variable" print
echo overridden variable
overridden variable
$ VAR="environment variable" make print
echo environment variable
environment variable

Заметьте, что значения переменных подставляются в текст рецепта как есть, и лишь потом рецепт выполняется. В то же время рецепт, если является командой оболочки, может определять и использовать свои переменные. Нужно только правильно экранировать «$», удваивая его.

Рецепты (каждый сам по себе, если их несколько) выполняются в отдельном подпроцессе. Причём это вовсе не обязательно будет процесс оболочки. Make может просто запустить указанную команду, т.е. сам работает оболочкой. Так что если вам нужен именно bash или zsh для выполнения команды, об этом надо специально позаботиться.

shell:
    echo 1-$$$$-$$0
    sh -c "echo 2-$$$$-$$0"
$ make
echo 1-$$-$0
1-15199-/bin/sh
sh -c "echo 2-$$-$0"
2-15200-/bin/sh

Соответственно, если вы делаете cd куда-то && что-то сделать, текущий каталог меняется только для этой команды, что очень удобно. А если в этом другом каталоге есть свой Makefile, то можно выполнить его правила. Это тоже очень удобно, иметь по Makefile в каждом подпроекте, со своими правилами сборки. А на «глобальном» уровне объединять сборки компонентов под одним правилом и делать другие глобальные вещи.

build:
    cd sub1 && $(MAKE) build
    cd sub2 && $(MAKE) build
$ make
cd sub1 && make build
make[1]: Entering directory '.../dirs/sub1'
echo building sub1
building sub1
make[1]: Leaving directory '.../dirs/sub1'
cd sub2 && make build
make[1]: Entering directory '.../dirs/sub2'
echo building sub2
building sub2
make[1]: Leaving directory '.../dirs/sub2'

Здесь MAKE — это предопределённая переменная, которая содержит полный путь до текущего make. Это чтобы не зависеть от всяких путей типа /usr/bin/make.

Makefile — это не только про сборку. Например, вместо длинных объяснений и тыкания в ссылки официальной документации, можно просто дать Makefile, в котором всё есть.

Вот, например, как поставить Docker и Docker Compose на Red Hat Enterprise Linux?

DOCKER_VERSION=17.03.2.ce
DOCKER_COMPOSE_VERSION=1.11.2

.PHONY: help
help:
    @echo "make install - to install Docker and other necessary components"
    @echo "make pull    - to download and update necessary Docker images"
    @echo "..."

# docker install targets

.PHONY: install
install: add_docker_repo install_docker start_docker install_docker_compose

.PHONY: add_docker_repo
add_docker_repo:
    sudo yum install -y yum-utils
    sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
    sudo yum -y makecache fast

.PHONY: enable_extras_repo
enable_extras_repo:         # it's necessary to install container-selinux which is required by Docker
    sudo yum-config-manager --enable rhel-7-server-extras-rpms
    sudo yum -y makecache fast

.PHONY: install_docker
install_docker:
    sudo yum -y --setopt=obsoletes=0 install docker-ce-$(DOCKER_VERSION)

.PHONY: start_docker
start_docker:
    sudo systemctl enable docker
    sudo systemctl start docker

.PHONY: install_docker_compose
install_docker_compose:
    sudo curl -L https://github.com/docker/compose/releases/download/$(DOCKER_COMPOSE_VERSION)/docker-compose-Linux-x86_64 -o /usr/bin/docker-compose
    sudo chmod +x /usr/bin/docker-compose

Есть в Makefile и условия, и циклы. Читайте документацию и разбирайтесь.

Старайтесь лишь не переусложнять Makefile, который вы пишите руками (Autotools генерируют Makefile, в результате он пугает). Оставьте только самые важные и часто используемые команды, чтобы было удобно.