把常用命令写成 Shell 脚本

学习目标

怎样保存命令,并在以后重复使用?本课学习编写针对固定文件或用户指定文件的脚本,在命令行运行脚本,以及把自己和他人编写的脚本组合进管道。

现在可以看到,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

哪一种结果正确?

  1. 各 .pdb 文件第一行和最后一行之间的全部行。
  2. 各 .pdb 文件的第一行和最后一行。
  3. 目录中每个文件的第一行和最后一行。
  4. 因为 *.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 命令的惯例。

来源:Software Carpentry《The Unix Shell:Shell Scripts》,The Carpentries 及课程作者。教学材料采用 CC BY 4.0。本文为中文翻译,保留课程示例和输出,并补充参数引用、通配符、边界条件及跟踪输出说明;不表示原作者认可这些补充。本稿未执行课程命令,展示的输出均来自原课程。

示例程序采用 MIT 许可。Copyright (c) The Carpentries.

MIT 许可全文
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

课程许可与版权说明。The Carpentries 的商标权不在上述许可中转让。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容