Перевод Anybody-can-write-good-bash-with-a-little-effort

О чём этот пост

Вежливое напоминае о том чтобы при написании сценариев оболички нужно полагаться на инстременты автоматизации, современные функции, правила безопасности и лучшие практики где это возможно


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

Программирование на оболочке - популярная и предсказуемая цель гнева в сообществах программистов1: практически у каждого есть ужасная история о старинном, сломанном или чудовищном сценарии оболочки, лежащем в основе критического компонента среды разработки или проекта.

Мои любимые примеры содержат:

  • Обычный run.sh, который обычно:
    1. Запускает непонятно что и непонятно где
    2. Не имеет executable bit
    3. Не указывает свою оболочку с помощью shebang
    4. Ожидается что будет запущен определённым пользователем в определённом контексте.
    5. Делает очень плохие вещи если запускается из неправильного каталога.
    6. Возможно форкается
    7. Возможно записывает pidfile или делает это неправильно.
    8. Может не проверять свой pidfile и делать мешанину с ранее запущеным скриптом.
    9. Всё вместе
  • make.sh (или build.sh, или compile.sh, или …), который:
    1. Не понимает стандартные переменные сборки, такие как CC, CXX, CFLAGS…
    2. Пытается реализовать собственный препроцессор.
    3. Содержит недоработанную реализацию многопоточности -j
    4. Содержит недоработанную реализацию make, включающую (broken) install и clean цели.
    5. Решает за вас что и куда установить
    6. Падает всегда, везде и непонятно почему.
    7. Оставляет сборку в неопределённом состоянии в случае прерывания.
    8. Продолжает работать после сбоев команд сборки.
    9. Всё вместе.
  • test.sh, который:
    1. Ожидает что будет запущен в каком-то виртуальном окружении (venv, container, директория с инструментами…)
    2. Пытается создать/настроить эту среду что ломает всё ещё больше.
    3. Неправильно определяет что нужно в среде запуска, пытается починить, но делает только хуже.
    4. Маскирует или игнорирует коды завершения тестовых процессов запускаемых внутри.
    5. Скрывает вывод запускаемых процессов.
    6. Содержит недоработанную реализацию модульного теста которая не очищает состояние или неправильно обрабатывает сигналы.
    7. Пытается быть умной в цветном выводе, не утруждаясь использованием isatty.
    8. Всё вместе.
  • env.sh, который:
    1. Возможно вообще не shell скрипт
    2. Возможно становится процессом с неопределенным состоянием и привелегиями.
    3. Возможно частично написан на Python.
    4. Всё вместе, в различной степени, на разных машинах…

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