Практическое написание сценариев командной оболочки 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. Так следует делать всегда, чтобы сделать процедуру парсинга независимой от изменений этой переменной во внешней части кода (по отношению к циклу).



← Команды Код-сниппеты →