Из этой статьи вы узнаете как устроен Makefile и как практически использовать программу make.

А конкретно, вы узнаете:

  • Как практически задавать правила.
  • Какие могут быть подводные камни.
  • Как подойти к выбору числа процессов.
  • Как пакетно сжать .png, .jpg, .js.
  • Как скопилировать все .less в .css одним махом.
  • Как проверить что нет явных ошибок в PHP и JS.

В статье используется GNU Make версии 4.2.

Введение

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

Примерный вид простейшего Makefile:

all: report.txt

report.txt: source.txt
	wc -l source.txt > report.txt

Запустим команду make с этим Makefile в каталоге (не забыв заменить пробелы на табы), и с каким-то текстовым файлом source.txt, мы получим в этом же каталоге файл report.txt, в котором будет записано число строк в файле source.txt.

$ make
wc -l source.txt > report.txt

$ cat report.txt 
8 source.txt

Если мы еще раз запустим make без изменения исходного файла, то make корректно отрапортует что ему нечего делать.

$ make
make: Nothing to be done for 'all'.

Исходные данные не поменялись, все верно. Делать в самом деле нечего!

В этой особенности находится источник силы команды make и причина народной любви: если у вас поменялся лишь один файл, то вам не придется перекомпилировать все-все файлы, что нужно было бы если бы вы не использовали make или аналоги.

Команда make умеет не только смотреть на время изменения файлов, но и выполнять команды для нескольких правил параллельно (ключ -j), используя все доступные процессорные ресурсы настолько, насколько вам это нужно, а также подбирать правила по шаблону. Для обычных программистских задач в make есть встроенные правила, позволяющие сократить шаблонный код до минимума.

Пример сложней

Например, такой Makefile найдёт все файлы .txt в текущем каталоге, посчитает в них строки, запишет результат в соответствующие файлы .wc:

TXTS = $(wildcard *.txt)
WCS = $(TXTS:%.txt=%.wc)

all: $(WCS)

%.wc: %.txt
	@wc -l $< > $@

Что здесь делается:

  1. Сначала найдем все исходные файлы .txt.
  2. Затем заменим расширение найденых файлов на .wc.
  3. Обозначаем в правиле all что нам нужны все файлы .wc.
  4. В шаблонном правиле показываем какой командой-рецептом получить .wc из соответствующего .txt.

При этом:

  • В самой команде $< будет заменено исходным файлом, а вместо $@ будет подставлен целевой файл. Все виды подстановок.
  • Команда с @ в начале не будет показана в выводе.

Запустим make с выполнением задач в восемь потоков:

$ time make -j8

real    0m0,007s

Команда make нашла все файлы и сделала всё, что было нужно.

Для примера, запустим тот же подсчет строк в один поток:

$ rm *.wc; time make

real    0m0,013s

Как видите, даже на такой примитивной задаче как подсчет строк в файлах можно получить прирост в скорости за счет запуска в несколько потоков. Также можно заметить что на очень простых задачах выгода от запуска в несколько процессов не всегда пропорциональна числу процессов (много съедает запуск параллельных задач).

Выбор числа потоков

Не существует какого-то конкретного и однозначного правила, из которого бы следовало что во всех случаях make нужно запускать с числом потоков (упомянутый выше ключ -j или --jobs), например, равным числу ядер процессора. Впрочем, даже такое грубое правило можно использовать как отправную точку.

Отклонения от такого правила нужны когда, например, вы знаете что операции требут загрузки чего-то по сети: в этом случае можно смело выполнять задачи с большей параллельностью чем есть процессоров (все равно они будут ждать данных из сети большую часть времени).

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

Дальше мы рассмотрим особенности выполнения в несколько потоков на практических задачах.

Долой переменные

Тот же Makefile можно еще больше упростить, отказавшись от переменных.

all: $(patsubst %.txt,%.wc,$(wildcard *.txt))

%.wc: %.txt
	wc -l $< > $@

