学习目标
怎样保存命令,并在以后重复使用?本课学习编写针对固定文件或用户指定文件的脚本,在命令行运行脚本,以及把自己和他人编写的脚本组合进管道。
现在可以看到,Shell 为什么能成为强大的编程环境:把经常重复的命令保存到文件中,以后只输入一个命令,就能重新运行整套操作。历史上,这种包含一组命令的文件通常叫作 Shell 脚本;它实际上就是一个小程序。
脚本可以加快工作,减少反复输入相同命令的负担,也能降低打字错误的机会,提升可复现性。以后重新处理自己的工作,或让别人基于这些工作继续研究时,运行脚本就能重现流程,不需要回忆或重新输入长长的命令列表。
保存并运行第一份脚本
先回到 alkanes/ 目录,创建 middle.sh:
$ cd alkanes
$ nano middle.sh
nano middle.sh 会在 Shell 中打开 nano 编辑器。文件不存在时,编辑器会创建它。直接在文件中输入:
head -n 15 octane.pdb | tail -n 5
这条管道是前面例子的变体:先取 octane.pdb 的前 15 行,再保留其中最后 5 行,也就是第 11 至第 15 行。此时只是把命令写进文件,还没有执行它。
在 nano 中按 Ctrl-O 保存,按 Ctrl-X 退出。确认 alkanes 目录里已经有 middle.sh。然后让当前使用的 Shell——Bash——执行文件中的命令:
$ bash middle.sh
课程给出的输出为:
ATOM 9 H 1 -4.502 0.681 0.785 1.00 0.00
ATOM 10 H 1 -5.254 -0.243 -0.537 1.00 0.00
ATOM 11 H 1 -4.357 1.252 -0.895 1.00 0.00
ATOM 12 H 1 -3.009 -0.741 -1.467 1.00 0.00
ATOM 13 H 1 -3.172 -1.337 0.206 1.00 0.00
这个结果与直接执行同一条管道的结果一致。
程序文件必须是纯文本
平时也会把 Microsoft Word 或 LibreOffice Writer 称为“文本编辑器”,但编程时需要更准确地区分。Word 默认的 .docx 文件不仅保存文本,也保存字体、标题等格式信息。这些额外信息不是普通字符,对 head 这样的工具没有意义;它期待的是由字母、数字和标点组成的文本。编辑程序时,应使用纯文本编辑器,或明确以纯文本格式保存。
用参数选择文件和行范围
如果想从任意文件中选择行,每次编辑 middle.sh 来替换文件名,可能比在命令行重新输入命令还慢。可以把脚本改得更通用:
$ nano middle.sh
在 nano 中,把 octane.pdb 替换成特殊变量 $1:
head -n 15 "$1" | tail -n 5
在 Shell 脚本里,$1 代表命令行的第一个文件名或其他参数。现在这样运行:
$ bash middle.sh octane.pdb
课程给出的输出为:
ATOM 9 H 1 -4.502 0.681 0.785 1.00 0.00
ATOM 10 H 1 -5.254 -0.243 -0.537 1.00 0.00
ATOM 11 H 1 -4.357 1.252 -0.895 1.00 0.00
ATOM 12 H 1 -3.009 -0.741 -1.467 1.00 0.00
ATOM 13 H 1 -3.172 -1.337 0.206 1.00 0.00
换一个文件也可以:
$ bash middle.sh pentane.pdb
对应的课程输出为:
ATOM 9 H 1 1.324 0.350 -1.332 1.00 0.00
ATOM 10 H 1 1.271 1.378 0.122 1.00 0.00
ATOM 11 H 1 -0.074 -0.384 1.288 1.00 0.00
ATOM 12 H 1 -0.048 -1.362 -0.205 1.00 0.00
ATOM 13 H 1 -1.183 0.500 -1.412 1.00 0.00
给参数加双引号
与给循环变量加双引号的理由一样,文件名可能包含空格,因此这里使用 "$1"。双引号能保留一个参数的边界。
目前调整所选行的范围,仍然需要修改脚本。把行数也改成命令行参数即可:$1、$2、$3 分别表示第一个、第二个、第三个参数。让后两个参数控制 head 和 tail 的行数:
$ nano middle.sh
head -n "$2" "$1" | tail -n "$3"
运行:
$ bash middle.sh pentane.pdb 15 5
课程给出的输出为:
ATOM 9 H 1 1.324 0.350 -1.332 1.00 0.00
ATOM 10 H 1 1.271 1.378 0.122 1.00 0.00
ATOM 11 H 1 -0.074 -0.384 1.288 1.00 0.00
ATOM 12 H 1 -0.048 -1.362 -0.205 1.00 0.00
ATOM 13 H 1 -1.183 0.500 -1.412 1.00 0.00
改变参数,就能改变脚本的行为:
$ bash middle.sh pentane.pdb 20 5
课程给出的输出为:
ATOM 14 H 1 -1.259 1.420 0.112 1.00 0.00
ATOM 15 H 1 -2.608 -0.407 1.130 1.00 0.00
ATOM 16 H 1 -2.540 -1.303 -0.404 1.00 0.00
ATOM 17 H 1 -3.393 0.254 -0.321 1.00 0.00
TER 18 1
加入说明注释
脚本已经能工作,但下一位阅读者可能要想一会儿才能知道它做什么。可以在开头加入注释:
$ nano middle.sh
# Select lines from the middle of a file.
# Usage: bash middle.sh filename end_line num_lines
head -n "$2" "$1" | tail -n "$3"
注释从 # 开始,到该行结束。计算机会忽略注释,但注释能帮助其他人以及未来的自己理解和使用脚本。每次修改脚本,也要检查注释是否仍然准确;把人引向错误方向的说明,比没有说明更糟。
接收数量不定的文件参数
例如,按长度给 .pdb 文件排序,可以输入:
$ wc -l *.pdb | sort -n
wc 是 word count 的缩写,-l 表示统计行数,sort -n 则按数值排序。把命令原样写进脚本,只能处理当前目录里的 .pdb 文件。如果还要处理其他类型的文件,就需要让脚本接收文件名。文件数量未知,无法只靠固定的 $1、$2 等变量。
特殊变量 $@ 表示传给脚本的全部命令行参数。为处理包含空格的参数,应写成 "$@";这种特殊语法相当于分别引用 "$1"、"$2",依此类推。
$ nano sorted.sh
# Sort files by their length.
# Usage: bash sorted.sh one_or_more_filenames
wc -l "$@" | sort -n
$ bash sorted.sh *.pdb ../creatures/*.dat
课程给出的输出为:
9 methane.pdb
12 ethane.pdb
15 propane.pdb
20 cubane.pdb
21 pentane.pdb
30 octane.pdb
163 ../creatures/basilisk.dat
163 ../creatures/minotaur.dat
163 ../creatures/unicorn.dat
596 total
练习:列出各文件中的不同物种
Leah 有几百个数据文件,格式如下:
2013-11-05,deer,5
2013-11-05,rabbit,22
2013-11-05,raccoon,7
2013-11-06,rabbit,19
2013-11-06,deer,2
2013-11-06,fox,1
2013-11-07,rabbit,18
2013-11-07,bear,1
课程数据中的例子位于 shell-lesson-data/exercise-data/animal-counts/animals.csv。命令 cut -d , -f 2 animals.csv | sort | uniq 可以列出这个文件里不同的物种。为避免每次重新输入这一串命令,编写 species.sh:它接收任意数量的文件名,对每个文件分别输出物种列表。
参考解答
# Script to find unique species in csv files where species is the second data field
# This script accepts any number of file names as command line arguments
# Loop over all files
for file in $@
do
echo "Unique species in $file:"
# Extract species names
cut -d , -f 2 $file | sort | uniq
done
代码保留课程原样。这里的 for file in $@ 和 $file 没有加引号;遇到带空格的文件名时,可能拆成多个词。实际使用应保留参数边界,例如循环遍历 "$@",把输入文件写成 "$file"。
把命令历史保存为可复现脚本
假设刚执行了一组有用的命令,例如生成论文中的图表,希望以后还能重新生成。与其重新输入并冒着打错的风险,可以保存最近的历史记录:
$ history | tail -n 5 > redo-figure-3.sh
redo-figure-3.sh 此时包含:
297 bash goostats.sh NENE01729B.txt stats-NENE01729B.txt
298 bash goodiff.sh stats-NENE01729B.txt /data/validated/01729.txt > 01729-differences.txt
299 cut -d ',' -f 2-3 01729-differences.txt > 01729-time-series.txt
300 ygraph --format scatter --color bw --borders none 01729-time-series.txt figure-3.png
301 history | tail -n 5 > redo-figure-3.sh
在编辑器中删掉命令前的序号,并删除最后那条保存历史记录的命令,就得到了制作该图的准确步骤记录。
练习:为什么命令执行前就进入历史记录?
运行:
$ history | tail -n 5 > recent.sh
文件的最后一条记录是 history 命令本身,说明 Shell 在实际执行命令前,就把它加入日志。为什么要这样做?
参考解答
如果命令导致崩溃或卡住,知道最后运行的命令有助于调查问题。如果只有成功运行以后才记日志,就无法在崩溃时保留最后一条命令。
实际编写脚本时,人们通常先在提示符下多次试运行命令,确认处理正确,再把它们保存为可复用文件。一次 history 加上少量清理,就能把对数据和工作流程的探索保存下来。
技术注意:历史记录可能含有命令行中输入的秘密。整理脚本前,应检查并删除真实凭据;这里的示例只有课程演示路径和文件名。
Nelle 的处理流程:创建脚本
Nelle 的导师要求所有分析都可复现。用脚本记录每一步最方便。先回到项目目录:
$ cd ../../north-pacific-gyre/
使用 nano 创建文件:
$ nano do-stats.sh
写入:
# Calculate stats for data files.
for datafile in "$@"
do
echo $datafile
bash goostats.sh $datafile stats-$datafile
done
保存为 do-stats.sh 后,只需输入下面的命令,就能重新执行分析的第一阶段:
$ bash do-stats.sh NENE*A.txt NENE*B.txt
也可以把输出交给 wc -l:
$ bash do-stats.sh NENE*A.txt NENE*B.txt | wc -l
这样显示处理过的文件数量,而不是文件名。这个脚本让运行者选择要处理哪些文件。也可以把固定选择写在脚本内部:
# Calculate stats for Site A and Site B data files.
for datafile in NENE*A.txt NENE*B.txt
do
echo $datafile
bash goostats.sh $datafile stats-$datafile
done
固定选择的优点是总能选中正确文件,Nelle 不必记得排除 “Z” 文件;缺点是只能处理这组文件。要处理全部文件(包括 “Z” 文件),或南极同事产生的 “G”“H” 文件,就必须编辑脚本。还可以让脚本先检查是否有命令行参数,没有参数时才使用 NENE*A.txt NENE*B.txt。这又引入了灵活性与复杂性之间的权衡。
技术注意:两份课程脚本的循环体仍有未加引号的变量。演示文件名没有空格;推广到任意文件名时,应分别保护输入名与输出名的参数边界。
练习:脚本中的变量与通配符
假设 alkanes 目录里的 script.sh 包含:
head -n $2 $1
tail -n $3 $1
在该目录中输入:
$ bash script.sh '*.pdb' 1 1
哪一种结果正确?
- 各
.pdb文件第一行和最后一行之间的全部行。 - 各
.pdb文件的第一行和最后一行。 - 目录中每个文件的第一行和最后一行。
- 因为
*.pdb外面的引号而报错。
参考解答
答案是 2。$1、$2、$3 代表传给脚本的参数;课程用下面的展开结果说明运行的命令:
$ head -n 1 cubane.pdb ethane.pdb octane.pdb pentane.pdb propane.pdb
$ tail -n 1 cubane.pdb ethane.pdb octane.pdb pentane.pdb propane.pdb
外层 Shell 不展开带单引号的 '*.pdb',因此脚本收到的第一个参数是字面量 *.pdb。脚本内部使用未加引号的 $1 时,Bash 再进行文件名展开,然后把所得文件名传给 head 和 tail。严格地说,执行展开的是 Shell,而不是这两个程序。若把脚本内部变量改为 "$1",该通配符就不再展开,这是理解本题的关键。
练习:找出给定扩展名中最长的文件
编写 longest.sh,接收一个目录名和一个文件扩展名,输出该目录中具有此扩展名且行数最多的文件名。例如:
$ bash longest.sh shell-lesson-data/exercise-data/alkanes pdb
它应输出课程 alkanes 目录中最长的 .pdb 文件。也可以换一个目录试试:
$ bash longest.sh shell-lesson-data/exercise-data/writing txt
参考解答
# Shell script which takes two arguments:
# 1. a directory name
# 2. a file extension
# and prints the name of the file in that directory
# with the most lines which matches the file extension.
wc -l $1/*.$2 | sort -n | tail -n 2 | head -n 1
管道前半部分 wc -l $1/*.$2 | sort -n 统计各文件行数,并按数值从小到大排序。文件多于一个时,wc 还会输出所有文件的总行数;tail -n 2 | head -n 1 去掉最后那条总计。如果只使用 wc -l $1/*.$2 | sort -n | tail -n 1,通常看到的是总计行。逐段搭建管道,可以确认自己理解每一步。
本解答按课程的多个匹配文件场景编写。单文件、无匹配文件、目录名带空格或特殊文件名,需要额外处理;原样复制并不意味着覆盖了这些边界。
练习:阅读三个脚本
再次考虑 shell-lesson-data/exercise-data/alkanes,其中包含若干 .pdb 文件,也可能包含你另外创建的文件。分别执行 bash script1.sh *.pdb、bash script2.sh *.pdb 和 bash script3.sh *.pdb 时,下面三个脚本做什么?
# Script 1
echo *.*
# Script 2
for filename in $1 $2 $3
do
cat $filename
done
# Script 3
echo $@.pdb
参考解答
三种情况下,外层 Shell 都先展开 *.pdb,再把文件名列表作为参数传入脚本。
- 脚本 1 列出名字里包含点号的所有文件。传入的参数实际上没有用到。
- 脚本 2 显示前三个
.pdb文件的内容。$1、$2、$3分别对应前三个参数。 - 脚本 3 显示全部参数,也就是全部
.pdb文件名,并在最后追加.pdb。$@表示所有参数。
课程给出的第三个输出为:
cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb.pdb
练习:排查脚本错误
假设在 Nelle 的 north-pacific-gyre 目录里把下面的脚本保存为 do-errors.sh:
# Calculate stats for data files.
for datafile in "$@"
do
echo $datfile
bash goostats.sh $datafile stats-$datafile
done
运行:
$ bash do-errors.sh NENE*A.txt NENE*B.txt
回显为空。要定位原因,用 -x 重新执行:
$ bash -x do-errors.sh NENE*A.txt NENE*B.txt
输出显示了什么?哪一行引入了错误?
参考解答
-x 让 Bash 在执行时打印各条命令,方便定位错误。这里 echo 没有打印内容:循环变量名写成了 datafile,回显却用了拼错的 datfile。变量不存在,因此展开为空字符串。示例故意保留这个拼写错误,供练习排查。
技术注意:bash -x 会执行脚本,并打印展开后的命令;它不是只检查语法的工具。处理凭据时,跟踪输出也可能暴露秘密。
要点
- 把命令保存为脚本,以便重复使用。
bash [filename]执行文件里的命令。$@代表脚本的全部参数。$1、$2等依次代表各个参数。- 变量值可能带空格时,使用引号保护参数边界。
- 让使用者选择文件,比在脚本中固定文件列表更灵活,也更符合 Unix 命令的惯例。











暂无评论内容