Скрипты

Откройте терминал и введите команды по порядку:

$ echo '#!/bin/bash' > hi.sh
$ echo 'echo Hello there from Shell' >> hi.sh
$ chmod 755 hi.sh
$ ./hi.sh
Hello there from Shell

Вы только что написали свой первый скрипт.

Тут, однако, потребуется небольшое разъяснение. Linux не обращает внимание на расширения файлов, его, по сути, нет — это просто название файла. bash‑скрипты принято называть с .sh на конце, но оболочка понимает, что это скрипт, по‑другому признаку. В частности — по первой строчке, которую мы выполнили до этого. #! называется shebang. Он напрямую сообщает оболочке, какой интерпретатор использовать. Ранее мы приводили пример с утилитой deluser, которая де‑факто была скриптом, написанным на Perl.

$ sed 1q /usr/sbin/deluser
#! /usr/bin/perl

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

Shebang, который мы указали в самом начале — это прямой путь до интерпретатора. В некоторых дистрибутивах он может лежать в другом месте. В системе есть приложение, которое ищет исполняемый файл по PATH — /usr/bin/env. Для переносимости лучше использовать его.

#!/usr/bin/env bash

В случае с python утилита env сможет запустить приложение в виртуальном окружении, если оно имеется.

Аргументы

Скриптам можно передавать аргументы, как и любой другой команде. Они также называются позиционными параметрами и доступны через $1, $2, $3 и т. д. В $0 лежит путь до скрипта с его именем.

#!/usr/bin/env bash
echo "Script name: $0"
echo "First argument: $1"
echo "Second argument: $2"
echo "Total arguments: $#"
echo "All arguments: $@"
echo "All arguments as a string: $*"

Выполним скрипт:

$ ./script.sh a b
Script name: ./script.sh
First argument: a
Second argument: b
Total arguments: 2
All arguments: a b

Аргументы можно сдвигать ключевым словом shift, т. е. затереть предыдущее и сделать его текущим:

#!/usr/bin/env bash
while [ $# -gt 0 ]; do
echo "Argument: $1"
shift
done

Чтобы получить все аргументы в виде одной строки, можно использовать $*.

Вы также можете определить их значения по умолчанию:

#!/usr/bin/env bash
arg1=${1:-"default_arg1"}
arg2=${2:-"default_arg2"}
echo "First argument: $arg1"
echo "Second argument: $arg2"

Используя встроенную команду getopts, можно создавать именованные аргументы:

#!/usr/bin/env bash
while getopts "a:b:" opt; do
case $opt in
a) arg1="$OPTARG" ;;
b) arg2="$OPTARG" ;;
*) echo "Usage: $0 -a arg1 -b arg2"; exit 1 ;;
esac
done
echo "Argument a: $arg1"
echo "Argument b: $arg2"

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

$ ./example.sh -a value1 -b value2

Регулярные выражения

Регулярные выражения — это мощный инструмент для поиска текста по шаблону. Мы уже частично обсуждали их в рамках утилиты grep. Оболочка bash позволяет работать с регулярными выражениями без вызова отдельной утилиты — через конструкцию [[ ... ]] и оператор =~.

Часть символов называются метасимволами — т. е. те, которые описывают сам шаблон. Вот их список: ^ и $ — совпадение с начала или конца строки; . — совпадает любой символ кроме перевода строки; [] — определяет набор символов или класс; {} — определяет количество символов, которые должны совпасть; - — определяет диапазон для совпадения; ? — делает предыдущий символ необязательным; *, + — совпадение от нуля или от одного шаблона соответственно; () — объединение нескольких выражений; | — логическое «или»; \ — экранирование метасимволов (например, тех же скобок).

Рассмотрим следующий пример. Информация по этой книге размещена по адресу https://highway.chrns.com/#about и пускай нам интересно, по какой‑то причине выделить домен из данной строки. Во‑первых, ссылка начинается с https; чтобы решение также работало для http, пометим последний символ как необязательный: https?://. Далее нас интересует всё до следующего знака / — это выражается как ([^/]+).

