Приемы написания скриптов в Bash. #2

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

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

  • Не копируются файлы с одинаковыми датой/временем, которые уже существуют (этого можно достигнуть и командой cp с ключом -u)
  • В директории-приемнике уничтожаются все файлы и директории, которых нет в источнике
  • Скрипт может синхронизировать данные не только локально, но и на удаленный компьютер

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

Собственно, как наверное уже многие догадались, мы не будем изобретать велосипед, а воспользуемся программой rsync, которая для этого и предназначена. Здесь задача — обернуть rsync нашим скриптом, чтобы им было удобно пользоваться. Ну кому охота писать нечто подобное? :

rsync -rlptgoDvEAH --delete --delete-excluded --super --force              \
--progress --log-file=/var/log/rs-total.txt --log-file-format=%o %i %f %b  \
/data/src/proj/perl/my/web/company/roga-i-kopyta/                          \
/data/save/proj/perl/my/web/company/roga-i-kopyta/

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

dir-sync -key1 src-sri -key2 dest-dir key3

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

# Добавим объявления необходимых переменных
fixPrmCnt=0
pSrcDir= # Директория-источник
pDstDir= # Директория-приемник

...

while [ 1 ] ; do
   if [ "$1" = "--yes" ] ; then
		pYes=1
   ...
   else
	  # Сюда вставляем обработку фиксированных параметров.
	  # Об этих параметрах (их форме) скрипт ничего не знает.
	  # Следовательно, как только появился «неизвестный ключ» -
	  # это и есть наш фиксированный параметр

	  (( fixPrmCnt++ )) # Номер входного параметра по порядку

		# Цифры (номер параметра) впереди — для наглядности и чтобы
		# легче было править
		if   [ 1 -eq $fixPrmCnt ] ; then
			pSrcDir="$1"
		elif [ 2 -eq $fixPrmCnt ] ; then
			pDstDir="$1"

		# Мы ожидаем только параметры, описанные выше
		# Все остальное - ошибка
		else
			errMess "Ошибка: неизвестный ключ"
			exit 1
		fi
	fi
	shift
done

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

Теперь определим функционал нашего скрипта клонирования. Он должен:

  • При запуске без параметров выводить краткую справку.
  • Требовать подтверждения в случае, если кроме двух заданных параметров не указано ничего. Да! Скрипт супер-десктруктивен, и эта вещь никак не окажется лишней.
  • Как было описано ранее, для подавления подтверждения используется ключ --yes. Это позволит использовать скрипт в других скриптах.

И вот здесь еще одна изюминка:

  • При указании ключа -i скрипт должен стать интерактивным.

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

Ну и собственно функционал:

  • Выбор режима: точная копия/обновление
  • Включение фонового режима (через nohup)
  • Задание имени лог-файла
  • Возможность посмотреть на получившуюся команду rsync (тоже в качестве примера, не более)

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

Опишем все ключи:

  • --yes : Подавить запрос подтверждения
  • -i | --interactive : включить интерактивный режим
  • -lf | --log-file= : задать имя лог-файла
  • -u | --update : режим обновления (по умолчанию — точная копия)
  • -sc | --show-command : показать конечную команду rsync
  • -n | --dry-run : «холостой режим» - rsync запускается и информирует о действиях, но ничего на самом деле не делает
  • -bg | --background : выполнить в фоне

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

# Объявление переменных
fixPrmCnt=0   # Счетчик фиксированных параметров
pInter=       # Интерактивный режим
pLogFile=     # Имя лог-файла
pUpdate=      # Режим обновления
pShowCmd=     # Показать команду rsync
pDryRun=      # Холостой режим
pBackgr=      # Выполнять в фоне
pSrcDir=      # Директория источник
pDstDir=      # Директория приемник
RSCmd=        # Команда rsync
RSPrm=        # Дополнительные параметры rsync

Мы не объявляем параметр pYes, так как он находится в нашей библиотеке. А теперь накидаем основные блоки программы.

Вот как выглядит обработка параметров:

if [ -z "$1" ] ; then
   usage
   exit
fi

while [ 1 ] ; do
	if [ "$1" = "--yes" ] ; then
		pYes=1
	elif [ "$1" = "-i" ] ; then
		pInter=1
	elif [ "$1" = "--interactive" ] ; then
		pInter=1
	elif procParmS "-lf" "$1" "$2" ; then
		pLogFile="$cRes" ; shift
	elif procParmL "--log-file" "$1" ; then
		pLogFile="$cRes"
	elif [ "$1" = "-u" ] ; then
		pUpdate=1
	elif [ "$1" = "--update" ] ; then
		pUpdate=1
	elif [ "$1" = "-sc" ] ; then
		pShowCmd=1
	elif [ "$1" = "--show-command" ] ; then
		pShowCmd=1
	elif [ "$1" = "-n" ] ; then
		pDryRun=1
	elif [ "$1" = "--dry-run" ] ; then
		pDryRun=1
	elif [ "$1" = "-bg" ] ; then
		pBackgr=1
	elif [ "$1" = "--background" ] ; then
		pBackgr=1
	elif [ -z "$1" ] ; then
		break # Ключи кончились
	else
		(( fixPrmCnt++ ))
		if   [ 1 -eq $fixPrmCnt ] ; then
			pSrcDir="$1"
		elif [ 2 -eq $fixPrmCnt ] ; then
			pDstDir="$1"
		else
			errMess "Ошибка: неизвестный ключ"
			exit 1
		fi
	fi
	shift
