Strizhechenko's bash development guideline (ru)
Опции 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
Кто работает в консольке медленно - тот всех бесит, особенно тех, кто стоит и смотрит как вы что-то пытаетесь показать, медленно набивая команды руками.
reverse search.
Итак, в любом уважающем себя и своих пользователей 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
позволяет грепать сразу по куче паттернов, вместо вызова большого числа грепов в цикле. Учтите, что каждый греп - это:
- exec()
- 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. Есть и разногласия, но это так - моя вкусовщина. Есть и много того, чего у меня здесь не описано.