Конечно, в таком виде Makefile не приобретает в понятности: важно это или нет, решать вам.

Виртуальные цели

Для большинства целей подразумевается что в итоге должен получиться файл на диске. Но иногда нежелательно, чтобы создание такого файла вообще проверялось. Возьмем правило вызова тестов PHPUnit:

test:
	php vendor/bin/phpunit

Если мы создадим файл test в текущем каталоге, то make откажется что-либо делать.

$ touch test
$ make test
make: 'test' is up to date.

Избежать таких неприятных и неожиданных ситуаций можно если явно обозначить виртуальные цели так:

.PHONY: test

test:
	php vendor/bin/phpunit

С таким указанием существование или отсутствие файла test ни на что не влияет:

$ touch test
$ make test
php vendor/bin/phpunit
PHPUnit 5.X.X ....

Пробелы или табуляция?

Исторически формат Makefile требует чтобы каждая отдельная строка рецепта была отбита табом. Если в начале строки оказался пробел, то make сообщит об ошибке:

$ make
Makefile:2: *** missing separator.  Stop.

Или так:

$ make
Makefile:2: *** missing separator (did you mean TAB instead of 8 spaces?).  Stop.

В более новых версиях GNU make (начиная с 3.82) есть возможность указать другой префикс для строк рецептов. Если нет какой-то явной необходимости, то лучше использовать формат по умолчанию.

Если нужно, заменить начальные пробелы (по четыре) можно так:

unexpand --tabs 4 --first-only Makefile > Makefile.tabs
mv Makefile.tabs Makefile

Практическая задача

Достаточно с введением! Рассмотрим практическую задачу: для всевозможные ресурсов сайта необходимо получить уменьшенную или скомпилированную версию. В частности:

  1. Для .jpg нужно получить .min.jpg, уменьшенные согласно рекомендаций Google.
  2. Аналогично, для .png нужно получить .min.png.
  3. Для всех .js нужно получить сжатые и сокращённые .min.js, по ходу проверив на очевидные ошибки.
  4. Для всех .less нужно получить готовые к использованию .css.
  5. Для всех .php, измененный за последнее время, нужно проверить синтаксис.
  6. И так далее, и тому подобное по потребностям.

Задача будет выполняться на сервере CI или при пуше через Git при деплое.

Реквизиты

Установим все требуемые программы:

apt install closure-compiler zopfli imagemagick node-less

Для работы всех рецептов ниже может потребоваться установить более свежие версии этих пакетов из testing или из backports. Например, так:

apt -t testing install zopfli

Смотрите по обстоятельствам.

Подготовка

Начнём с заголовка для Makefile. У нас он будет выглядеть так:

NPROC=$$(nproc)
MAKEFLAGS+="-j $(NPROC)"
SHELL=/bin/bash

Здесь мы в переменной NPROC записываем доступное число ядер из команды nproc, научаем make использовать все доступные ядра без необходимости прописывать их явно в командной строке, назначаем полный баш вместо ущербного /bin/sh, используемого по умолчанию. Два доллара ($$) нужно использовать для запуска команд и обращения к переменным шелла, в отличии от получения значений встроенных переменных.

Число процессов указываем в переменной чтобы можно было переназначить её при запуске make. Это нужно когда, например, вы запускаете задачу на CI сервере с количеством памяти, недостаточным для выполнения в максимальном числе потоков. Например, так:

make NPROC=2

В таком виде в переменной NPROC будет число два, а make будет использовать только два процессора. Другие программы тоже можно будет проинструктировать не использовать больше этого числа процессоров.

В инструкциях ниже исходим из того что все статические файлы лежат в стандартном каталоге htdocs внутри текущего каталога www, в котором уже лежит наш Makefile. Зададим это умолчание в переменной в начале файла:

ASSETS=htdocs/

Там же, в начале Makefile, в правиле all будут перечислены все отдельные подправила в качестве зависимостей:

all: minify-png minify-jpg minify-js less-css lint-php
.PHONY: minify-png minify-jpg minify-js less-css lint-php clean

Их же мы указываем как виртуальные.

Пережмём PNG

Начнём с простого: с пережатия .png файлов. Такое сжатие делается прямолинейно и без особых подводных каменей.

minify-png: $(patsubst %.png,%.min.png,$(shell find $(ASSETS) -type f -name \*.png -not -name \*.min.png))

%.min.png: %.png
	@zopflipng --lossy_transparent -y -m $< $@ > /dev/null
	@echo Minified $$(basename $<): $$(du -bh $< $@ | cut -f1)

Что здесь происходит:

  1. Для правила minify-png в качестве зависимостей укажем все найденные файлы .png, не включая уже уменьшенные .min.png. При этом сделаем подстановку, заменив исходное расширение .png на целевое .min.png.
  2. Пережмем файл, удаляя цветовую информация для прозрачных пикелей, перезаписывая файлы, используя больше итераций. Вывод команды сжатия не показываем.
  3. Покажем размеры исходного и уменьшенного файла.

Проверяем:

$ make minify-png
Minified example5.png: 44K 28K
Minified example1.png: 236K 136K
Minified example2.png: 260K 172K
Minified example4.png: 268K 172K
Minified example3.png: 532K 396K

Вот так просто мы ужали наши файлы с 1,4 Мб до 904 Кб, и всё это без потери качества.

Почему Zopfli? Zopfli vs OptiPNG

Программа OptiPNG - другой популярный вариант уменьшить картинки. Правила для работы с ней:

%.min.png: %.png
	@optipng -silent -strip all -clobber -out $@ $<
	@echo Minified $$(basename $<): $$(du -bh $< $@ | cut -f1)

Путём несложного эксперимента можно убедиться что Zopfli сжимает лучше. На моих тестовых картинках OptiPNG показала результат примерно на 170 Кб более худший, чем Zopfli. Картинки у OptiPNG получились примерно на 20% больше, чем у Zopfli.

Уменьшаем JPG

Пережатие .jpg подразумевает потерю качества. По рекомендациям главного поисковика следует использовать качество 85, но это может приводить к появлению неприятных артефактов. Исключить их можно пережимая файлы с качеством 91.

QUALITY = 91
#QUALITY = 85

minify-jpg: $(patsubst %.jpg,%.min.jpg,$(shell find $(ASSETS) -type f -name \*.jpg -not -name \*.min.jpg -size +30k))

%.min.jpg: %.jpg
	@flock --wait 600 Makefile convert $< -sampling-factor 4:2:0 -strip -quality $(QUALITY) -interlace JPEG -colorspace RGB $@
	@echo Minified $$(basename $<): $$(du -bh $< $@ | cut -f1)
  1. Для правила minify-jpg делаем всё то же, что для minify-png, лишь только не рассматривая файлы меньше 30 Кб. Их мы будем оптимизировать вручную.
  2. Запускаем команду convert из состава ImageMagick с рекомендуемыми параметрами, но делаем это в один поток при помощи команды flock, для учёта блокировки используя сам Makefile. Это нужно потому что команда convert сама умеет использовать все процессорные ресурсы: запуск нескольких команд параллельно не только не ускоряет работу, но и может привести к ошибкам.
  3. Показываем размеры старого и нового файла.

Совсем без указания времений ожидания освобождения файла использовать команду flock мы не можем, так как по умолчанию flock будет ждать освобождения файла бесконечно, что нам не подходит: если вдруг convert или другая команда попадёт в бесконечный цикл, то это будет потребует ручного вмешательства для востановления работы CI или деплоя.

Коробкое время ожидания мы не можем установить потому что на самом деле make будет запускать все эти команды параллельно. Например, у вас есть сотня файлов. Средни них есть очень большие и не очень. Команда make запустит сжание всех файлов одновременной, а значит в списке процессов вы увидите все команды, запущенные одновременно согласно числа процессоров. Например, так:

flock --wait 600 Makefile convert htdocs/example1.jpg ...
flock --wait 600 Makefile convert htdocs/example2.jpg ...
flock --wait 600 Makefile convert htdocs/example3.jpg ...
...

Если мы поставим время ожидания равным, например, пяти секундам, то если любые одна или несколько команд до нашей в очереди будут выполняться по сумме больше пяти секунд, то наша команда завершится с ошибкой по таймауту.

Потому время ожидания для flock указываем соответственно максимальному ожидаемому времени для выполнения всех-всех операций сжатия. В нашем случае мы считаем что всё-всё должно сжаться за 600 секунд или 10 минут.

Проверяем:

$ make minify-jpg
Minified example5.jpg: 132K 148K
Minified example3.jpg: 144K 108K
Minified example2.jpg: 136K 132K
Minified example4.jpg: 192K 32K
Minified example1.jpg: 116K 64K

Наши файлы в целом уменьшились с 748 Кб до 496 Кб заведомо без видимой потери качества.

Можно заметить что не все гладко: один файл не уменьшился, а увеличился. Имеет смысл выявить такие файлы и исключить их из процедуры оптимизации. Например, если мы заметим что наши файлы малого качества и размера лежат в каталоге с цифрами в начале, то можно исключить их из подверженных оптимизации так:

...$(shell find $(ASSETS) ... -size +30k -not -regex '.*/[0-9][0-9][0-9].*')...

Более сложным вариантом будет сравнивать размеры после оптимизации, копируя исходный файл поверх оптимизированного если последний оказался больше.

JavaScript

Скрипты будем уменьшать с помощью Closure Compiler, используя наиболее безопасный метод оптимизации.

minify-js: $(patsubst %.js,%.min.js,$(shell find $(ASSETS) -type f -name \*.js -not -name \*.min.js))

%.min.js: %.js
	@flock --wait 600 Makefile closure-compiler --js $< --compilation_level WHITESPACE_ONLY --js_output_file $@
	@echo Minified $$(basename $<): $$(du -bh $< $@ | cut -f1)

По процедуре ничего нового, кроме как что команду closure-compiler мы запускаем в один поток потому потому что та требовательна к объёму оперативной памати. Запустив несколько экземпляров closure-compiler параллельно можно легко получить ошибку аллокации памяти или даже призвать OOM Killer.

Проверяем:

$ make minify-js
Minified example4.js: 8,0K 4,0K
Minified example3.js: 52K 32K
Minified example1.js: 12K 4,0K
Minified example5.js: 8,0K 4,0K
Minified example2.js: 8,0K 4,0K

Объём скриптов легко сократился с 88 Кб до 48 Кб.

Кроме оптимизации файлов Closure Compiler проверяет их на корректность. Если у вас есть ошибки, то вы узнаете об этом. Например, так:

htdocs/example1.js:49: ERROR - Parse error. IE8 (and below) will parse 
                       trailing commas in array and object literals
                       incorrectly. If you are targeting newer versions
                       of JS, set the appropriate language_in option.
        text: 'example',
        ^

1 error(s), 0 warning(s)
Makefile:24: recipe for target 'htdocs/example1.min.js' failed
make: *** [htdocs/example1.min.js] Error 1
make: *** Waiting for unfinished jobs....

Выполнение других правил не будет продолжено. Сама программа make завершится с кодом ошибки и ваша система CI сообщит о проблеме в обычном отчете.

Проверка JavaScript кода на ошибки

В случае CI описанная в прошлом абзаце ситуация - штатная и естественная. Ведь именно для этого нужен CI, чтобы ловить ошибки. Но если отдельного этапа CI нет, то это проблема: вся процедура деплоя через Git будет сломана. Нужен какой-то способ проверить скрипты до деплоя. Встречайте правило js-lint:

JSLINT_MAXPROCS = $(shell printf $$(expr $$(vmstat -S m -s | grep 'free memory' | tr -d [:alpha:][:blank:]) / 250))