done

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

checkParm "$pSrcDir" "Не задана директория-источник"
checkParm "$pDstDir" "Не задана директория-приемник"

if [ "$pInter" = "1" ] && [ "$pYes" = "1" ] ; then
   errMess "Несовместимые параметры: --yes и -i"
   exit 1
fi

# Откусывыаем конечную слэш, если она задана
pSrcDir="${pSrcDir%/}"
pDstDir="${pDstDir%/}"

checkDir "$pSrcDir"
checkDir "$pDstDir"

Неинтерактивная часть скрипта выглядит очень простой:

# Если неинтерактивный запуск
if [ "$pInter" != "1" ] ; then

	# Запрос подтверждения
	if [ "$pYes" != "1" ] ; then
		echo "Скрипт ${curScript##*/} приветствует Вас!"
		showInfo
		myAskYesNo "Это может повлечь необратимые последствия! Вы уверены?" || exit
	fi

	createCmd

Функции showInfo и createCmd мы еще рассмотрим — это, собственно, отображение информации о параметрах и генерация команды rsync.

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

   cat <<EOF
Скрипт ${curScript##*/} приветствует Вас!
Точкой на любой вопрос Вы сможете прервать выполнение.
Выберите желаемый режим:
------------------------
   c) clone   (полное клонирование)
   u) update  (только обновление)
   .) Выход
EOF
   input1 "Твой выбор: " "cu."
   [ "$cRes" = "." ] && exit

   pBackgr= # Чтобы не наложился параметр, заданный с командной строки
   input1 "Хотите ли Вы выполнить операцию копирования в фоне? (y/n): " "yn."
   [ "$cRes" = "." ] && exit
   [ "$cRes" = "y" ] && pBackgr=1

   # А здесь может быть по умолчанию то, что пришло с командной строки
   read -p "Введите имя лог-файла (по умолчанию: $pLogFile): " a1
   [ -n "$a1" ] && pLogFile="$a1"
   [ "$a1" = "." ] && exit

   pShowCmd= # Чтобы не наложился параметр, заданный с командной строки
   input1 "Вывести команду синхронизации на экран? (y/n): " "yn."
   [ "$cRes" = "." ] && exit
   [ "$cRes" = "y" ] && pShowCmd=1

   createCmd

   echo # Дополнительный отступ для читабельности
   showInfo

	if [ "$pShowCmd" = "1" ] ; then
		echo "Команда rsync:"
		echo "  $RSCmd" "${RSPrm[@]}"
	fi

   myAskYesNo "Запускаем! Вы уверены?" || exit

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

Здесь также присутствуют функции showInfo и createCmd.

А теперь немного модифицируем функцию input1 (см. в библиотеке) так, чтобы она принимала параметр, который говорит о том, что в случае нажатия точки, нужно выходить из скрипта - «dot-exit». Мы исключим по одной строке на обработку каждого параметра! Сейчас часть кода, отвечающая за это выглядит так:

   input1 "Твой выбор: " "cu." "dot-exit"

   pBackgr= # Чтобы не наложился параметр, заданный с командной строки
   input1 "Хотите ли Вы выполнить операцию копирования в фоне? (y/n): " "yn." "dot-exit"
   [ "$cRes" = "y" ] &amp;&amp; pBackgr=1

   # А здесь может быть по умолчанию то, что пришло с командной строки
   read -p "Введите имя лог-файла (по умлочанию: $pLogFile): " a1
   [ -n "$a1" ] &amp;&amp; pLogFile="$a1"

   pShowCmd= # Чтобы не наложился параметр, заданный с командной строки
   input1 "Вывести команду синхронизации на экран? (y/n): " "yn." "dot-exit"
   [ "$cRes" = "y" ] &amp;&amp; pShowCmd=1

Можно пойти дальше, и ввести несколько функций для ввода параметров. Но это оставим на следующий раз.

Концовка очевидна:

if [ "$pBackgr" = "1" ] ; then
   nohup $RSCmd "${RSPrm[@]}" &amp;
else
   $RSCmd "${RSPrm[@]}"
fi

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

А теперь рассмотрим функции, которые тоже просты и понятны.

