第28章:读取键盘输入

迄今为止我们所写的脚本都缺乏一个重要的计算机程序的特性——交互性(interactivity),即,程序和用户之间交互的能力。很多程序不需要交互性,而有些程序则受益与能接收直接从用户处而来的输入。例如,上一章中的脚本:

#!/bin/bash

# test-integer2: evaluate the value of an integer.

INT=-5

if [[ "$INT" =~ ^-?[0-9]+$ ]]; then
    if [ "$INT" -eq 0 ]; then
        echo "INT is zero."
    else
        if [ "$INT" -lt 0 ]; then
            echo "INT is negative."
        else
            echo "INT is positive."
        fi
        if [ $((INT % 2)) -eq 0 ]; then
            echo "INT is even."
        else
            echo "INT is odd."
        fi
    fi
else
    echo "INT is not an integer." >&2
    exit 1
fi

每次想要变更 INT 的数值,我们不得不编辑脚本。如果脚本能要求用户给一个数值,就会更有用了。本章中,我们将学习如何能将交互性加入到程序中。

read 从标准输入读取数值

read 内建命令用来读取标准输入的单独一行。该命令可以用来读取键盘输入,或者当启用重定向时,从一个文件中读取一行数据。该命令句法如下:

read [-options] [variable...]

其中,options 是一个或多个可用的选项,列在表 28-1 中,variable 是一个或多个变量的名称,用来获取输入值。如果没有提供变量名,shell 变量 REPLY 会包含一行数据。

基本上,read 从标准输入分配字段到指定变量。如果我们修改整数评估脚本以使用 read,那看起来会是这样:

#!/bin/bash

# read-integer: evaluate the value of an integer.

echo -n "Please enter an integer -> "
read int

if [[ "$int" =~ ^-?[0-9]+$ ]]; then
    if [ "$int" -eq 0 ]; then
        echo "$int is zero."
    else
        if [ "$int" -lt 0 ]; then
            echo "$int is negative."
        else
            echo "$int is positive."
        fi
        if [ $((int % 2)) -eq 0 ]; then
            echo "$int is even."
        else
            echo "$int is odd."
        fi
    fi
else
    echo "Input value is not an integer." >&2
    exit 1
fi

我们用带 -n 选项的 echo(用来抑制输出时尾随的换行符)来显示一个提示,随后用 read 为变量 INT 输入一个值。运行这个脚本会导致这样:

[me@linuxbox ~]$ read-integer
Please enter an integer -> 5
5 is positive.
5 is odd.

read 可以将输入分配到多个变量,如下面这个脚本所示:

#!/bin/bash

# read-multiple: read multiple values from keyboard
echo -n "Enter one or more values > "

read var1 var2 var3 var4 var5
echo "var1 = '$var1'"
echo "var2 = '$var2'"
echo "var3 = '$var3'"
echo "var4 = '$var4'"
echo "var5 = '$var5'"

在这个脚本中,我们分配并显示了五个值。需要注意当给到 read 不同数目的值的时候,它的行为是怎样的,如下:

[me@linuxbox ~]$ read-multiple
Enter one or more values > a b c d e
var1 = 'a'
var2 = 'b'
var3 = 'c'
var4 = 'd'
var5 = 'e'
[me@linuxbox ~]$ read-multiple
Enter one or more values > a
var1 = 'a'
var2 = ''
var3 = ''
var4 = ''
var5 = ''
[me@linuxbox ~]$ read-multiple
Enter one or more values > a b c d e f g
var1 = 'a'
var2 = 'b'
var3 = 'c'
var4 = 'd'
var5 = 'e f g'

如果 read 收到的值少于期望的数目,则多余的变量为空值,当有额外数目的输入,则会导致最后的一个变量包含了所有额外的输入。

如果在 read 命令之后没有列出变量,就会将输入分配给一个名为 REPLY 的 shell 变量。

#!/bin/bash

# read-single: read multiple values into default variable

echo -n "Enter one or more values > "
read

echo "REPLY = '$REPLY'"

运行后效果如下:

[me@linuxbox ~]$ read-single
Enter one or more values > a b c d
REPLY = 'a b c d'

选项

read 支持的选项见表 28-1 所述。

表 28-1:read 选项

选项

描述

-a array

将输入分配到数组 array,索引从 0 开始。我们将在第 35 章学习数组。

-d delimiter

delimiter 字符串中的第一个字符用来指示输入的终结,而非用换行符来指示。

-e

使用读行来处理输入。这允许以与命令行相同的方式进行输入编辑。

-i string

如果用户简单地按了 Enter 键后,使用 string 作为默认的回复。需要 -e 选项。

-n num

读取输入的 num 个字符,而非整行输入。

-p prompt

使用字符串 prompt 字符串为输入显示一个提示。

-r

原始模式。不将反斜杠字符解释为转义。

-s

静默模式。当用户输入时,不回显字符到显示器。当输入密码和其它机密信息时,该选项很有用。

-t seconds

超时。在 seconds 秒之后终止输入。如果输入超时,read 会返回一个非零的退出状态。

-u fd

从文件描述符 fd 输入,而非标准输入。