js-lint:
	@find $(ASSETS) -type f -name \*.js -not -name \*min.js -print0 | xargs -P$(JSLINT_MAXPROCS) -0 -I{} \
	    closure-compiler --js "{}" --summary_detail_level 1 --js_output_file /dev/zero
  1. Если у вас десяток или под сотню .js файлов, то ждать пока они все последовательно проверятся - это мучение. Одним вариантом было бы сделать какой-то реперный файл, от которого make будет смотреть нужно ли проверять этот .js или нет. Мы выбрали другой вариант: исходя из 250 Мб на один процесс closure-compiler посчитаем сколько их одновременно можно запустить в расчете на свободную оперативную память.

  2. В самом правиле мы находим все интересующие нас файлы, отбиваем их нулём (\0) и передаём в xargs, какая программа запускает параллельную обработку всех файлов согласно ранее определенного максимального количества процессов.

Проверяем:

$ make js-lint
htdocs/example1.js:49: ERROR - Parse error. IE8 (and below) will parse 
                       trailing commas in array and object literals
                       incorrectly. If you are targeting newer versions
                       of JS, set the appropriate language_in option.
        text: 'example',
        ^
Makefile:31: recipe for target 'js-lint' failed
make: *** [js-lint] Error 123

Это правило не следует добавлять в число целей, требуемых по умолчанию внутри цели all. Лучше будет сделать отдельную цель test, в требованиях к которой указать эту проверку и другие.

LESS

Для компиляции LESS файлов используем штатную утилиту.

less-css: $(patsubst %.less,%.min.css,$(shell find $(ASSETS) -type f -name \*.less))

%.min.css: %.less
	@lessc --relative-urls --compress $< > $@

Тут всё просто и понятно. Документация на ключ для получения относительных путей до картинок при импорте других .less файлов. В более новых версиях lessc может потребоваться использовать другие ключи для сжатия итоговых файлов.

PHP

Проверять все-все файлы можно, но нужно будет ограничивать каталоги, в которых проверяются файлы. В частности, нужно будет убрать из проверки каталог vendor, так как там и файлов много, и файлы странные бывают. Можно упростить если проверять только файлы, изменившиеся за последнее время:

lint-php:
	@find . -type f -name \*.php -mtime -2 -print0 | xargs -0 -P$(NPROC) -n1 php -l | grep -v "No syntax errors detected" || exit 0 && exit 1

PHP не предъявляет особых требований к памяти, потому используем все доступные процессоры, число которых берем из ранее определенной переменной NPROC.

Проверяем:

$ make lint-php
PHP Parse error:  syntax error, unexpected end of file, expecting ';' in ./htdocs/example2.php on line 4
Errors parsing ./htdocs/example2.php
xargs: php: exited with status 255; aborting
Makefile:45: recipe for target 'lint-php' failed
make: *** [lint-php] Error 1

Правило очистки

Обычно ожидается что если какие-то правила в Makefile что-то создают, то для них есть правило, которое удаляет результаты. Обычно это правило называется clean.

clean:
	find $(ASSETS) -type f -name \*.min.\* -delete -print

Итоговый файл

Со всеми правилами должен получиться подобный файл:

NPROC=$$(nproc)
MAKEFLAGS+="-j $(NPROC)"
SHELL=/bin/bash

ASSETS=htdocs/

all: minify-png minify-jpg minify-js less-css lint-php
.PHONY: minify-png minify-jpg minify-js less-css lint-php clean

minify-png: $(patsubst %.png,%.min.png,$(shell find $(ASSETS) -type f -name \*.png -not -name \*.min.png))

%.min.png: %.png
	@zopflipng --lossy_transparent -y -m $< $@ > /dev/null
	@echo Minified $$(basename $<): $$(du -bh $< $@ | cut -f1)

QUALITY = 91
#QUALITY = 85

