Эффективное устранение проблем с расширением переменных в Bash

Bash-скрипты часто дают сбой из-за тонких ошибок расширения переменных. Это подробное руководство разбирает распространенные проблемы, такие как неправильное цитирование, обработка неинициализированных значений и управление областью видимости переменных в под-оболочках и функциях. Изучите основные методы отладки (`set -u`, `set -x`) и освойте мощные модификаторы расширения параметров (например, `${VAR:-default}`), чтобы писать надежные, предсказуемые и безошибочные скрипты автоматизации. Перестаньте отлаживать загадочные пустые строки и начните писать скрипты уверенно.

Эффективное устранение проблем с расширением переменных в Bash

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

Полезная ментальная модель такова: Bash не просто заменяет $name на текст и выполняет команду. Он расширяет переменные, может разбить результат на слова, может расширить glob'ы, а затем, наконец, выполняет команду с результирующим списком аргументов. Большинство исправлений сводятся к контролю этих шагов.

Неустановленные переменные становятся пустыми, если их не остановить

По умолчанию этот скрипт выводит пустое значение и продолжает работу:

printf 'Deploying %s\n' "$APP_VERSION"

Если APP_VERSION была обязательной, это ошибка. Используйте расширение параметров, когда переменная обязательна:

: "${APP_VERSION:?APP_VERSION must be set}"
printf 'Deploying %s\n' "$APP_VERSION"

Ведущий : — это команда "no-op". Расширение выполняет проверку. Если переменная не установлена или пуста, Bash выводит сообщение и завершает работу в неинтерактивной оболочке.

Для необязательных значений сделайте значение по умолчанию очевидным:

log_level=${LOG_LEVEL:-INFO}
retry_count=${RETRY_COUNT:-3}

Двоеточие имеет значение. ${VAR:-default} использует значение по умолчанию, когда VAR не установлена или пуста. ${VAR-default} использует значение по умолчанию только когда VAR не установлена. Это различие важно, если пустая строка является допустимым значением конфигурации.

set -u также может отлавливать неустановленные переменные:

set -u

Это полезно во многих скриптах, но не заменяет четкую проверку. Это также может удивить вас при работе с необязательными позиционными параметрами, массивами или переменными, которые намеренно проверяются на существование. Используйте ${1:-}, когда аргумент может отсутствовать:

mode=${1:-help}

Заключайте переменные в кавычки, если только вы не хотите разделения и подстановки glob'ов

Это самая распространенная проблема расширения:

file="Quarterly Report *.txt"
rm $file

Без кавычек Bash сначала расширяет $file, затем разделяет его по пробелам, затем обрабатывает * как подстановочный знак. Команда может получить несколько аргументов, которые вы не планировали. В кавычках она получает ровно один аргумент:

rm -- "$file"

-- защищает команды от значений, начинающихся с тире. Это важно для имен файлов, таких как -rf.

Используйте двойные кавычки для переменных, подстановок команд и большинства расширений параметров:

cp "$source_file" "$destination_dir/"
printf 'User: %s\n' "$user_name"

Одинарные кавычки отличаются. Они предотвращают расширение полностью:

printf 'Home is $HOME\n'   # выводит буквальный текст
printf "Home is $HOME\n"   # выводит значение

Если вы видите скрипт, собирающий строки вроде 'prefix-$value', это, вероятно, ошибка. Используйте двойные кавычки, когда значение должно расширяться.

Массивы решают многие проблемы построения аргументов

Много сломанного Bash возникает из-за хранения нескольких опций команды в одной строке:

opts="-a --delete --exclude *.tmp"
rsync $opts "$src/" "$dest/"

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

opts=(-a --delete --exclude '*.tmp')
rsync "${opts[@]}" "$src/" "$dest/"

"${opts[@]}" расширяет каждый элемент массива как отдельный аргумент. Это именно то, что нужно для большинства конструкций команд.

То же самое применимо при сборе имен файлов:

files=("$report_dir"/*.txt)
for file in "${files[@]}"; do
  [[ -e $file ]] || continue
  process_report "$file"
done

Защита [[ -e $file ]] || continue обрабатывает случай, когда ни один файл не соответствует и glob остался буквальным, в зависимости от опций оболочки.

Подстановка команд удаляет завершающие символы новой строки

$(command) захватывает stdout, но Bash удаляет завершающие символы новой строки. Обычно это нормально для строки версии и неправильно для данных, где важны завершающие символы новой строки.

version=$(git describe --tags --always)
printf 'Version: %s\n' "$version"

Для построчного вывода предпочитайте mapfile, когда вам нужен массив:

mapfile -t names < <(find "$base_dir" -maxdepth 1 -type f -name '*.log' -printf '%f\n')
for name in "${names[@]}"; do
  printf 'log=%s\n' "$name"
done

Избегайте for item in $(ls). Это ломается на пробелах, символах glob и необычных именах файлов. Циклически обрабатывайте glob'ы или используйте find с осторожными разделителями.

Переменные в конвейерах могут находиться в подоболочке

Это ловит людей, потому что цикл, кажется, выполняется правильно:

count=0
printf '%s\n' a b c | while IFS= read -r line; do
  count=$((count + 1))
done
printf 'count=%s\n' "$count"

Во многих конфигурациях Bash цикл while в конвейере выполняется в подоболочке. Увеличение происходит, но count родительской оболочки остается неизменным.

Вместо этого используйте подстановку процесса:

count=0
while IFS= read -r line; do
  count=$((count + 1))
done < <(printf '%s\n' a b c)
printf 'count=%s\n' "$count"

Или заставьте конвейер выдавать нужное вам значение и захватите это значение напрямую.

Локальные переменные предотвращают случайные перезаписи

Переменные в функциях Bash являются глобальными, если не объявлены как local. Это может превратить вспомогательную функцию в источник странных ошибок расширения:

env=prod

load_config() {
  env=dev
}

load_config
printf '%s\n' "$env"  # dev

Используйте local для временных значений:

load_config() {
  local env=dev
  printf 'loaded defaults for %s\n' "$env"
}

local — это функция Bash. Это нормально в скриптах Bash, но это еще одна причина, по которой скрипт не следует запускать с sh.

Используйте фигурные скобки, когда имена касаются другого текста

$prefix_file означает переменную с именем prefix_file, а не $prefix, за которым следует _file. Используйте фигурные скобки, чтобы сделать границу ясной:

prefix=app
printf '%s\n' "${prefix}_file"

Фигурные скобки также требуются для многих операций расширения параметров:

path=/var/log/nginx/access.log
printf 'dir=%s\n' "${path%/*}"
printf 'file=%s\n' "${path##*/}"

${path%/*} удаляет самое короткое совпадающее окончание. ${path##*/} удаляет самое длинное совпадающее начало. Они полезны, но не злоупотребляйте ими, когда dirname или basename сделали бы скрипт более понятным для вашей команды.

Отлаживайте расширение, выводя реальные аргументы

set -x показывает команды после расширения. Улучшите трассировку с номерами строк:

PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
mv $file $target_dir
set +x

Трассировка покажет, стала ли команда mv Quarterly Report *.txt /tmp/out или mv 'Quarterly Report *.txt' /tmp/out. Держите xtrace подальше от секретов.

Для более безопасной ручной проверки выводите значения с %q:

printf 'file=%q\n' "$file" >&2
printf 'target_dir=%q\n' "$target_dir" >&2

%q делает пробелы и специальные символы видимыми таким образом, который легче читать, чем простой echo.

Практический контрольный список

Когда переменная Bash расширяется неправильно, проверьте это по порядку:

  1. Выполняется ли скрипт под Bash, а не sh?
  2. Действительно ли переменная установлена? Используйте ${VAR:?message} для обязательных значений.
  3. Заключено ли каждое расширение в кавычки, если только разделение не является намеренным?
  4. Используете ли вы массив для нескольких аргументов?
  5. Поместил ли конвейер ваш цикл в подоболочку?
  6. Перезаписала ли функция глобальную переменную из-за отсутствия local?
  7. Нужны ли фигурные скобки, чтобы отделить имя переменной от соседнего текста?

Эти проверки скучны в лучшем смысле. Они превращают большинство ошибок расширения из "Bash странный" в конкретное, исправимое правило.

Косвенное расширение и namerefs требуют особой осторожности

Bash может расширять переменную, имя которой хранится в другой переменной:

name=APP_ENV
printf '%s\n' "${!name}"

Это выводит значение APP_ENV. Это мощно, но делает скрипты более трудными для чтения и может стать небезопасным, если имя переменной поступает от пользователя. Если вам нужно только отображение имен на значения, ассоциативный массив понятнее:

declare -A endpoints=(
  [dev]='https://dev.example.test'
  [prod]='https://api.example.com'
)

printf '%s\n' "${endpoints[$env]:?unknown environment}"

Bash также имеет namerefs с declare -n, часто используемые во вспомогательных функциях. Они полезны в скриптах библиотечного стиля, но могут создавать удивительные побочные эффекты. Используйте их только тогда, когда передача массива или переменной по ссылке действительно упрощает код.

Удаление по шаблону — это не сопоставление с регулярным выражением

Операторы расширения параметров, такие как ${file%.log} и ${path##*/}, используют шаблоны оболочки, а не регулярные выражения. Это различие имеет значение.

file='access.log'
printf '%s\n' "${file%.log}"

Это удаляет суффикс .log. Это не означает "удалить все, что соответствует регулярному выражению". Для проверок регулярных выражений используйте [[ ... =~ ... ]]:

if [[ $port =~ ^[0-9]+$ ]]; then
  printf 'numeric\n'
fi

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

Экспортируйте только то, что нужно дочерним процессам

Установка переменной в Bash не делает ее автоматически доступной для команд, которые запускает скрипт:

APP_ENV=prod
./run-app

run-app не увидит APP_ENV, если она не экспортирована или не предоставлена встроенно:

export APP_ENV=prod
./run-app

# или
APP_ENV=prod ./run-app

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

Обратное также верно: дочерний процесс не может изменить переменные родительской оболочки. Если вспомогательный скрипт выводит export TOKEN=..., обычный запуск не обновит вызывающий скрипт. Вам пришлось бы выполнить его через source, и source следует резервировать для доверенного кода оболочки.

Проверка в реальном мире перед отправкой

Прежде чем назвать скрипт или настройку контейнера завершенными, прочитайте его один раз так, как будто вы следующий человек, которому придется отлаживать его в 2 часа ночи. Это меняет то, что вы замечаете. Подсказка, которая имела смысл при написании скрипта, может быть неоднозначной, когда она появляется в журнале CI. Имя службы Docker, которое казалось очевидным, может не соответствовать имени переменной в приложении. Значение по умолчанию Bash может быть безопасным для разработки и опасным для производства.

Мне нравится проводить короткий пробный прогон с намеренно неудобными значениями. Используйте путь с пробелами. Используйте пустое необязательное значение. Попробуйте имя файла, начинающееся с тире. Запустите скрипт из другого рабочего каталога. Запустите контейнер без одной ожидаемой переменной окружения. Эти тесты не являются навороченными, но они выявляют предположения, которые обычно ломаются в первую очередь.

Также проверьте сообщение об ошибке. Если единственный вывод — failed, то совет из статьи не был реализован. Полезная ошибка говорит, какое значение использовалось, какая проверка не удалась и что оператор может изменить. Это не означает дампа каждой переменной окружения или вывода секретов. Это означает быть конкретным там, где конкретность помогает: путь к конфигурации, имя отсутствующей команды, имя сети, имя хоста службы или порт, который процесс пытался привязать.

Последняя привычка — держать примеры близкими к тому, как система на самом деле запускается. Если в производстве используется Compose, тестируйте с Compose. Если скрипт запускается systemd, тестируйте его с systemd или с аналогично минимальным окружением. Если команда должна быть безопасной для копирования и вставки, включите в сам пример цитирование, разделители -- и проверку. Читатели копируют рабочие шаблоны чаще, чем предупреждения.

Этот этап проверки — не бюрократия. Это то, как маленькая автоматизация остается скучной. Скучное — это то, что вам нужно от подсказок оболочки, загрузчиков конфигурации, расширения переменных, диагностики контейнеров и сети Docker. Чем менее удивительно поведение, тем легче следующему оператору доверять ему.

Для расширения переменных в частности добавьте еще одну привычку к этой проверке: выводите количество аргументов, когда команда ведет себя странно. Маленький помощник может сделать невидимое видимым:

show_args() {
  local i=1
  for arg in "$@"; do
    printf 'arg[%d]=%q\n' "$i" "$arg" >&2
    i=$((i + 1))
  done
}

show_args mv $file $target_dir
show_args mv "$file" "$target_dir"

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