使用各种选项,我们可以用 read 做出有趣的事情。例如,用 -p 选项,可以提供一个提示字符串。

#!/bin/bash

# read-single: read multiple values into default variable

read -p "Enter one or more values > "

echo "REPLY = '$REPLY'"

-t-s 选项,我们可以写一个读取「秘密」输入和一个如果在规定时间内没有完成输入就会超时的脚本。

#!/bin/bash

# read-secret: input a secret passphrase

if read -t 10 -sp "Enter secret passphrase > " secret_pass; then
    echo -e "\nSecret passphrase = '$secret_pass'"
else
    echo -e "\nInput timed out" >&2
    exit 1
fi

该脚本提示用户需要用十秒钟的时间输入一个密码。如果在指定时间内没有完成输入,脚本就会因为错误而退出。因为包含了 -s 选项,密码字符在被键入时不会回显在屏幕上。

同时使用 -e-i 选项,可以给用户提供一个默认的响应。

#!/bin/bash

# read-default: supply a default value if user presses Enter key.

read -e -p "What is your user name? " -i $USER
echo "You answered: '$REPLY'"

在脚本中,我们提示用户输入一个用户名,并使用了环境变量 USER 以提供一个默认值。当运行脚本时,它显示了一个默认字符串,并且,如果用户简单地按下了 Enter 键,read 将会把默认字符串分配给 REPLY 变量。

[me@linuxbox ~]$ read-default
What is your user name? me
You answered: 'me'

IFS

通常,shell 对输入执行分词以提供给 read。我们已经看到,这意味着被一个或多个空格分隔的多个单词在输入行上成为了单独的项目,并且被 read 分配给了单独的变量。这个行为是由一个名为 IFS(Internal Field Separator 内部字段分隔符)的 shell 变量所配置的。默认的 IFS 的值,包含了一个空格、一个制表符和一个换行符,其中任一字符都会将项目之间彼此分隔开。

我们可以调整 IFS 的值以控制字段分隔,以便输入到 read。例如,/etc/passwd 文件包含了使用冒号作为字段分隔符的多行数据。将 IFS 的值改成单个冒号,可以用 read 输入 /etc/passwd 的内容,并成功地将字段分隔为不同的变量。这里有一个脚本,就是做这个工作的:

#!/bin/bash

# read-ifs: read fields from a file

FILE=/etc/passwd

read -p "Enter a username > " user_name

file_info="$(grep "^$user_name:" $FILE)"

if [ -n "$file_info" ]; then
    IFS=":" read user pw uid gid name home shell <<< "$file_info"
    echo "User = '$user'"
    echo "UID = '$uid'"
    echo "GID = '$gid'"
    echo "Full Name = '$name'"
    echo "Home Dir. = '$home'"
    echo "Shell = '$shell'"
else
    echo "No such user '$user_name'" >&2
    exit 1
fi

该脚本提示用户输入系统上一个账户的用户名,然后显示该用户记录在 /etc/passwd 文件中的其它字段。脚本中有两行比较有意思。第一个是:

file_info=$(grep "^$user_name:" $FILE)

这一行将 grep 命令的结果分配到变量 file_infogrep 用正则表达式确保了用户名会匹配 /etc/passwd 文件中的单行。

第二个有趣的是这个:

IFS=":" read user pw uid gid name home shell <<< "$file_info"

这一行有三个部分组成:一个变量分配、带一个变量名列表作为参数的 read 命令、和一个新奇的重定向操作符。我们先来看变量分配。

shell 允许在一个命令前立即进行一个或多个变量分配。这些分配为随后的命令变更了系统环境。分配的效用仅仅是临时变更了该命令持续期间的系统环境。在本例中,IFS 的值被变更为一个冒号。作为替代,我们还可以这样编写:

OLD_IFS="$IFS"
IFS=":"
read user pw uid gid name home shell <<< "$file_info"
IFS="$OLD_IFS"

我们将 IFS 的值保存到一个新变量中,执行 read 命令,然后恢复 IFS 的原始值。很明显,做着相同的事情,将变量分配放置在命令之前是一个更简明的方式。

<<< 操作符只是了一个 here 字符串(here string)。here 字符串就像 here 文档,只不过更短,由单个字符串组成。在上面这个例子中,在 /etc/passwd 中的一行数据被投送到标准输入的 read 命令中。我们可能想知道为何选择这种斜的方式,而不是这样:

echo "$file_info" | IFS=":" read user pw uid gid name home shell

好吧,有一个理由……

不能管道传输 read

read 命令自然地从标准输入取得输入,但是你不能这么做:

echo "foo" | read

我们期待这样能工作,但是不能。命令会显示执行成功,但是 REPLY 变量将总是空的。为什么呢?

这个解释与外壳处理管道的方式有关。 在 bash(和 一些其它的 shell 如 sh)里,管道会创建 subshells。这是用来执行管道中的命令的 shell 及其环境的副本。在刚才的例子中,read 是在一个 subshell 中执行的。

