В этой достаточно сложной статье мы покажем как своими руками написать утилиту, которая позволит собирать сложные проекты со множеством файлов и которая не зависит от выбранного вами языка программирования. Утилита, которую мы опишем далее, называется 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 (от нашей команды), находится тут.