showInfo()
{
	local a1

	if [ "$pUpdate" = "1" ] ; then
		a1="обновление"
	else
		a1="клонирование"
	fi

	padMid 80 "Режим" "$a1" ; echo $cRes
	padMid 80 "Источник" "$pSrcDir" ; echo $cRes
	padMid 80 "Приемник" "$pDstDir" ; echo $cRes
	padMid 80 "Лог-файл" "$pLogFile" ; echo $cRes

	transYesNoRu $pBackgr
	padMid 80 "Выполнять в фоне" "$cRes" ; echo $cRes

	transYesNoRu $pDryRun
	padMid 80 "Выполнять в холостом режиме" "$cRes" ; echo $cRes
}

Здесь мы пользуемся библиотечной функций padMid, чтобы красиво и ровно выводить значения параметров (параметр «80» — ширина строки). Функция transYesNoRu из 1 делает «да», из всего остального «нет».

Вывод при этом примерно таков:

Режим..................................... клонирование
Источник.......................... /data/src/proj/fed16
Приемник........................... /data/src/proj/sync
Лог-файл......................... /var/log/dir-sync.log
Выполнять в фоне................................... Нет
Выполнять в холостом режиме........................ Нет

И наконец сердце скрипта — генерация команды rsync, где последовательно добавляются ключи в соответствии с заданными параметрами.

createCmd()
{
	RSCmd="$rsync"

	if [ "$pUpdate" = "1" ] ; then
		RSCmd="$RSCmd -urlptgoDvEAH"
	else
		RSCmd="$RSCmd -rlptgoDvEAH --delete"
	fi

	# Если в фоне - не надо никакого вывода, и наоборот
	if [ "$pBackgr" = "1" ] ; then
		RSCmd="$RSCmd -q"
	else
		RSCmd="$RSCmd --progress -v"
	fi

	if [ "$pDryRun" = "1" ] ; then
		RSCmd="$RSCmd -n"
	fi

	RSCmd="$RSCmd --super --force"

	# Дополнительные параметры - элементами массива
	n=-1
	((n++)) ; RSPrm[n]="--log-file=$pLogFile"
	((n++)) ; RSPrm[n]="$pSrcDir/"
	((n++)) ; RSPrm[n]="$pDstDir/"
}

То есть createCmd формирует переменную RSCmd, которая потом запускается в оконечной части скрипта.

Особо отметим использование массива RSPrm. Дело в том, что если в именах файлов будут встречаться пробелы (а мы пишем более-менее универсальный скрипт, который этот момент должен учесть), то сборка одной строки RSCmd работать не будет. Помните концовку: $RSCmd "${RSPrm[@]}" ? Если бы все набивалось только в строку $RSCmd и концовка выглядела бы как $RSCmd, то имя директории или лог-файла с пробелами было бы разбито интерпретатором bash. Например, при указании директории источника «my dir», вместо копирования «my dir» куда указано, была бы попытка копирования my в dir, а затем еще в это «куда-то».

Попытки собрать строку как

RSCmd="$RSCmd \"$pSrcDir/\" \"$pDstDir/\" "

, то есть добавить эскапированные кавычки в эту строку, также успехом не увенчаются. Мы получим имена файлов типа "my dir" вместе с кавычками.

Использование массива решает эту проблему. Массив инициализируется также как обычная переменная (RSPrm=), точнее, он и является обычной переменной до тех пор, пока не будет использоваться как массив. И мы именно так и поступаем, когда выполняем ((n++)) ; RSPrm[n]="--log-file=$pLogFile" . Индексы массива в bash начинаются от нуля. Для универсальности и читабельности мы инициализируем n=-1, чтобы потом просто инкрементировать ее и получить новый действительный индекс.

Использование же массива происходит в концовке:

$RSCmd "${RSPrm[@]}"

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

Вообще использование массивов таким образом крайне полезно, когда параметрами выступают строки, содержащие пробелы. Например, тот же rsync может принимать параметры фильтрации файлов, типа: '-f- *.tmp', означающее, что при синхронизации игнорируются *.tmp файлы. Так вот, '-f- *.tmp' это единый параметр, который содержит в себе пробел. Если Вы будете собирать строки один раз, то можете указывать эти параметры в кавычках или апострофах типа:

rsync ... '-f- *.tmp' '-f- *.log' ...

Но если вы такую строку попытаетесь собрать предварительно, а затем выполнить ее — будет караул! Например:

param="-f- *.tmp"
param="$param -f- *.log"

аналогично не сработает и

param="'-f- *.tmp'"
param="$param '-f- *.log'"

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

Резюме

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

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

Обращу внимание читателей, не знакомых с rsync — одну из директорий можно задавать на удаленной машине в виде

[user@][host:]dir-from-root

, то есть

vova@mycomp:/save/work
mycomp:/save/work

вызов скрипта может быть, например, таким:

dir-sync -u /work mycomp:/save/work



См. примеры кода для статьи.

  • Soft Space SIA
    Latvia
  • Этот адрес электронной почты защищен от спам-ботов. У вас должен быть включен JavaScript для просмотра.
  • +371 25 46 42 49