Практическое написание сценариев командной оболочки Bash/Команды

← Bash подстановки Глава Команда read →
Команды


Основной задачей командной оболочки, как ни странно, является запуск команд. Все что вы пишите в сценарии является командами, собранными в списки и, возможно, обернутые циклами и/или условиями. Даже рассмотренные нами ранее функции это лишь способ порождать новые команды, не прибегая к другому языку программирования. В этом разделе мы рассмотрим принципы, которым следуют любые команды любого сценария. Собственно эти правила устанавливает сама *nix-система, а не командная оболочка. Тем не менее, их важно знать и понимать при написании сценария.

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

Командная оболочка, с точки зрения операционной системы, это не более чем *nix-процесс. Как и любой другой процесс, командная оболочка:

  • Позволяет открывать/закрывать файловые дескрипторы, связанные с оболочкой. Через файловые дескрипторы процесс получает данные из файлов, путем чтения файловых дескрипторов, либо отдает данные в файлы, путем записи в них. На другом конце дескриптора могут быть различные типы файлов, поэтому операции записи и чтения могут иметь разный эффект, который зависит от того, на какой тип файла ссылается дескриптор, но на данном этапе важно понять, что оболочка дает разработчику некоторые инструменты для работы с дескрипторами.
  • Открывает три стандартных дескриптора с номерами 0, 1 и 2. Дескриптор 0 называют стандартным потоком ввода (STDIN); 1 — стандартным потоком вывода (STDOUT); 2 — стандартным потоком ошибок (STDERR). В интерактивном режиме STDIN связывается с буфером клавиатуры, поэтому оболочка и ждет, пока он заполнится (пользователь введет команду). Когда оболочке передается сценарий, то STDIN перекрывается файлом сценария. STDOUT и STDERR по умолчанию связываются с драйвером терминального устройства (TTY), либо ведомой частью псевдотерминала. В зависимости от того, с каким устройством вывода драйвер работает, вывод попадает на то или иное устройство, либо попадает в некоторый файл. Разница между STDOUT и STDERR связана с тем, что вывод через STDERR не буферизуется.
  • Может назначать обработчики асинхронных сигналов операционной системы (команда trap).
  • Имеет буфер переменных окружения, которые передаются в момент создания процесса.
  • Имеет буфер входящих аргументов (аргументы команды, передаваемых командной оболочкой).

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

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

Некоторые ошибки начинающих программистов связаны с тем, что порой они не понимают в каком контексте исполняются их команды в сценарии.

Основы теории управления заданиями править

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

Начнем с ключевых понятий группы процессов и сессии. Группой процессов называется один и более процессов с одинаковым идентификатором PGID (Process Group Identifier). Каждый новый процесс всегда либо порождает новую группу и становится ее лидером, либо прикрепляется/переходит в существующую группу и наследует PGID лидера группы.

Одна или несколько групп могут работать в рамках одной сессии. Собственно сессия — это одна или несколько групп процессов с одинаковым идентификатором SID (Session Identifier). Внутри сессии также выделяется так называемый лидер сессии.

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

Внутри одной сессии одна из существующих групп процессов является активной или находящейся на переднем плане (in foreground). В то же время остальные группы работают в фоновом режиме (in background). Только процессы активной группы могут читать ввод с терминала и принимать сигналы с терминала (например SIGINT, SIGQUIT, SIGTSTP). Главной особенностью лидера сессии является то, что он может управлять терминалом, а также именно лидер сессии получает сигнал SIGHUP при разрыве соединения с ним.

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

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

Новая группа процессов, запускаемая из оболочки, обычно сразу становится активной, т.е. может получать ввод с терминала, однако команды и конвейеры, запускаемые с амперсандом (&) на конце, становятся частью фоновой группы процессов.

В такой системе управление заданиями сводится к следующим операциям:

  • Порождение новых групп процессов;
  • Приостановка/продолжение групп процессов внутри сессии;
  • Перемещение некоторой группы процессов в фоновый режим и переключение терминала на другую группу;
  • Рассылка сигналов в процессы через отправку их в отдельно взятую группу или даже сессию;
  • Ожидание лидерами групп процессов завершения своих потомков;
  • Перемещение процессов между группами и сессиями.

Категории команд править

В командной оболочке все команды делятся на две категории: внутренние (builtins) и внешние. Основной состав команд устанавливают стандарты POSIX, причем некоторые из команд для командной оболочки могут быть внутренними. Командные оболочки могут добавлять свои внутренние команды, которые в чем то улучшают работу интерпретатора, но с другой стороны вносят некоторую непереносимость между командными оболочками и даже системами.

О том, внутренняя команда сейчас исполняется или внешняя важно знать потому, что все внешние команды исполняются в подоболочках, тогда как внутренние исполняются в контексте оболочки, которая их вызвала. Compound-выражения не являются ни внутренними, ни внешними командами, а скорее скрывают от вас некоторые дополнительные действия, исполняемые над командными списками внутри интерпретатора. Тем не менее, со стороны compound-выражение ведет себя как внутренняя команда, поэтому для простоты мы будем относить такие выражения к внутренним командам.

Список внутренних команд Bash можно получить с помощью команды compgen с ключом -b:

$ compgen -b | while (( COUNTER++ )); read REPLY || [[ -n $REPLY ]]; do printf "$REPLY "; (( COUNTER % 8 == 0 )) && echo; done

. : [ alias bg bind break builtin 
caller cd command compgen complete compopt continue declare
dirs disown echo enable eval exec exit export
false fc fg getopts hash help history jobs
kill let local logout mapfile popd printf pushd
pwd read readarray readonly return set shift shopt
source suspend test times trap true type typeset
ulimit umask unalias unset wait

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

Давайте рассмотрим несколько показательных примеров.

#!/bin/bash

# Строка с технологическими переменными
LINE='${FUNCNAME:-MAIN}:\\n  BASHPID=$BASHPID\\n  \$\$=$$\\n  TTY=$(tty)\\n  LEVEL[1]=$BASH_SUBSHELL\\n  LEVEL[2]=$SHLVL\\n  \$@=$@\\n'

# Для эксперимента мы объявим функцию
custom_cmd() {
    eval echo -e "$LINE"
}

# Технологическая строка на главном уровне сценария
eval echo -e "$LINE"

# Вызов функции на главном уровне сценария
custom_cmd "function"

# Вызов функции в подоболочке
(custom_cmd "subshell")

# Вызов функции в подоболочке, но с отделением от родительской
custom_cmd "detached subshell" &
wait # Ждем завершения параллельной подоболочки. Мы используем эту команду, чтобы вывод параллельной задачи не смешивался.

# Попробуем вызвать функцию в командном списке условия
if custom_cmd "in if condition"; then
    :
fi

# Попробуем вызвать функцию в условии цикла
until custom_cmd "in until condition"; do
    :
done

# Вывод из подстановки
echo -e "$(custom_cmd "command result substitution")\n"

# Попробуем вызвать функцию в конструкции case..esac
case "test" in

    *) custom_cmd "in case..esac branch" ;;

esac

Вывод будет такой

MAIN:
 BASHPID=347  
 $$=347       
 TTY=/dev/tty2
 LEVEL[1]=0   
 LEVEL[2]=2   
 $@=

custom_cmd:   
 BASHPID=347  
 $$=347       
 TTY=/dev/tty2
 LEVEL[1]=0   
 LEVEL[2]=2   
 $@=function  

custom_cmd:   
 BASHPID=350  
 $$=347       
 TTY=/dev/tty2
 LEVEL[1]=1   
 LEVEL[2]=2   
 $@=subshell  

custom_cmd:   
 BASHPID=352  
 $$=347       
 TTY=not a tty       
 LEVEL[1]=1
 LEVEL[2]=2
 $@=detached subshell

custom_cmd:
 BASHPID=347
 $$=347
 TTY=/dev/tty2       
 LEVEL[1]=0
 LEVEL[2]=2
 $@=in if condition  

custom_cmd:
 BASHPID=347
 $$=347
 TTY=/dev/tty2       
 LEVEL[1]=0
 LEVEL[2]=2
 $@=in until condition

custom_cmd:
 BASHPID=356
 $$=347
 TTY=/dev/tty2
 LEVEL[1]=1
 LEVEL[2]=2
 $@=command result substitution

custom_cmd:
 BASHPID=347
 $$=347
 TTY=/dev/tty2
 LEVEL[1]=0
 LEVEL[2]=2
 $@=in case..esac branch
Обратите внимание на следующие моменты
  • Корневая командная оболочка самого сценария была развернута в процессе с PID равным 347. Далее, сравнивая с этим PID, мы можем понять, где подоболочка, а где корневая оболочка. Обратите внимание, что корневая оболочка (лидер сессии) занял терминальное устройство через драйвер /dev/tty2. Вы можете убедиться, что функция по умолчанию исполняется в корневой оболочке как внутренняя команда. Если мы хотим исполнить функцию в подоболочке, то мы используем круглые скобки, либо амперсанд (&), чтобы исполнить команду параллельно, либо вызываем команду в подстановке результата функции. Здесь этого не показано, но команды также исполняются в подоболочках, если они вызываются в конвейере, а также в подстановках результата процесса.
  • Вы можете убедиться, что подоболочки наследуют открытые дескрипторы от родительской оболочки, кроме случая, когда команда исполняется параллельно, где STDIN отсоединяется от устройства ввода терминала. Подоболочки также наследуют переменные окружения у родительской оболочки, что далее будет показано.
  • Переменная BASHPID всегда показывает настоящий PID оболочки, в которой исполняется команда. Переменная $$ показывает PID только родительской оболочки (т.е. фактически указывает на лидера сессии).
  • Вы можете убедиться, что compound-выражения не исполняют свои командные списки в подоболочках, тем не менее, вам не запрещено исполнять их в подоболочках используя, например, круглые скобки. Во многом использование данной возможности зависит от ситуации.
  • В ваших сценариях вы можете определять, на каком уровне вложенности подоболочек находится сейчас интерпретатор с помощью переменных BASH_SUBSHELL и SHLVL. Эти переменные похожи, но показывают вложенность относительно разных точек:
    • BASH_SUBSHELL показывает реальную текущую вложенность, не принимая в расчет текущий сеанс пользователя, начиная с нулевого значения, т.е. 0 соответствует корневой оболочке для самого сценария, а каждое последующее значение соответствует вложенности дочерней оболочки, которые ветвились от этой корневой оболочки.
    • SHLVL показывает вложенность корневой оболочки, учитывая сеанс пользователя. Причем эта переменная начинает считать с единицы.

Чтобы уловить разницу между BASH_SUBSHELL и SHLVL, воспользуемся таким примером

# В интерактивном режиме введите
$ ( ( ( (echo "$BASH_SUBSHELL:$SHLVL"))))
4:1

# Обратите внимание, что пробелы обязательны, чтобы интерпретатор не запутался между операторами () и (( )).
# Значение 4 означает, что команда echo выполнилась на 4-м уровне вложенности относительно оболочки, в котороую мы сейчас вводим команды.

# Теперь в текущем сеансе командной оболочки откройте еще один, вызвав команду bash
$ bash
# Мы теперь на втором уровне вложенности командной оболочки. Та же команда вернет следующее:
$ ( ( ( (echo "$BASH_SUBSHELL:$SHLVL"))))
4:2

# Значение 4 означает то же самое, что и раньше, а SHLVL увеличился на единицу.

# Поробуем создать еще один вложенный сеанс
$ bash
# Третий уровень.
$ ( ( ( (echo "$BASH_SUBSHELL:$SHLVL"))))
4:3

# Нажмите Ctrl+D или введите exit, чтобы закрыть текущую оболочку
$ exit
# Снова второй уровень.
$ exit
# Первый уровень
$ echo "$BASH_SUBSHELL:$SHLVL"
0:1

Запуская сценарий, значение SHLVL обычно всегда равняется 2, так как все сценарии всегда запускаются в подоболочке. Обычно сценарии не порождают новые уровни вложенности сеанса, поэтому значение 2 может подсказывать, что команда исполняется в сценарии.

Запуск в подоболочках править

Для простого запуска в подоболочке командного списка, достаточно заключить его в круглые скобки:

# Одной строкой
(<команда 1>;<команда 2>; ...<команда N>)

# В несколько строк
(
   <команда 1>
   <команда 2>
   ...
   <команда N>
)

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

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

Наверное основной мотивацией использования подоболочек является возможность изменять ключевые переменные оболочки без опаски «задеть» последующий код. Например, следующий пример демонстрирует изменение ключевой переменной IFS, которая отвечает за разбиение списков по словам.

LIST_1="one two three" # Этот список разделяется пробелами
LIST_2="one:two:three" # Этот список разделяется символом двоеточия

(
    IFS=":" # Чтобы вывести все элементы второго списка построчно, мы присваиваем IFS символ разделителя
    # Здесь IFS всего лишь копия.
    echo "Printing the list 2 ..."
    for item in $LIST_2; do
        echo "$item"
    done    
)

# В корневой оболочке IFS не изменилась.
echo "Printing the list 1 ..."
for item in $LIST_1; do
    echo "$item"
done

В результате оба списка будут выведены построчно правильно

Printing the list 2 ...
one
two
three
Printing the list 1 ...
one
two
three

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

# Пропускает очередное вхождение через цепочку обязанностей
parseBddEntry() {
    (
        RC=$?
        [[ RC -ne 0 ]] && exit $RC
        [[ ${1,,} == 'given' ]] || exit 0
        shift
        echo "GIVEN: $@" # Аргументы являются копиями, переданными функции parseBddEntry.
        exit 1 # Этот exit завершает только данную подоболочку.
    )
    (
        RC=$? # Переменная $? была скопирована из корневой оболочки, т.е. в ней фактически
        # хранится статус последней команды, вызванной в parseBddEntry, которой является
        # предыдущая подоболочка. Если подоболочка возвращает 0, то это означает, что она передает
        # обязанность далее по цепочке. Если не ноль, то значит процедура взяла обязанность на себя.
        [[ RC -ne 0 ]] && exit $RC
        [[ ${1,,} == 'when' ]] || exit 0
        shift
        echo "WHEN: $@"
        exit 2
    )
    (
        RC=$?
        [[ RC -ne 0 ]] && exit $RC
        [[ ${1,,} == 'then' ]] || exit 0
        shift
        echo "THEN: $@"
        exit 3
    )
    (
        RC=$?
        [[ RC -ne 0 ]] && exit $RC
        [[ ${1,,} == 'and' ]] || exit 0
        shift
        echo "AND: $@"
        exit 4
    )
    (
        RC=$?
        [[ RC -ne 0 ]] && exit $RC
        echo "Warning: unknown entry: $@"
        exit 5
    )
    return $?
}

NEWLINE=$'\n'

# Тест, написанный в стиле BDD
BDD_TEST="Given two numbers: 2 and 2
When I add the first number with the second
Then I should get a total of 4
"

# Мы переопределяем переменную, чтобы разбирать тест только по строкам
IFS="$NEWLINE"
for msg in $BDD_TEST
do
    # Мы запускаем функцию разбора в подоболочке только для того, чтобы снова
    # переопределить IFS, чтобы разбить msg по пробелам и передать ее функции разными аргументами
    (IFS=' '; parseBddEntry $msg)
    echo "  Status: Handled by $?"
done

Результат работы процедуры представлен ниже

GIVEN: two numbers: 2 and 2
  Status: Handled by 1     
WHEN: I add the first number with the second
  Status: Handled by 2
THEN: I should get a total of 4
  Status: Handled by 3

Данный пример является искусственным, но он демонстрирует, что вызов командного списка в подоболочках является по сути написанием сценариев внутри сценариев. Здесь мы прогоняем многострочный BDD тест через функцию parseBddEntry. Каждая строка проходит через все звенья цепочки обязанностей в надежде, что одно из них распознает первое слово и возьмет исполнение вхождения на себя. В этом примере все строки в любом случае пройдут по всей цепочке, но только в одном звене будет исполнена процедура из-за механизма проверки статуса последнего звена.

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

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

#!/bin/bash

LOCK_FILE="/tmp/JKhlgGJk87.lock"       # Имя файла-блокировки.

# Команда set -C запрещает перезаписывать уже существующие файлы. Таким образом, первый запуск создаст
# файл блокировки командой : > "$LOCK_FILE". Последующие запуски будут проваливаться на этой команде,
# заставляя завершить сценарий в самом начале.
if ! (set -C; : > "$LOCK_FILE") 2> /dev/null; then
    echo "ERROR: Cannot launch the script: It is already in progress."
    exit 65
fi
trap "rm -f $LOCK_FILE" EXIT SIGKILL   # Чтобы гарантированно снимать блокировку после завершения сценария.

# Ваш код
read

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

#!/bin/bash

LOCK_FILE="/var/lock/$(basename $0)" # Создаем блокировку более корректно, в системной директории.

(
    flock -nx 99 || exit 65 # Пытаемся захватить блокировку эксклюзивно или сразу выходим.
    trap "rm -f $LOCK_FILE" EXIT SIGKILL # Чтобы гарантированно удалить файл блокировки.
    
    # Ваш код
    read

) 99> "$LOCK_FILE" # Файл блокировки создается путем связывания его с 99 дескриптором.
RC=$?
[[ RC -eq 65 ]] && echo "ERROR: Cannot launch the script: It is already in progress."
exit $RC

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

#!/bin/bash
# Файл: what-a-number.sh
RESULT=$(
    (echo -n "$1" | grep -oE [-+[[:digit:]]]*) &> /dev/null
    [[ $? -eq 0 ]] || { echo "Not a number"; exit 1;}
    if [[ $1 -gt 0 ]]; then
        echo "Positive number"
    elif [[ $1 -lt 0 ]]; then
        echo "Negative number"
    else
        echo "Number is zero"
    fi
    exit 0
)
if [[ $? -eq 0 ]]; then
    echo "$RESULT"
else
    echo "WARNING [$?]: $RESULT"
fi

Данный сценарий получает на вход число, проверяет его, а затем выводит результат о том, больше или меньше оно нуля, либо равно нулю. Если пользователь передал не число, то сценарий так и напишет «не число».

Здесь весь анализ и вывод ответа делается в подоболочке. Корневая оболочка, в свою очередь, анализирует результат работы подоболочки и выводит строку, которая из нее приходит.

$ what-a-number.sh 5
Positive number
$ what-a-number.sh -32
Negative number
$ what-a-number.sh abc
WARNING [1]: Not a number

Однако будьте осторожны, когда используются declare и local:

#!/bin/bash

declare STRING=$(echo "Hello"; exit 3)

echo "$?" # НЕПРАВИЛЬНО: На самом деле выводится код команды declare, который 
          #         перекрывает настоящий код возврата 3.

# ПРАВИЛЬНО

declare STRING
STRING=$(echo "Hello"; exit 3)
echo "$?" # Увидим 3, как и ожидалось

# АНАЛОГИЧНО С local

custom() {
	local str
	str=$(echo "Hello"; exit 3)
}

STRING=$(custom)
echo "$?" # Увидим 3, как и ожидалось

Командная оболочка не дает возможности разделять переменные между оболочками в runtime, другими словами нельзя вернуть измененное значение переменной в корневую оболочку через простое присваивание. Для какого-либо обмена информацией следует использовать файлы. В простом случае достаточно использовать конвейер.

Запуск команд в конвейере править

Конвейер (pipeline) — это инструмент, который позволяет вам автоматически связать несколько команд по принципу «выход одной является входом для другой». В конвейер связываются команды, которые в качестве входа для данных используют STDIN.

Конвейер объявляется так:

команда_1 | команда_2 | команда_3 ... | команда_N

В этой цепочке команда_1 производит данные и отправляет их в STDOUT, который через специальный pipe-файл связывается с STDIN команда_2. Аналогично, STDOUT команда_2 связывается с STDIN команда_3, но уже через другой pipe-файл и так далее. Таким образом, произведенные данные с левого края конвейера, проходя по его узлам и преобразуясь некоторым образом в них, попадают в STDOUT на правом крае конвейера. Помните, что все узлы конвейера запускаются в подоболочках. К моменту начала работы с данными, все подоболочки уже развернуты в процессах. Все процессы конвейера запускаются в одной группе процессов, т.е. они могут работать на переднем плане или в фоне только одновременно. В группе процессов связанных конвейером, процесс порождаемый самой левой командой становится лидером группы.

По умолчанию связываются только пары STDOUT и STDIN, поэтому вывод STDERR будет теряться. Чтобы он не терялся, вы должны перенаправлять STDERR в STDOUT вручную, например

команда_1 2>&1 | команда_2

В Bash, начиная с версии 4.2, чтобы это проделывать, можно использовать операцию |&:

команда_1 |& команда_2

В очень редких случаях, когда вам интересно только то, что попадает в STDERR, можно заглушать STDOUT:

команда_1 2>&1 >/dev/null | команда_2

Помните, при работе с дескрипторами порядок перенаправления важен, т.е. сначала STDERR должен стать дублером STDOUT и только потом STDOUT может быть заглушен через >/dev/null.

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

set -o pipefail

Рассмотрим несколько примеров с конвейерами.

Обычно конвейерами пользуются когда весь процесс направлен на разбор большого списка слов и для фильтрации. В следующем примере мы разбиваем список всех разделяемых библиотек в каталоге /usr/lib на три колонки, по 30 символов в каждой. Если имя библиотеки больше 30 символов (а такие есть), то ее имя занимает отдельную строку. Ниже показан сценарий, который выполняет указанные действия

#!/bin/bash