Результат будет храниться в массиве BASH_REMATCH. По индексу 0 хранится полное совпадение, все остальные — соответствуют совпадению в ().

#!/usr/bin/env bash
if [[ "$1" =~ ^https?://([^/]+) ]]; then
echo "Domain is ${BASH_REMATCH[1]}"
fi

Ещё пример:

#!/usr/bin/env bash
current_date="(date +\"Today: %Y-%m-%d\")"
if [[ $current_date =~ ([0-9]{4})-([0-9]{2})-([0-9]{2}) ]]; then
echo "Full match: ${BASH_REMATCH[0]}"
echo "Year: ${BASH_REMATCH[1]}"
echo "Month: ${BASH_REMATCH[2]}"
echo "Day: ${BASH_REMATCH[3]}"
fi

Функции

Как и в других языках программирования, в bash есть функции. Они позволяют переиспользовать код и не писать одно и то же много раз.

function add {
  echo $(($1 + $2))
}

Запуск команды ничем не отличается от любой другой команды — просто вызовите func_name. К слову, параметры передаются точно так же, как и для любой другой команды.

$ add 1 2
3

Функции, однако, не возвращают значение как таковое — они возвращают код возврата. Чтобы вернуть его явным образом — используйте ключевое слово return:

function written_to_fail {
  return 1
}
# ...

$ written_to_fail
$ echo $?
1

Любые переменные, что вы создаёте, всегда попадают в глобальную область видимости — т. е. будут доступны за пределами функции. Во‑первых, это не очень хорошо с точки зрения сокрытия данных, а во‑вторых, это засоряет окружение. Чтобы избежать «просачивания» переменных за пределы функции, используйте ключевое слово local при объявлении:

function test {
  local var="some local var"
  echo inside: $var
}

$ test
$ echo outside: $var
inside: some local var
outside:

Вас, к слову, никто не ограничивает в вызове функции самой этой функции, т. е. возможна рекурсия:

#!/usr/bin/env bash

factorial() {
  if [ $1 -le 1 ]; then
    echo 1
  else
    local prev=$(factorial $(($1 - 1)))
    echo $(($1 * prev))
  fi
}
echo "Factorial of 5 is $(factorial 5)"

Хоть bash и не требует «точки входа» через специальную функцию, хорошая практика для больших скриптов вынести всю логику в функцию main и запустить её в конце скрипта. Это увеличит читаемость кода, поможет с декомпозицией и модульностью и поможет избежать неожиданных выполнений функций.

Если вы писали программы на python, то наверняка помните конструкцию вида:

if __name__ == '__main__':
    # the best code is written here

В bash можно сделать почти так же:

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main
fi

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

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

Ранее мы уже обсуждали, что программы могут принимать сигналы от операционной системы — bash‑скрипты не исключение. Вы можете прервать его выполнение, например, нажав Ctrl+C.

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

#!/usr/bin/env bash
trap 'echo "Ctrl+C pressed!"; exit 1' SIGINT
while true; do
sleep 1
done

В качестве аргументов можно передать список сигналов:

trap 'echo "Signal received. Exiting..."; exit' SIGINT SIGTERM SIGHUP

Как вы уже заметили, вам нужно вручную вызвать команду exit, т. е. сигналы можно проигнорировать:

trap '' SIGINT # ignore SIGINT (Ctrl+C)

Чтобы сбросить перехват по конкретному сигналу, нужно вызвать команду trap с указанием нужного вам сигнала без команды.

trap SIGINT # reset

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

#!/usr/bin/env bash

cleanup() {
  echo "Terminating child process..."
  kill "$child_pid"
  exit
}

trap cleanup SIGINT SIGTERM
# Launching a background process
(sleep 10) &
child_pid=$!
echo "Press Ctrl+C to terminate the script and the background process."
wait $child_pid

Ветвление

Одна из самых важных конструкций любого языка программирования — ветвление. Здесь, как и в Си, используется ключевое слово if, однако в bash блок не обрамляется в фигурные скобки; условие завершается словом fi. Вы также можете создавать более сложные ветвления добавив ещё одно условие через elif и отловить все условия, которые не попали под предыдущие блоки через else.

if test-commands; then
  consequent-commands;
[elif more-test-commands; then
  more-consequents;]
[else alternate-consequents;]
fi

Примечание

Условный оператор if ожидает получение 0, а не 1 как в других языках программирования. Помните, что код возврата 0 означает, что команда выполнена успешно, без ошибок!

Приведём пример для иллюстрации:

if grep -q "pattern" file.txt; then
  echo "Pattern found."
fi

Сравнение строк:

  • =, == — проверить, что строки эквивалентны
  • != — проверить, что строки не равны
  • -z — проверить, пустая ли строка
  • -n — проверить, что строка не пустая

Сравнение чисел (в скобках эквивалент в языке Си): -eq (==), -ne (!=), -gt (>), -lt (<), -ge (>=), -le (<=).

Проверка файлов: -e — существование файла; -f — проверка, что файл существует и он является обычным файлом (не символьная ссылка, не устройство и т. д.); -d — существует ли директория; -r — файл доступен для чтения; -w — можно писать; -x — можно исполнять.

Логические операции: &&, ||, ! (отрицание).

#!/usr/bin/env bash
number=42
if [ $number -eq 42 ]; then
  echo "It's definitely 42!"
fi

$ ./script.sh
It's definitely 42!

В случае, когда требуется проверять совпадение с несколькими условиями сразу, то лучше использовать операторы case/esac:

#!/usr/bin/env bash

fruit="apple"
case $fruit in
  "apple")
    echo "It's an apple."
    ;;
  "banana")
    echo "It's a banana."
    ;;
  "orange"|"lemon")
    echo "It's a citrus fruit."
    ;;
  *)
    echo "Unknown fruit."
    ;;
