Однажды я был маленьким, и задавался вопросом — вот если Unix way это (упрощенно) небольшие, довольно простые утилиты и библиотеки, которые делают одну вещь, но делают её хорошо (Peter H. Salus: "...that do one thing and do it well"), то… Где тогда утилита, которая занимается шаблонизацией и не хватает звёзд с неба? Вот есть у тебя некоторый шаблон, и есть некоторые данные, которые ты имеешь желание в этот шаблон подставить. Брать для этого Jinja2? Писать что-то своё используя sed
+ awk
? Или тащить %tool_name% на несколько мегабайт ради столь тривиальной задачи?
Спустя некоторое время, вновь столкнувшись с подобной задачей, и поняв что попытка найти что-то подходящее вновь претерпела фиаско, было принято волевое решение — да-да, написать свой прекрасный проект велосипед шаблонизатор для использования в CLI. Ограничения были выбраны следующие:
- Статическая линковка — один бинарный файл без каких-либо зависимостей (он мне понадобится в docker scratch)
- Итоговый размер должен быть минимально возможным (постараться уместиться в 100Кб без upx)
На чем писать, если хочется боли компактного результата и быстрого выполнения — естественно, берём C. Какой шаблонизатор использовать, если хочется минимализма? Под такую задачу хорошо подойдет mustache. И вот, спустя некоторое время появляется утилита под кодовым именем mustpl (must — mustache, tpl — template).
Как её использовать?
Предельно просто — дай на вход путь до файла с шаблоном, файла с данными для этого шаблона (или передай их в виде JSON-строки используя флаг -d
), и опционально передай нужные переменные окружения. Для примера давай представим, что у нас есть следующий шаблон для Nginx (nginx.tpl
):
server {
listen 8080;
server_name{{#names}} {{ . }}{{/names}};
location / {
root /var/www/data;
index index.html index.htm;
}
}
И мы имеем желание сгенерировать из него настоящий конфиг, подставив в качестве server_name
значения example.com
и google.com
. Для этого достаточно выполнить:
$ export SERVER_NAME_1=example.com
$ mustpl -d '{"names": ["${SERVER_NAME_1:-fallback.com}", "google.com"]}' ./nginx.tpl
server {
listen 8080;
server_name example.com google.com;
location / {
root /var/www/data;
index index.html index.htm;
}
}
Или другой пример, с циклом, но тем же конфигом для Nginx. Берём данные (data.json
):
{
"servers": [
{
"listen": 8080,
"names": [
"example.com"
],
"is_default": true,
"home": "/www/example.com"
},
{
"listen": 1088,
"names": [
"127-0-0-1.nip.io",
"127-0-0-2.nip.io"
],
"home": "/www/local"
}
]
}
Берём шаблон (nginx.tpl
):
{{#servers}}
server {
listen {{ listen }};
server_name{{#names}} {{ . }}{{/names}}{{#is_default}} default_server{{/is_default}};
location / {
root {{ home }};
index index.html index.htm;
}
}
{{/servers}}
И рендерим:
$ mustpl -f ./data.json ./nginx.tpl
server {
listen 8080;
server_name example.com default_server;
location / {
root /www/example.com;
index index.html index.htm;
}
}
server {
listen 1088;
server_name 127-0-0-1.nip.io 127-0-0-2.nip.io;
location / {
root /www/local;
index index.html index.htm;
}
}
Естественно, что конфигом Nginx вы не ограничены, да и вообще — рендерить можно любые текстовые данные. Единственное, наверняка будут сложности с python и yaml (там, где отступы имеют значение), но если что — создавайте issue, подумаем что можно придумать.
Красота — она в простоте. Кроме всего прочего, шаблонизатором поддерживаются и условия (это уже не совсем Logic-less получается, ну да ладно), и подключение других файлов-шаблонов, и escaping значений — все детали и нужные ссылки сможешь найти в readme файле репозитория с приложением.
А как установить?
На данный момент есть 3 пути по установке — это скачивание уже готового бинарного файла под необходимую архитектуру со страницы релизов, собственная сборка из исходников и использование готового docker-образа с приложением.
Для сборки потребуется только gcc
(и musl-dev
, если собираешь, скажем, в alpine linux), а docker-образ уже собран под наиболее популярные платформы, так что всё, что потребуется тебе сделать в твоём Dockerfile, это лишь:
COPY --from=ghcr.io/tarampampam/mustpl:latest /bin/mustpl /bin/mustpl
Крайне рекомендую не использовать тегlatest
из-за того, что при мажорных изменениях есть риск получить обратно-несовместимые изменения. Лучше всего использовать версионирование в форматеX.Y.Z
в связке с настроенным (dependa|renovate)bot.
Поддержки windows на данный момент нет так как "а зачем?". Если будет такой запрос — создайте issue, подумаем что можно сделать.
Но почему ты просто не взял %tool_name%?
Кроме того, что целью был минимальный размер итогового бинарного файла, отсутствие зависимостей и скорость работы, есть ещё как минимум одна очень важная причина — это комфортное использование в docker, а именно — навык парсить переменные окружения (примерно как envsubst
) и возможность использования в качестве точки входа (entrypoint).
Скорее всего ты знаешь, что основной процесс, запускаемый в контейнере — должен иметь PID равный 1 (в неймспейсе контейнера). Нужно это для того, чтоб демон докера мог корректно общаться (отправляя сигналы) с приложением, что у тебя в этом самом контейнере крутится.
Именно необходимость сохранить PID 1 является причиной тому что, как правило, в entrypoint-скриптах используются конструкции вида:
#!/bin/sh
set -e
if [ -n "$MY_OPTION" ]; then # если переменная окружения имеется
sed -i "s~foo~bar ${MY_OPTION}~" /etc/app.cfg # то подставляем её в конфиг
fi;
exec "$@" # <-- а вот это самое интересное
Которая в паре со следующими entrypoint
/cmd
в dockerfile:
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/bin/app", "--another", "flags"]
Работает следующим образом:
- Запускается процесс
sh
c PID 1, который выполняет скрипт/docker-entrypoint.sh
- Скрипт выполняет все необходимые модификации конфига некоторого приложения (если это необходимо), и вызывает
exec
(который заменяет текущий процесс новым, не изменяя при этом свой PID, детали вman exec
) - Запускается процесс
app
с аргументами--another flags
и его PID становится 1
И мне очень хотелось иметь возможность отказаться от этих самых entrypoint скриптов, так как они тянут массу зависимостей (а distroless же наше всё), да и писать их утомляет очень быстро. И было принято решение научить mustpl выполнять этот самый exec
самостоятельно. Т.е. чтоб алгоритм запуска был следующий:
- Запускается mustpl с PID 1, который читая файл шаблона и данные для него генерирует необходимый конфиг для некоторого приложения
- Выполняет
exec
, запуская нужное приложение, не меняя PID (т.е. оставляя его равным 1)
Как это выглядит? Тоже очень просто, давай создадим файлы с шаблоном (template.ini
):
[config]
value = {{ my_option }}
Данными для него (data.json
):
{
"my_option": "${MY_OPTION:-default value}"
}
И следующий Dockerfile
:
FROM alpine:latest
COPY --from=ghcr.io/tarampampam/mustpl /bin/mustpl /bin/mustpl
COPY ./data.json /data.json
COPY ./template.ini /template.ini
ENTRYPOINT ["mustpl", "-f", "/data.json", "-o", "/rendered.txt", "/template.ini", "--"]
CMD ["sleep", "infinity"]
Теперь давай соберем образ и запустим его:
$ docker build --tag test:local .
$ docker run --rm --name mustpl_example -e "MY_OPTION=foobar" test:local
В этот момент происходит следующее:
- Запускается
mustpl
(т.к. он указан вentrypoint
), который читает файлы/data.json
и/template.ini
- В данных шаблона значение для
my_option
заменяется наfoobar
, так как переменная окруженияMY_OPTION
установлена (мы же указали-e "MY_OPTION=foobar"
; в противном случае там бы оказалось значениеdefault value
) - Шаблон рендерится, и сохраняется в
/rendered.txt
-
mustpl
сохраняет все аргументы, что были указаны после пути до файла с шаблоном (это единственный обязательный параметр), трактуя их как имя и параметры запускаемого приложения (в нашем случае этоsleep
с аргументомinfinity
), двойное тире--
необходимо чтоб любые последующие флаги не парсилисьmustpl
а читались "как есть" - Запускается процесс
sleep
и PID равный 1 сохраняется уже за ним, а mustpl просто завершает свою работу (фактически происходит замена образа, но это сейчас не так важно)
Давай проверим, так ли это на самом деле (выполним в отдельном терминале):
$ docker exec mustpl_example ps aux
PID USER TIME COMMAND
1 root 0:00 sleep infinity # <-- PID как видим на самом деле == 1
7 root 0:00 ps aux
$ docker exec mustpl_example cat /rendered.txt
[config]
value = foobar # <-- а вот и наше значение!
$ docker kill mustpl_example
В общем и целом, если тебе понадобиться запустить приложение в контейнере, которое для своей конфигурации требует именно файл (а не флаги запуска или переменные окружения), и у тебя есть желание не хардкодить значения кофигурации, а сделать возможность их менять с помощью переменных окружения — то однозначно присмотрись к этой тулзовине.
Вместо заключения
Область применения этой утилиты, естественно, ограничена. Да, она не умеет Jinja-like модификаторов, кастомных функций (хотя, они описаны в спецификации mustache), да много ещё чего. Но она умеет просто шаблонизировать, и если сделает кому-то жизнь чуточку проще — я буду счастлив. Инструкции по установке, готовые бинарники, документация — всё это найдете в репозитории этой тулы.
Отдельное спасибо jetexe и AlexndrNovikov за ревью и режим "желтой уточки".
Комментарии (11)
MentalBlood
26.08.2022 21:51+2Пример неудачный: написание результата кажется проще чем комбинация шаблона и данных
git-merge
27.08.2022 00:06+1идея
tool -data data.yaml template.tpl
- очень хорошая, я тоже об этом думал.идея пробросить туда ENV тоже весьма
а вот собственный язык шаблонов здесь всё портит (конечно это моё мнение).
мне кажется что очень хорошим вариантом шаблонного языка является какой-нибудь язык-embedded.
Например perl-embedded (что-то вроде Mojo)
JS-embedded (масса вариаций вроде ejs)
конечно же PurePHP :)
-
так далее.
Что хорошо в embedded языках, так это то,
что шаблоны сложнее hello world тоже доступны и сложная логика на них реализуема.
что не требуется учить язык чтоб их использовать
и главное: оно легко расширяемо (какую-нибудь библиотеку форматирования даты времени очень просто подключить в такие шаблоны)
masai
27.08.2022 10:04+1а вот собственный язык шаблонов здесь всё портит (конечно это моё мнение).
Тут реализовано подмножество популярного mustache.
мне кажется что очень хорошим вариантом шаблонного языка является какой-нибудь язык-embedded.
Бывает полезно, но из пушки по воробьям в данном случае.
paramtamtam Автор
27.08.2022 13:11Но поиграться с тем же Lua мне было бы интересно, спасибо за идею
masai
27.08.2022 15:30Плюсую Lua. Пробовал работать с разными встраиваемыми языками и Lua оказался самым беспроблемным. Если нравится синтаксис Python, то можно Starlark ещё посмотреть.
Oxyd
27.08.2022 08:19+6Всё уже придумано до нас. ;-)
export VAR1="bla-bla-bla" VAR2="blablabla" VAR3="blabla-bla" envsubst < file_with.vars|tee file_with.vars >/dev/null
И вот как-то так оно и работает
Если кому нужна подсвечивалка по регуляркам, ловите, мне не жалко. ;-)
#!/usr/bin/env bash # # hlt approved by shellcheck ;-) # hl_start="\\\e[0;1;4;32m" hl_end="\\\e[0m" usage() { cat << 'EOF' hlt: 'sed -E' regular expression highlighter from file or stdin. Usage: hlt '<regular expression>' <file> or command | hlt '<regular expression>' EOF exit 1 } if [[ -z "$1" ]]; then usage fi if [[ -n "$2" ]]; then file="$2" else file="/dev/stdin" fi result="$(sed -E "s/(""$1"")/$hl_start\1$hl_end/g" "$file")" echo -e "$result" exit 0
А главное практически всегда есть в системе. И да. это реально самый простой шаблонизатор. Но творение автора тоже может быть применимо в определённых кейсах.
PS: Такое чуйство, что автор раньше писал статьи для Журнал Хакер ;-)
alef13
27.08.2022 11:32+3Однажды я был маленьким, и задавался вопросом — вот если Unix way это (упрощенно) небольшие, довольно простые утилиты и библиотеки, которые делают одну вещь, но делают её хорошо (Peter H. Salus: "...that do one thing and do it well"), то… Где тогда утилита, которая занимается шаблонизацией и не хватает звёзд с неба?
GNU m4
Taraflex
28.08.2022 08:44-1Статическая линковка — один бинарный файл без каких-либо зависимостей (он мне понадобится в docker scratch)
Итоговый размер должен быть минимально возможным (постараться уместиться в 100Кб без upx)
Есть mustache шаблонизатор на bash
github.com/tests-always-included/mo
32 KB
Если вырезать комментарии, то выйдет наверное вполовину меньше.
alef13
всегда sed хватало...