Написание bash-скриптов

Командный арсенал оболочки bash позволяет успешно писать простые сценарии (без этого средства автоматизации системным администраторам пришлось бы вручную вводить команды в командную строку). Ваше мастерство в использовании командных строк вы должны воплотить в искусстве создания bash-сценариев (и наоборот), что поможет вам извлечь максимальную пользу из времени, потраченного на изучение оболочки bash. Но когда окажется, что ваш bash-сценарий превысил в объеме сотню строк или вам потребовались средства, которыми bash не обладает, это будет означать, что настало время переходить к языку Perl или Python.

В среде bash комментарии начинаются со знака # и продолжаются до конца строки. Как и в командной строке, вы можете разбить одну логическую строку на несколько физических строк, обозначив переход на новую строку символом обратной косой черты (). И, наоборот, можно поместить на одной строке несколько операторов, разделив их точкой с запятой.

Сценарий для оболочки bash может состоять только из последовательности командных строк. Например, следующий сценарий helloworld просто выполняет команду echo.

#!/bin/bash
echo "Hello, world!"

Первая строка содержит “банальный” описательный оператор, который сообщает, что данный текстовый файл является сценарием, предназначенным для интерпретации командной оболочкой /bin/bash. При принятии решения о том, как выполнить этот файл, ядро отыщет соответствующий синтаксис. С точки зрения оболочки, “стремящейся” выполнить этот сценарий, первая строка представляет собой просто комментарий. Если бы оболочка находилась в другом каталоге, вам пришлось бы отредактировать эту строку.

Для того чтобы подготовить этот файл к выполнению, достаточно установить его бит, “отвечающий” за выполнение.

$ chmod  helloworld
$ ./helloworld3
Hello, world!

Если ваша оболочка понимает команду helloworld без префикса ./, это означает, что в вашем пути поиска указан текущий каталог (.). И это плохо, поскольку дает другим пользователям возможность устраивать для вас “ловушки” в надежде, что вы будете пытаться выполнить определенные команды при переходе к каталогу, в котором они имеют доступ для записи.

Можно также непосредственно запустить (активизировать) оболочку в качестве интерпретатора сценария.

$ bash helloworld
Hello, world!
$ source helloworld
Hello, world!

Первая команда выполнит сценарий helloworld в новом экземпляре оболочки bash, а вторая — заставит вашу начальную оболочку прочитать содержимое файла, а затем выполнить его. Второй вариант полезен в случае, когда сценарий устанавливает переменные среды или выполняет другие операции настройки, применимые толь- ко к текущей оболочке. Обычно этот вариант используется при написании сценариев, включающих содержимое файла конфигурации, написанного в виде ряда присваиваний значений bash-переменным.

Если вы пришли сюда из мира Windows, то вам привычно использовать понятие расширения файла, по которому можно судить о типе файла, а также о том, можно ли его выполнить. В мире UNIX и Linux признак того, может ли файл быть выполнен (и если да, то кем), содержится в специальных битах полномочий. При желании вы можете наделить свои bash-сценарии суффиксом .sh, который бы напоминал вам об их типе, но тогда при выполнении соответствующей команды вам придется вводить суффикс .sh, поскольку UNIX не интерпретирует расширения специальным образом.

От команд к сценариям

Прежде чем переходить к особенностям написания bash-сценариев, остановимся на методике этого процесса. Многие пишут bash-сценарии так же, как Perl- или Python- сценарии, т.е. используя какой-нибудь текстовый редактор. Но все же удобнее рассматривать в качестве интерактивной среды разработки сценариев режим с приглашением на ввод команды.

Предположим, например, что у вас есть журналы регистрации, именованные с суффиксами .log и .LOG и разбросанные по всей иерархической системе каталогов. Допустим также, что вы хотите изменить эти суффиксы, приведя их к “прописному” виду. Прежде всего, попытаемся отыскать все эти файлы.

$ find . -name '*log'
.do-not-touch/important.log
admin.com-log/
foo.log
genius/spew.log
leather_flog