esac

Можно использовать шаблоны (globs):

case $file in
  *.txt)
    echo "It's a text file."
    ;;
  *)
    echo "Unknown file type."
    ;;
esac

Или описывать шаблон:

#!/usr/bin/env bash

read -p "Enter yes or no: " input
case $input in
  [Yy]|[Yy][Ee][Ss])
    echo "You said yes."
    ;;
  [Nn]|[Nn][Oo])
    echo "You said no."
    ;;
  *)
    echo "Invalid input."
    ;;
esac

Для создания простого интерактивного меню можно использовать ключевое слово select. Ранее мы уже упоминали переменную PS3, её как раз можно использовать для создания текста‑приветствия:

#!/usr/bin/env bash

PS3="Choose your favorite color: "
select color in red green blue yellow quit; do
  case $color in
    red|green|blue|yellow)
      echo "You chose $color."
      ;;
    quit)
      echo "Goodbye!"
      break
      ;;
    *)
      echo "Invalid choice. Try again."
      ;;
  esac
done

Пример вывода:

$ ./menu.sh
1) 700 nm
2) 555 nm
3) 460 nm
4) 580 nm
5) quit
Choose your favorite color: 1
You chose 700 nm.
Choose your favorite color: 6
Invalid choice. Try again.
Choose your favorite color: 5
Goodbye!

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

#!/usr/bin/env bash
PS3="Select a file to process: "
select file in $(ls); do
  if [ -f "$file" ]; then
    echo "You selected $file."
    # Here we do something with the file
    break
  else
    echo "Invalid selection. Please try again."
  fi
done

Циклы

Оболочка bash поддерживает несколько разных видов циклов.

until — цикл будет выполняться, пока не выполнится условие в [ ]:

#!/usr/bin/env bash

cnt=0
until [ $cnt -gt 5 ]
  do
  echo Counter: $cnt
  ((cnt++))
done

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

$ until [ $cnt -gt 5 ] ; do echo Counter: $cnt; ((cnt++)); done

Другая форма цикла записывается через while, который выполняется пока верно условие:

while test-commands; do consequent-commands; done

И третья форма — через for, позволяет итеррироваться по списку:

#!/usr/bin/env bash

for number in {1..5}; do
  echo "Number: $number"
done