在类 Unix 系统中的 subshells 会创建环境副本,以供进程在执行中使用。当进程完成时,即摧毁环境副本。这意味着一个 subshell 永远不能改变其父进程的环境read 分配变量,变量随之成为环境的一部分。在前面这个例子中,read 分配了值 foo 到其 subshell 中的变量 REPLY,但是当命令退出时, subshell 及其环境被摧毁了,所以分配的效果也就消失了。

使用 here 字符串是解决该问题的一个方法。另一个方法会在第 36 章中讨论。

验证输入

伴随着键盘输入的新能力而来的一个额外的编程挑战是验证输入。编写良好的程序和垃圾代码之间的差异, 通常在于程序处理意外事件的能力。通常,意外输入会以错误的输入形式出现。在上一章中,我们已经在评估程序中完成了一部分,我们检查了整数的值,筛选出空值和非数字字符。程序在每次接收到输入时执行这种检查很重要,它防止了非法数据。这对于分享给多个用户的程序来说,尤其重要。如果一款程序仅使用一次或仅仅由作者执行一些特殊的任务,那么出于经济上的考虑忽略这些保护措施是可以被原谅的。甚至于如果程序执行的是如删除文件之类的危险任务,那么,为了以防万一,加入数据验证是明智的。

这里有一个示例程序,验证各种输入:

#!/bin/bash

# read-validate: validate input

invalid_input () {
    echo "Invalid input '$REPLY'" >&2
    exit 1
}

read -p "Enter a single item > "

# input is empty (invalid)
[[ -z "$REPLY" ]] && invalid_input

# input is multiple items (invalid)
(( "$(echo "$REPLY" | wc -w)" > 1 )) && invalid_input

# is input a valid filename?
if [[ "$REPLY" =~ ^[-[:alnum:]\._]+$ ]]; then
    echo "'$REPLY' is a valid filename."
    if [[ -e "$REPLY" ]]; then
        echo "And file '$REPLY' exists."
    else
        echo "However, file '$REPLY' does not exist."
    fi

    # is input a floating point number?
    if [[ "$REPLY" =~ ^-?[[:digit:]]*\.[[:digit:]]+$ ]]; then
        echo "'$REPLY' is a floating point number."
    else
        echo "'$REPLY' is not a floating point number."
    fi

    # is input an integer?
    if [[ "$REPLY" =~ ^-?[[:digit:]]+$ ]]; then
        echo "'$REPLY' is an integer."
    else
        echo "'$REPLY' is not an integer."
    fi
else
    echo "The string '$REPLY' is not a valid filename."
fi

这个脚本提示用户输入一个项目。 随后对该项目进行分析以确定其内容。 如我们所见,脚本使用了许多迄今已经学过的概念,包括了 shell 函数、[[ ]](( ))、控制操作符、if 和一堆正则表达式。

互动性的一个常见类型是菜单驱动(menu-driven)。在菜单驱动程序中,展示给用户一个选择列表并要求其选择一个。例如,我们可以想象一个程序如下:

Please Select:

1. Display System Information
2. Display Disk Space
3. Display Home Space Utilization
0. Quit

Enter selection [0-3] >

使用从写 sys_info_page 程序中所学的知识,我们可以构建一个菜单驱动程序,来执行上面这个菜单的任务:

#!/bin/bash

# read-menu: a menu driven system information program

clear
echo "
Please Select:
1. Display System Information
2. Display Disk Space
3. Display Home Space Utilization
0. Quit
"
read -p "Enter selection [0-3] > "

if [[ "$REPLY" =~ ^[0-3]$ ]]; then
    if [[ "$REPLY" == 0 ]]; then
        echo "Program terminated."
        exit
    fi
    if [[ "$REPLY" == 1 ]]; then
        echo "Hostname: $HOSTNAME"
        uptime
        exit
    fi
    if [[ "$REPLY" == 2 ]]; then
        df -h
        exit
    fi
    if [[ "$REPLY" == 3 ]]; then
        if [[ "$(id -u)" -eq 0 ]]; then
            echo "Home Space Utilization (All Users)"
            du -sh /home/*
        else
            echo "Home Space Utilization ($USER)"
            du -sh "$HOME"
        fi
        exit
    fi
else
    echo "Invalid entry." >&2
    exit 1
fi

脚本在逻辑上分成两个部分。第一部分显示了菜单和从用户处输入的回应。第二部分识别回应并执行所选择的行为。注意脚本中用到的 exit。用在这里是防止脚本在某个行为已经执行之后再去执行不必要的代码。在一个程序中出现多个 exit,通常是一个坏主意(这使得程序在逻辑上很难理解),但是在这个脚本中,是有效的。

总结

本章中,我们迈出了走向互动性的第一步,允许用户通过键盘输入数据到程序中。使用目前学到的技术可以写出许多有用的程序,如指定的计算程序和供神秘的命令行工具的易用的前端程序。下一章中我们将在菜单驱动程序概念的基础上,使其更完善。

额外学分

认真研究本章中的程序,并全面了解它们的逻辑结构方式,这很重要,因为以后的程序将越来越复杂。作为练习,使用 test 命令而非 [[ ]] 复合命令重写本章中的程序。提示:使用 grep 以评估正则表达式并检测退出状态。这会是一个好的实践。

扩展阅读

Last updated

Was this helpful?