Anybody can write good bash
Перевод Anybody-can-write-good-bash-with-a-little-effort
О чём этот пост
Вежливое напоминае о том чтобы при написании сценариев оболички нужно полагаться на инстременты автоматизации, современные функции, правила безопасности и лучшие практики где это возможно
Программирование на баше - популярная боль у программистов и сисадминов. Практически у каждого есть история о большом, ужасном, непонятном и неподдерживаемом скрипте который лежит в основе какого важного компонента среды разработки или проекта.
Программирование на оболочке - популярная и предсказуемая цель гнева в сообществах программистов1: практически у каждого есть ужасная история о старинном, сломанном или чудовищном сценарии оболочки, лежащем в основе критического компонента среды разработки или проекта.
Мои любимые примеры содержат:
- Обычный
run.sh
, который обычно:- Запускает непонятно что и непонятно где
- Не имеет executable bit
- Не указывает свою оболочку с помощью
shebang
- Ожидается что будет запущен определённым пользователем в определённом контексте.
- Делает очень плохие вещи если запускается из неправильного каталога.
- Возможно форкается
- Возможно записывает pidfile или делает это неправильно.
- Может не проверять свой pidfile и делать мешанину с ранее запущеным скриптом.
- Всё вместе
make.sh
(илиbuild.sh
, илиcompile.sh
, или …), который:- Не понимает стандартные переменные сборки, такие как CC, CXX, CFLAGS…
- Пытается реализовать собственный препроцессор.
- Содержит недоработанную реализацию многопоточности -j
- Содержит недоработанную реализацию make, включающую (broken) install и clean цели.
- Решает за вас что и куда установить
- Падает всегда, везде и непонятно почему.
- Оставляет сборку в неопределённом состоянии в случае прерывания.
- Продолжает работать после сбоев команд сборки.
- Всё вместе.
test.sh
, который:- Ожидает что будет запущен в каком-то виртуальном окружении (venv, container, директория с инструментами…)
- Пытается создать/настроить эту среду что ломает всё ещё больше.
- Неправильно определяет что нужно в среде запуска, пытается починить, но делает только хуже.
- Маскирует или игнорирует коды завершения тестовых процессов запускаемых внутри.
- Скрывает вывод запускаемых процессов.
- Содержит недоработанную реализацию модульного теста которая не очищает состояние или неправильно обрабатывает сигналы.
- Пытается быть умной в цветном выводе, не утруждаясь использованием isatty.
- Всё вместе.
env.sh
, который:- Возможно вообще не shell скрипт
- Возможно становится процессом с неопределенным состоянием и привелегиями.
- Возможно частично написан на Python.
- Всё вместе, в различной степени, на разных машинах…
Я испытал всё это и лично виновен в (небольшом) количестве из них. Несмотря на это я считаю что bash скрипты занимают важную (и незаменимую) нишу в моём цикле разработки и должны занять ту же нишу в вашем.
Ниже я расскажу о шагах, которые предпринимаю для написание (надёжного, компоновочного) bash.
Основы
Bash скрипт не попадает в мои ужасные истории если он:
- Имеет
executable bit
- Имеет
shebang
и он выглядит так:#!/usr/bin/env bash
Объяснение: не во всех системах есть (хорошая) версия GNU bash в/bin/bash
. Известно, что macOS поставляет старую версию по этому пути, а другие платформы могут использовать другие пути. - Имеет базовый коментарий вначале о том для чего этот скрипт.
- Имеет
set -e
(и в идеалеset -euo pipefail
) Объяснение:set -e
, завершит скрипт при любой ошибке внутри.set -u
делает ошибкой неопределённые переменные, что поможет избежать фатальных последствий в таких случаях какrm -rf "${PERFIX}/usr/bin"
.set -o pipefail
расширяет-e
, делая любой сбой в конвейере фатальным, а не только последнюю команду. - Может либо:
- Запускаться из любой директории.
- Штатно завершаться в неправильной директории.
Я так же добавляю эти две функции в каждый свой скрипт:
function installed {
cmd=$(command -v "${1}")
[[ -n "${cmd}" ]] && [[ -f "${cmd}" ]]
return ${?}
}
function die {
>&2 echo "Fatal: ${@}"
exit 1
}
Поправка: Redditor заметил что функция installed излишне осторожная и многословная.
Они прекрасно сочетаются с условными тестами и операторами bash (и друг с другом), чтобы я мог легко проверить работоспособность в начале моих скриптов:
[[ "${BASH_VERSINFO[0]}" -lt 4 ]] && die "Bash >=4 required"
deps=(curl nc dig)
for dep in "${deps[@]}"; do
installed "${dep}" || die "Missing '${dep}'"
done
Некоторые другие тонкости:
- Я использую
shopt -s extglob
иshopt -s globstar
в некоторых своих скриптах, немного предпочтя его (простым) вызовамfind
. Сравните этот вызовfind
:items=$(find . -name 'foo*' -o -name 'bar*')
и более короткий (и без вызова дополнительных процессов):
items=(**/@(foo|bar)*)
Есть хорошее объяснение
globbing
от Linux Journal тут; объясняющее реализациюglobstar
в документации GNU shopt
Автоматический линтинг и форматирование
С точки зрения популярности и функциональности лидирует shellcheck. Судя по чейнджлогу разрабатывается более 7 лет. Доступен во всех менеджерах пакетов.
С версии 0.7.0, shellcheck может автоматически генерировать diff с некоторыми исправлениями.
shellcheck -f diff my_script.sh | patch
И включает проверку (опциональную) на необязательные фигурные скобки.
# Плохо
foo="$bar"
stuff="$# $? $$ $_"
# Лучше
foo="${bar}"
stuff="${#} ${?} ${$} ${_}"
Также есть bashate и mvdan/sh, которые я не использовал.
Переменные среды, а не флаги.
Раньше я использовал встроенные команды shift и getopt (иногда одновременно) для синтаксического анализа флагов. Сейчас я от этого отказался и перешёл на следующую схему:
- Логические и тривиальные флаги передаются через переменные среды:
VERBOSE=1 STAMP=$(date +%s) frobulate-website
Я считаю, что это значительно легче читать и запоминать, чем флаги (я использовал -v или -V для подробного описания в этом скрипте?), и позволяет мне использовать этот красивый синтаксис для значений по умолчанию:
VERBOSE=${VERBOSE:-0} STAMP=${STAMP:-$(date +%s)}
- По возможности stdin, stdout и stderr используются вместо специальных позиционных файлов:
VERBOSE=1 DEBUG=1 frobulate-website < /var/www/index.html > /var/www/index2.html
-
Единственные параметры - позиционные, и должны соответствовать паттерну переменной длины аргументов (например program
[arg ...]) - -h и -v добавляются только в том случае, если программа имеет нетривиальную обработку аргументов и, как ожидается, будет (существенно) переработана в будущем.
- Обычно я предпочитаю вообще не реализовывать -v, отдавая предпочтение строке в заголовке вывода -h.
- Выполнение команды без аргументов рассматривается как эквивалент -h.
- Все другие типы флагов, входных данных и механизмов (включая getopt) используются только в крайнем случае.
Сочиняйте свободно
Не бойтесь сочетать сабшелы и пайпы:
# Сочетание выводов двух `stage-run` передаётся
# через пайп в `stage-two`
(stage-one foo && stage-one bar) | stage-two
Или использовать блоки кода для группировки операций:
# Блоки кода не являются подоболочками, поэтому exit сработает
risky-thing || { >&2 echo "risky-thing didn't work!"; exit 1; }
Подоболочки и блоки могут использоваться во многих из одних и тех же контекстов; какой из них вы будете использовать, зависит от того, нужна ли вам независимая временная оболочка или нет:
# Оба примера работают, но последний сохраняте переменные
(read line1 && read line2 && echo "${line1} vs. ${line2}") < "${some_input}"
# line1 и line2 не сохраняются
{ read line1 && read line2 && echo "${line1} vs. ${line2}"; } < "${some_input}"
# line1 и line2 сохраняются и могут быть использованы далее
Обратите внимание на небольшие синтаксические различия: блоки требуют интервала и конечной точки с запятой (когда они находятся в одной строке).
Используйте подстановку процессов, чтобы избежать создания временных файлов и управления ими: Плохо:
function cleanup {
rm -f /tmp/foo-*
}
output=$(mktemp -t foo-XXXXXX)
trap cleanup EXIT
first-stage output
second-stage --some-annoying-input-flag output
Хорошо:
second-stage --some-annoying-input-flag <(first-stage)
Вы также можете использовать их для чистой обработки stderr:
# stderr big-task перенаправлен в stdout и передаётся на обработку
(big-task > /dev/null) 2> >(sed -ne '/^EMERG: /p')
Итого
Bash - это особенно плохой язык программирования, на котором особенно легко писать (небезопасный, нечитаемый) код.
Это также особенно эффективный язык с идиомами и примитивами, которые трудно (кратко, точно) воспроизвести на объективно хороших языках.
Кроме того, в ближайшее время он никуда не денется: согласно sloccount, в kubernetes@e41bb32 содержится 28055 строк bash.
Мораль истории: bash проникнет в ваши проекты. Вы должны быть готовы, используя передовой опыт и хорошие инструменты, когда это произойдет.
Если вам каким-то образом удастся уберечь его от своих проектов, люди будут использовать bash для развертывания ваших проектов или для интеграции в свои проекты. Вы должны быть готовы оправдать поведение вашего проекта и (не) соответствие (опять же, объективно плохому) статус-кво UNIX-подобных сред на случай, когда они столкнутся с трудностями.