Список может быть любым, можно вытащить его из переменной или вовсе пройтись по всем файлам в директории через /path/to/dir/*.

#!/usr/bin/env bash

for user in $(cat /etc/passwd | cut -d: -f1); do
  echo "User: $user"
done

Если вам по душе цикл for в стиле Си, то его тоже можно сконструировать:

#!/usr/bin/env bash

for ((i=0; i<5; i++)); do
  echo "Index is $i"
done

Как и в Си, здесь имеются ключевые слова break и continue для управления циклом.

#!/usr/bin/env bash

for number in {1..10}; do
  if [ $number -eq 5 ]; then
    continue # Skipping 5
  fi
  if [ $number -eq 8 ]; then
    break # Leaving at 8
  fi
  echo "Number: $number"
done

Обмен данными

Рассмотрим пример работы двух скриптов, обменивающихся данными между собой через именованный конвейер. Сначала создайте именованный конвейер: mkfifo mypipe.

#!/usr/bin/env bash

for i in {1..5}; do
  echo "Message $i from Producer" > mypipe
  sleep 1 # Simulating a delay
done

Клиент:

#!/usr/bin/env bash

while read line; do
  echo "Consumer received: $line"
done < mypipe

Команда read также поддерживает тайм‑аут для ввода:

read -t 5 -p "Enter something (you have 5 seconds): " input
echo "You entered: $input"

Код возврата — 0, если всё ок, и 1 — если не успели. Можно данные сразу сохранять в переменные:

echo "Enter your first and last name:"
read first_name last_name
echo "First Name: $first_name"
echo "Last Name: $last_name"

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

while true; do
  read -p "Enter a number: " num
  if [[ "$num" =~ ^[0-9]+$ ]]; then
    echo "You entered a valid number: $num"
    break
  else
    echo "Invalid input. Try again."
  fi
done

Если вы хотите ввести пароль, то ввод можно скрыть:

read -s -p "Enter your password: " password
echo
echo "Password saved!"

Команду read также можно использовать для создания массива:

echo "one two three" | read -a my_array
echo "First element: ${my_array[0]}"
echo "Second element: ${my_array[1]}"

Или использовать другой разделитель:

echo "A1:B2:C3" | read -d ':' first_part
echo "First part: $first_part"

С её помощью можно считывать файл построчно:

while read -r line; do
echo "Processing: $line"
done < file.txt

Утилита set и отладка скрипта

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

$ set -o
# or:
$ shopt

Либо узнать о ни так:

$ echo $-
himBHs

Допустим, вы написали скрипт и сделали ошибку в названии какой-нибудь переменной. Такое часто бывает. Скорее всего вы не сразу обнаружите ошибку — придётся несколько раз запустить скрипт, чтобы осознать, что в каком-то определённом месте у вас вместо ожидаемого значения почему-то пустота. Через set вы можете заставить скрипт завершаться сразу, как только будет попытка обратиться к переменной, которая не задана:

#!/usr/bin/env bash
set -u
echo $variable
ehco "end"

Если запустить скрипт, то вы получите сообщение об ошибке, а последняя строчка где выводится слово end не будет распечатана, т. к. скрипт будет остановлен:

$ ./not_exist.sh
./not_exist.sh: line 2: variable: unbound variable

К слову, вы можете запускать сам bash с нужными ключами, т. е. можно не писать там set как одну из команд:

#!/bin/bash -u
echo $variable
ehco "end"

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

Также bash предоставляет инструмент отладки через опцию -x — дополнительная информация может настраиваться через ранее упомянутую переменную PS4.

#!/usr/bin/env bash
set -x # Enable debugging
echo "Let's try debug output!"
set +x # Disable debugging

По умолчанию в PS4 лежит +. В этом случае вывод скрипта выше будет иметь вид:

+ echo "Let's try debug output!"
Let's try debug output!
+ set +x

Однако можно выводить больше информации, задав PS4, например:

PS4='+ Line $LINENO: ' # Include the line number
PS4='+ ${BASH_SOURCE}:$LINENO: ' # Include script name and line number
PS4='+ ${FUNCNAME[0]}:$LINENO: ' # Include function name and line number
PS4='+ PID $$, Line $LINENO: ' # Include process ID and line number

Изменено: