Практическое написание сценариев командной оболочки Bash/Команда read: различия между версиями

Материал из Викиучебника — открытых книг для открытого мира
Содержимое удалено Содержимое добавлено
→‎Основы: орфография
Строка 292: Строка 292:
# "1234 6789"
# "1234 6789"


# Проигнорировать перенос строки можно с помощью -N.
# Учесть перенос строки можно с помощью -N.
# Перенос строки будет тоже прочитан. В этом можно убедиться, если заключить
# Перенос строки будет тоже прочитан. В этом можно убедиться, если заключить
# REPLY в кавычки.
# REPLY в кавычки.

Версия от 07:50, 13 октября 2021

← Bash подстановки Глава Код-сниппеты →
Команда read


Команда read служит для чтения файлов. В Bash команда read отличается большим количеством вспомогательных опций:

  • Например вы можете читать файл и переносить данные прямо в массив (опция -a).
  • Можно указывать файловый дескриптор явно (опция -u).
  • Можно указать разделитель явно (опция -d).
  • Можно вводить таймаут на операцию чтения для защиты от зависания (опция -t).
  • Можно указать сколько символов прочитать (опции -n и -N).
  • Можно вывести приглашающее сообщение (опция -p).

В этом разделе мы рассмотрим приемы применения команды read.

Основы

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



← Bash подстановки Код-сниппеты →