В этой достаточно сложной статье мы покажем как своими руками написать утилиту, которая позволит собирать сложные проекты со множеством файлов и которая не зависит от выбранного вами языка программирования. Утилита, которую мы опишем далее, называется redo и она при скромном на первый взгляд функционале, позволяет отслеживать зависимости в сборочных файлах и запускать пересборку только в случае изменения любого из файлов «проекта» или же в случае изменения самого скрипта построения проекта. Также сами сборочные скрипты могут быть написаны на любом скриптовом языке или же языке программирования общего назначения, поскольку являются обычными файлами с командами, которые просто передаются в командную оболочку.
Если стало интересно, что именно мы будем реализовывать, то добро пожаловать под кат.
Систему для сборки целей redo придумал американско-немецкий математик, криптолог и ученый-компьютерщик Даниэль Джулиус Бернштейн (Daniel J. Bernstein). Данную систему Бернштейн задумал как максимально простую, минимальную и лишенную недостатков классических сборочных систем. Исходя из таких принципов построения, у redo есть следующие преимущества:
- нет никакого нового языка для команд сборки, а также нового формата файла для описания процесса сборки — redo разрабатывался так, чтобы было достаточно простого использования shell;
- нет зависимости от какого-либо языка разметки или языка программирования, redo может использовать любой язык программирования в описании процесса сборки, а также сам redo может быть реализован на любом языке программирования;
- крайняя простота redo (будет описано далее), благодаря чему реализацию можно сделать буквально за пару дней и легко интегрировать в свои проекты
Также помимо этого, у redo есть хранимое состояние, благодаря которому происходит отслеживание изменений необходимых для сборки целей правил. Более того, нет никаких ограничений на то, как описывать само состояние — в разных реализациях используются разные подходы, неизменным остается лишь принцип работы и ее базовое описание.
В redo, как в иных системах сборки, есть сборочный файл. Данный сборочный файл представляет собой обычный shell-скрипт и имеет расширение *.do. В этом файле описываются команды для сборки целей на основании текущих файлов и скрипту передается три аргумента, которые имеют следующие имена и смыслы:
- $1 — имя цели;
- $2 — базовое имя цели (имя цели без расширения);
- $3 — имя файла
При этом результатом работы redo является или собранный файл ($3 это и есть имя результирующего файла), или содержимое стандартного потока вывода, а под целью мы понимаем некоторую позицию (описание файла, папки или что-то иное) в файле сборки, которая требует применения команд для получения из нее нужного результата. Помимо регулярных файлов сборки, которые обычно совпадают по наименованию с названием файла результата, есть еще default сборочные файлы, которые выполняются для всех файлов в папке или для множества целей: сборочные файлы default.do выполняются в случае отсутствия указания конкретных целей.
Важнейшим понятием является понятие зависимостей, т.е тех файлов/папок от сборки которых зависит сборка текущей цели. Как правило, большая часть проектов имеет в сборочных файлах одну или несколько зависимостей, которые в сборочном файле указываются с помощью команды redo-ifchange. Данная команда является частью самой реализации redo и после нее указывается лишь список тех фалов/папок, от которых зависит текущая сборочная команда, и если в них имеется какое-либо изменение — то redo-ifchange запустит пересборку цели. При этом изменения в *.do файле приведут к автоматической пересборке цели вне зависимости от действия redo-ifchange.
А что если есть зависимость от файла, который еще не создан, но который может быть создан в процессе работы сборочного скрипта или в ходе самой сборки? Для таких случаев в реализациях redo есть особая процедура, которая именуется redo-ifcreate, синтаксис и принцип работы схож с процедурой redo-ifchange, но работает для еще не созданных файлов или тех единиц сборки, которые еще только предстоит создать.
Примерно таким образом описывается система redo (детальную информацию об устройстве и принципах, правда, без эталонной реализации можно подчерпнуть тут или тут), а значит для создания собственной реализации этой интересной утилиты нужно реализовать три вещи: хранение информации о зависимостях (т.е состояние), процедуру redo-ifchange и процедуру redo-ifcreate. К сожалению, референсной реализации от автора концепции нет и поэтому есть некоторые отличия в уже существующих вариантах redo, но любая из этих реализаций обязательно содержит указанные три элемента.
Из-за простоты и крайнего минимализма в описании утилиты свой вариант redo можно выпустить очень быстро, а сама утилита есть и на C, C++, Haskell, Go, Python, bash и даже на такой экзотике как Inferno Shell, но … нет на D !
И именно этот момент мы сейчас исправим…
В нашей реализации мы будем использовать наименьшее число элементов из стандартной библиотеки (наименьшее, но все равно импортов получается слишком много), а также, для уменьшения размеров исполняемого файла будем использовать портированный с С вариант получения SHA256 хэша для файла, который мы описывали в одном из рецептов. Кроме того, для более красивого вывода мы добавим ряд упрощенных функций, которые позволяют дать 4 разных вида окраски — ошибка (error), отладочная информация (log), информация (info) и предупреждение (warning):
import std.algorithm; import std.file; import std.format; import std.path; import std.process; import std.stdio; import std.string : replace, strip; alias error = function(string message) { format("\u001b[31m\u001b[49m\u001b[1mError:\u001b[0m\u001b[97m\u001b[49m\u001b[1m %s \u001b[0m", message).writeln; }; alias info = function(string message) { format("\u001b[32m\u001b[49m\u001b[1mInfo:\u001b[0m\u001b[97m\u001b[49m\u001b[1m %s \u001b[0m", message).writeln; }; alias log = function(string message) { format("\u001b[34m\u001b[49m\u001b[1mLog:\u001b[0m\u001b[97m\u001b[49m\u001b[1m %s \u001b[0m", message).writeln; }; alias warning = function(string message) { format("\u001b[33m\u001b[49m\u001b[1mWarning:\u001b[0m\u001b[97m\u001b[49m\u001b[1m %s \u001b[0m", message).writeln; };
Работает данная раскраска за счет использования escape-последовательностей и работает исключительно в Posix-системах, не является обязательной для работы redo и может быть выведена из состава программы.
В уже упоминавшийся блок с алгоритмом SHA256 мы добавляем в блок extern(C) ряд новых «заимствований», которые нужны для работы с файлами функциями стандартной библиотеки C, а также необходимы для работы самого SHA256. Часть которую мы добавим выглядит примерно так:
extern (C) int open(scope const(char*) pathname, int flags) pure nothrow @nogc; extern (C) ssize_t pread(int fd, void* buf, size_t count, off_t offset); extern (C) int close(int fd); extern (C) void* memset(scope return void* s, int c, ulong n) pure nothrow @nogc; extern (C) void* memcpy(scope return void* s1, scope const(void*) s2, ulong n) pure nothrow @nogc;
Также добавляем обертку для получения SHA256 хэша для файла:
string sha256sum(string filepath) @trusted { char* filename = cast(char*) filepath.dup; int fd = open(filename, O_RDONLY); scope (exit) { fd.close; } char* hash = hashfile(fd); return hash.to!string; };
Далее, определим ряд вспомогательных функций для работы с файлами, которые будут повсеместно использоваться далее и которые сильно облегчают восприятие кода:
alias onlyFiles = function(string directoryPath) { return directoryPath.dirEntries(SpanMode.shallow).filter!`a.isFile`; }; alias getExtension = function(string filepath) { return filepath.extension.replace(".", ""); }; auto lineFromFile(string filePath) { return File(filePath, `r`).readln.strip; } auto lineToFile(string line, string filePath) { File(filePath, `w`).writeln(line); }
Функция onlyFiles служит универсальным генератором списка файлов в некоторой папке и использует всем знакомый синтаксис UFCS, комбинирующий методы в цепочку выполняемых функций. Функция getExtension написана на очевидный и понятный манер и служит для извлечения расширения без его служебных компонентов (точка в расширении вырезается с помощью replace из std.string). Две функции lineFromFile и lineToFile далее будут использоваться особенно интенсивно, поскольку они позволяют прочитать/записать строку из некоторого файла и необходимы для работы с состоянием, которое будет хранить redo.
В этом варианте redo состояние (т.е данные по изменениям для файлов) будут храниться в скрытой папке под названием .redo, и эту папку мы будем называть мета-директорией или мета-папкой:
enum string metaDirectory = `.redo`;
Определяем эту папку в начале функции main и будем в дальнейшем использовать (это, кстати, позволяет если не нравится, изменить папку под состояние). В этой папке будут храниться две подпапки под названием: change и create, каждая из которых будет содержать текстовые файлы, в каждом из которых, соответственно, будет храниться только одна строка, которая представляет собой имя файла зависимости. В самом текстовом файле прописывается только имя файла зависимости, а папка в которой создан файл соответствует процедуре, которая была использована в сборочном скрипте: change — использовалась redo-ifchange, create — использовалась redo-ifcreate. Также под каждую цель заводится отдельная папка (с именем цели) в которой и содержатся указанные файлы и каталоги.
Далее поблочно опишем структурные элементы реализации redo.
Функции cleanChangeSum и cleanCreateSum служат для начальной очистки папок перед запуском утилиты и выглядят примерно так:
auto cleanChangeSum(string dependency, string target) { auto changeDirectory = format(metaDirectory ~ "/%s/change/", target); foreach (a; changeDirectory.onlyFiles) { if (lineFromFile(a) == dependency) { remove(a); } } } auto cleanCreateSum(string dependency, string target) { auto createDirectory = format(metaDirectory ~ "/%s/create/", target); foreach (b; createDirectory.onlyFiles) { if (lineFromFile(b) == dependency) { remove(b); } } }
Функция cleanAll очистит всю мета-папку:
auto cleanAll(string target) { auto targetDirectory = metaDirectory ~ "/" ~ target; if (targetDirectory.exists) { foreach (w; targetDirectory.onlyFiles) { remove(w); } } }
Функция getChangeSum также, как и первые две функции, принимает два аргумента — описание зависимости и описание цели, но возвращает в качестве значения хэш-сумму для файла зависимости. Выглядит это следующим образом:
auto getChangeSum(string dependency, string target) { string changeSum; auto changeDirectory = format(metaDirectory ~ "/%s/change/", target); foreach (c; changeDirectory.onlyFiles) { if (lineFromFile(c) == dependency) { changeSum = baseName(c); break; } } return changeSum; }
Функция upToDate проверяет устарела ли имеющаяся зависимость для текущей цели через сопоставление текущего хэша (вычисляет налету) с тем. что есть в днный момент (имя файла для зависимости):
auto upToDate(string dependency, string target) { string oldSum; auto changeDirectory = format(metaDirectory ~ "/%s/change/", target); foreach (d; changeDirectory.onlyFiles) { if (lineFromFile(d) == dependency) { oldSum = baseName(d); break; } } return (sha256sum(dependency) == oldSum); }
А следующая функция doPath определяет путь для файла сборки (рецепта) — это либо *.do файл с именем точно таким же как и у цели, либо файл default, который применяется ко всем файлам:
auto doPath(string target) { string doFilePath; if (target.getExtension != "do") { if ((target ~ ".do").exists) { doFilePath = target ~ ".do"; } else { auto path = format(`%s/default.%s.do`, target.dirName, target.getExtension); if (path.exists) { doFilePath = path; } } } return doFilePath; }
Помимо анализа пути до сборочных файлов и прочих вышеописанных процедур требуется вычисление хэшей для зависимостей и генерации состояние для redo, что обеспечивается функциями genChangeSum и genCreateSum, который используются в одной из последующих за ней процедур:
auto genChangeSum(string dependency, string target) { cleanChangeSum(dependency, target); auto path = format(metaDirectory ~ "/%s/change/%s", target, sha256sum(dependency)); lineToFile(dependency, path); } auto genCreateSum(string dependency, string target) { cleanCreateSum(dependency, target); auto path = format(metaDirectory ~ "/%s/create/%s", target, sha256sum(dependency)); lineToFile(dependency, path); }
Для работы с shell-скриптами нам потребуется функция getShebang, которая из сборочного скрипта извлекает строку команды вызова программы, которая требуется для исполнения некоего скрипта:
auto getShebang(string filepath) { string shebang; foreach (line; File(filepath, `r`).byLine) { if (startsWith(cast(string) line, "#!")) { shebang = strip(cast(string) line); break; } } return shebang; }
Основную работу в утилите redo делает процедура doRedo, которая выглядит примерно так:
auto doRedo(string target) { string tmp = target ~ `---redoing`; string doFilePath = doPath(target); auto createDirectory = format(metaDirectory ~ `/%s/create/`, target); auto changeDirectory = format(metaDirectory ~ `/%s/change/`, target); if (!createDirectory.exists) { mkdirRecurse(createDirectory); } if (!changeDirectory.exists) { mkdirRecurse(changeDirectory); } if (doFilePath == "") { if (!target.exists) { error(format(`No .do file found for target: %s`, target)); return; } } else { bool trigger; bool isPrepared = (upToDate(doFilePath, target) || (target.exists)); if (!isPrepared) { trigger = true; } if (!trigger) { foreach (e; createDirectory.onlyFiles) { auto dependency = lineFromFile(e); if (dependency.exists) { warning(format(`%s exists but should be created`, dependency)); return; } else { trigger = true; } } } if (!trigger) { foreach (f; changeDirectory.onlyFiles) { auto dependency = lineFromFile(f); auto shell = executeShell(`REDO_TARGET="%s" redo-ifchange "%s"`.format(target, dependency)); if (baseName(f) != getChangeSum(dependency, target)) { trigger = true; } } } if (trigger) { info(format(`redo %s`, target)); cleanAll(target); genChangeSum(doFilePath, target); string cmd = getShebang(doFilePath); string rcmd; if (cmd == "") { rcmd = format( `PATH=.:$PATH REDO_TARGET="%s" sh -e "%s" 0 "%s" "%s" > "%s"`, target, doFilePath, baseName(target), tmp, tmp ); } else { rcmd = format( `PATH=.:$PATH REDO_TARGET="%s" sh -c "%s" "%s" 0 "%s" "%s" > "%s"`, target, cmd, doFilePath, baseName(target), tmp, tmp ); } info(format(`[build command]: %s`, rcmd)); auto rc = executeShell(rcmd); if (rc.status != 0) { error(format(`Redo script exited with a non-zero exit code: %d`, rc.status)); error(rc.output); remove(tmp); info(format(`[removing temporary file]: %s`, tmp)); } else { if (tmp.exists) { if (tmp.getSize == 0) { info(format(`[removing]: %s`, tmp)); remove(tmp); } else { info(format(`[copying]: from %s to %s`, tmp, target)); copy(tmp, target); } } } } } }
В самом начале процедуры мы определяем наименование временного файла для результата сборки, которое собирается из имени файла цели и постикса ---redoing
. После чего формируются пути для папок create и change и проверяется созданы ли они ранее или нет, также проверяется существует ли файл сборки для цели — папки в случае отсутствия будут созданы, а вот отсутствие сборочного файла вызовет ошибку. Если файл существует, то проверяется свежесть зависимостей и наличие файла цели, и если хотя бы одно из этих условий неверно, то устанавливается триггер для запуска дальнейших действий. Если триггер не был установлен, то происходит следующее: проверяется наличие несуществующих файлов, которые указаны в качестве зависимостей, и в случае если такие файлы есть, то выдается ошибка. Если таковых файлов нет, но они должны быть созданы, то триггер будет установлен. Аналогичная проверка производится для существующих файлов зависимостей, и если файл зависимости изменился (достигается запуском redo-ifchange в отдельном shell), то устанавливается триггер и срабатывает запись изменения в мета-директорию.
После того, как прошел ряд необходимых проверок на свежесть и качество изменений, в случае если триггер был установлен, начинается выполнение основной работы, а именно сборки. Сначала выводится наименование цели для сборки, после чего очищаются данные в мета-папке для цели посредством вызова cleanAll. Затем, за очисткой идет генерация хэшей для файлов задействованных в сборке целей и определяется строка с описанием пути до интерпретатора.
Если путь до интерпретатора оказывается пустым, значит, для вызова сборочного скрипта используется обычный shell, которому просто передается на выполнение do-файл; если путь до интерпретатора непустой, значит был использован какой-то иной скриптовый язык и происходит передача do-файла интерпретатору этого языка. Данная возможность реализована путем формирования строки нужной команды в переменной rcmd, которая затем используется в функции executeShell.
Эта функция возвращает кортеж, который содержит два значения: status — код возврата в ходе исполнения команды в вызванном shell и output — перехваченный программой выход со стандатного вывода shell. Если код в status не равен нулю, это значит, что произошел сбой в выполнении команды сборки и в этом случае выдается сообщение об ошибке, а также удаляется временный файл. Если же выполнение команды прошло успешно, то проверяется существование временного файла, и если его размер равен нулю (файл по какой-то причине не сформировался в ходе работы redo), то он удаляется; в противном случае — происходит копирование временного файла в файл результа. Весь процесс сопровождается выводом информации о результатах с красивой подсветкой.
На этом реализация не окончена и требьуется определить функционал, который соответствует командам redo-ifchange и redo-ifcreate. В нашей реализации не создано отдельных функций для этих процедур, поскольку мы воспользуемся свообразным трюком: в этой программе будут совмещены обе этих процедуры, а какая именно будет использована в работе пользователем будет зависеть от того, как программа будет вызвана. Ключевым моментом здесь будут не опции командной строки, а наименование самой программы — именно оно будет отпределять какую из процедур требуется осуществить.
Делается это через получение аргументов командной сроки, отсечением необходимых данных от массива, а также чтением переменных окружения:
string programName = arguments[0]; string[] targets = arguments[1..$]; switch (programName) { case "redo-ifchange": if (environment.get("REDO_TARGET", "") == "") { error(`REDO_TARGET not set`); return; } foreach (target; targets) { doRedo(target); string redoTarget = environment.get("REDO_TARGET", ""); if (!upToDate(target, redoTarget)) { genChangeSum(target, redoTarget); } } break; case "redo-ifcreate": if (environment.get("REDO_TARGET", "") == "") { error(`REDO_TARGET not set`); return; } foreach (target; targets) { string redoTarget = environment.get("REDO_TARGET", ""); if (target.exists) { warning(format(`%s exists but should be created`, target)); } doRedo(target); if (target.exists) { genCreateSum(target, redoTarget); } } break; default: foreach (target; targets) { environment["REDO_TARGET"] = target; doRedo(target); } break; }
Что тут происходит ? Если, допустим, файл этой программы переименовать в redo-ifchange, и этот файл будет запущен в ходе работы точно такого же файла программы, но с именем redo, то произойдет считывание переменных окружения и если они не содержат упоминание цели, то произойдет ошибка; в противном случае, цели будут прочитаны из переменных окружения и если зависимости для цели устарели, то они будут обновлены. Аналогично работает и redo-ifcreate, но вместо обновления зависимостей происходит их создание.
Как же с утилитой работать ?
Очень просто. Компилируем исходный текст в исполняем файл и называем его redo, а потом создаем на него символические ссылки с именами redo-ifchange и redo-ifcreate:
ldc2 -release redo.d ln -s redo redo-ifchange ln -s redo redo-ifcreate
главное, чтобы сама redo и ссылки на нее находились по пути который есть в текущей переменной окружения PATH. А далее создаем некоторый проект в некоторой папке и в ней создаем do-файл с именем проекта, после чего запускаем redo. К примеру, сама утилита в нашей реализации собиралась вот таким do-файлом с именем redo.do:
redo-ifchange redo.d ldc2 -release -Os --boundscheck=off redo.d -of $3 strip $3
После чего запускалась команда redo с именем проекта (в нашем случае — redo):
redo redo
И выглядело это вот так:

Полный код утилиты доступен здесь, а если вас заинтересовала сама система redo, то вот небольшой список материалов для ознакомления:
- Описание redo от автора идеи
- redo: a recursive, general-purpose build system
- redo на C
- наша первая попытка создать redo, порт проекта по ссылке выше, выполнен на BetterC
- Make на мыло, redo сила
P.S: Код нашей версии основан на коде, взятом из реализации redo на bash, а первоначальная версия порта уже на D (от нашей команды), находится тут.