Практическое написание сценариев командной оболочки Bash/Команда read
← Команды | Глава | Код-сниппеты → |
Команда read | ||
Команда read
служит для чтения файлов. В Bash команда read
отличается большим количеством вспомогательных опций:
- Например вы можете читать файл и переносить данные прямо в массив (опция
-a
). - Можно указывать файловый дескриптор явно (опция
-u
). - Можно указать разделитель явно (опция
-d
). - Можно вводить таймаут на операцию чтения для защиты от зависания (опция
-t
). - Можно указать сколько символов прочитать (опции
-n
и-N
). - Можно вывести приглашающее сообщение (опция
-p
).
В этом разделе мы рассмотрим приемы применения команды read
.
Основы
правитьПо умолчанию вызов команды read
читает стандартный поток ввода оболочки, c которым обычно связывается клавиатура. Этим пользуются чтобы получить от пользователя некоторые данные, которые использует скрипт. В параметрах команды вы должны указать одну или несколько переменных, в которые будет записываться ввод. Если ни одной переменной не указано, то в Bash используется предопределенная переменная с именем REPLY
# Запуск с консоли
$ read
hello # Ввод пользователя
$ echo $REPLY
hello
Так как команда по умолчанию никак не намекает на то, что ожидает ввод пользователя, рекомендуется использовать ее с опцией -p
, если она читает ввод с клавиатуры
# Запуск с консоли
$ read -p "Enter your name: "
Enter your name: John # Ввод пользователя
$ echo $REPLY
John
К сожалению опция -p
не переносится между оболочками, поэтому в переносимых сценариях обычно пишут так
#!/bin/bash
echo -n "Enter your name: "
read
echo $REPLY
Вы можете облегчить жизнь пользователю, если включите интерактивный режим команды через опцию -e
. Такая возможность появилась, начиная с Bash 4. В этом режиме включается возможность автодополнения путей и имен файлов при нажатии клавиши Tab ↹. Используя опцию -i
, вы можете предложить пользователю некоторое значение по умолчанию, с которым он может согласиться или немного отредактировать. Например
read -e -p "Enter the path to the file: " -i "/usr/local/etc/"
echo $REPLY
# Результат:
# Enter the path to the file: /usr/local/etc/
# /usr/local/etc/
Команда read
ожидает признака конца файла, чтобы завершить свою работу. Если ввод производится с клавиатуры, то до нажатия клавиши ↵ Enter оболочка приостановится в ожидании ввода. Если бесконечное ожидание ввода не желательно для сценария, то следует поставить некоторый таймаут в секундах.
#!/bin/bash
read -p "Enter your name: " -t 5
[[ -n $REPLY ]] && echo $REPLY || echo -e "\nTimeout!"
# Результат:
# Enter your name: # Ждем 5 секунд
# Timeout!
Если вам известно из какого файлового дескриптора читать, то его можно указать явно
#!/bin/bash
TEMPFILE=$(mktemp -p /tmp tmp.XXXXXXXX)
trap "rm -f $TEMPFILE" EXIT SIGKILL # Чтобы удалить временный файл после завершения сценария
echo "Hello, World!" >> $TEMPFILE
exec 3<>$TEMPFILE # Открываем 3-й дескриптор и связываем его с временным файлом
read -u 3
[[ -n $REPLY ]] && echo $REPLY || echo -e "\nTimeout!"
# Результат:
# Hello, World!
В предыдущем примере мы использовали опцию для указания дескриптора, но мы могли бы то же самое сделать через простой redirection.
#!/bin/bash
TEMPFILE=$(mktemp -p /tmp tmp.XXXXXXXX)
trap "rm -f $TEMPFILE" EXIT SIGKILL
echo "Hello, World!" >> $TEMPFILE
read -t 3 <$TEMPFILE # Простой redirection стандартного потока ввода
[[ -n $REPLY ]] && echo $REPLY || echo -e "\nTimeout!"
На практике текст может содержать управляющие последовательности или экранированные символы, которые помечаются слешем \
. По умолчанию команда read
затирает такие слеши. Чтобы прочитать текст в том виде, в котором он есть (plain text), следует всегда использовать опцию -r
. Сравните
#!/bin/bash
read <<< $(
echo "\n\t\v\a"
)
echo $REPLY
# Результат:
# ntva
read -r <<< $(
echo "\n\t\v\a"
)
echo $REPLY
# Результат:
# \n\t\v\a
Чтение ввода по разделителю
правитьКоманда read
анализирует количество переданных ей аргументов и размещает части фрагмента по разделителю. По умолчанию используются разделители, записанные в переменной IFS
. Для начала сравните
read line1 <<< $(echo "word1 word2 word3")
echo "line1 = $line1"
echo "line2 = $line2"
echo "line3 = $line3"
read line1 line2 <<< $(echo "word1 word2 word3")
echo "--------"
echo "line1 = $line1"
echo "line2 = $line2"
echo "line3 = $line3"
read line1 line2 line3 <<< $(echo "word1 word2 word3")
echo "--------"
echo "line1 = $line1"
echo "line2 = $line2"
echo "line3 = $line3"
# Результат:
# line1 = word1 word2 word3
# line2 =
# line3 =
# --------
# line1 = word1
# line2 = word2 word3
# line3 =
# --------
# line1 = word1
# line2 = word2
# line3 = word3
В первом вызове мы передаем одну переменную команде, поэтому никакого разбиения по разделителю не происходит: read
помещает весь фрагмент в переменную. Во-втором вызове мы передаем две переменные, поэтому в первую из них заносится значение до первого валидного разделителя (в данном случае это пробел), а остаток помещает во-вторую переменную. Наконец в третьем вызове мы размещаем все три подстроки в каждой переменной.
Вы можете убедиться, что символ пробела не является единственным валидным разделителем. Если бы слова разделялись символом табуляции, то вы бы увидели такой же результат:
read line1 line2 line3 <<< $(echo -e "word1\tword2\tword3")
echo "--------"
echo "line1 = $line1"
echo "line2 = $line2"
echo "line3 = $line3"
# Результат:
# --------
# line1 = word1
# line2 = word2
# line3 = word3
Такой подход к разделению удобен, когда структура входящей строки вам известна, но на практике это не всегда так. В таких ситуациях прибегают к помощи цикла while
. Здесь пользуются тем, что если read
есть что прочитать, то она возвращает нулевой код. Таким образом, типичный парсер имеет такое начало
parse() {
echo $((++COUNTER)): $@
}
while read -r LINE; do
parse "$LINE"
done <<< $( echo "word1 word2 word3" )
Положим, что мы не знаем структуры входной строки, но знаем что в ней слова разбиваются по пробелам. Как же нам разбить входящую строку по пробелам и получать каждое слово по отдельности?
В Bash вы можете поступить разными способами. Первый способ заключается в использовании опции -d
, которая определяет разделитель.
while read -r -d ' ' LINE; do
parse "$LINE"
done <<< $( echo "word1 word2 word3" )
# Результат:
# 1: word1
# 2: word2
Полученный результат не совсем нас удовлетворяет, потому что мы потеряли последнее слово. На самом деле функция read
здесь не виновата: это произошло из-за цикла while
, который получает ненулевой код после последнего чтения. Вы можете убедиться, что в переменной LINE
хранится word3
после выхода и цикла.
Чтобы этого не происходило, нам нужно немного усложнить условие цикла while
, а именно требуется добавить проверку на строку нулевой длины в переменной LINE
:
while read -r -d ' ' LINE || [[ -n $LINE ]]; do
parse "$LINE"
done <<< $( echo "word1 word2 word3" )
# Результат:
# 1: word1
# 2: word2
# 3: word3
Это решение работает, если встроенная команда read
поддерживает опцию -d
. Если опция не поддерживается, вы можете использовать следующее решение
TAIL="word1 word2 word3"
while read -r LINE TAIL <<< $TAIL ; [[ -n $LINE ]]; do
parse "$LINE"
done
# Результат:
# 1: word1
# 2: word2
# 3: word3
Вы можете гибко управлять разделителем слов через переменную IFS
.
# Предположим, что в строке используется еще один разделитель в виде символа ';'.
# Чтобы разделять строку по этому разделителю, достаточно добавить этот символ в переменную IFS.
TAIL="word1 word2 word3 ; word5 ; word6"
while IFS="$IFS;" read -r LINE TAIL <<< $TAIL ; [[ -n $LINE ]]; do
parse "$LINE"
done
# Результат:
# 1: word1
# 2: word2
# 3: word3
# 4: word5
# 5: word6
Обратите внимание на следующую тонкость. Если IFS
изменяется как экспортированная переменная, то нам не обязательно заботится о её предыдущем значении.
Если вам нужно коренным образом поменять разделитель, то нужно очистить IFS
и присвоить ей конкретный разделитель.
parse() {
# Добавим кавычки, чтобы были видны начальные и завершающие пробелы
# ----
# V V
echo "$((++COUNTER)): '$@'"
}
TAIL="word1 word2 word3 ; word5 ; word6"
while IFS=";" read -r LINE TAIL <<< $TAIL ; [[ -n $LINE ]]; do
parse "$LINE"
done
# Результат:
# 1: 'word1 word2 word3 '
# 2: ' word5 '
# 3: ' word6'
Единственным недостатком такого решения является то, что начальные и конечные пробелы перестали автоматически удаляться, так как символ пробела перестал быть разделителем. Это можно исправить с помощью следующих подстановочных операций.
TAIL="word1 word2 word3 ; word5 ; word6"
while IFS=";" read -r LINE TAIL <<< $TAIL ; [[ -n $LINE ]]; do
LINE="${LINE#"${LINE%%[![:space:]]*}"}"
LINE="${LINE%"${LINE##*[![:space:]]}"}"
parse "$LINE"
done
# Результат:
# 1: 'word1 word2 word3'
# 2: 'word5'
# 3: 'word6'
В заключении попробуем проделать то же самое, но через опцию -d
. Главным удобством этого является отсутствие необходимости редактировать IFS
.
TAIL="word1 word2 word3 ; word5 ; word6"
while read -r -d ';' LINE || [[ -n $LINE ]]; do
parse "$LINE"
done <<< $TAIL
# Результат:
# 1: 'word1 word2 word3'
# 2: 'word5'
# 3: 'word6'
Вам не запрещено управлять поведением команды через -d
и IFS
одновременно. Все зависит от структуры текста, который вы разбираете.
Посимвольное чтение
правитьПосимвольное чтение обычно используется в интерактивном режиме, когда вам нужно делать некоторую постобработку после нажатия клавиш. С другой стороны, посимвольно читаются и файлы, особенно если вам известны выравнивания полей.
В Bash у команды read
есть две опции для указания того, сколько символов нужно прочитать:
-n
. Читает первые N символов до первого разделителя фрагментов. Если до первого разделителя фрагментов символов оказывается меньше, чем запрошено, то будут прочитаны только они. Напомним, что по умолчанию разделителем фрагментов является символ переноса строки.-N
. Читает первые N символов, игнорируя разделитель фрагментов.
Разницу между опциями можно уловить на следующем примере.
# Читаем первые 4 символа
read -n 4 <<< $( echo "1234 6789" )
echo $REPLY
# Результат:
# "1234"
# Читаем первые 7 символов
read -n 7 <<< $( echo "1234 6789" )
echo $REPLY
# Результат:
# "1234 67"
# Читаем первые 13 символов, но так как 10 символ это перенос строки,
# то по факту будет прочитано 9 символов
read -n 13 <<< $( echo -e "1234 6789\n1234 6789" )
echo $REPLY
# Результат:
# "1234 6789"
# Учесть перенос строки можно с помощью -N.
# Перенос строки будет тоже прочитан. В этом можно убедиться, если заключить
# REPLY в кавычки.
read -N 13 <<< $( echo -e "1234 6789\n1234 6789" )
echo "'$REPLY'"
# Результат:
# "'1234 6789
# 123'"
# Тем не менее, можно добиться того же результата и с помощью -n, если указать в качестве разделителя
# признак конца файла. Символ '' эквивалентен $'\0'.
read -n 13 -d '' <<< $( echo -e "1234 6789\n1234 6789" )
echo $REPLY
# Результат:
# "1234 6789 123"
Чтение в массив
правитьПри желании вы можете считывать данные прямо в массив. Для этого нужно указать доступный массив через опцию -a
. Сами правила чтения при этом не изменяются.
#!/bin/bash
declare -a ARRAY
TAIL="word1 word2 word3 ; word5 ; word6"
OLD_IFS=$IFS
IFS=";"
read -r -a ARRAY <<< $TAIL
IFS=$OLD_IFS
for i in "${!ARRAY[@]}"; do
echo [$i]: ${ARRAY[$i]}
done
# Результат:
# [0]: word1 word2 word3
# [1]: word5
# [2]: word6
Пример чтения файла
правитьВ завершении этой главы мы рассмотрим пример приложения, читающего простой конфигурационный файл. Ниже перечислены соглашения по формату файла:
- Файл хранит записи в виде пары ключ-значение. Разделителем служит символ
=
. - Валидным значением для ключа и для значения признается серия символов, не являющихся пробелами, причем внутренние пробелы считаются частью ключа или значения.
- Любые пробельные символы, окружающие ключ или значение, подчищаются. Если для значения нужно сохранить пробельные символы, то строку значения нужно заключить в двойные кавычки. Двойные кавычки после парсинга автоматически затираются.
- В файле разрешены однострочные комментарии, начинающиеся на символ
#
. Комментарий действует от этого символа и до конца строки. - Ключ и значение должны находиться на одной строке, т.е. многострочный вариант записи пары ключ-значение не разрешается.
Ниже представлен сценарий парсинга.
#!/bin/bash
TEMPFILE=$(mktemp -p /tmp tmp.XXXXXXXX)
trap "rm -f $TEMPFILE" EXIT SIGKILL # Для удаления временного файла после завершения сценария
# Тестовый текст
cat >> $TEMPFILE <<EOF
#comment
parameter1=value1 # comment
parameter2 = value 2 # comment
parameter3 = " value 3 " # comment
# comment
# commented_param=value
parameter 4 = value4
EOF
# Функция подчищает пробельные символы вокруг переданной ей строки.
trim() {
[[ $# -eq 1 ]] || return
local variable=$1
local value
printf -v value "$(eval echo "\$$variable")"
value=${value#"${value%%[![:space:]]*}"}
value=${value%"${value##*[![:space:]]}"}
printf -v "$variable" "$value"
}
# Функция подчищает конечный комментарий.
cleanup_comments() {
[[ $# -eq 1 ]] || return
local variable=$1
local value
printf -v value "$(eval echo "\$$variable")"
value=${value%%\#*}
printf -v "$variable" "$value"
}
# Функция подчищает двойные кавычки
cleanup_quots() {
[[ $# -eq 1 ]] || return
local variable=$1
local value
printf -v value "$(eval echo "\$$variable")"
value="${value%\"*}"
value="${value#\"*}"
printf -v "$variable" "$value"
}
declare -A CONFIG
# Процедура парсинга
while IFS= read -r LINE <&4 || [[ -n $LINE ]]; do # Чтение очередной строки
LINE=${LINE//[$'\r']} # Удаляем символ возврата каретки (если есть)
while IFS='=' read -r LHS RHS; do # Пытаемся разбить строку на левую и правую части
trim LHS # Обрезаем пробелы
if [[ ! $LHS =~ ^\ *# && -n $LHS ]]; then # Проверяем левую часть на пустоту
cleanup_comments RHS # Чистим от комментариев
if [[ $RHS =~ \".*\" ]]; then # Проверяем двойные кавычки
cleanup_quots RHS # Удаляем двойные кавычки, если они есть
else
trim RHS # Иначе удаляем все пробельные символы
fi
CONFIG["$LHS"]=$RHS # Наконец заносим значение в ассоциативный массив
fi
done <<< "$LINE"
done 4< $TEMPFILE
# Проверка
for key in "${!CONFIG[@]}"; do
echo "Key='$key' Value='${CONFIG[$key]}'"
done
Результат
Key='parameter3' Value=' value 3 '
Key='parameter2' Value='value 2'
Key='parameter1' Value='value1'
Key='parameter 4' Value='value4'
- Пояснения
- Чтобы не дублировать повторяющийся код, мы заготовили три вспомогательные функции для удаления пробелов, комментариев и кавычек. Все они имеют одинаковый интерфейс. Они ожидают всего один аргумент — имя целевой переменной. С этой переменной они работают как с ссылкой, т.е. они обрабатывают значение переменной по ссылке и записывают новое значение по той же ссылке.
- Парсинг файла реализуется двумя циклами
while
. Внешний цикл забирает очередную строку и работает, пока файл не кончится. Внутренний цикл разбивает строку, переданную ему внешним циклом, на левую часть (LHS, left-hand statement) и правую часть — RHS (right-hand statement), причем разделителем мы управляем черезIFS
. - Не запрещено заготавливать файл конфигурации в окружении Windows. По этой причине мы на всякий случай зачищаем строки от символа возврата каретки. Если бы мы этого не делали, то он попадал бы в поле со значением.
- Обратите внимание, что внешний цикл читает из 4-го дескриптора, который привязывается к файлу конфигурации. Мы так сделали, чтобы внутренний цикл мог пользоваться стандартным дескриптором ввода.
- Обратите внимание, что внешний цикл затирает
IFS
. Так следует делать всегда, чтобы сделать процедуру парсинга независимой от изменений этой переменной во внешней части кода (по отношению к циклу).
← Команды | Код-сниппеты → |