minify-jpg: $(patsubst %.jpg,%.min.jpg,$(shell find $(ASSETS) -type f -name \*.jpg -not -name \*.min.jpg -size +30k))

%.min.jpg: %.jpg
	@flock --wait 600 Makefile convert $< -sampling-factor 4:2:0 -strip -quality $(QUALITY) -interlace JPEG -colorspace RGB $@
	@echo Minified $$(basename $<): $$(du -bh $< $@ | cut -f1)

minify-js: $(patsubst %.js,%.min.js,$(shell find $(ASSETS) -type f -name \*.js -not -name \*.min.js))

%.min.js: %.js
	@flock --wait 600 Makefile closure-compiler --js $< --compilation_level WHITESPACE_ONLY --js_output_file $@
	@echo Minified $$(basename $<): $$(du -bh $< $@ | cut -f1)

JSLINT_MAXPROCS = $(shell printf $$(expr $$(vmstat -S m -s | grep 'free memory' | tr -d [:alpha:][:blank:]) / 250))

js-lint:
	@find $(ASSETS) -type f -name \*.js -not -name \*min.js -print0 | xargs -P$(JSLINT_MAXPROCS) -0 -I{} \
	    closure-compiler --js "{}" --summary_detail_level 1 --js_output_file /dev/zero

less-css: $(patsubst %.less,%.min.css,$(shell find $(ASSETS) -type f -name \*.less))

%.min.css: %.less
	@lessc --relative-urls --compress $< > $@

lint-php:
	@find . -type f -name \*.php -mtime -2 -print0 | xargs -0 -P$(NPROC) -n1 php -l | grep -v "No syntax errors detected" || exit 0 && exit 1

clean:
	find $(ASSETS) -type f -name \*.min.\* -delete -print

Сначала очистим все файлы, появившиеся ранее по мере тестирования:

$ make clean
find htdocs/ -type f -name \*.min.\* -delete -print
htdocs/example1.min.png
htdocs/example2.min.png
htdocs/example2.min.jpg
...
htdocs/example1.min.js
htdocs/example1.min.css

Пересоздадим всё уменьшенные версии с самого начала:

$ make
Minified example1.jpg: 112K 60K
Minified example2.js: 4,0K 4,0K
Minified example2.jpg: 132K 192K
...
Minified example4.png: 264K 168K
Minified example3.png: 528K 392K

Можно заметить что make выполняет все правила в перемешку, показывая результаты по ходу выполнения. Изменить такое поведение можно ключом --output-sync или -O. В этом случае строки в выводе будут групироваться по правилам. С другой стороны эта опция ломает определение запуска из терминала, а значит вы останетесь без цвета в выводе из тех программ, что показывают цвет при работе в терминале.

Что дальше?

Теперь в каталоге рядом с исходными файлами лежат уменьшенные. В простейшем варианте пути до файлов везде на сайте нужно заменить путями до уменьшенных версий. Это неудобно и потому что нужно делать замену, и неудобно потому что уменьшенных вариантов может не быть. Нужно другое решение.

В случае nginx эту проблему решить очень просто:

location / {
    expires max;
    set $orig_uri $uri;
    rewrite ^(.*\.)(css|js|gif|png|jpg|json)$ $1min.$2 break;
    try_files $uri $orig_uri @backend;
}

Приятным бонусом к такой схеме будет невозможность посмотреть исходный файл если есть уменьшенная копия. Например, для уменьшенных .js не будут видны комментарии.

Если добавить отладочный заголовок:

add_header Try-Files "$uri $orig_uri";

То можно увидеть что nginx будет всегда сначала пытаться открыть уменьшенный файл, и только потом, при отсутствии уменьшенного, открывать исходный:

$ curl -I https://www.test.ru/example.js 
HTTP/2 200
try-files: /example.min.js /example.js

Конец

Надеюсь, у вас всё получилось. Об опечатках сообщайте.

Нашли, что можно исправить или улучшить? Нашли ошибку? Напишите!