Эффективное устранение проблем с расширением переменных в 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 расширяется неправильно, проверьте это по порядку:
- Выполняется ли скрипт под Bash, а не
sh? - Действительно ли переменная установлена? Используйте
${VAR:?message}для обязательных значений. - Заключено ли каждое расширение в кавычки, если только разделение не является намеренным?
- Используете ли вы массив для нескольких аргументов?
- Поместил ли конвейер ваш цикл в подоболочку?
- Перезаписала ли функция глобальную переменную из-за отсутствия
local? - Нужны ли фигурные скобки, чтобы отделить имя переменной от соседнего текста?
Эти проверки скучны в лучшем смысле. Они превращают большинство ошибок расширения из "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"
Первый вызов показывает, что получила бы сломанная команда; второй показывает исправленную версию. Как только вы увидите список аргументов, ошибки цитирования перестают казаться загадочными.