makesure — make с человеческим лицом

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Многие ли из вас используют всевозможные вспомогательные shell-скрипты в своих проектах? Это также могут быть Python или Perl скрипты. Обычно такие скрипты используются на этапе сборки или для других задач автоматизации проекта.


Примерами таких задач могут служить:


  • вспомогательные скрипты для Git,
  • запуск тестов/линтеров,
  • запуск необходимых докер контейнеров,
  • запуск БД-миграций,
  • собственно, сборка проекта,
  • генерация документации,
  • автоматизация публикации релизов,
  • развертывание и т.д.

Впрочем, часто для подобных целей используют системы сборки.
Make — пожалуй, наиболее известный из подобных инструментов.
Похожий функционал известен разработчикам nodejs и любим ими в виде скриптов в package.json (npm run-scripts). Ветераны Java вспомнят Ant.


Но nodejs/Ant требуют установки, make хоть и способен выполнять функции task runner довольно неудобен в этой роли, будучи на самом деле очень олдскульным build tool со многими вытекающими "особенностями".
А shell-скрипты требуют некоторой системы и неизбежной рутины в написании (обработка аргументов, help-сообщения и т.д.).
Хотя, например, Taskfile представляет прекрасный шаблон для подобных скриптов.


Так и родился makesure.


Что это? Это инструмент, который может работать с файлом Makesurefile такого вида:


@goal downloaded
@doc downloads code archive
@reached_if [[ -f code.tar.gz ]]
  wget http://domain/code.tar.gz

@goal extracted
@depends_on downloaded
  tar xzf code.tar.gz 

@goal built
@doc builds the project
@depends_on extracted
  npm install
  npm run build

@goal deployed
@doc deploys the built project
@depends_on built
  scp -C -r build/* user@domain:~/www

@goal default
@depends_on deployed

По сути это поименованные кусочки shell (называемые целями), объединённые в одном файле. Это позволяет легко перечислить цели (с пояснительным текстом):


$ ./makesure -l
Available goals:
  downloaded : downloads code archive
  extracted
  built      : builds the project
  deployed   : deploys the built project
  default

и вызвать любую из них по имени:


$ ./makesure deployed
$ ./makesure                  # по умолчанию будет исполнена цель с именем default

Да, вот так просто.


Впрочем, это ещё не все. Цели могут декларировать зависимости от других целей и makesure будет учитывать это при выполнении. Это поведение весьма близко к оригинальному make. Цель также может декларировать условие того что она уже достигнута. В этом случае тело цели (соответствующий shell скрипт) уже не будет исполняться. Этот простой механизм позволяет очень удобно и декларативно выражать идемпотентную логику работы, а, проще говоря, ускорять сборку, так как то, что уже выполнено, не будет выполняться повторно. Эта фича уже была навеяна идеями из Ansible.


Это было вступление. Я же хотел сосредоточиться в этой статье на освещении процесса дизайна одной из фич этого инструмента.


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


Ну, что-то типа


@define VERSION='1.2.3'
@goal built
  echo "Building $VERSION ..."
@goal tested
  echo "Testing $VERSION ..."

Изначально я хотел спроектировать эту часть в наиболее общем виде. Так, я решил, что в инструменте будет понятие prelude — это скрипт, идущий перед всеми декларациями @goal. Целью этого скрипта будет инициализация глобальных переменных. Ну какой-нибудь такой гипотетический пример


# prelude starts
if [ -f version.txt ]
then
  @define VERSION=`cat version.txt`
else
  @define VERSION='0.0.1'
fi
# prelude ends
@goal built
  echo "Building $VERSION ..."

Идея была в том чтоб где-то приблизиться по функционалу к make, не вводя при этом отдельный язык программирования, а использовать знакомый shell.


Пару отягчающих моментов. Во-первых, следует учесть, что под капотом каждый из @goal-скриптов выполняется в отдельном процессе shell. Сделано это намеренно, чтобы исключить возможность зависимостей через глобальные переменные между целями, что может сделать логику исполнения более императивной и запутанной. make в этом смысле ведет себя подобным образом, а точнее еще "хуже" — там каждая строка исполняется в отдельном shell.


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


@define VERSION="$(curl -s http://domain/version.txt)"

В-третьих, должна иметься возможность переопределить значение переменной в момент запуска, например так


./makesure built -D VERSION=0.0.2 

Первый и второй моменты несколько плохо сочетаются, исключая простую возможность подмешивания скрипта prelude в начале каждого @goal-скрипта как модели исполнения.


В результате решение было все-же найдено. Каждое вхождение defile VAR=val под капотом заменялось на что-то типа VAR=val; echo "VAR='$VAR'" >> /tmp/makesure_values, а в начало каждого @goal-скрипта неявно добавлялось . /tmp/makesure_values.
Были некие дополнительные нюансы, связанные с реализацией третьего пункта, но они не слишком существенны для упоминания.


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


По поводу скорости, на системах где присутствует /dev/shm (все современные Линуксы?) он был использован вместо /tmp. macOS — ☹️ — там это не поддерживается.


По поводу гарантии подчистки временных файлов — набор тестов был доработан таким образом, чтоб падать, если вдруг по какой-то причине мусор не был удалён.


Как это обычно бывает, свежий взгляд со стороны от человека, ранее не вовлеченного в дело, может быть весьма ценен.
Так, в какой-то момент мне поступил pull-request с предложением по оптимизации этой части логики. Участник предложил применить более простую логику без временных файлов. Некоторое время я был в недоумении. Как я сам не додумался до этого решения раньше? Однако, погрузившись в некоторые воспоминания, я понял, что мой вариант решения был неслучаен.


Дело в том, что по моему замыслу должна была быть возможность делать так


A=Hello                # invisible to goals
@define B="$A world"   # visible to goals

По моей задумке, достигается это тем, что в файл /tmp/makesure_values попадают уже "вычисленные" @define-значения.
И это принципиально не работает в предложенном участником способе.


Каково же было моё удивление, когда я обнаружил, что этот кейс не работает и с моей имплементацией!


Первым моим побуждением было устранить эту проблему и покрыть этот случай недостающими тестами.


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


А что если вообще удалить концепцию prelude как произвольного скрипта перед целями? Оставить только @define?
Почему нет? Ведь less is more, а worse is better.


Вот несколько мыслей, которыми я руководствовался:


  • Эта фича нешироко используется (или вообще не используется) и имеет баги реализации
  • Мы еще не знаем как правильно использовать этот функционал. Возможно его неправильное использование/злоупотребление им.
  • Вносит неопределенность. Если необходима такая сложная логика инициализации, почему бы не использовать для этого отдельную цель @goal initialized?
  • Усложняет реализацию и делает её менее производительной за счет использования временных файлов.

Да и вообще при разработке продукта или библиотеки очень важно реализовывать минимально возможный функционал, и именно тот который необходим пользователям сейчас. Весьма часто разработчики поддаются соблазну добавить какие-то очевидные улучшения и функции, которые не критически важны и/или избыточны, просто потому что это кажется простым. Более того, по этой же причине часто полезно явно исключить определенные фичи/сценарии использования. Потому что вы всегда можете их добавить позже, если будет явный запрос от пользователей. Удалить же какую-то неудачную фичу может быть гораздо более проблематично.


Решено. Урезаем концепцию prelude, оставляем лишь возможность @define.


Однако на этом вопросы не заканчиваются.


  • Возможно также есть смысл переработать синтаксис:
    • @define VAR='hello' (как сейчас) vs
    • @define VAR 'hello' (более консистентно с синтаксисом остальных директив)
    • Разрешить или запретить строки с двойными кавычками? Другими словами, хотим ли мы поддерживать подстановку переменных:
      • @define W=world
      • @define HW="Hello $W!"
  • Реализация
    • pass-through в shell (как сейчас)
      • Гибкость с подстановкой переменных, но труднее с валидацией
    • или ручной парсинг
      • Сложнее в реализации, но больший контроль в валидации неинициализированных переменных; если нужно, можем запретить функции shell, например @define A="$(curl https://google.com)"

Дело в том, что текущая реализация, как уже говорилось выше, основана на, буквально, передаче всего что идёт после слова @define на исполнение в shell. А это значит, что можно написать


@define echo 'Hello'

и оно не выдаст ошибку, но сделает какую-то несанкционированную ерунду.


Если попытаться добавить простенькую регулярку на соответствие VARNAME=, то и это легко обойти


@define A=aaa echo 'Hello' # будет вызвана команда echo у которой задана переменная окружения A

Естественно, хотелось бы запретить такие "возможности".


Имеем дилемму. Либо же отказываемся от передачи в shell и добавляем ad-hoc парсер этой директивы или же имеем что имеем.


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


Знаете ли вы сколькими способами в Bash можно определить переменную со значением hello world?


H=hello\ world
H='hello world'
H=$'hello world'
H="hello world"
W=world
H="hello $W"
H=hello\ $W
H='hello '$W
H='hello'\ $W
H=$'hello '"world"
H='hello'$' world'
H=$'hello'\ $' world'
H='hello'$' '"world"
H='hello world';
H="hello world" # with comment
H=$'hello world'     ;            # with semicolon, spaces and comment
# и т.д.

И это еще далеко не все варианты!


Почему дополнительная сложность реализации неприемлема? Потому что один из фундаментальных принципов, которые я положил в основу этого инструмента, это worse is better. Это значит что простота реализации и минимальный размер утилиты более предпочтительны, чем богатая функциональность.


Вы можете спросить: а зачем вообще полагаться на синтаксис bash? Почему бы не ввести свой ограниченный синтаксис, скажем, как-нибудь так:


@define W  'world'
@define HW 'hello {{W}}'

Идея заманчива, но не лишена недостатков, так как привносит усложнение ментальной сложности инструмента.
Дело в том, что инструмент спроектирован таким образом, что его синтаксис полностью укладывается в синтаксис shell. Это чрезвычайно удобно, так как вы можете выбрать в своей IDE подсветку shell для Makesurefile и это будет работать! Но это также значит, что необходимо, чтобы все синтаксические конструкции несли тот же смысл, что и в shell. Очевидно, что логика подстановки значений в гипотетическом облегченном синтаксисе не соответствует модели shell и это придется дополнительно знать пользователю.


Вообще убрать возможность подстановки переменных было бы тоже вариантом. Но оказалось, что те немногие, кто уже использует makesure, включая меня самого, уже полагаются на эту возможность.


Результатом тягостных раздумий явилось компромиссное решение. Мы по-прежнему передаём строку на исполнение в shell, но перед этим валидируем её бережно написанной регуляркой. Да, я знаю, что парсить регулярками нельзя. Но мы и не парсим! Мы только отсекаем невалидные варианты, а парсит shell. Интересный момент. На самом деле, эта регулярка более строгая, чем парсер shell:


@define VERSION=1.2.3    # makesure won't accept
@define VERSION='1.2.3'  # OK

@define HW=${HELLO}world    # makesure won't accept  
@define HW="${HELLO}world"  # OK  

Что я нахожу даже плюсом, т.к. это более консистентно.


В остальном эта директива хорошо покрыта тестами — как то, что должно парситься, так и то, что не должно.


Подытожим. Спроектировали фичу. Потом перепроектировали, при этом смогли упростить и уменьшить код, ускорить его и при этом добавить дополнительные проверки.


На этом, пожалуй, стоит остановить свое повествование.


Заинтересовавшихся приглашаю опробовать утилиту makesure в своих проектах.
Тем более, что она не требует инсталляции (как это?) и хорошо портабельна.

Источник: https://habr.com/ru/post/599137/


Интересные статьи

Интересные статьи

Рано или поздно, каждый пэхапешник, пишущий на битриксе, начинает задумываться о том, как бы его улучшить, чтобы и всякие стандарты можно было соблюдать, и современные инструменты разработки использов...
Мы продолжаем разбирать варианты нагрузочных тестов. В этой статье мы разберем методику тестирования и проведем нагрузочный тест, с помощью которого попытаемся определить число пользователей, которые ...
Микроконтроллеры, например, те, что работают на базе RP2040 от Raspberry Pi, отлично подходят для любых проектов по созданию роботизированных устройств. Один из лучших вариантов — C...
Всем, всем, всем, преподающим информатику детям лет 10 — 14! По ссылке доступен русский перевод курса «Введение в информатику с MakeCode для Minecraft». По ссылке страница курса у вас скор...
Как обновить ядро 1С-Битрикс без единой секунды простоя и с гарантией работоспособности платформы? Если вы не можете закрыть сайт на техобслуживание, и не хотите экстренно разворачивать сайт из бэкапа...