find /usr/lib -type f -name *.so \
    | while read REPLY || [[ -n $REPLY ]]; do 
        echo "${REPLY##*/}"; done \
    | while (( COUNTER++ )); read REPLY || [[ -n $REPLY ]]; do 
        if [[ ${#REPLY} -gt 30 ]]; then 
            (( (COUNTER-1) % 3 != 0  )) && echo; COUNTER=0; printf '%*s\n' 30 "$REPLY" 
        else printf '%*s' 30 "$REPLY"; (( COUNTER % 3 == 0 )) && echo
        fi 
    done

Результат работы в укороченном виде будет таким

klibc-xcgdUApi-P9SoPhW_fi5gXfvWpw.so
               libman-2.9.1.so             libmandb-2.9.1.so          libcheckcciss_tur.so
           libcheckdirectio.so       libcheckemc_clariion.so              libcheckhp_sw.so
               libcheckrdac.so        libcheckreadsector0.so                libchecktur.so
            libforeign-nvme.so                libprioalua.so                 libprioana.so
               libprioconst.so            libpriodatacore.so                 libprioemc.so
                 libpriohds.so               libpriohp_sw.so                 libprioiet.so
               libprioontap.so        libpriopath_latency.so              libpriorandom.so
                libpriordac.so               libpriosysfs.so        libprioweightedpath.so
              libhgfsServer.so                     libvix.so         libdeployPkgPlugin.so
               libguestInfo.so                libpowerOps.so           libresolutionKMS.so
                libtimeSync.so                libvmbackup.so
apt_inst.cpython-38-x86_64-linux-gnu.so
apt_pkg.cpython-38-x86_64-linux-gnu.so
        _constant_time.abi3.so              _openssl.abi3.so              _padding.abi3.so
_gi.cpython-38-x86_64-linux-gnu.so
_speedups.cpython-38-x86_64-linux-gnu.so
               _sodium.abi3.so
netifaces.cpython-38-x86_64-linux-gnu.so
netifaces.cpython-38d-x86_64-linux-gnu.so

.......

                 libxt_ipvs.so                  libxt_LED.so               libxt_length.so
                libxt_limit.so                  libxt_mac.so                 libxt_MARK.so
                 libxt_mark.so            libxt_multiport.so               libxt_nfacct.so
                libxt_NFLOG.so              libxt_NFQUEUE.so                  libxt_osf.so
                libxt_owner.so              libxt_physdev.so              libxt_pkttype.so
               libxt_policy.so                libxt_quota.so              libxt_RATEEST.so
              libxt_rateest.so               libxt_recent.so             libxt_rpfilter.so
                 libxt_sctp.so              libxt_SECMARK.so                  libxt_SET.so
                  libxt_set.so               libxt_socket.so             libxt_standard.so
            libxt_statistic.so               libxt_string.so             libxt_SYNPROXY.so
                  libxt_tcp.so               libxt_TCPMSS.so               libxt_tcpmss.so
          libxt_TCPOPTSTRIP.so                  libxt_TEE.so                 libxt_time.so
                  libxt_TOS.so                  libxt_tos.so               libxt_TPROXY.so
                libxt_TRACE.so                  libxt_u32.so                  libxt_udp.so

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

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

#!/bin/bash

declare -a NAMES=(Alice Matt Bob Brad Henry Rodney Bart Homer Liza Kyle Butters John)
declare -a SURNAMES=(Simpson Trump Cooper Croner Davidson Harrys Coldest Drumster)

get_random() {
    local number
    while [[ $number -le $1 ]]; do
        number=$RANDOM
        : $((number %= $2))
    done
    printf "$number"
}

append_name() {
    while read REPLY || [[ -n $REPLY ]]; do
        echo "$REPLY ${NAMES[$(get_random 0 $((${#NAMES[@]} - 1)) )]}"
    done
}

append_surname() {
    while read REPLY || [[ -n $REPLY ]]; do
        echo "$REPLY ${SURNAMES[$(get_random 0 $((${#SURNAMES[@]} - 1)) )]}"
    done
}

for item in $(seq 1 10); do
    echo "$item. "
done | append_name | append_surname

Ниже показан результат запуска. Конечно многое зависит от генератора псевдослучайных чисел.

1. Butters Davidson
2. Brad Harrys     
3. Bob Harrys      
4. Homer Trump     
5. Brad Croner     
6. Liza Croner     
7. Bob Cooper
8. Brad Davidson
9. Henry Harrys
10. Matt Coldest

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

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

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

  • Эти задачи запускаются в подоболочке, но родительский PID (PPID) будет ссылаться на корневую оболочку, а не на процесс init. Задача будет зарегистрирована в таблице задач родительской оболочки, которую можно вывести командой jobs. По умолчанию, когда корневая оболочка завершается, она посылает сигнал SIGHUP всем задачам, входящих в её сессию, которые в свою очередь по умолчанию завершаются по этому сигналу. Чтобы предотвратить реакцию на этот сигнал, когда это не желательно, дочерняя задача должна запускаться через команду disown -h <команда> & или nohup <команда> &. Если вы программируете только для Bash, встроенной в оболочку команде disown нужно отдавать предпочтение, так как она может разрывать связь с родительской оболочкой в любой момент времени, а не только во время запуска, а также с ее помощью можно удалять задачу из таблицы с определенными опциями. Кроме Bash disown есть также в Ksh и Zsh.
  • В интерактивном режиме можно выводить задачи заднего плана на передний (in foreground) командой fg. В сценариях такая возможность обычно не используется.
  • Задачи заднего плана копируют контекст родительской оболочки, но не полностью. В частности, это означает, что стандартные потоки вывода задач заднего плана будут направлены на то же устройство вывода, что и у корневой оболочки, однако, стандартный поток ввода будет отсоединен. На практике это обычно выливается в то, что вывод задач заднего плана может перемешиваться с выводом корневой оболочки.
  • Корневая оболочка оставляет за собой право управлять командами, запущенными на заднем плане, и может следить за их исполнением (команды jobs, wait, fg, bg, disown и др.).

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

Для запуска команды (командного списка) или конвейера на заднем плане нужно поставить символ амперсанда (&) в самом конце конструкции:

<команда> & # Запуск одной команды
<команда_1> && <команда_2> & # Запуск конкатенации двух команд в параллель 
(<команда_1> ; <команда_2>) & # Запуск командного списка в параллель
<команда_1> | <команда_2> & # Запуск конвейера в параллель

Обратите внимание на следующие тонкие моменты:

  • Символ амперсанда всегда стоит в конце конструкции, которая запускается на фоне.
  • Амперсанд является разделителем команд. Таким образом, следующая запись синтаксически верная и означает запустить три команды, каждую в параллель корневой оболочке
    sleep 3 & sleep 3 & sleep 3 &
    
  • Если команды не объединяются &&, ||, конвейером или блоком, то символ амперсанда применяется только к последней команде:
    echo "a" ; echo "b" & # В параллель будет запущена только команда echo "b"
    
    # В данном случае нужно объединить команды в блок
    { echo "a"; echo "b"; } & # Теперь в параллель запускается блок команд
    

Конвейер может быть запущен в параллель из подоболочки:

(<команда_1> | <команда_2>) &

С одной стороны это эквивалентно

<команда_1> | <команда_2> &

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

Echo() {
    read -p "Enter something (to skip just press Enter key): "
    echo "$REPLY"
}

echo "-1-"
Echo | Echo | Echo & # станет активной группой вопреки &
jobs
wait
echo "-2-"
( Echo | Echo | Echo ) &
jobs
wait

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

-1-
Enter something (to skip just press Enter key): [1]+  Running                 Echo | Echo | Echo &
# Вводим что-нибудь или нажимаем Enter

-2-
[1]+  Running                 ( Echo | Echo | Echo ) &

После запуска подоболочки в параллель, родительская оболочка может узнать ее PID через встроенную переменную $!. Знание PID требуется при использовании команды wait.

#!/bin/bash

routine() {
    while true; do
        echo "$RANDOM"
        sleep 2
    done
}

job_killer() {
    sleep 10
    kill -SIGKILL $1
}

routine & # Запускаем процедуру с бесконечным циклом
ROUTINE_PID=$! # Запоминаем PID подоболочки, запущенной в параллель
echo "Run routine with pid=$ROUTINE_PID"

job_killer $ROUTINE_PID & # Запускаем другой параллельный процесс, который через 10 секунд убьет первую процедуру
KILLER_PID=$! # Запоминаем PID процесса-убийцы

wait $ROUTINE_PID $KILLER_PID 2>/dev/null # Ожидаем завершение дочерних подоболочек в родительской оболочке

# Если wait запускается без аргументов, то родительская оболочка будет ждать все подоболочки, запущенные в параллель.
# Можно также использовать внутренний номер задачи в таблице, но в сценариях как правило указывается PID.

exit 0

Контекст оболочки править

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

К следующим объектам контекста вы можете обратиться из сценария с помощью конструкций языка:

  • простые переменные или переменные, объявленные в сценарии;
  • сложные объекты: простые массивы, ассоциативные массивы;
  • аргументы текущей функции или, если функция не исполняется, аргументы сценария;
  • переменные окружения или экспортируемые переменные;
  • файловые дескрипторы.

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

По умолчанию, переменной не существует в контексте, пока ей не присвоено какое-то значение. Это позволяет вам использовать идентификаторы переменных без их объявления: они будут просто раскрываться в пустоту. На практике не объявленные переменные часто приводят к неявным ошибкам. Приведем несколько примеров.

if [[ $ID -eq 0 ]]; then
    echo "Do very important things ..."
fi

Если переменная ID не объявлена, то условие всегда будет ИСТИННЫМ. Если бы ID выполняла роль переключателя в какой-то очень важной процедуре, то неверная инициализация приводила бы к ложному срабатыванию (или не срабатыванию) кода, заключенному в условие.

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

# Если рассматривать численные значения переменных, то проверка через (( )) надежнее, чем [[ ]].
if (( $ID == 0 )); then                     # НАДЕЖНО, НО ПЛОХО ПЕРЕНОСИТСЯ ИЗ-ЗА (()):
    echo "Do very important things ..."     #  Если ID раскрывается в пустоту, то это приведет к ошибке интерпретатора.
fi
if (( ID == 0 )); then                      # НЕНАДЕЖНО, ЕСЛИ ИНИЦИАЛИЗАЦИЯ НЕ ГАРАНТИРУЕТСЯ:
    echo "Do very important things ..."     #  Если ID не определена, то условие всегда ИСТИННО, т.е. работает так же как [[ $ID -eq 0 ]].
fi

# Со строковыми значениями тоже много сложностей.
# Следующая проверка ИСТИННА, если переменная не пустая.
if [[ ${STRING} ]]; then                  # НЕ РЕКОМЕНДУЕТСЯ:
    echo "Do very important things ..."   #  Может работать, а может не работать, т.е. в разных оболочках поведение зависит от реализации команды test.
fi
if [[ -n ${STRING} ]]; then               # ПРАВИЛЬНО: Встроенная опция -n команды test проверит переменную на пустоту.
    echo "Do very important things ..."
fi

# Если проверка зависит от конкретного проверяемого значения, то инициализация должна гарантироваться или должны быть усложнены проверки.
if [[ -n ${STRING} && ${STRING} == "Unknown" ]]; then   # ПРАВИЛЬНО, ЕСЛИ ИНИЦИАЛИЗАЦИЯ НЕ ГАРАНТИРУЕТСЯ
    echo "Do very important things ..."
fi

В Bash можно защитить весь сценарий от не инициализированных переменных через специальную опцию интерпретатора:

#!/bin/bash
set -u # Эта опция запретит не проинициализированные переменные, поэтому сценарий не будет исполняться полностью,
       # пока ID не будет явно проинициализирована

#ID=0 # Раскомментируйте эту строку, чтобы сценарий заработал
if (( ID == 0 )); then
    echo "Do very important things ..."
fi

Инициализацию можно гарантировать следующей подстановкой

: ${ID:=-1} # Всегда будет чем-то проинициализирована
if (( ID == 0 )); then # ПРАВИЛЬНО: инициализация гарантированна
    echo "Do very important things ..."
fi

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

Массивы править

В оригинальном Bourne shell не было массивов, и они появились значительно позже, в клонах sh. В Bash поддержка массивов появилась благодаря тому, что она унаследовала синтаксис ksh88.

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

Массив в Bash можно породить множеством способов, но лучше придерживаться какого то одного в рамках одного сценария.

# Массив порождается при заполнении хотя бы одним элементом
simple_array[0]="value"          # Простой массив
associative_array[key]="value"   # Ассоциативный массив

# Простой массив порождается при инициализации
simple_array=()
simple_array=("value" "value")
simple_array=([0]="value" [1]="value") # Если считать цифры ассоциативными ключами, то простые массивы это частный случай ассоциативных

# Ассоциативные массивы также порождаются при инициализации
associative_array=([key1]="value" [key2]="value")

# В Bash через команду declare можно явно объявить массив
declare -a simple_array       # Простой массив
declare -A associative_array  # Ассоциативный массив

Для массивов предусмотрены особые подстановки для работы с ними.

# Простой массив
declare -a simple=(one two three four five six seven)

# Обращение к отдельному элементу по индексам
echo ${simple[2]} ${simple[0]} ${simple[1]}
# Разрешены отрицательные индексы, которые отсчитывают массив справа налево
echo ${simple[-1]} ${simple[-2]} ${simple[-3]}

# Вывести все элементы
echo ${simple[@]}
# или
echo ${simple[*]}

# Можно получать срез массива
echo ${simple[@]:2:3} # Начать с индекса 2 и взять 3 элемента: three four five

# Размер массива
echo ${#simple[@]}

Для ассоциативных массивов подстановки немного видоизменяются ввиду специфики хранения по ключам.

# Ассоциативный массив
declare -A ids=([Alice]=1521 [Bob]=6524 [John]=4831)

# Обращение к элементам по ключам
echo ${ids[Alice]} ${ids[John]} ${ids[Bob]}

# Вывести все значения массива
echo ${ids[@]}
# или
echo ${ids[*]}

# Вывести все ключи
echo ${!ids[@]}

# Вывести размер ассоциативного массива
echo ${#ids[@]}
Следует помнить о следующих особенностях реализации массивов
  • В простом массиве для добавления нового элемента в конец вам нужно знать последний не занятый номер. В Bash добавить элемент в конец простого массива можно по-разному:
    declare -a simple=(Alice Bob John)
    
    # Можно воспользоваться подстановкой, которая возвращает размер массива. Этот метод является универсальным.
    simple[${#simple[@]}]='Peter'
    
    # В Bash предусмотрен особый синтаксис для добавления элемента в конец.
    simple+=('James')
    
  • В простом массиве нельзя вставить новый элемент между двумя любыми другими без дополнительных ухищрений.
    declare -a simple=(Alice Bob John Peter Cory Liza)
    
    # Допустим мы хотим вставить James'a между Bob'ом и John'ом. Для этого мы должны построить новый массив,
    # в котором новый элемент мы вставляем между двумя срезами старого.
    simple=(${simple[@]:0:2} 'James' ${simple[@]:2:${#simple[@]}-1})
    
  • Удаление элементов из простого массива очень особенное. Чтобы удалить элемент из массива, нужно использовать команду unset. Технически значение будет удалено из массива, однако на месте элемента окажется «дырка», т.е. массив сам по себе не смещает правый от удаляемого элемента край для заполнения освободившегося места. Из-за этого, удаление элемента нужно производить так.
    declare -a simple=(Alice Bob John)
    
    echo ${#simple[@]}       # Сейчас размер 3.
    unset simple[1]          # Удаляем элемент Bob.
    echo "'${simple[1]}'"    # Запись фактически удалена.
    echo ${#simple[@]}       # Сейчас размер 2.
    echo ${simple[2]}        # По второму индексу у нас по-прежнему John, т.е.по индексу 1 образовалась дырка.
    
    simple=("${simple[@]}")  # Чтобы удалить дырку, нужно сформировать массив из остатка заново, чтобы индексы построились заново.
    
    echo ${simple[1]}        # Теперь John под индексом 1.
    echo ${#simple[@]}       # Размер по-прежнему 2.
    
  • При обращении к простому массиву без индекса, всегда выводится элемент с индексом 0. При этом к ассоциативному массиву обращаться без ключа нельзя.
    declare -a simple=(Alice Bob John)
    echo "$simple" # Alice
    

В Bash массивы нельзя передавать между командами, однако в Bash есть много возможностей писать прямо в массив (команды read и mapfile). Рассмотрим некоторые приемы работы с командой mapfile.

Команда mapfile является реализацией команды readarray в Bash и readarray может использоваться как псевдоним. Основной задачей этой команды является заполнение массива данными из некоторого источника по некоторым правилам. С этой командой следует быть осторожным, так как не все опции в ней переносятся между разными версиями Bash. Также могут быть различия в поведении: например опция может не работать, если не указать -c явно. При записи в массив данных из файла, использование mapfile предпочтительнее, чем read.

# Производит чтение потока с данными и переписывает его в массив. Если массив явно не указывается, то по умолчанию
# используется массив MAPFILE.

mapfile [-n count] [-O origin] [-s count] [-t] [-u fd] -d [delimeter]
        [-C callback [-c quantum]] [array]

# -n       Максимальное число строк, которые нужно прочитать из потока. Если не указано, будет читать все.
# -O       Указать индекс массива, с которого нужно начать запись в массив.
# -s       Число первых строк, которые нужно пропустить перед записью.
# -t       Удаляет символы перевода строки.
# -u       Номер читаемого файлового дескриптора. Если не указан, то читается стандартный поток ввода.
# -d       Символ разделителя фрагментов. По умолчанию используется символ разделителя строки.
# -C       Позволяет определить функцию или фрагмент кода, который вызывается после прочтения определенного числа строк.
# -c       Пороговое число строк, после прочтения которых вызывается функция или код, указанный с опцией -C.
# array    Имя массива, в который нужно писать ввод. Если не указан, то используется MAPFILE.

В следующем примере мы переписываем данные, предоставленные другой подоболочкой, в массив.

#!/bin/bash

# Функция, которая будет вызываться после каждого чтения
callback() {
    printf "Callback:\n  Line: %d\n  Data: '%s'\n" "$(($1 + 1))" "$2"
}

# Используйте -t, чтобы затирать символы перевода строки, иначе они попадут в массив.
mapfile -t -C 'callback' -c 1 < <(
cat <<EOF
Alice
Bob
John
EOF
)
echo
echo "--- RESULT ---"
printf "%s\n" "${MAPFILE[@]}"

Результат работы будет таким:

Callback:
  Line: 1
  Data: 'Alice'
Callback:
  Line: 2
  Data: 'Bob'
Callback:
  Line: 3
  Data: 'John'

--- RESULT ---
Alice
Bob
John

Если бы разделитель был другой, мы могли бы указать его через опцию -d:

....
mapfile -t -C 'callback' -c 1 -d ':' < <(
cat <<EOF
Alice:Bob:John
EOF
)
....

К сожалению, опция -d не поддерживает сразу несколько разделителей. Тем не менее, вы можете обходить это ограничение, если будете парсить строку внутри функции Callback.

#!/bin/bash

declare -A EMPLS

callback() {
    local data=$2
    local lhs=${data%%:*}
    local rhs=${data#*:}
    EMPLS["$lhs"]=$rhs
}

mapfile -t -C 'callback' -c 1 < <(
cat <<EOF
Alice:age:23;position:manager;
Bob:age:31;position:tech;
John:age:51;position:boss;
EOF
)

MAPFILE=()

for person in ${!EMPLS[@]}; do
    echo "$person:"
    echo "  ${EMPLS[$person]}"
done

Результат

Alice:
  age:23;position:manager;
John:
  age:51;position:boss;
Bob:
  age:31;position:tech;

Передаваемые параметры править

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

К параметрам команды можно обращаться с помощью встроенных переменных:

  • $@ и $* — запросить все переданные параметры. В Bash 3 и выше для этих целей также можно использовать предопределенный массив ${BASH_ARGV[@]}, только помните, что аргументы в нем записаны в порядке, обратном переданному.
  • $0 — команда, переданная корневой командной оболочке.
  • $1, $2, $3, $4 ... — первый, второй, третий, четвертый (и так далее) переданный аргумент. Количество передаваемых аргументов зависит от ограничений системы, а не от командной оболочки. На практике стараются избегать очень большого числа аргументов, предпочитая такую передачу данных передачей через файлы.
  • ${!#} или ${@: -1} или ${@:$#} — в Bash 3 и выше эта переменная ссылается на последний переданный параметр. Обычно это полезно, когда вы вызываете функции рекурсивно, когда обрабатывать аргументы с конца удобнее.
  • $# — получить общее количество переданных параметров.

Команде отдается полная свобода в интерпретации передаваемых параметров, поэтому правила их передачи определяются только разработчиком команды. Со временем было придумано множество нотаций, чтобы программировать опции для команд более-менее одинаково.

Далее мы рассмотрим основные правила, которые рекомендует POSIX и здравый смысл.

  • Имя команды по возможности должно состоять из 2–9 символов.
  • Имена команд должны быть записаны строчными буквами и цифрами. Тем не менее, если вы используете команду, объявленную через функцию Bash только внутри самого сценария, этому правилу можно строго не следовать.
  • В команде все ее параметры отделяются друг от друга (и от имени команды) одним или несколькими пробелами, если при этом общая длина всей полученной строки не упирается в системное ограничение на длину командной строки.
    # Пусть команда имеет имя example
    example param1 param2 param3
    # Эквивалентно
    example          param1     param2         param3
    # Эквивалентно
    example \
    param1 param2 \
    \
                              param3   # Пробелы перед param3 сохраняются в результирующей командной строке
    # Символ '\' позволяет записать вызов команды в многострочной форме. Он помечает,
    # что продолжение команды находится на следующей строке.
    
  • В команде могут быть опции, которые могут начинаться на одно тире (т.н. короткая опция), например -h, или на два тире — длинная опция (например, --help). В общем случае короткая опция может начинаться на +. Так делается, когда опция выполняет функцию двухпозиционного переключателя: на - включить (первый вариант исполнения опции); на + — отключить (второй вариант исполнения).
  • Короткие опции всегда состоят из одного символа, если не считать символ тире. Это позволяет их группировать. Например -abc эквивалентно тому, если бы вы записали их отдельно -a -b -c. Длинные опции группировать нельзя, но зато их преимущество в том, что они более очевидные.
  • Короткой опции может соответствовать длинная (например -h и --help), но в общем случае это не обязательно. Оформление длинными опциями было придумано, потому что в сложных командах бывает трудно понять вызов, когда используется много коротких опций, т.е. длинные опции помогают подчеркнуть некоторый смысл. С другой стороны, алфавит ограничен и символы под короткие опции следует экономить. Длинные опции менее ограничены размером алфавита.
  • У коротких и длинных опций могут быть аргументы. Если для опции вводится аргумент, то он обычно обязателен. Для коротких опций их аргументы следуют через пробелы или сразу за опцией, потому что это не создает неоднозначности. Для длинных опций есть варианты, как оформлять аргументы: они могут следовать через пробел, либо может использоваться символ равно, вокруг которого пробелы использовать нельзя.
    # Пусть у команды example есть опции -i и --in-file, которой нужно передать имя файла.
    example -a -b -i file.txt
    # Эквивалентно
    example -a -b -ifile.txt
    # Эквивалентно
    example -abi file.txt # В группировке, опция с аргументом всегда должна стоять последней. Такая опция в группировке может быть только одна.
    # Эквивалентно
    example -a -b --in-file=file.txt
    # Эквивалентно, но зависит от реализации парсера аргументов
    example -a -b --in-file file.txt
    
  • Если команда может принимать простые аргументы вместе с опциями, то все опции всегда должны следовать перед ними. Чтобы помочь парсеру аргументов «нащупать» границу между ними, используется два тире --. В общем случае -- не обязательно, но с помощью этой записи легче реализовать парсер аргументов. Кроме того, это запрещает смешивать опции с простыми аргументами.
    example -abi file.txt -- param1 param2 param3 # -- говорит, что опции закончились.
    # Эквивалентно
    example -a -b --in-file file.txt -- param1 param2 param3
    
    # -- запрещают вызывать команду например так, чтобы пресечь неоднозначный вызов:
    example --in-file file.txt param1 param2 param3 -a -b
    
  • Порядок опций не должен влиять на их интерпретацию, кроме случая, когда они взаимоисключаемые: в этом случае побеждает опция, следующая последней.

Для Bash команд, реализуемых на языке командной оболочки, очень часто приходится программировать парсеры опций. Для этих целей может быть реализован самописный цикл while, разбирающий параметры команды, пока они не кончатся; системная утилита getopt; встроенная в Bash команда getopts. Ниже мы покажем как строится типовой парсер опций с помощью команды getopt. Команде getopt следует отдавать предпочтение (если она есть в системе), потому что с помощью нее можно парсить как короткие, так и длинные опции, по крайней мере в Linux. Встроенная в Bash getopts может парсить только короткие опции.

#!/bin/bash
# 
# Файл: example.sh
#
# Вызов getopt.
#   Команде getopt нужно передать спецификацию, определяющую какие опции передавать
#   можно. С помощью опции -o мы определяем спецификацию для коротких опций, а 
#   с помощью -l (--long) мы определяем спецификацию для длинных опций. Если опция
#   может принимать аргумент, то после нее нужно поставить двоеточие (:). Если
#   аргумент для опции не обязателен (т.е. в команде есть значение по умолчанию),
#   то мы ставим два двоеточия.
#
#
ARGS=$(getopt -o 'a:l::v' --long 'article:,language::,lang::,verbose' -- "$@") || exit
eval "set -- $ARGS" # Устанавливаем параметры позиционно, чтобы привести командную строку
                    # к единой спецификации.
# Цикл разбора опций
while true; do
    case "$1" in
      -v|--verbose)
            ((VERBOSE++)); shift;;
      -a|--article)
            ARTICLE=$2; shift 2;;
      -l|--lang|--language)
            # Для параметров с опциями по умолчанию следует делать проверку
            # на пустоту.
            if [[ -n $2 ]]; then
                LANG=$2
            fi
            shift 2;;
      --)  shift; break;;     # Конец строки с опциями
      *)   exit 1;;           # Ошибка
    esac
done

REMAINING=("$@")

printf "[ARTICLE]='%s'\n[LANGUAGE]='%s'\n[VERBOSE]=%d\n[REMAINING_ARGS]=%s\n" \
    "$ARTICLE" \
    "$LANG" \
    "$VERBOSE" \
    "${REMAINING[*]}"

Приведем примеры возможных вызовов.

$ ./example.sh  -a'Bash practice' -l'KOI8-R' --verbose -- "More args"
[ARTICLE]='Bash practice'
[LANGUAGE]='KOI8-R'
[VERBOSE]=1
[REMAINING_ARGS]=More args

# Эквивалентно
$ ./example.sh -a'Bash practice' --lang='KOI8-R' --verbose -- "More args"      
[ARTICLE]='Bash practice'
[LANGUAGE]='KOI8-R'      
[VERBOSE]=1
[REMAINING_ARGS]=More args

# Эквивалентно
$ ./example.sh --article='Bash practice' --lang='KOI8-R' --verbose -- "More args"
[ARTICLE]='Bash practice'
[LANGUAGE]='KOI8-R'
[VERBOSE]=1
[REMAINING_ARGS]=More args

# Если пропустить аргумент для --lang, то ничего не сломается
$ ./example.sh --article='Bash practice' --lang= -- "More args"
[ARTICLE]='Bash practice'
[LANGUAGE]='C.UTF-8'
[VERBOSE]=0
[REMAINING_ARGS]=More args

На практике вы можете встретить многочисленные исключения из данных правил, обычно связанные с историческими причинами:

  • Примеры, которые приходят на ум сразу, это команды tar и ps
    # Можно так
    $ tar xvf myarchive.tar.gz
    $ ps aux
    # а можно так
    $ tar -xvf myarchive.tar.gz
    $ ps -aux
    
  • Команда find оформляет свои длинные опции через одно тире
    $ find /var/log/nginx -type f -name "*.log"
    

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

Переменные окружения править

Переменные окружения (или экспортированные переменные) — это переменные, которые передает вызываемой команде корневая командная оболочка. Эти переменные позволяют реализовать что-то похожее на одностороннюю общую память между семейством процессов: каждая дочерняя подоболочка получает одни и те же переменные окружения от родительской оболочки; дочерние подоболочки тоже могут порождать подоболочки и передавать переменные окружения своим потомкам, причем, если это нужно, в измененном, дополненном или укороченном виде. Переменные окружения передаются только в одну сторону, т.е. их изменение в подоболочке не повлияет на родительскую оболочку. К переменным окружения мы обращаемся так же как и к локальным.

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

  • С помощью команды declare -x <имя>=<значение>. Удалить переменную окружения можно командой declare +x <имя> (может не поддерживаться). Без аргументов declare -x выведет список переменных окружения.
  • С помощью команды export <имя>=<значение>. Удалить переменную окружения можно командой export -n <имя> (может не поддерживаться).

Удалить переменную окружения можно также командой unset <имя>, если вы передаете имя переменной окружения. Также с переменными окружения можно работать с помощью команды env.

#!/bin/bash

declare -x ENV1="one"
declare -x ENV2="two"

(
    echo $BASH_SUBSHELL: $ENV1 $ENV2
    declare -x ENV3="three"
    (
        echo $BASH_SUBSHELL: $ENV1 $ENV2 $ENV3
        unset ENV1
        (
            echo $BASH_SUBSHELL: $ENV1 $ENV2 $ENV3
        )
    )
)

Результат

1: one two
2: one two three
3: two three

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

#!/bin/bash

declare -x EXPORTED="exported"

cust_command() {
    echo $ONE_ENV $TWO_ENV # Печатаем переменные окружения
    echo "$EXPORTED" # Эта переменная пришла сюда из родительской оболочки
}

# Чтобы передать переменные окружения конкретной команде, их нужно перечислить через пробел перед командой.
ONE_ENV="Hello" TWO_ENV="World!" cust_command

( cust_command )

echo "'$ONE_ENV $TWO_ENV'" # Здесь будет пусто, потому что переменные окружения, переданные ранее, отсюда не видны.

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

env -i <команда> # Позволяет затереть все переменные окружения для вызываемой команды (опция -i).
env VAR1=VAL1 VAR2=VAL2 VAR3=VAL3 <команда> # Временно добавить перечисленные переменные окружения для вызываемой команды.
env -u HOME <команда> # Удалить переменную окружения HOME для команды (опция -u).

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

Потоки данных править

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

Файловый дескриптор — это неотрицательное целое число, которое связывается с потоком данных и по которому процесс выполняет чтение и/или запись в этом потоке.

В языке командной оболочки существуют операторы, позволяющие манипулировать потоками данных (I/O Redirection). Манипулирование потоками зависит также от места в сценарии: например вы можете манипулировать потоками только для конкретной команды или подоболочки. В общем случае вы можете использовать следующие операторы для манипуляции потоками:

  • [n]<word. Перенаправить файл word в поток для чтения через дескриптор [n]. Указываемый дескриптор должен быть открыт на чтение, если он открыт, либо открывается на чтение. Дескриптор можно не указывать, тогда неявно будет использован дескриптор 0.
  • [n]<<[-]word
            ... <данные> ...
    word
    
    Эта конструкция позволяет перенаправлять данные на чтение через дескриптор [n], записанные в файле-сценария до слова word. Указываемый дескриптор должен быть открыт на чтение, если он открыт, либо открывается на чтение. Если дескриптор не указывать, то неявно будет использован дескриптор 0. Благодаря этому перенаправлению, можно хранить данные и код сценария в одном файле. Если перед word в начале указать символ тире, то перед чтением очередной строки, будут удаляться начальные пробелы и табуляция (может не поддерживаться).
  • [n]<<< word. Похожа на предыдущее перенаправление, но если там фрагмент завершался стоп-словом, здесь вы направляете строку word, заключенную в одинарные или двойные кавычки.
  • [n]>[|]word. Перенаправить данные на запись в файл word через дескриптор [n]. Указываемый дескриптор должен быть открыт на запись, если он открыт, либо открывается на запись. Если дескриптор не указан, то по умолчанию используется дескриптор 1. Форма >| позволяет игнорировать опцию noclobber, если она включена для сценария. Напомним, что опция noclobber запрещает перезаписывать уже существующие файлы. Обратите внимание, что запись производится в два этапа: сначала файл сжимается до нулевого размера (truncate), а затем в него записываются новые данные. Это приводит к перезаписыванию старых данных новыми.
  • [n]>>word. Перенаправить данные на дозапись в файл word через дескриптор [n]. Указываемый дескриптор должен быть открыт на запись, если он открыт, либо открывается на запись. Если дескриптор не указан, то по умолчанию используется дескриптор 1. Дозапись означает, что данные будут дозаписаны в конец, т.е. предыдущие данные сохранятся.

Рассмотрим несколько примеров. К самым простым ситуациям относятся манипулирование стандартными потоками ввода/вывода команд. Пусть для примера это будет команда cat.

#!/bin/bash

readonly TMP_FILE='/tmp/test.txt'
trap "rm -f $TMP_FILE" EXIT       # Чтобы удалять временный файл, после завершения сценария.

# Напомним, что команда cat по умолчанию ожидает файлы, которые ей нужно напечатать на устройстве вывода.
# Если файлы команде не передаются, то она печатает свой стандартный поток ввода.

# В этом примере мы делаем перенаправление и для потока вывода, и для потока ввода. Оба перенаправления применяются
# в данном случае только для команды cat.
# Поток вывода мы направляем во временный файл, а поток ввода представляет сам файл-сценария с фрагментом
# до первого слова EOF. Отметим, что EOF означает End-of-File, т.е. конец файла. Мы используем эту аббревиатуру
# по традиции. Вам ничто не запрещает использовать любое другое слово.
cat >"$TMP_FILE" <<EOF
One
    Two
        Three
EOF
echo "--- 1 ---"
cat "$TMP_FILE"

# Мы можем добиться того же эффекта, если перенаправим в команду строку. Получается то же самое. Разница только
# в том, что в предыдущем вызове фрагмент ограничивало слово, а здесь кавычки.
cat >"$TMP_FILE" <<< "One
    Two
        Three
"
echo "--- 2 ---"
cat "$TMP_FILE"
echo "--- 3 ---"
cat <"$TMP_FILE"

# Обратите внимание, что в предыдущих примерах на запись в файл, мы файл записывали по сути дважды: вторая запись
# перезатирала предыдущие данные. В этом примере мы допишем данные к тем, что были записаны после второй записи.
cat >> "$TMP_FILE" <<EOF
    Four
          Five
               Six
EOF
echo "--- 4 ---"
cat "$TMP_FILE"

Результат работы сценария

--- 1 ---    
One
    Two      
        Three
--- 2 ---    
One
    Two      
        Three

--- 3 ---    
One
    Two
        Three     

--- 4 ---
One
    Two
        Three     

    Four
          Five    
               Six

Обратите внимание на то, что при манипулировании дескрипторами ожидается, что они открыты, и открыты в правильном режиме. Так как все стандартные команды открывают дескрипторы 0, 1 и 2, мы не указываем их явно, но в общем случае могли бы их указать и получить такое же поведение, например

....
cat 1>"$TMP_FILE" 0<<< "One
    Two
        Three
"
....

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

  • [n]<>word. Открыть дескриптор [n] на чтение и на запись и связать его с файлом word. Если не указать дескриптор явно, то подразумевается дескриптор 0. Обратите внимание, что если дескриптор с указанным номером еще не открыт, то он будет открыт. Этот оператор обобщает некоторые из вышеприведенных нами (мы сообщаем это сейчас, чтобы вы не запутались), т.е.:
    • [n]>word. Открыть дескриптор на запись и связать его с файлом word.
    • [n]>>word. Открыть дескриптор на дозапись и связать его с файлом word.
    • [n]<word. Открыть дескриптор на чтение и связать его с файлом word.
  • [n]<&word. Дублирование файлового дескриптора [n], открытого на чтение. Если word является уже открытым на чтение файловым дескриптором, то он становится дублером файлового дескриптора, указанного в [n]. Если word не является файловым дескриптором, открытым на чтение, то возвращается ошибка. Допустимо в качестве word указывать тире, что означает закрыть файловый дескриптор [n]. Во всех случаях, когда [n] не указывается явно, подразумевается дескриптор 0.
  • [n]>&word. Дублирование файлового дескриптора [n], открытого на запись. Если word является уже открытым на запись файловым дескриптором, то он становится дублером файлового дескриптора, указанного в [n]. Если word не является файловым дескриптором, открытым на запись, то возвращается ошибка. Допустимо в качестве word указывать тире, что означает закрыть файловый дескриптор [n]. Во всех случаях, когда [n] не указывается явно, подразумевается дескриптор 1.
  • [n]<&digit-. Перемещает дескриптор digit, открытый на чтение, на номер [n], после чего закрывает дескриптор digit на своем старом месте. Если дескриптор [n] не указывается явно, то подразумевается дескриптор 0.
  • [n]>&digit-. Перемещает дескриптор digit, открытый на запись, на номер [n], после чего закрывает дескриптор digit на своем старом месте. Если дескриптор [n] не указывается явно, то подразумевается дескриптор 1.

Изложенные операторы могут сильно запутать вас, если ранее вы не часто работали с нестандартным вводом/выводом, поэтому попытаемся прояснить их смысл на следующих примерах.

#!/bin/bash

readonly TMP_FILE='/tmp/test.txt'
trap "rm -f $TMP_FILE" EXIT

# Первая порция записи в файл TMP_FILE:
# мы открыли для команды cat 3-й дескриптор на запись и связали его с файлом. В данном случае мы делаем
# это для примера. Так как команда cat в своей реализации понятия не имеет что делать с дескриптором 3,
# мы продублировали его в первый дескриптор, т.е. все что попадет в STDOUT – попадет в файл, связанный с третьим дескриптором.
# Нет нужды беспокоится о закрытии 3 дескриптора, потому что об это позаботится система в момент завершения
# команды cat. Дескриптор 1 (до дублирования) по прежнему открыт, но в данном контексте он замещен 3 дескриптором на все время исполнения команды cat.
#
# Обратите внимание, что при работе с файловыми дескрипторами, порядок очень важен. Понимайте это как микропрограмму на входе
# в команду: открыть дескриптор 3 и продублировать его в дескриптор 1. Если бы мы сделали все наоборот, то произошла бы
# ошибка, так как на момент дублирования третий дескриптор еще был бы не проинициализирован.
cat 3>$TMP_FILE 1>&3 <<<"------
Alice
Bob
John"

# Вторая порция записи.
# Обратите внимание, что мы открываем 3-й дескриптор на дозапись, чтобы не потерять уже записанные данные. В
# остальном ничего не поменялось.
cat 3>>$TMP_FILE 1>&3 <<<"------
Peter
Maggy
Greg"

# Третья порция записи.
# Обратите внимание, что такого же эффекта мы можем добиться, если 3-й дескриптор после открытия мы не
# продублируем, а переместим. В нашем примере для нас это не играет какой-либо роли, но в принципе 3-й дескриптор
# можно будет переоткрыть повторно для чего-либо еще.
cat 3>>$TMP_FILE 1>&3- <<<"------
Tracy
Garold
Troy"

# Простой принтер: печатает все, что попадает в дескриптор 0.
printer() {
    while read REPLY || [[ -n $REPLY ]]; do
        echo "$REPLY"
    done
}

# Обратите внимание, что compound-выражение while также по умолчанию читает дескриптор 0. В этом примере
# мы открываем третий дескриптор на чтение и связываем его с файлом, в который писали ранее.
# Так как наша импровизированная команда ничего не знает о третьем дескрипторе, мы продублировали его в 0-ой.
# Дескрипторы открываются только для команды printer.
printer 3<$TMP_FILE <&3

Результат работы сценария будет таким

------
Alice 
Bob   
John  
------
Peter 
Maggy 
Greg  
------
Tracy 
Garold
Troy

Продемонстрируем еще один поучительный пример.

#!/bin/bash

printer() {
    # Читаем дескриптор 0
    while read REPLY || [[ -n $REPLY ]]; do
        echo "$REPLY"
    done

    # Читаем дескриптор 3
    while read REPLY || [[ -n $REPLY ]]; do
        echo "$REPLY"
    done <&3
}
#                                                                                             ____ Дубль дескриптора 3 в 0
#                                                                                            /   _ Дубль дескриптора 4 в 0
#                                                                                           /   /
printer 3< <( for i in 1 2 3; do echo $i; done ) 4< <( for i in 3 4 5; do echo $i; done ) <&3 <&4

# Результат
# 3
# 4
# 5
# 1
# 2
# 3

Предыдущий пример должен наглядно показать, что такое дублирование файлового дескриптора и как важен порядок дублирования. Мы открываем два дескриптора (3 и 4), которые связываются с автоматическими файлами, порождаемыми подстановкой результата процесса. В 3-й дескриптор пишутся символы 1, 2 и 3; в 4-й дескриптор пишутся символы 3,4 и 5. Затем мы продублировали 3-й дескриптор в 0-й, а после этого сразу 4-й в 0-й. Обратите внимание, что, так как дублирование 4-го в 0-й произошло последним, по факту первый цикл читает 3, 4 и 5, поэтому мы видим эти цифры первыми. Тем не менее, 3-й дескриптор не был закрыт после дублирования, поэтому мы этот дескриптор можем прочитать вторым циклом, указав его явно внутри функции.

Манипулирование потоками в командной оболочке править

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

#!/bin/bash

LOG_FILE="/tmp/$0.log"

say() {
    echo "$(date +"%T"): $@"
}

# Первый дескриптор подоболочки теперь связан с файлом LOG_FILE.
# Теперь все команды, направляя свой вывод в первый дескриптор, пишут его в один файл для логирования.
# Так как файл открывается на дописывание, мы не потеряем вывод предыдущего запуска.
(
    say "Hello"
    say "How are you?"
    say "I'm fine. Thank you!"

) >>"$LOG_FILE"

cat "$LOG_FILE"

echo -n > "$LOG_FILE"

# Вы можете использовать блок для разделения дескрипторов между командами.
# Разница только в том, что команды исполняются в корневой оболочке.
echo "------------------"
{
	say "Hello"
    say "How are you?"
    say "I'm fine. Thank you!"

} >>"$LOG_FILE"

cat "$LOG_FILE"

echo "------------------"
printer() {
    echo "Printer #$1"
    while read REPLY || [[ -n $REPLY ]]; do
        echo "  $REPLY"
    done
}

# Аналогично, все функции подоболочки могут читать один и тот же вход. Но в этом примере
# это в общем то бесполезно.
(
    printer 1
    printer 2

) <"$LOG_FILE"

Результат

13:37:33: Hello
13:37:33: How are you?        
13:37:33: I'm fine. Thank you!
------------------
13:37:33: Hello
13:37:33: How are you?
13:37:33: I'm fine. Thank you!
------------------
Printer #1
  13:37:33: Hello
  13:37:33: How are you?
  13:37:33: I'm fine. Thank you!
Printer #2

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

Вам наверное стало интересно, как же перенаправлять стандартные дескрипторы в корневой оболочке, или как делать это временно, для некоторого участка кода в корневой оболочке или подоболочке? Это можно сделать через дублирование дескрипторов и через встроенную команду exec следующим образом.

#!/bin/bash

LOG_FILE="/tmp/$0.log"

trap "rm -f $LOG_FILE" EXIT

# Помните, что манипулирование дескрипторами через команду exec всегда направлено на текущую подоболочку.

# Мы используем следующую оберточную функцию, чтобы перенаправить STDOUT и STDERR в один
# файл для всех команд, следующих после ее вызова.
begin_logging() {
    # Делаем дубли для STDOUT и STDERR в 7 и 8 дескрипторы соответственно. Эти номера мы выбрали случайно -
    # взять можно любые другие допустимые номера.
    # Далее мы перенаправляем STDOUT в файл, а STDERR в STDOUT.
    exec 7>&1 8>&2 >>"$LOG_FILE" 2>&1
}

# Эта оберточная функция парна функции begin_logging. Она откатывает STDOUT и STDERR к
# первоначальному состоянию.
end_logging() {
    # Откатывание STDOUT и STDERR сводится к перемещению дескрипторов обратно на их первоначальные номера.
    exec 1>&7- 2>&8-
}

say() {
    echo "$(date +"%T"): $@"
}

# С этого момента все команды будут писать свой вывод в файл LOG_FILE.
begin_logging

say "Hello from root shell."
cat "$RANDOM" 2>&1 # Провоцируем ошибку, чтобы продемонстрировать, что STDERR направлен в файл логирования. Перенаправление идет как явно ...
cat "$RANDOM" # ... так и неявно

# Откатываем стандартные дескрипторы вывода на их первоначальные позиции.
end_logging

echo "Out of logging 1."

# Аналогично можно поступать и в подоболочках.
(
    begin_logging
    say "Hello from subshell."
    end_logging
    echo "Out of logging 2."
)


echo "RESULT"
echo "---------"
cat "$LOG_FILE"

Результат работы сценария

Out of logging 1.
Out of logging 2.
RESULT
---------
20:13:53: Hello from root shell.
cat: 10661: No such file or directory
cat: 11747: No such file or directory
20:13:53: Hello from subshell.

В Bash STDOUT и STDERR можно направлять одновременно через более короткие операторы &> и &>>, т.е.

&>$LOG_FILE
# эквивалентно более длинной записи
>$LOG_FILE 2>&1

# А запись
&>>$LOG_FILE
# эквивалентна такой
>>$LOG_FILE 2>&1

Однако эти операторы не переносимы между разными командными оболочками.

Никогда не закрывайте стандартные дескрипторы 0, 1 и 2 для всей оболочки, так как некоторые команды, которые в ней запускаются, могут на это непредсказуемым образом реагировать. Потоки 1 и 2 рекомендуется перенаправлять на устройство /dev/null, если вы хотите пресекать вывод с них.

Перехват сигналов править

Сигналы — это системные асинхронные уведомления, посылаемые процессу с целью уведомить его о чем-нибудь. Функция или код, который вызывается по пришествии некоторого сигнала, называется обработчиком сигнала (signal handler). Сигналы являются первым простейшим методом межпроцессного взаимодействия.

Как и любой другой *nix-процесс, командная оболочка умеет принимать и обрабатывать сигналы. На каждый сигнал, определенный в системе, командная оболочка реагирует некоторым образом, определяемым стандартами POSIX. Вы можете изменять реакцию для большинства сигналов, т.е. назначать им собственные обработчики, с помощью встроенной команды trap.

Команда trap принимает два аргумента:

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

Важно помнить о следующих правилах при написании собственных обработчиков сигналов

  • Вызов trap без аргументов покажет все установленные, не стандартные обработчики сигналов.
  • Чтобы проигнорировать сигнал, достаточно передать пустой обработчик:
    trap '' SIGINT SIGHUP # Игнорируем сигналы INT и HUP.
    
  • Чтобы вернуть стандартный обработчик для некоторого сигнала(ов), нужно написать следующим образом
    trap - SIGINT SIGHUP
    
  • В Bash кроме системных сигналов, начинающихся на префикс SIG, есть еще сигналы, встроенные в оболочку: EXIT, DEBUG, RETURN и ERR. Эти сигналы, строго говоря, сигналами не являются, а являются уведомлениями внутри оболочки, которым через trap можно назначить обработчик:
    #!/bin/bash
    
    trap "echo handled EXIT" EXIT      # Посылается, когда оболочка завершает работу.
    trap "echo handled DEBUG" DEBUG    # Посылается перед вызовом любой команды. 
    trap "echo handled RETURN" RETURN  # Вызывается после обработки команд source или точка (.).
    trap "echo handled ERR" ERR        # Сигнал посылается, когда некоторая команда возвращает ненулевой код.
    
    [[ 2 -eq 3 ]] # Будет послан сигнал ERR, потому что команда возвращает ненулевой код.
    
    . validation.bash # Будет послан сигнал RETURN, после того как содержимое файла будет извлечено в текущую оболочку.
    
    # Когда оболочка завершает свою работу, будет послан сигнал EXIT.
    
  • Подоболочки не наследуют обработчики сигналов своих родительских оболочек и это демонстрирует следующий пример
    #!/bin/bash
    
    whome() {
        echo "$1"
    }
    
    ME="main"
    trap "whome \"\$ME\"" SIGINT # Ожидается, что подоболочки будут наследовать этот обработчик
    
    (
        ME="detached subshell"
        sleep 3
    ) &
    PID=$!
    (sleep 1; echo "Sending SIGINT to $PID"; kill -SIGINT $PID)
    wait
    
    # Посылаем сигнал из другой подоболочки, но ожидаемого сообщения мы не увидим. Сработает стандартный обработчик
    # по сигналу SIGINT.
    
    Из этого следует вывод, что в каждой подоболочке устанавливаются свои обработчики сигналов.
    ....
    (
        trap "whome \"\$ME\"" SIGINT # Эту строку мы добавили.
        ME="detached subshell"
        sleep 3
    ) &
    ....
    # Результат
    # Sending SIGINT to 579
    # detached subshell
    #
    

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

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

#!/bin/bash

PERIOD=2

# Бесконечный таймер, который будет посылать сигнал по прошествии некоторого периода.
timer() {
    local target=$1
    while true; do
        sleep $PERIOD
        kill -SIGUSR1 $target || exit 0
    done
}

# Обработчик сигнала
ticker() {
    echo "tick"
}

trap "ticker" SIGUSR1 # Вешаем обработчик сигнала таймера.
trap "kill -9 \$TIMER_PID" EXIT # Чтобы убивать процесс таймера при завершении текущей оболочки.

(timer $$) &  # Запуск таймера
TIMER_PID=$!
echo "INFO: To interrupt press Ctrl+C"
# Запускаем бесконечный цикл, в котором основной процесс может делать некоторую работу.
while true; do
    sleep 1
done



← Bash подстановки Команда read →