Delphi 7 на костылях: автоматизация подготовки ресурсов
Эпиграф: «Пусть это вдохновит Вас на подвиг!» (Бел Кауфман, «Вверх по лестнице, ведущей вниз»).
О костылях и велосипедах, неотъемлемой части современной некромантии.
Это история интеграции в процесс разработки одного единственного решения. Решение доведено до конечного результата, ссылка на репозиторий будет далее по тексту.
Казалось бы, что может быть более простым и естественным, чем прозрачная, без «нелепых телодвижений», работа с размещёнными в специально выделенной для этого папке ресурсами? А если среда разработки выпущена на границе тысячелетия?
Первым великим неудобством, с которым я столкнулся, «подписавшись» года четыре назад на сопровождение и активную доработку проекта на Delphi 7, было категорическое неудобство работы с входящими в проект относительно крупными SQL запросами. Проект обеспечивает отчётность перед поставщиками (десятки поставщиков, взаимодействие с которыми идёт через множество компаний-интеграторов), и этих запросов там, что гуталина у сторожа — «ну просто завались». Причём запросы эти изначально описывались прямо в тексте, вперемешку с кодом… представьте мой восторг с учётом того, что запросы приходилось время от времени переносить в SSMS, исправлять и переносить обратно. А если вспомнить, что и Delphi, и в SQL используются одиночные кавычки, становится ещё печальнее.
Первая реакция на эту «красоту» была вполне предсказуемой: срочно отделить данные от кода! Идеальным (и очевидным) решением кажется создание структуры папок с файлами запросов, которые при компиляции автоматически попадали бы в ресурсы с соответствующими идентификаторами. При этом, однако, компилятору нужно явно предоставлять список ресурсов в виде отдельно сформированного *.rc файла с соответствующими именами для доступа, который надо ещё предварительно сформировать.
Однако, компиляция проекта из-под IDE Delphi 7 является чёрным ящиком без малейшей возможности прикрутить к ней хоть что-то своё. У неё просто нет ни одного хука, чтобы зацепить собственный обработчик. Современные версии работают с MS Build, но у меня-то этого нет! Конечно, для сборки продуктивной версии можно использовать батник и компилятор командной строки, где можно добавить любую предварительную обработку, но для запуска из-под IDE этот вариант не годится.
Ещё одна печалька оказалась в том, что файл ресурсов (*.rc) перекомпилируется только тогда, когда изменилась его собственная дата. То, что изменилась дата файла, на который он ссылается (то есть сам ресурс), компилятор не волнует никак. Плюс rc-файл ещё и создать надо! И очень, очень хочется делать это автоматически.
Но сначала появился костыль. До этого я работал с Дельфи лет восемь назад, и с ресурсами тогда совсем не сталкивался, зато свободно делал компоненты. Итак, для решения вопроса «по-быстрому» я набросал визуальный компонент, который можно было кинуть на форму и открыть текст в редакторе (в окончательной версии - вообще двойным кликом). Набросок был готов в течение пары часов, затем после нескольких подходов компонент был окончательно оформлен и разделён на две части (просто текст и SQL) ради синтаксической подсветки в редакторе кода. Ниже, на скриншоте, Вы можете увидеть аж четыре компонента (два из которых успели активно поработать), но… Проект, хотя и обеспечил очень быстро возможность по-человечески работать с текстом запросов, был заброшен в силу присущих ему принципиальных недостатков.
Первым и самым важным из них оказался формат хранения контента в .dfm файле — «почти текстовый». Текст принудительно разбивался на строки, закавычивался, все символы не из базовой латиницы записывались многосимвольными кодами… Представляете себе, как выглядели при этом диффы? Невозможным оказывался также контекстный поиск по всему проекту (тексты запросов в него не попадали). Поиск сторонними средствами по латинице мог споткнуться о размер строки и её принудительный перенос посреди слова.
object SqlSALES: TSqlVar
SQL.Strings = (
'SET NOCOUNT ON'
''
'IF OBJECT_ID(N'#39'tempdb..#Sales'#39', N'#39'U'#39') IS NOT NULL '
'DROP TABLE #Sales;'
''
'IF OBJECT_ID(N'#39'tempdb..#ClientIDs'#39', N'#39'U'#39') IS NOT NULL '
'DROP TABLE #ClientIDs;'
''
'-- '#1055#1072#1088#1072#1084#1077#1090#1088#1099': '#1076#1072#1090#1072' '#1086#1090', '#1076#1072#1090#1072' '#1076#1086', '#1075#1088#1091#1087#1087#1072
'-- '#1056#1077#1072#1083#1080#1079#1072#1094#1080#1103
'SELECT '
#9'OperDate, -- '#1044 +
#1072#1090#1072
...
На это накладывался ещё один немаловажный недостаток, присущий самописным компонентам Дельфи в принципе: компонент не может быть зарегистрирован на уровне проекта, его надо явно и заранее устанавливать в IDE, но далеко не все это умеют. Если открыть проект в среде без соответствующих компонентов, то они, после гневного сообщения просто пропадут. В наше время, с сокращением количества опытных кадров, не гнушающихся работать с древнючей Delphi, это выглядит, как натуральная бас-факторная мина. Хотя и без того там этих мин — хоть ведром черпай...
Еще одна существенная проблема заключалась в невозможности использовать для редактирования при таком подходе сторонние редакторы.
С учётом перечисленного, проект с компонентами замер на полушаге из-за потери целесообразности, а я начал исследовать возможность всё-таки перейти на использование текстовых файлов с автоматическим добавлением их в ресурсы.
Итак, я вернулся к идее хранить отдельные запросы в файлах. Предстояло решить следующие проблемы:
Должен автоматически формироваться файл со списком ресурсов для компилятора
ВАЖНО!!! изменение файла с запросом должно автоматически попасть в программу, при первой же компиляции (даже если не делать build)
Обеспечить включение ресурсов в операцию контекстного поиска по проекту.
Опционально — ещё и шифровать для пущего пафоса. Требование проекта на уровне «Очень желательно».
По первому пункту я решил использовать Gulp.js — инструмент для сборки фронтенда, с которым мельком удалось познакомится незадолго до этого. Он умеет следить за изменением файлов в папке и обрабатывать это событие. Мне требовалась лишь возможность запустить по событию командный файл. Этот же файл используется и для билда продуктивной версии.
Обеспечение второго пункта разложилось на две половинки. Во-первых, при изменении исходных файлов требовалось сразу же, не дожидаясь перестроения списка, изменить дату .rc файла. Это инициирует запрос на перезагрузку файла при переключении в Delphi. Во-вторых, этот файл должен быть постоянно открыт в IDE, иначе компилятор не обращает внимания на то, что он изменился (он-то, наивный, предполагает, что я всё через IDE делаю). А так — при переключении извне в IDE дата файла проверяется и задаётся вопрос о необходимости его перезагрузить.
Третий пункт (контекстный поиск) решается добавлением исходного файла в проект, до первого USES, в директиве компилятора вида:
{%File 'Res\SRC\SQL\Import\Sectors\leafSectors.sql'}
Четвёртый решается элементарно запуском отдельной утилитки для шифрования всё в том же пакетном файле, и расшифровкой непосредственно в основном проекте при обращении к ресурсу.
Ну, и собственно список файлов сформировать надо было. В результате шапка проекта стала выглядеть примерно так:
SomeProject.dpr
library SomeProject;
{
Текст между тегами ниже генерируется и обновляется автоматически!
Этот фрагмент нужен для подключения исходных файлов ресурсов к поиску
текста по файлам проекта.
Для разового обновления запустить res/CompileRc/CompileAllResources.cmd
Для автоматического обновления запустить _Автообновление ресурсов.cmd
Пока работает автообновление, после каждого изменения исходных файлов в папке res\src
автоматически запускается res/CompileRc/CompileAllResources.cmd
Чтобы среда разработки автоматически подтягивала изменения, в ней должен быть
открыт в редакторе исходник 'Res\AutoGenerated.rc'. <= Можно поместить
курсор куда-нибудь сюда -----^^^^^^^^^^^^^^^^^^^^ и нажать Ctrl + Enter
}
{<AUTOGENERATED_RC>}
{%File 'Res\SRC\SQL\DB_Updates.sql'}
{%File 'Res\SRC\SQL\Foo\Sales.sql'}
{%File 'Res\SRC\SQL\Foo\Stocks.sql'}
...
{$R 'Res\AutoGenerated.res' 'Res\AutoGenerated.rc'}
{</AUTOGENERATED_RC>}
uses
...
AutoGenerated.rc (пример):
SQL_DB_Updates RCDATA PREPARED\SQL_DB_Updates
SQL_Foo_Sales RCDATA PREPARED\SQL_Foo_Sales
SQL_Foo_Stocks RCDATA PREPARED\SQL_Foo_Stocks
...
Галпфайл был простой до безобразия:
gulpfile.js
const { watch, series } = require('gulp');
const spawn = require('child_process').spawn;
function compileResources(cb) {
var cmd = spawn('CompileRc\\CompileAllResources.cmd', [], {stdio: 'inherit'});
cmd.on('close', function (code) {
cb(code);
});
}
exports.default = function() {
compileResources(code=>{})
watch('Src/**', compileResources); // series(compileResources, ...)
};
Простота связана с тем, что вся процедура подготовки ресурсов должна быть независимой от монитора изменений, и запускаться отдельно (в том числе при сборке приложения средствами командной строки). Галп только взял на себя запуск отдельно созданного и отлаженного пакетника по событию изменения исходных файлов.
Кстати, пришлось его радикально править, когда пришлось обновить Галп после обновления Ноды. Текущие версии у меня такие:
>gulp --version
CLI version: 2.3.0
Local version: 4.0.2
>node -v
v12.20.0
Впрочем, это уже не важно — от Галпа я буквально в процессе написания статьи избавился.
Основной пакетник:
сразу же меняет дату сгенерированного файла
составляет список файлов в обслуживаемой папке, за вычетом исключений
запускает программку на Perl, которая преобразует сырой список файлов во все виды, в которых он употребляется, в том числе генерирует rc-файл, и формирует задание на подготовку (шифрование) для изменённых файлов
запускает подготовку ресурсов
выделяет из dpr ранее добавленный туда список ссылок. Если он изменился, то заменяет его
компилирует сформированный rc-файл
Информирует о времени запуска и завершения и об обслуживаемой папке (на случай одновременного запуска нескольких экзкмпляров.
И всё это запускается из отслеживающего изменения монитора.
CompileAllResources.cmd
@Echo off
set BatchDir=%~dp0
cd %BatchDir%
touch ..\AutoGenerated.rc
FOR %%i IN ("%BatchDir%..") DO (set target=%%~fi)
echo.
echo [%TIME%] STARTING: ===== %target% =====
echo.
if not exist %BatchDir%..\prepared\*.* md %BatchDir%..\prepared > nul
call %BatchDir%..\AutoCompileRc.Config.cmd
FOR %%i IN ("%DprFile%") DO (set DprFileOnly=%%~nxi)
call %BatchDir%WaitWhileRunned.cmd git
cd %BatchDir%..
%BatchDir%bin\find SRC -type f | %BatchDir%bin\grep -E -v --file=%BatchDir%excludes.lst>%BatchDir%ResFiles.lst
%BatchDir%bin\perl %BatchDir%CreateRc.pl %BatchDir%ResFiles.lst AutoGenerated.rc %BatchDir%RcSources.lst %BatchDir%PrepareIt.cmd
cd %BatchDir%
echo {$R 'Res\AutoGenerated.res' 'Res\AutoGenerated.rc'}>>RcSources.lst
call between.cmd %DprFile% "\{<AUTOGENERATED_RC>\}\s*" "\{<\/AUTOGENERATED_RC>\}">RcSourcesOld.lst
fc RcSources.lst RcSourcesOld.lst>nul
if errorlevel 1 (
call ReplaceBetween.cmd %DprFile% RcSources.lst "\{\<AUTOGENERATED_RC\>\}\s*" "\{\<\/AUTOGENERATED_RC\>\}">%DprFileOnly%.tmp
copy %DprFileOnly%.tmp %DprFile%>nul
del %DprFileOnly%.tmp
)
echo Preparing updated and new files...
cd ..
call %BatchDir%PrepareIt.cmd
echo Compiling resources...
brcc32 AutoGenerated.rc
cd %BatchDir%
echo.
echo [%TIME%] DONE: ===== %target% =====
echo.
Внутри он содержит сборную солянку технологий, использует линуксовские find и grep (под Виндой они ставятся вместе с git) и даже Перл. Каюсь, побаловаться захотелось. Забавный опыт, хотя и немного травматичный. Своей лаконичностью и непрозрачностью (вроде наличия «переменной по умолчанию») он напомнил мне ассемблер:
CreateRc.pl
#!c:/Perl/bin/perl
open (IN, "<".$ARGV[0]) || die $!; # Список исходных файлов для ресурсов
open (RC_ENC, ">".$ARGV[1]) || die $!; # Формируемый исходник (.rc)
open (LST, ">".$ARGV[2]) || die $!; # Формируемый файл со списком
# исходников, который будет вставлен в проект между тегами
# {<AUTOGENERATED_RC>} и {</AUTOGENERATED_RC>}
open (ENC_CMD, ">".$ARGV[3]) || die $!; # Формируемый пакетный файл (.cmd)
# для подготовки каждого изменённого файла (например, щифрование)
while(<IN>){
split /\n/;
$File = $_; # Для каждого из исходных файлов
$File =~ s/\//\\/g; # - приводим разделитель каталогов
$File =~ s/\n//; # - исключаем перевод строки
$Name = $File; # Имя ресурса
$Name =~ s/^Src\\//i; # - исключаем префикс (папка)
$Name =~ s/\\/_/g; # - вместо разделителя каталогов — подчёркивание
$Name =~ s/\..*$//; # - исключаем расширение
$Dest = "PREPARED\\".$Name."";
$_ = $File;
$EncryptIt = ! /\\Bin\\/i; # Ресурсы из папок и подпапок bin не шифруем
print RC_ENC $Name
, substr(" ", 1, 40 - length($Name))
, " RCDATA "
, $Dest
, "\r\n";
$_ = $File;
if (! /\\Bin\\/i) {
# Добавляем в проект текстовые ресурсы (здесь: формируем вставку в dpr файл)
# Ресурсы из папок и подпапок bin не считаем текстом и в проект не включаем
print LST
"{\%File 'Res\\"
, $File
, "'}\r\n";
}
# Только для новых или обновлённых файлов: добавляем команду на подготовку
# (шифрование либо просто копирование)
if (! (-f $Dest) || ( (stat $File)[9] > (stat $Dest)[9] ) ) {
if ($EncryptIt) {
# Шифрование: encrypt.cmd <источник> <приёмник> <имя ресурса в нижнем регистре>
# имя ресурса может быть использовано для генерации пароля.
# Еncrypt.cmd определяете самостоятельно.
print ENC_CMD "call encrypt.cmd "
, $File
, substr(" ", 1, 40 - length($File))
, " "
, $Dest
, " \""
, lc $Name
, "\"\r\n";
} else {
print ENC_CMD "copy "
, $File
, substr(" ", 1, 40 - length($File))
, " "
, $Dest
, ">nul\r\n";
}
}
}
close IN;
close RC_ENC;
close LST;
close ENC_CMD;
В процессе многолетней эксплуатации вылезли забавные особенности. Для примера, обновление git (с последующим обновлением линуксовских утилит) как-то раз мою автоматизацию сломало. Конкретно, более новый grep отказался воспринимать список исключений как список регэкспов, по одному на строку. Find тоже что-то такое подбрасывал (по крайней мере, тот, что установлен глобально сейчас, уже не отрабатывает именно так, как ожидается). В результате пришлось зафиксировать их версию — банально кинуть бинарники в репозиторий, чтобы потом не плакать, благо, они не шибко большие, и изменять я их не планирую. Было ли что-то подобное в связи с Перлом — не помню, но на всякий случай и его туда же пихнул.
Помню ещё, что добавление шифрования вынудило запоминать дату изменения файлов и шифровать только обновлённые, иначе просто долго получалось — скрипт не успевал отработать до того, как я отвечал согласием на предложение перезагрузить исходные файлы в IDE.
И уже после того, как взялся за эту статью, решил всё-таки избавиться от Галпа. Зачем мне (или тому, кто попробует это за мной повторить) неконтролируемая глобальная внешняя зависимость, имеющая ещё одну неконтролируемую внешнюю зависимость (я по node.js), и из возможностей которой используется откровенный мизер?
Результат — проект FolderMonitor, написанный на Delphi (https://bitbucket.org/danik-ik/foldermonitor/src/master/). Собственно, именно он прописан как монитор по умолчанию в инсталляторе предыдущего проекта (да, я не только сделал из этого отдельный проект, но даже сдалал инсталлятор в виде пакетного файла, см. https://bitbucket.org/danik-ik/compilerc/src/master/README.md).
Инсталлятор проекта CompileRc работает с использованием git. Он создаёт ветку в репозитории проекта и добавляет туда необходимые модули и настроечные файлы. Вместо шифрования по умолчанию используется заглушка (копирование), что позволяет добавлять ресурсы «как есть». Вот скриншот репозитория тестового проекта в SmartGit, после добавления в него CompileRc (все три коммита ветки CompileRc, как и сама ветка, сформированы инсталлятором), запуска монитора и добавления нескольких файлов ресурсов (в рабочем дереве):
В основу монитора изменений был положен широко известный в пример, с некоторыми доработками по результатам эксплуатации. Во что он там обёрнут, можете глянуть в репозитории, если интересно, там кода — всего ничего. Консольное приложение, в режиме ожидания управляется с клавиатуры (пуск/пауза/принудительный запуск).
Основа монитора:
(******************************************************************************
Ожидание (в отдельном потоке) изменений в папке, формирование
соответствующего события.
На основании широко известного примера:
https://webdelphi.ru/2011/08/monitoring-izmenenij-v-direktoriyax-i-fajlax-sredstvami-delphi-chast-1/
Проверяются события:
- изменение имени файла или папки
- изменение размера
- изменение времени последней записи
******************************************************************************)
unit FolderMonitorCore;
interface
uses Classes, Windows, SysUtils;
type
TFolderMonitorCore = class(TThread)
private
FDirectory: string;
FScanSubDirs: boolean;
FOnChange : TNotifyEvent;
procedure DoChange;
public
constructor Create(ASuspended: boolean; ADirectory:string; AScanSubDirs: boolean);
property OnChange: TNotifyEvent read FOnChange write FOnChange;
protected
procedure Execute; override;
end;
implementation
{ TFolderMonitorCore }
constructor TFolderMonitorCore.Create(ASuspended: boolean; ADirectory: string;
AScanSubDirs: boolean);
begin
inherited Create(ASuspended);
FDirectory:=ADirectory;
FScanSubDirs:=AScanSubDirs;
FreeOnTerminate:=true;
end;
procedure TFolderMonitorCore.DoChange;
begin
if Assigned(FOnChange) then
FOnChange(Self);
end;
procedure TFolderMonitorCore.Execute;
var ChangeHandle: THandle;
begin
// инициируем ожидание изменений, получаем соответствующий хэндл
ChangeHandle:=FindFirstChangeNotification(PChar(FDirectory),
FScanSubDirs,
FILE_NOTIFY_CHANGE_FILE_NAME+
FILE_NOTIFY_CHANGE_DIR_NAME+
FILE_NOTIFY_CHANGE_SIZE+
FILE_NOTIFY_CHANGE_LAST_WRITE
);
// Проверяем корректность инициации, иначе выбрасывается исключение
{$WARNINGS OFF}
Win32Check(ChangeHandle <> INVALID_HANDLE_VALUE);
{$WARNINGS ON}
try
// выполняем цикл пока не получим запрос на завершение
while not Terminated do
begin
{ Важное отличие от оригинального примера: ожидание НЕ бесконечно,
периодически проверяется флаг выхода }
case WaitForSingleObject(ChangeHandle, 1000) of
WAIT_FAILED: Terminate; {Ошибка, завершаем поток}
WAIT_OBJECT_0: // Дождались изменений
begin
// Задержка — страховка от повторной реакции на несколько изменений подряд
// в процессе единственного сохранения
// (при сохранении исходника в Sublime Text выскакивало стабильно).
// Проблема была следствием ключевого правила: если изменение происходит
// ПОСЛЕ запуска приложения, то по окончании приложение запускается повторно.
// Величина задержки подобрана пальцем в небо.
sleep(5);
WaitForSingleObject(ChangeHandle, 1); // изменение уже было, поэтому результат не проверяется
// Ещё одно отличие: завершение имеет приоритет перед событием изменения
if not Terminated then
Synchronize(DoChange);
end;
WAIT_TIMEOUT: {DO NOTHING}; // идём на следующий круг,
// либо завершаем по условию цикла
end;
FindNextChangeNotification(ChangeHandle);
end;
finally
FindCloseChangeNotification(ChangeHandle);
end;
end;
end.
Итог
На сегодня работа в ресурсами выглядит следующим образом. Запросы хранятся в отдельных файлах и сгруппированы по папкам. Когда я собираюсь их править, запускаю монитор. Всю папку с исходниками открываю в редакторе (например, VS Code). Исправляю то, что надо, принудительно сохраняю (если полагаться на автосохранение при потере фокуса, и переключиться из редактора прямо в Delphi, то изменения не успеют дойти до rc-файла). В Delphi заранее и всегда открыт AutoGenerated.rc. Когда я переключаюсь в Delphi, дата rc-файла уже изменена монитором, и среда задаёт запрос на его перезагрузку. Любая последующая компиляция подхватывает произведённые изменения с первого раза. То есть, если упростить, то при запущенном мониторе: изменил исходный запрос (или что там в ресурсах лежит) → сохранил → переключился в Delphi → Reload? Yes! → запустил. При этом риск запуска с устаревшим вариантом ресурса практически отсутствует.
На мой взгляд, всё это «преодоление» позволило вопреки всем ретронеудобствам поддерживать постоянный интерес к работе, качественно разбавляя рутину и не позволяя затосковать по причине отсутствия каких-нибудь модных перламутровых пуговиц. Больше скажу, когда подобных вызовов стало меньше, а рутинные операции многократно ускорились (в смысле трудозатрат), даже как будто чего-то не хватать стало. Наверное, мне просто сложно работать на всём готовом.
Так что если Вы вдруг оказались на должности штатного некроманта — просто помните о том, что не боги горшки обжигают, и у Вас есть шансик сделать себе хорошо и нескучно.
P.S.
Если честно, хотел в первую очередь рассказать о параметризованных модульных тестах и об автоматизации их запуска, но вот вылезла именно эта тема, не отвертишься: «пиши меня», и всё тут.
UPD:
Меня спрашивали в личку про скомпилирванный FolderMonitor. Если нечем скомпилировать, можно взять здесь: https://disk.yandex.ru/d/VTbuGvB5jabD6w
Возможно, осталось за кадром: данное решение только генерирует валидный для Delphi код и автоматизирует рутину: своевременно добавляет файл ресурса к списку на компиляцию и к списку внешних файлов проекта, и своевременно компилирует rc в RES (средствами Delphi). Всё. Шифрует ещё в конкретном проекте, но это как раз излишество в общем случае. Запуская его под монитором изменений я перевожу работу с ресурсами из состояния статики («созданы раз и навсегда») в динамику («редактирую на лету»).
UPD2:
В связи с вновь открывшимися обстоятельствами (спасибо @DrPassза комментарий https://habr.com/ru/post/550020/#comment_22874706) у меня появилась возможность подцепить кодогенерацию на событие «BeforeCompile» IDE. Ранее найти информацию по этому вопросу мне просто не удавалось. Воистину, знание, что (или где, или как) конкретно искать — одно из самых важных в профессии разработчика :). Думаю, что в ближайшее время (по мере этого времени наличия) сделаю и опубликую плагин для Delphi, который будет по событиям BeforeCompile и AfterCompile выполнять лежащие рядом с проектом батники (вроде «{{dprpath}}compilerEvents\before.cmd», при их наличии, разумеется), отображая интерактивно и одновременно логируя результат выполнения. Макет из глины и палок уже практически заработал.
UPD3:
Испробовав на практике подготовку ресурсов по событиям компилятора вместо мониторинга изменений исходных файлов. К сожалению, не удалось (пока?) спровоцировать компиляцию тогда, когда исходники ресурсов изменились, а IDE считает, что ничего не менялось и вообще не запускает компиляцию, вместе с соответствующими событиями. Поэтому приведённое в статье решение так и осталось в строю.