Так, похоже на то, что нам надо включить в шаблон точку и игнорировать при поиске каталоги. Нажмите комбинацию клавиш <Ctrl+P>, чтобы вернуть команду в командную строку, а затем модифицируйте ее.

$ find .-type f -name '*.log'
.do-not-touch/important.log
foo.log
genius/spew.log

Ну вот, это уже выглядит лучше. Правда, каталог .do-not-touch (т.е. “не трогать”) вызывает смутное чувство опасности; но мы можем избавиться от этого неприятного холодка.

$ find . -type f -name '*.log ' | grep -v .do-not-touch
foo.log
genius/spew.log

Теперь все в порядке: мы получили абсолютно точный список файлов, которые должны быть переименованы. Попробуем сгенерировать несколько новых имен.

$ find . -type f -name '*.log ' I grep -v .do-not-touch | while read fname
> do
> echo mv $ fname $ {fname/. log/. LOG/}
> done
mv foo.log foo.LOG
mv genius/spew.log genius/spew.LOG

Да, это именно те команды, которые позволят переименовать нужные файлы. А как мы это делаем в реальности? Мы могли бы снова вызвать уже выполненную команду и отредактировать команду echo, чтобы заставить оболочку bash выполнять команды mv, а не просто выводить их. Ведь передача команд в отдельную копию оболочки bash — более надежный вариант работы, который к тому же требует меньшего объема редактирования.

Нажав комбинацию клавиш <Ctrl+P>, мы обнаруживаем, что оболочка bash заботливо свернула наш мини-сценарий в одну-единственную строку. К этой “уплотненной” командной строке мы просто добавляем канал, передающий наши выходные данные команде bash -х.

$ find . -type f -name '*.log ' I grep -v .do-not-touch | while read fname;
    do echo mv $fname $ {fname/. log/. LOG/}; done | bash -x
+ mv foo.log foo.LOG
+ mv genius/spew.log genius/spew.LOG

Ключ -x команды bash обеспечивает вывод каждой команды перед ее выполнением.

Теперь мы завершили переименование файлов, но нам хотелось бы сохранить этот сценарий, чтобы можно было использовать его снова. Встроенная в bash команда fc по своему действию во многом аналогична нажатию комбинации клавиш <Ctrl+P>, но вместо возврата последней команды в командную строку она передает команду в заданный вами редактор. Добавьте в свой файл строку идентификационного комментария, поместите сохраненный файл в приемлемый для вас каталог (например, ~/bin или /usr/local/bin), сделайте файл исполняемым, и вы получите настоящий сценарий.

Итак, подытожим.

• Разрабатывайте сценарий (или его часть) в виде конвейера команд, причем пошагово и в режиме выполнения командных строк.

• Пересылайте результат в стандартный выходной поток, проверяя правильность работы используемых вами команд.

• На каждом этапе используйте буфер ранее выполненных команд для их появления в командной строке и инструменты редактирования — для их модификации.

• Пока вы получаете неправильные результаты, можно считать, что вы, по сути, ничего не сделали, и до тех пор, пока команды не заработают так, как надо, ничего (из уже сделанного) не надо удалять.

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

• Используйте команду fс, чтобы зафиксировать свою работу в редакторе, оформите ее соответствующим образом и сохраните.

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

Организация ввода и вывода данных

Команда echo не отличается интеллектуальностью, но зато проста в применении. Для получения большего контроля над выводом данных используйте команду printf. Она не очень удобна, поскольку предполагает, что вы должны в явном виде указывать в нужных для вас местах символы перехода на новую строку (\n), но позволяет использовать символ табуляции и другие средства форматирования результата. Сравните результаты выполнения следующих двух команд.

$ echo "\taa\tbb\tcc\n"
\taa\tbb\tcc\n
$ printf "\taa\tbb\tcc\n"
аа bb сс

В некоторых системах работа команд echo и printf поддерживается на уровне ОС (обычно соответствующие им исполняемые файлы хранятся в каталогах /bin и /usr/ bin соответственно). Хотя эти команды и встроенные в оболочку утилиты в целом подобны, они могут иметь незначительные отличия, и особенно это касается команды printf. Вы можете либо придерживаться bash-синтаксиса, либо вызывайте “внешнюю” команду printf, указывая ее полный путь.

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

#!/bin/bash

echo -n "Введите свое имя: "
read user_name

if [ -n "$user_name" ]; then
        echo "Привет $user_name!"
        exit 0
else
        echo "Вы не назвали свое имя!"
        exit 1
fi

Ключ -n в команде echo подавляет привычный переход на новую строку, но здесь была бы кстати команда printf. Мы еще рассмотрим вкратце синтаксис оператора if, но его действие здесь, с нашей точки зрения, очевидно. Ключ -n в операторе if обеспечит значение истины, если его строковый аргумент не окажется нулевым. Вот как выглядит результат выполнения этого сценария.

$ sh readexample
Введите свое имя: Anton
Привет Anton!

Функции и аргументы командной строки

Аргументы командной строки служат для сценария переменными с числовыми именами: $1 — первый аргумент командной строки, $2 — второй и т.д. Аргумент $0 содержит имя, по которому был вызван сценарий, например ./bin/example.sh, т.е. это не фиксированное значение.

Переменная $# содержит количество переданных аргументов командной строки, а переменная $* — все эти аргументы. Ни одна из этих переменных не учитывает аргумент $0.

При вызове сценария с некорректными аргументами или вообще без них должно быть выведено короткое сообщение с напоминанием о том, как следует использовать данный сценарий. Приведенный ниже сценарий, принимающий два аргумента, проверяет, являются ли оба эти аргумента каталогами, и, убедившись в этом, отображает их. Если аргументы оказываются недопустимыми, сценарий выводит сообщение о его правильном использовании и завершается с ненулевым значением кода возврата. Если программа, вызвавшая этот сценарий, проверит код возврата, она будет “знать”, что этот сценарий не удалось выполнить корректно.

#!/bin/bash

function show_usage {
    echo "Использование: $0 source_dir dest_dir"
    exit 1
}
# Основная программа начинается здесь

if [ $# -ne 2 ]; then
    show_usage
else # Существуют два аргумента
    if [ -d $1 ]; then
        source_dir=$l
    else
        echo 'Недопустимый каталог-источник'
        show_usage
    fi
    if [ -d $2 ]; then
        dest_dir=$2
    else
        echo 'Недопустимый каталог-приемник'
        show_usage
    fi
fi

printf "Каталог-источник: ${source_dir}\n"
printf "Каталог-приемник: ${dest_dir}\n"

Для вывода сообщения о правильном использовании данного сценария мы создали отдельную функцию show_usage. Если бы позже этот сценарий был модифицирован и стал бы принимать дополнительные аргументы, это сообщение нужно было бы изменить только в одном месте.

$ mkdir ааа bbb
$ sh showusage ааа bbb
Каталог-источник: ааа
Каталог-приемник: bbb
$ sh showusage foo bar
Недопустимый каталог-источник
Использование: showusage source__dir dest_dir

Аргументы bash-функций обрабатываются практически так же, как аргументы командной строки: $1 — первый аргумент командной строки, $2 — второй и т.д. Как видно из приведенного выше примера, аргумент $0 содержит имя сценария.

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

function show_usage {
    echo "Использование: $0 source_dir dest_dir"
    if [ $# -eq 0 ]; then
        exit 99 # Выход с любым ненулевым кодом возврата
    else
        exit $1
    fi
}

В этой версии функции добавляется анализ наличия аргументов. Внутри функции переменная $# сообщает, сколько аргументов было ей передано. Сценарий завершится с кодом возврата 99, если при его вызове не было передано никаких аргументов. Но при передаче конкретного значения, например, такого, как

show_usage 5

сценарий завершится с этим кодом после вывода сообщения об использовании сценария. (Переменная $? содержит статус завершения последней выполненной команды, независимо от того, была ли она использована в сценарии или в командной строке.)

Между функциями и командами в оболочке bash существует строгая аналогия. Вы можете определить полезные функции в своем файле ~/.bash_profile, а затем использовать их в командной строке как команды. Например, если ваш узел стандартизировал в сети порт 7988 для протокола SSH (в форме “безопасность через сокрытие”), вы можете определить в своем файле ~/.bash_profile функцию ssh, чтобы быть уверенным в том, что она всегда будет запускаться (как команда) с ключом -р 7988.

function ssh {
    /usr/bin/ssh  7988 $*
}

Подобно многим командным оболочкам, bash обладает механизмом псевдонимов (alias), который может репродуцировать этот ограниченный пример в еще более лаконичном виде, тем не менее функции остаются более универсальным и более мощным средством. Поэтому забудьте о псевдонимах и просто используйте функции.

Область видимости переменных

Переменные в рамках сценария имеют глобальный статус, но функции могут создавать собственные локальные переменные с помощью объявления local. Рассмотрим следующий код.

#!/bin/bash

function localizer {
    echo "==> В функции localizer начальное значение а равно '$а' "
    local а
    echo "==> После объявления local значение а стало равным '$а' "
    a="localizer version"
    echo "==> При выходе из функции localizer значение а равно '$а' "
}
a="test"
echo "До вызова функции localizer значение а равно '$а' "
localizer
echo "После вызова функции localizer значение а равно '$а' "

По приведенным ниже результатам выполнения сценария scopetest.sh видно, что локальная версия переменной $а внутри функции localizer “забивает” глобальную переменную $а. Глобальная переменная $а видна в функции localizer до тех пор, пока не встретится объявление local, т.е. по сути, объявление local работает как команда, которая в момент выполнения создает локальную переменную.

$ sh scopetest.sh
До вызова функции localizer значение а равно 'test'
==> В функции localizer начальное значение а равно 'test'
==> После объявления local значение а стало равным ' '
==> При выходе из функции localizer значение а равно 'localizer version'
После вызова функции localizer значение а равно 'test'

Поток управления

Выше в этой главе мы уже рассмотрели несколько конструкций if-then и if-thenelse (они работают вполне ожидаемым образом). Терминатором (признаком конца) для оператора if служит оператор fi. Для образования цепочки if-операторов можно использовать ключевое слово elif, означающее “else if”.

if [ $base -eq 1 ] && [ $dm -eq 1 ]; then
    installDMBase
elif [ $base -ne 1 ] && [ $dm -eq 1 ]; then
    installBase
elif [ $base -eq 1 ] && [ $dm -ne 1 ]; then
    installDM
else
    echo '==> Installing nothing'
fi

Как и специальный [] -синтаксис для выполнения операций сравнения, так и “ключеподобные” имена операторов целочисленного сравнения (например -eq) уходят “наследственными корнями” в использование утилиты /bin/test из ранней командной оболочки Стивена Борна. В действительности квадратные скобки — это не что иное, как условное обозначение вызова утилиты test; они не являются частью оператора if.

В табл. 2.2 собраны bash-операторы сравнения для чисел и строк. В отличие от Perl, в bash используются текстовые операторы для чисел и символические операторы для строк.

В оболочке bash предусмотрены возможности оценки свойств файлов (снова-таки как освобождение от наследства /bin/test). Некоторые из операторов тестирования и сравнения файлов приведены в табл. 2.3.

Несмотря на всю полезность формы elif, зачастую лучше (с точки зрения ясности программного кода) использовать case-структуру выбора варианта. Ниже показан ее синтаксис на примере функции, которая централизирует процесс регистрации сообщений для сценария. Конкретные варианты описываются закрывающими скобками после каждого условия и двумя точками с запятой, завершающими блок операторов, который должен быть выполнен при реализации заданного условия. Оператор case завершается ключевым словом esac.

# Уровень протоколирования устанавливается в глобальной
# переменной LOG_LEVEL. Возможные варианты перечислены в порядке
# от самого строгого до наименее строгого: Error, Warning, Info и Debug.
function logMsg {
    message_level-$l
    message_itself=$2
    if [ $message_level -le $LOG_LEVEL ]; then
        case $message_level in
            0) message_level_text="Error" ;;
            1) message_level_text="Warning" ;;
            2) message_level_text="Info" ;;
            3) message_level_text="Debug" ;;
            *) message_level_text="Other"
        esac
        echo "${message_level_text}: $message_itself"
    fi
}

