Найти тему
VK Cloud

Как работает bash: разбираемся в деталях

Оглавление

Перевели статью, которая поможет лучше понять, как работает bash, а значит, сэкономить время и уменьшить количество ошибок. Далее текст от лица автора.

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

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

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

Язык программирования, на котором писала ваша бабушка

Bash создан Брайаном Фоксом (легендарный и недооцененный парень) и выпущен в 1989 году как open source замена для Bourne Shell, вышедшей в 1976 году. Его название является аббревиатурой от Bourne Again SHell.

Если вы привыкли писать на любом другом языке программирования, bash (и сценарии оболочки в целом) не будут для вас интуитивно понятными. Синтаксис не запоминающийся, переменные странные, область видимости — полная дичь, и поток управления никогда не делает то, что вы думаете.

Как и в случае с CSS, я перестала бояться писать сценарии bash, когда узнала о нем несколько ключевых моментов: как он работает, в чем действительно хорош и как заставить его делать то, что мне нужно. Я также столкнулась с множеством глупых маленьких ошибок, которые мне просто пришлось заучить.

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

Примечание: я предполагаю, что вы обладаете некоторыми предварительными знаниями в программировании и сценариях командной оболочки. Если вы только начинаете изучение — вот хороший ресурс для начала. Я предполагаю, что вы, по крайней мере, знаете, как использовать терминал и следующие команды: ls, cd, pwd, cat, grep и написали (или попытались написать) один или два сценария.

Кстати, поскольку эта статья относится к миру Linux и операционных систем, у меня есть примечание для тех, кто занимается этими вопросами дольше меня: нормально (даже рекомендуется!) исправлять меня, если я ошибаюсь, просто будьте вежливы.

Версии

Язык сценариев оболочки, c которым большинство из нас работает в настоящее время, — это версия bash для Mac и Linux, используемая для эмуляции терминала в /bin/bash.

Debian (и, соответственно, Ubuntu и Linux Mint) теперь использует другой, но в основном совместимый язык сценариев оболочки (dash) для системных задач. Прим. переводчика: так утверждает автор статьи, но я везде вижу использование bash.

Вы также можете установить zsh в качестве основной оболочки, который в целом похож на bash, но имеет и отличия.

Из-за всех этих небольших вариаций хорошей идеей будет поместить #!/bin/bash (или какой-либо другой язык сценариев оболочки, который вы хотите использовать) вверху файлов, чтобы указать, что сценарий оболочки должен использовать конкретно этот язык, а не какой-либо еще из установленных на сервере.

Указание языка оболочки в заголовке файла сделает его поведение более предсказуемым. Например, ответы на Stack Overflow обычно предполагают, что вы используете именно bash.

Основы

Давайте сначала рассмотрим несколько фундаментальных вещей.

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

Синтаксис

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

Очень важно правильно использовать пробелы и точки с запятой.

