Опции bash для разработки

Разрабатывайте скрипты с опциями set -eu

  • set -e - падать на ошибках
  • set -u - считать ошибкой обращение к неопределенной переменной
  • set -o pipefail - заставить пайпы перестать скрывать ошибки предыдущих команд

Профиты

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

Минусы

«Ложные» и ложные падения. Про них расскажу подробнее. Вот пример скрипта:

#!/usr/bin/env bash

set -eu

x=1
((x++))
echo $x
x=0
((x++))
echo $x

Казалось бы, он должен вывести:

2
1

Но в bash версии 4+ увеличение переменной до единицы даё код возврата 1. Вроде даже находил почему и даже подумал что «ок, логично».

Можно обойти это, используя преинкремент:

#!/bin/bash

set -eu
x=1
((++x))
echo $x
x=0
((++x))
echo $?

Ещё часто «ложным» падением заканчивается возврат

cmd | grep "something"

при обработке текста, если этого something в stdin не было. В итоге код с set -e иногда обрастает конструкциями вроде

cmd | grep "something" || true

Суть set -e в том, чтобы все ошибки были обработаны. Go style прямо.

Примечание: конструкции вроде:

[ -n $something ] && echo OH ERROR
будут падать до вывода . Их тоже придётся “залепливать”   true, либо заменять на
if [ -n "$something" ] then
    echo OH ERROR
fi

Также обратите внимание, что:

false || return

всегда возвращает ошибку. Не надо так. На мой взгляд, падают - и пусть падают, если это действительно ошибка. Не надо писать ручное падение, если результат не будет значительно отличаться. Несмотря на всю уродливость || true - такой код всё равно остаётся чище и проще, чем с ручной обработкой всех возможных ошибок.

Благодаря set -u код обрастает «костылями» немного другого рода:

variable="${1:-default_value}"

Зато код проверки переданных функциям параметров становится значительно чище (практически пропадает).

Стоит ли включать их в продакшне?

На мой взгляд - да. Developer вариант должен быть максимально близким к продакшну.

Частая аргументация против: “в идеале. реально при “set -e” часто получаем wtf, когда внезапно в один момент половина скрипта молча перестает работать”.

Отвечу: вызываем c bash -x и половина скрипта перестаёт работать отнюдь не молча. Плюс у вызывающей стороны тоже должен быть включен set -e, чтобы вы узнали о том, что где-то что-то упало. Также избавиться от молчаливых падений можно с помощью конструкции вроде:

trap __exit EXIT

__exit() {
    RETVAL=$?
    if [ "$RETVAL" != 0 ]; then
            echo -n "ERROR($RETVAL): $0 "
            for ((i=${#FUNCNAME[@]}; i>0; i--)); do
                    echo -n "${FUNCNAME[$i-1]} "
            done | sed -e 's/ $/\n/; s/ / -> /g'
    fi
    return $RETVAL
}

Ещё один аргумент: “мой посыл в том, что чаще всего лучше эти вещи проверять другим способом — явными проверками и разбиением на мелкие блоки”.

Ответ: как только в скрипте появляется set -eu - вам приходится писать явные проверки, иначе скрипт падает. Если не появляется - вы не пишете все явные проверки, потому что вы человек. К слову, разбиение на мелкие блоки, лучше даже в виде утилит, действительно довольно хорошо помогает изолировать проблемное место.

Bash unofficial strict mode

@dshevchenko_biz подкинул ссылку на unofficial bash strict mode, в принципе он включает в себя совет по set -eu и ещё несколько важных вещей: http://redsymbol.net/articles/unofficial-bash-strict-mode/

Shellcheck и статический анализ

Если вы используете bash в качестве языка программирования, то и относитесь к нему как к языку и используйте статический анализатор

Самый популярный анализатор для shell/bash на текущий момент - shellcheck. Есть плагины к Atom, можно запускать из консольки. Сейчас проект активно развивается, так что рекомендую следить за тем, насколько старую версию вы используете. К примеру за время которое я его использую (около полугода) пофиксили около трёх досадных багов, которые ложно обвиняли меня в плохом коде. Одного факта поиска неиспользуемых переменных уже достаточно, но он гораздо умнее, находит кучу подводных камней и must-have для джуниоров.

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

Вообще корень многих зол в bash - передача аргументов. Всегда вот все с ней тупят, мучаются и велосипедят. 190% людей путают

$*
$@
"$*"
"$@"
  • 100% - это до того, как узнали чем они отличаются.
  • 90% - после.

shellcheck может предупредить вас о ситуации:

rm -rf $DIRECTORY/$file

которая, если не использовать set -eu может превратится в

rm -rf /

в принципе современный coreutils итак не даст вам этого сделать, но ведь не только в / аргумент может схлопнуться.

Но в принципе писать код можно и без shellcheck, следуя двум правилам:

  • кавычьте все “$переменные” в 99.9% случаев
  • тестируйте упорнее

Структурирование кода

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

Модуль = утилита

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

Можно применять архитектурный подход, используемый в git - в зависимости от первого параметра ($1) запускать скрипт расположенный в, “$0-$1” которому прозрачно пробрасывать все параметры кроме первого. Дерево команд гита довольно здоровое, около 160 утилит, сам же git является маааленьким бинариком.

Почему в баше не очень принято делать библиотеки

Конечно, никто не запрещает использовать конструкцию

source lib/yourlib.sh
source lib/yourlib2.sh

можно также писать

. lib/yourlib.sh

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

source ./mylib.sh
…
rate_my_code .
…

или

…
./rate_my_code.sh .
…

Во втором случае недоумения «откуда этот rate_my_code взялся?» у читателя не возникает. Также у rate_my_code.sh может быть свой –help, объясняющий что и как.

В питоне этот момент хорошо сглаживается прекрасным механизмом импортов, где всё явно:

from lib.sublib import func

сразу видно откуда берётся func.

Переопределения функций

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

Как делать и либу и утилиту в одном

В конструкции

source libname.sh

есть возможность передачи аргументов. А аргументы можно анализировать. Благодаря этому можно получить что-то похожее на конструкцию:

if __name__ == '__main__' :

в python:

func1() {
    echo qwe
}

# в конце файла
main() {
    cmd1
    cmd2
    if somelogic "$@"; then
        onemorecmd
    fi
}

if [ "${1:-}" != ':' ]; then
    main "$@"
fi

В результате можем получить в интерактивном шелле доступ ко всем функциям, определённым в скрипте:

$ source libname.sh :
$ func1
qwe

что даёт бонус в виде удобства тестирования и отладки. В подобном виде можно оформлять хуки /etc/sysconfig/.

Дебаг

Совет №4. Юзайте дебаг. В bash есть кое что, чего мне не хватает вообще ни в одном другом языке программирования.

bash -x scriptname.sh

bash -x - это автоматизированная система управления добавлением и выводом дебаговых принтов. На самом деле она крутая и сразу проясняет дело. Вывода полно, но зато вы узнаете абсолютно всё, что происходит под капотом, вызов каждой функции, её аргументы, и прочее прочее.

Пример:

$ cat x.sh
#!/bin/bash

x=10
y=20
z=$((x+y))
echo $z

$ ./x.sh
30
$ bash -x x.sh
+ x=10
+ y=20
+ z=30
+ echo 30
30

Антипаттерны

&>/dev/null используйте только в крайних случаях

Вместе с stderr в /dev/null улетает ваше время на отладку.

Помните о том, что большая часть утилит умеет кушать файлы аргументом

Не надо плодить пайпы там, где не надо!

Плохо:

cat "$file" | grep "$pattern"

Хорошо:

grep "$pattern" "$file"

Скорость работы в shell

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

Итак, в любом уважающем себя и своих пользователей shell есть reverse search, вызываемый нажатием ctrl+r и вводом части команды. Из того, что приходит в голову: bash, ipython, psql. Не используя эту возможность, вы будете люто, бешенно злить коллег тем, что вы медленные, а зная про ctrl+shift+r (шаг назад в поиске) вы не будете злиться, пропустив нужную команду в поиске.

“modern” linux utils, что устарело, что пришло на замену

Совет №6: используйте современные утилиты, а не заброшенные авторами 10+ лет назад.

В первую очередь - выкиньте ifconfig/vconfig. Они отжили своё, вместо них давно уже появился унифицированный и простой iproute2. Вообще iproute2 втянул в себя довольно много всего:

  • arp - ip neigh
  • vconfig - ip link
  • route - ip route/ip rule
  • ifconfig - ip addr/ip link

Совет №6.1: иногда /proc/net/dev обработать проще, чем грепать вывод iproute2/ifconfig. Там нет информации об IP адресах, но статистику по потерям/пакетам/объёму трафика оттуда читать очень легко. По поводу грепа ip - полезный пример команды:

ip -o-4 addr show label eth*
  • -o запись в 1 строчку
  • -4 только ipv4
  • label eth* - только ethernet

Отживает своё и netstat. На смену ему пришли ss и conntrack. Сталкивался я также недавно и с тем, что netstat не отображал 1 из сокетов nginx. Если не ошибаюсь, у него были проблемы с отображением при использовании нескольких тредов, память изменяет. Важная заметка: формат вывода ss, мягко говоря, слабо ориентирован на человека, в сравнении с тем же netstat. Читать это глазами ТЯЖЕЛОВАТО.

lshw вот был классным, но вроде тоже скоро исчезнет. Некоторое новое оборудование уже не отображает. Вместо lshw предлагается использовать lspci, lsusb и lscpu. ls /sys/pci /sys/cpu /sys/usb было бы забавнее, внутри возможно почти так и есть

Не надо делать баш ещё медленнее!

Bash - довольно медленный язык. Но - в ваших силах не делать его еще медленнее своим неидеальным кодом. Изучите ВСЕ coreutils, grep, sed, awk. Научитесь в нужные моменты использовать builtin’ы bash - и тогда в некоторых случаях вы будете сильно выигрывать по скорости. Как правило наибольший прирост в скорости дают замены циклов и их тел на одну команду, избавление от лишних вызовов команд и пайпов. Плюс в баше есть возможность очень легко “параллелить”:

for job in {1..10}; do
 somecmd &
done
wait

Сложные регулярки и sed

Частый аргумент против сложных регулярок в sed: “если что-то будет повторяться или надо автоматизировать - есть питон”.

На мой взгляд так появляются свои sed/awk, напианные на python, который тоже раскуривать надо. А регулярки от этого никуда не пропадают, зато появляется:

import re

re.match("абсолютно та же самая регулярка", ...)

На самом деле в некоторых случаях, написать простенькую утилитку на python - не зло. Просто стоит знать, что скорее всего её можно написать в 20-30 символов на bash + sed + grep, а не в 20-30 строк на python.

Вдобавок много команд можно хранить в sed-скрипте. Команда regexp - не обязательно должа быть однострочной.

$ cat x.sed
#!/usr/bin/env sed -E -f

# не материмся!
s/bullshit/something/g
# пишем умные комментарии
s/thing/wut/g
# ведь комментарии - благодать!
s/(some)(wut)/\2\1/g

$ echo bullshit | ./x.sed
wutsome

В итоге это можно юзать как утилиту, и не думать о его внутреннем устройстве и ужасе regex. То же самое относится к awk. Не обязательно держать всю сложность в одном скрипте, если её становится много - выносите. Проще жить будет.

Хитрости с grep

Хорошо зная регулярные выражения, вы можете заменять здоровые цепочки grep x grep -v y grep -c z одним egrep. К слову: читаемость может значительно ухудшиться, но можно присвоить строку регулярки переменной, название которой служит комментарием.

Есть еще крутая опция:

grep -f file_with_patterns

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

  1. exec()
  2. read() всего грепаемого файла.

-f позволяет сделать дело за один read() и один exec().

Откажитесь от внешних программ

Множество действий можно сделать силами bash, без вызова внешних программ - sed, awk, grep. Суть в том, что на запуск внешней программы тратится время - открытие бинарика, передача ему параметров и всё такое. Казалось бы мелочь, ~0.05мс, но когда у вас grep вызывается внутри

while read line; do
   if echo $line | grep няшный; then
      echo $line | sed -e 's/котик/кот/g'
   fi
done < $file > ${file}.fixed

С крупными файлами это будет работать очень долго. Но что делать? Заменить вызов внешних команд на bash’евые build-in где это только возможно!

while read line; do
   if [[ "$line" = *няшный* ]]; then
      echo ${line//котик/кот}
   fi
done < $file > ${file}.fixed

Примеры замен:

echo | grep

if echo $line | grep -q $word; then можно заменить на [[ "$line" = *$word* ]]

awk/cut

Иногда натравливать внешние программы оказывается быстрее. При работе с большими (более 1000 строк) файлами вывод части колонок лучше сделать при помощи awk, либо cut, если IFS один

awk '${print $3" "$5}' $file

можно заменить на

while read tmp tmp word1 tmp word2 tmp; do
   echo $word1 $word2
done < $file

var=$(echo | sed)

Не стоит также вызывать sed ради простейших замен в переменной:

s="$(echo $line | sed 's/котик/кот/g')"

лучше сделать это с помощью bash’евских подстановок

s="${line//что-то/другое}"

Если нужно удалить что-то:

всё до первого попадения a с начала:

s="${line#*a}"

всё до последнего попадения a с начала

s="${line##*a}"

всё с конца до последнего попадения a

s="${line%a*}"

всё с конца до первого попадения a

s="${line%%a*}"

удалить первую a (заменить на пустоту)

s="${line/a/}"

удалить все a (заменить на пустоту)

s="${line//a/}"

Не обрабатывайте вывод запроса к БД регулярками

bash-4.1# echo -e "SELECT first 10 id FROM users;\n" | isql-fb -q  /var/db/billing.gdb  -p something -u SYSDBA
          ID
 ============
            2
            1
          306
          287
          288
          312
          313
          293
          295
          296

А нам, скажем нужны только id для while read. Так вот, не стоит делать что-то в духе:

echo -e "SELECT first 10 id FROM users;\n" | isql-fb -q  /var/db/billing.gdb  -p something -u SYSDBA > file
egrep -v "(ID|=========|^\s*\t*$)" file > file2
while read line; do
  действия над $line
done < file2

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

echo -e "SELECT first 10 'rowrow', id FROM users;\n" | isql-fb -q  /var/db/billing.gdb  -p something -u SYSDBA > file
grep rowrow file | while read line; do
   действия над $line
done

Кстати, не знаю откуда у меня такое пошло, но мне гораздо более привычно, когда stdin / stdout у while-loop задаются после done, даже если это результат команды, чем передача через пайп сверху, например:

while read line; do
  echo $line
done <<< "$(iptables -nvL)" > file

Хотя вообще - такая конструкция зло. Лучше скидывать всё в файл заранее:

iptables -nvL > file
while read line; do
  echo $line
done < file > file2

Откажитесь или уменьшите sleep

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

Чтение строки в массив

read умеет читать сразу в массив и ему можно задать разделитель:

Не очень

read line <<< "a b c"
array=( $line )

Получше:

read -a array <<< "a b c"

Разделитель:

IFS=';' read -a array <<< "a;b;c"

Xargs

Совет #7: xargs мало кому понятен, но позволяет писать эффективный и короткий код. В основном его используют после пайпов.

Например объем исходников на C в текущей директории:

find . -type f -name *.[ch] | xargs wc -l

Еще xargs позволяет рулить “threads pool’ом”:

http://coldattic.info/shvedsky/pro/blogs/a-foo-walks-into-a-bar/posts/7

Типичное “неиспользование” xargs:

find $find_filter | while read filename; do
  something $filename
done
find $find_filter | xargs something

Короткие примеры изящного использования некоторых утилит

head умеет отсекать последние N строк из вывода:

echo "1
2
3
4
5" | head -n -2

выведет:

1
2
3

отрезав последние 2 строчки.

P.S: tail умеет нечто подобное, но там немного непонятно:

echo "1
2
3
4
5" | tail -n +3
3
4
5

он отрезает верхние N-1 строчек. Почему -1 - не совсем понятно, если кто прояснит - будет круто.

Strongbash

Многое из написаного дублируется с code-style в Carbon Soft и Cloudfox. Есть и разногласия, но это так - моя вкусовщина. Есть и много того, чего у меня здесь не описано.