Эта функция иллюстрирует общепринятую парадигму “уровня регистрации” (log level), используемую многими приложениями административного характера. Код этого сценария позволяет генерировать сообщения на различных уровнях детализации, но действительно регистрируются или отрабатываются только те из них, которые “проходят” глобально устанавливаемый порог $LOG_LEVEL. Чтобы прояснить важность каждого сообщения, его текст предваряется меткой, описывающей соответствующий уровень регистрации.

Циклы

Конструкция for...in предназначена для упрощения выполнения некоторых действий для группы значений или файлов, особенно при универсализации файловых имен, т.е. замене реальных символов в имени и расширении универсальными (например “*”и “?”) с целью формирования целых списков имен файлов. Шаблон *.sh в приведенном ниже цикле for позволяет обработать целый список совпадающих с ним (шаблоном) имен файлов из текущего каталога. Оператор for, проходя по этому списку, по очереди присваивает имя каждого файла переменной $file.

#!/bin/bash
suffix=BACKUP--`date +%Y%m%d-%H%M`
for script in *.sh; do
    newname="$script.$suffix"
    echo "Copying $script to $newname..."
    cp $script $newname
done

Результат выполнения этого сценария таков.

$ sh forexample
Copying rhel.sh to rhel.sh.BACKUP--20091210-1708...
Copying sles.sh to sles.sh.BACKUP--20091210-1708...

В раскрытии имени файла здесь нет ничего магического; все работает в точном соответствии с тем, как написано в командной строке. Другими словами, сначала имя файла раскрывается (т.е. шаблон заменяется существующим именем), а затем уж обрабатывает- ся интерпретатором в развернутом виде.

С таким же успехом вы могли бы ввести имена файла статически, как показано в этой командной строке.

for script in rhel.sh sles.sh; do

В действительности любой список имен, содержащих пробельные символы (включая содержимое переменной), обрабатывается как объект циклической конструкции for...in.

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

for (( i=0 ; i < $CPU_COUNT ; i++ )) ; do
CPU_LIST="$CPU_LIST $i"
done

На примере следующего сценария иллюстрируется bash-цикл while, который часто применяется для обработки аргументов командной строки и чтения строк файла.

#!/bin/bash
ехес 0<$1
counter=l
while read line; do
echo "$counter: $line"
$((counter++))
done

Вот как выглядит результат выполнения этого сценария.

ubuntu$ sh whileexainple /etc/passwd
1: root:х:0:0:Superuser:/root:/bin/bash
2: bin:x:l:l:bin:/bin:/bin/bash
3: daemon:x:2:2:Daemon:/sbin:/bin/bash

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

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

Выражение $((counter++)) выглядит несколько странно. Обозначение $((...)) говорит о вычислении выражения. Кроме того, оно делает необязательным использование символа $ для обозначения имен переменных. Удвоенный знак “плюс” (++) — это оператор постинкремента, знакомый, например, по языку С. Он возвращает значение переменной, с которой он связан, но также имеет побочный эффект, который состоит в приращении значения этой переменной.

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

while read line; do
echo "$((counter++)) : $line"
done

Массивы и арифметика

Сложные структуры данных и вычисления нельзя отнести к сильной стороне оболочки bash. Но, по крайней мере, вы можете надеяться на использование массивов и арифметических операций.

Все bash-переменные представляют собой строковые значения, поэтому оболочка bash не делает различия в присваиваниях между числом 1 и символьной строкой “1”. Различие лежит в использовании переменных. Следующий код иллюстрирует это различие.

#!/bin/bash

а=1
b=$((2))

с=$а+$b
d=$(($a+$b))

echo "$а + $b = $с \t(знак плюс как строковый литерал)"
echo "$а + $b = $d \t(знак плюс как арифметическое сложение)"

При выполнении этого сценария получим такой результат.

1 + 2 = 1+2 (знак плюс как строковый литерал)
1 + 2 = 3 (знак плюс как арифметическое сложение)

Обратите внимание на то, что знак “плюс” в присваивании переменной $с не работает как оператор конкатенации для строк. Он остается всего лишь литеральным символом, и посему эта строка эквивалентна следующей.

с="$а+$b"

Для того чтобы добиться вычисления, необходимо заключить выражение в двойные скобки: $((...)), как показано выше в присваивании переменной $d. Но даже эта мера предосторожности не позволяет получить в переменной $d числового значения; это значение по-прежнему хранится в виде строки “3”.

В оболочке bash реализован обычный ассортимент операторов: арифметических, логических и отношений (подробнее см. соответствующие man-страницы).

Массивы в командной оболочке bash могут показаться немного странными объектами (да они и используются не очень часто). Тем не менее при необходимости их можно применять. Литеральные массивы ограничиваются круглыми скобками, а отдельные элементы разделяются пробельными символами. Для включения литеральных пробелов в элемент можно использовать кавычки.

example=(aa 'bb сс' dd)

Для доступа к отдельным элементам массива используйте выражение ${имя_ массива [индекс]}. Индексация начинается с нуля. Такие индексы, как “*” и “@”, относятся к массиву в целом, а специальные конструкции ${#имя_массива[ * ] } и ${#имя_массива[ @ ] } возвращают количество элементов в массиве. Не спутайте эти выражения с конструкцией ${#имя_массива} — и хотя эта форма кажется более логичной, но в действительности она содержит указатель на первый элемент массива (эквивалент для ${#имя_массива[0]}).

Можно подумать, что выражение $example[1] должно служить однозначной ссылкой на второй элемент массива, но оболочка bash интерпретирует эту строку так: $example (обозначение ссылки на $example[0]) плюс литеральная строка [1]. Отсюда вывод: ссылаясь на переменные массива, всегда используйте фигурные скобки (без каких-либо исключений).

Рассмотрим сценарий, который иллюстрирует некоторые особенности bash-мacсивов и подводные камни, на которые можно наткнуться при управлении ими.

#!/bin/bash

    example=(aa 'bb сс' dd)
    example[3]=ее

    echo "example[@] = ${example[0]}"
    echo "Массив example содержит ${#example[@]} элементов"

for elt in "${example[8] }"; do
        echo " Элемент = $elt"
    done

Вот как выглядит результат выполнения этого сценария.

$ sh arrays
example [@] = аа bb сс dd ее
Массив example содержит 4 элемента
    Элемент = аа
    Элемент = bb сс
    Элемент = dd
    Элемент = ee

Этот пример кажется простым, но лишь потому, что мы организовали его “хорошее поведение”. Но если потерять бдительность, вы можете попасть в ловушку. Например, сценарий с заменой for-строки вариантом

for elt in ${example[@]}; do

(т.е. без кавычек, в которых заключено выражение массива) также работает, но вместо четырех элементов он выведет пять: аа, bb, сс, dd и ее.

Важно помнить, что все bash-переменные все равно остаются строками, поэтому работа с массивами — в некотором роде иллюзия. В тонкостях, связанных с тем, когда и как строки разбиваются на элементы, можно утонуть. Для того чтобы не рисковать, лучше используйте язык Perl или Python. Настойчивые читатели, желающие разобраться в нюансах, могут с помощью Google обратиться к руководству Менделя Купера (Mendel Cooper) Advanced Bash-Scripting Guide.

Last updated