Например, можно получить ошибку “[grep isn’t a valid command”, если забыть поставить пробел внутри квадратных скобок [] или “Unexpected end of file”, когда вы забыли точку с запятой после {}.

При определении переменной пробел между переменной и знаком = и между знаком = и значением приводит к разным результатам. Существует важное различие между одинарными кавычками и двойными.

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

Структура

Сценарии оболочки понимают операторы управления выполнением: операторы if, циклы while, циклы for, операторы case и так далее.

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

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

tac ~/error.log \
| grep -m1 -E "Error|Running restart" \
| grep -q "Error" \
&& echo "Found error since last restart"

Примечание: \ обозначают перенос строки, tac похож на cat, но выводит файл в обратном порядке.

Это выглядит уродливо, но эффективно, и иллюстрирует сильные и слабые стороны сценариев оболочки.

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

Что такое поток? Что такое команда?

Каждая команда — это программа, которая делает одну вещь. Grep, например, ищет вещи и возвращает строки. Запросы и файлы подаются на вход, найденные строки идут с выхода.

Вы можете подумать: «Да ладно, именно так работает все программирование», но в данном случае все немного сложнее, и это особенно важно понять.

Входы и выходы передаются от команды к команде в виде текстовых потоков. Есть три места, куда эти потоки идут и откуда берутся:

  • stdin: Стандартный ввод.
  • stdout: Стандартный вывод.
  • stderr: Стандартный вывод ошибок.

Это называется «поток», потому что строки выводятся в разных точках выполнения команды/функции, а не в конце, как вы могли бы подумать.

Вы отправляете текст на стандартный вывод с помощью таких команд, как printf и echo. Неопытный программист может думать, что это просто команды для вывода сообщений отладки, как в Python или JavaScript. Это не так.

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

Функции

Определим функцию:

function hello () {
printf "Hello World! \n"
local variable="Something \n"
printf "$variable"
echo some more stuff to print $variable
}

Если запустить в терминале $ hello.sh, вы получите:

Hello World!
Something
some more stuff to print Something

Команды echo и printf отправляют текстовые потоки на стандартный вывод. Если вы запустите нашу функцию приветствия из терминала, stdout будет выведен на вашу консоль.

Мы можем перенаправить вывод и отправить его в файл или в качестве ввода для другой команды.

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

Если вы хотите завершить функцию, для этого есть пара команд: return и exit. Команды выхода и возврата принимают числовой код: 0 означает успех, все остальное означает сбой. Команда return завершит работу функции, а команда exit завершит работу самой оболочки.

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

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

Вот основные моменты:

  • | называется каналом, и вы используете его для отправки вывода другим командам. Например, мы можем попробовать hello | grep ‘Hello’. Эта конструкция отправит весь вывод команды hello в grep, который вернет строки, содержащие «Hello». Мое любимое повседневное использование каналов — history | grep «команда», когда я забыла точную команду, которую я набрала ранее, но я знаю, что в ней есть определенное слово.
  • > с именем файла справа перенаправит вывод и запишет его в файл, а не на консоль. Файл будет полностью перезаписан. Например, logging_function> tmp_error.log. Если вам нравится Python, вы можете попробовать pip freeze> needs.txt.
  • >> похоже на >, но дописывает в файл, а не перезаписывает его. Например, logging_function >> error.log.
  • < обратно к >. Это перенаправление отправляет содержимое файла справа команде слева. Попробуйте grep foo <foo.txt. < is the reverse of. >

Каналы работают параллельно. Например, следующее будет работать только в течение одной секунды:

sleep 1 | sleep 1 | sleep 1 | sleep 1 | sleep 1

Участники канала не заставляют следующую команду в очереди ждать, пока они не будут полностью выполнены. Они обрабатывают и отправляют вывод по мере поступления.

Условия if

Ничто не иллюстрирует «маленькие инструменты» лучше, чем условие if в bash, которое на самом деле состоит из пяти ключевых слов:

if [ <expression> ]; then
<commands>
fi

Заметили, что условие заканчивается ключевым словом fi?

Так же и с оператором case … esac. Когда я узнала об этом, я понадеялась, что while будет завершено с помощью elihw, а until — с litnu. Но это, к сожалению, не так — эти операторы завершаются ключевым словом done.

[ это команда, а ] — это аргумент, который говорит прекратить принимать другие аргументы. If, else, elif и fi являются ключевыми словами.

Вот, например, ошибка:

/bin/sh: 1: [: true: unexpected operator

Вы можете подумать, что скрипт столкнулся с ошибочным [ и выдал синтаксическую ошибку. Это не так!

На самом деле происходит то, что команда [ получила неожиданный аргумент: true. Ошибка была на самом деле, потому что я использовала == вместо =, что было вычислено как true и неправильно при использовании оператора [.

Контроль выполнения

Я предпочитаю использовать if только там, где это действительно необходимо. Обычно я предпочитаю операторы bash: && и ||.

&& или || ставится после команды/функции. Если команда возвращает код 0, то команда справа от && будет выполнена, а команда справа от || нет:

$ will_return_0 && echo "I will print"
$ will_return_0 || echo "I will not print"
$ will_return_1 || echo "I will print"
$ will_return_1 && echo "I will not print"

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

$ /bin/true && echo "I will print" || echo "I will not print"
$ /bin/false && echo "won't print" || echo "will print"

Но будьте внимательны! Порядок применения важен, нужно использовать вначале &&, а потом ||, но не наоборот. Если выполнить приведенную ниже команду:

/bin/false || echo "will print" && echo "won't print"

То в результате будет выведено обе строки, а не одна, как ожидалось.

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

Я также использую команду: test. Это то же самое, что и [ без вводящего в заблуждение синтаксиса. Менее знакомый синтаксис, мне кажется, лучше, потому что он сигнализирует читателю, что тот может не понимать, что именно происходит.

Переменные

Переменные в bash «дикие». Они работают так, как будто вы поместили их значение в скрипт и запустили его.

Переменные не имеют типа (число, строка, массив и так далее) и действуют, как нужно в данный момент: как строка, команда, число, несколько чисел и так далее. Они могут даже интерпретироваться как несколько ключевых слов, если в вашей «строке» есть пробелы.

Такое поведение переменных может привести к некоторым ошибкам, поэтому вам никогда не следует принимать рискованный ввод данных от пользователя в сценарий оболочки (например из интернета). Если вы веб-разработчик и знаете, насколько опасен eval, то каждый сценарий оболочки — это гигантский оператор eval. Удаленное выполнение кода в любом количестве!

Для примера введите в терминале:

$ MY_VAR="echo stuff"
$ $MY_VAR

Вы должны увидеть выполнение команды, и вывод «stuff» на консоль. Такое поведение может сделать длинные скрипты глючными и непредсказуемыми. Например, попробуйте такой код:

$ HELLO="hello world"
$ test $HELLO = "hello world" && echo yes

Выполнение вызовет ошибку, потому что bash читает код как test hello world = “hello world”.

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

test "$HELLO" = "hello world"

или:

[ "$HELLO" = "hello world" ]

Двойные кавычки в bash это не ограничители строк. Bash не обрабатывает строки так, как другие языки. Кавычки немного больше похожи на круглые скобки в других языках (но не на скобки bash — это подоболочки).

Одиночные и двойные кавычки различаются в bash. В большинстве случаев используются двойные кавычки. В чем разница? Двойные кавычки расширяют переменные, одинарные кавычки понимают их буквально. Например:

var=stuff
echo $var
echo "$var"
echo '$var'

Выведет:

stuff
stuff
$var

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

test -z "$empty" && echo "variable is empty"

Также можно добавить параметр в сценарий, который покажет неустановленные переменные:

set -o nounset

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

Область видимости

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

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

Имя глобальной переменной должно быть ЗАГЛАВНЫМИ буквами. Чтобы сделать переменную доступной для всего терминала, используйте export VAR=»value».

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

Команды

Моя самая нелюбимая вещь в bash — это запоминание маленьких загадочных команд; sed? cat? Что это значит? Имя, конечно, не скажет мне. Man зачастую трудны для понимания, и я, конечно, не вспомню, в каком порядке все должно идти.

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

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

Специальные переменные

Иногда вы сталкиваетесь со странными бессмысленными переменными, такими как $@ и $!. Вот их полный список.

Полезные переменные для написания скриптов и однострочников: $— и $*.

Они обе дают вам аргументы, с которыми работает команда/функция: $- — дает флаги, а $1-9 — ввод. Например:

curl -s example.com

Флаг — это -s, а example.com — это ввод.

Получая входные ключевые слова с $, важно использовать его так: ${2}, ${25} и так далее. Bash не поймет что-то вроде $42, если есть две цифры. Можно также сделать что-то вроде этого:

iterator=4
echo ${$iterator}

Подпроцессы и скобки

Если вы видите такую конструкцию:

while ( something ); do

То эти параметры вовсе не то, что вы думаете.

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

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

Во-первых, что они делают?

Давайте рассмотрим примеры:

!#/bin/bash
myVar="foo"
echo $myVar
(
echo "Inside the sub-shell"
echo $myVar
myVar="bar"
echo $myVar
)
echo "Back in the main shell now"
echo $myVar

Вы получите вывод:

foo
Inside the sub-shell
foo
bar
Back in the main shell now
foo

Обратите внимание, как переменная была изменена для контекста подпроцесса, но не за его пределами. Вот еще одна вещь, которая удобна для подпроцессов:

!#/bin/bash
echo "We start out in home"
pwd
(
echo "In the subshell"
cd /tmp
pwd
)
echo "Back in the main shell now"
pwd

Результат выполнения:

We start out at root
/home/username
In the subshell
/tmp
Back in the main shell now
/home/username

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

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

Скрипты, которые вы запускаете с помощью ./, запускаются в своем собственном процессе, а не в подпроцессе вашего терминала, тогда как скрипты, которые вы запускаете с помощью команды source, запускаются так, как если бы вы вводили команды напрямую.

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

Чтобы ваша функция запускалась в подпроцессе каждый раз, когда вы ее используете, вам нужно обернуть ее в скобки. Чтобы ваша функция возвращала определенный код завершения без запуска в подпроцессе и не вызывала выход из всего сценария, используйте return вместо exit.

Разделение на части

Иногда вам может потребоваться разбить ваш скрипт на файлы. Команда source поможет вам:

parent.sh:
!#/bin/bash
source /path/to/script.sh

В целом, это работает так, как будто вы добавили весь контент скрипта script.sh в скрипт parent.sh. Это значит, что если вы установите переменную в родительском скрипте, все скрипты, подключенные как source, будут иметь доступ к этой переменной.

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

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

Обработка ошибок

Я стараюсь группировать команды в функцию и обрабатывать ошибки, если функция не выполнилась:

my_function || handle_error

Успехов!

Автор: Денис Романенко

Источник: https://mcs.mail.ru/blog/detali-raboty-bash

Что еще почитать:
Go и кеши CPU
Какой язык программирования учить, чтобы за вами охотились HR крупных компаний
Как стать VR-разработчиком: полный список технологий, которые стоит изучить