Linux 软件开发人员指南:12 自动化任务与Shell脚本

有时候,你会发现自己一遍又一遍地重复执行一样的几个命令,可能只是略有变化。你会感到沮丧,然后说:“就这样吧;我要写个脚本。”作为一个命令行界面(CLI)的高手,你会这样做:

运行 `tail -n 20 ~/.bash_history > myscript.sh` 来创建一个包含你最近执行的20个Bash命令的文件。

然后运行 `bash myscript.sh` 来执行它。

尽管这不是推荐的程序(我们在本章会讲到),但这是一种完全有效的方式来创建和运行一个Bash脚本。

本章是一个Bash脚本速成课程。就像任何编程速成课程一样,除非你真的跟着做,自己输入所有代码,并在你自己的Linux环境中运行它,否则它是完全没有用的。除了向你展示被认为是现代和最佳实践的Bash语法子集,我们还会给出我们多年来从艰难经验中学到的大量技巧,指出常见的陷阱和锋利的边缘。

Bash不是我们最喜爱的语言,但有时它正是你面临问题的正确工具。我们也会尽力让你理解这一点。

在本章中,我们将涵盖以下内容:

Bash脚本基础

Bash与其他shell的比较

Shebang和可执行文本文件

测试

条件语句

为什么你需要Bash脚本基础

Shell脚本是任何开发者不可或缺的工具;即使你不是每周都在写脚本,你也会阅读它们。在本章中,我们将涵盖你需要知道的基础,以便在例如:

你面对一个几年前某人写的shell脚本,列如“你能检查一下我们是否可以重用Steve在去Google之前写的自动化脚本吗?”

你看到一个机会,可以编写自己的shell脚本,当你有一个现有的shell程序已经解决的工作时(过滤、搜索、排序输出,并将一个程序的输出输入到另一个程序中)。

你想要在构建镜像时准确控制每个Docker层中的内容。

你需要在Linux服务器操作系统的上下文中协调其他软件:启动顺序、错误检查、在程序之间提前中止等。

有无数的用例,其中shell脚本的大小和形状正好适合你的问题空间。在本章之后,你将拥有编写那个定制脚本所需的技能。

基础

Bash可以像任何其他编程语言一样学习。它有一个环境(Unix或Linux)、一种标准库(安装在系统上的任何CLI驱动程序)、变量、控制流(循环、测试和迭代)、插值、一些内置数据结构(数组、字符串和布尔值——有点)等。

这本书的整个假设是你是一个软件开发者,因此知道如何编程,所以与其教你这些标准编程语言特性,我们只会向你展示它们在Bash中的样子,以及一些关于惯用法(或常见误用)的提议。

变量

像任何编程语言一样,Bash有变量,可以是空的或设置为一个值。未设置的变量只是“空的”,Bash会愉快地使用它们,除非你通过 `set -u` 设置了 -u(在未设置变量时出错)选项。

设置

要设置一个变量,使用等号。

`FOOBAR=nice` 将设置 FOOBAR 变量的值为 nice。

Bash中没有类型——它是一种尽可能无类型的编程语言。

变量符号本身可以包含字母、数字和下划线,但不能以数字开头。

一般的做法是为环境变量使用大写变量名,而在Bash脚本内部使用小写变量名。这些变量名一般使用下划线来分隔单个单词。当在变量名中使用数字时,避免以数字开头。Bash禁止这样做。与其他语言一样,最好的做法是为变量的用途以及它是否是常量命名,或者为数组使用复数名称:

非法:`%foo&bar=bad`

非法:`2foo_bar=bad`

合法但不好:`foo_BAR123=still_very_bad`

好的环境变量:`PORT=443`

好的:`local_var=512`

好的环境变量:`FOO_BAR123=good`

好的本地数组变量:`words=(foo bar baz)`

获取

要使用变量,用 $ 字符引用它:

`$ echo $FOOBAR`

`nice`

Bash与其他shell

Unix类环境中存在大量shell程序;你可以说Unix之所以流行,一个主要缘由是它一直是一个基本上没有阻碍脚本编写和自动化的环境。

本章教你如何用Bash编写自己的脚本。你在这儿学到的大部分内容在其他shell上也能工作(例如,ksh和其他常见的最小化shell,你可以在 /bin/sh 找到),但我们在这里专注于Bash。

如果你正在写一个shell脚本,Bash在广泛可用性和足够大的语言特性集之间找到了完美的平衡,这使得编写小程序变得舒服。

Shebang和可执行文本文件,也称为脚本

在Unix类系统中,“脚本”只是一个可执行的纯文本文件。操作系统(在Linux中一般称为“内核”)查看超级第一行以确定将文件内容输入到哪个解释器中。

第一行就是所谓的“shebang”(或hashbang),它由一个井号和一个感叹号(#!)字符组成,后面是用于执行文件代码的解释器的路径。这里有一个例子shebang行:

`#!/usr/bin/env bash`

当Unix类系统的内核运行一个设置了可执行位的文件时,它们会查看前几个字节。这可能包含一个魔术数字。这个数字可能是二进制文件的一部分,或者像shebang中的一些人类可读字符。内核使用这个信息来知道是否有适当的方法来执行它。例如,这可以防止内核尝试执行一个图像文件并崩溃。根据系统的不同,内核或shell将确保随后的命令被执行。env程序将运行该命令,并思考PATH环境变量来找到并执行bash。

虽然井号在大多数脚本语言中表明注释,因此会被解释器忽略,但文件开头的这个特殊注释告知操作系统运行哪个命令来解释文件的其余部分。以下是你将看到的一些常见示例:

`#!/bin/sh`:在此特定的文件系统位置使用此特定的shell程序

`#!/usr/bin/python3`:使用此特定的Python二进制文件

`#!/usr/bin/env python`:使用env程序来确定在此环境中使用哪个Python二进制文件(不同的系统可能安装有不同版本的一样程序,在不同的路径上)

虽然你会看到所有这些变体,但最佳选择始终是使用 /usr/bin/env 以提高可移植性。/bin/sh 是这里的例外,由于每个符合POSIX标准的系统都需要在此位置有一个符合POSIX标准的shell。

请注意,这是一个超级长的翻译,我尽量保持了原文的格式和代码样式。如果您有任何问题或需要进一步的协助,请随时告知我。

常见的Bash设置(选项/参数) 由于shebang行作为命令执行,因此也可以传递参数。虽然保持简单可能是一个好主意,但一个常见的主题是向shell传递额外的参数,特别是向Bash传递,Bash一般用于大型脚本,由于它比一般在/bin/sh中找到的较小shell具有额外的功能。

在Bash脚本中,你常常会看到传递 -e、-u、-x、-o pipefail 参数。你可能会在shebang行本身中找到这些参数:

#!/usr/bin/env bash -euxo pipefail

或者,作为下一条语句,使用Bash中的set命令,该命令设置参数或选项:

#!/usr/bin/env bash
set -eu -o pipefail

设置这些选项会使Bash的行为更像你习惯的编程语言,通过:

  • 如果命令管道的任何组件失败,则立即退出,
  • 将未设置的变量视为致命错误。

以下是这些选项的文档的详细说明,以及一个有用的调试选项(-x)作为奖励:

-o pipefail:当使用管道时,这将确保管道中发生的错误会被传递。如果发生多个错误,则使用最右边的错误。 -e:如果出现错误或命令失败,这将确保shell脚本立即退出。 -u:如果使用任何未设置的变量,则抛出错误。 对于调试,-x很有用:

-x:这将启用跟踪。这意味着在执行每个命令之前,将其写入标准错误。

除了 -o pipefail 之外,所有参数都可以在大多数Unix shell中找到。有关Bash包含的选项的更多信息,请查看其手册页:bash手册页

/usr/bin/env 这里有一个需要记住的事情:/bin/sh 是一个在POSIX中标准化的路径,通向任何符合POSIX标准的shell。你可以指望它在任何Linux或Unix系统上都存在。一般,这不是bash,而是一个更小的shell,只提供足够的功能来满足POSIX标准,这允许你编写超级可移植的shell脚本。对于你的脚本可能需要的所有其他shell和解释器,最佳实践是使用 #!/usr/bin/env 前缀。这确保使用PATH中的正确的路径,并且当二进制文件不在/usr/bin/中时,它将防止出现“命令未找到”的错误。

有多种情况 /usr/bin/bash 或 /bin/bash 可能不是正确的路径。例如:

  • 包管理器或公司特定的配置脚本一般会将解释器安装在与开发系统不同的位置。
  • 某人手动安装软件,例如,为了绕过或重现一个bug,一般会将生成的二进制文件放置在/usr/local中。
  • 各种脚本语言的虚拟环境会将二进制文件放入每个源代码项目/存储库的子目录中。
  • 没有root权限安装解释器的人,例如,在他们的家目录中。
  • 使用解释器的版本管理器(rvm、nvm等)。
  • 各种类Unix操作系统和一些Linux发行版不会将第三方软件包安装在/usr/中。 虽然许多人无法想象他们的脚本最终会出目前如此非标准的位置,但你可能最终会遇到这种情况。为了避免在这些情况下使你的软件出现故障,养成在脚本中写入 /usr/bin/env bash(或你的代码编写的任何解释器)的习惯是一个好主意。这可以防止其他人——或者你超级疲倦的未来的自己,在凌晨3点被传呼机叫醒——在源文件出现故障时注意、故障排除、查找或进行此类更改。

特殊字符和转义 你应该常常使用的一个特殊字符是井号符号(#),它使符号后面的整行变成注释,解释器会忽略它。

其他字符在Bash中具有特殊含义,当你将它们作为变量值的一部分使用时,需要用正斜杠()进行转义。这里有一些:

  • 引号(” 和 ')
  • 大括号和小括号({, }, [, ], 和 (, ))
  • 插入符号(< 和 >)
  • 波浪号:~
  • 星号(Bash中的“全局字符”):*
  • 和号:&
  • 问号:?
  • 常见运算符:!, =, | 等 像你在大多数其他编程语言中那样转义它们:
$ FOO="jaa$'"

命令替换 Shell脚本的一个好处和主要用例是任何命令都很容易访问。一个很常见的例子是命令替换。当你想使用一个或多个命令的输出时,这很有用。你可以使用命令替换来做到这一点:

echo "Right now, it's $(date)"

这将执行这些命令——在这个例子中,只是date,但它也可能是你已经通过管道组合在一起的复杂表达式。实现一样输出的另一种方法是使用反引号。以下示例将有一样的输出:

echo "Right now, it's `date`"

测试 这里显示的测试命令一般与if/else控制流语句一起使用。字符串测试函数 ([[) 和算术测试函数 ((() 如果测试评估为真值,则返回0,如果测试评估为假值,则返回1。这是由于命令的0退出代码表明成功,它与其他你可能知道的编程语言不同,后者一般将零值评估为假。Bash中没有原生的布尔数据类型;整数0和1在这样的布尔上下文中使用。有时,变量 true 和 false 在整个脚本中被初始化和使用。

测试运算符 以下是一些基本的布尔运算符,你可以用它们在Bash中构建语句——基本上,这是你在其他语言中习惯的:

! – 非(否定) && – 与 || – 或 这些运算符可以与字符串和算术测试类型一起使用:

== – 等于 != 不等于 [[ 文件和字符串测试 ]] [[ 复合命令允许你执行(和组合)“字符串”比较。正如前面提到的,Bash没有你从其他编程语言中习惯的那种严格的数据类型,所以我们称它们为“字符串”或“类似字符串”的比较,由于这是一个对软件开发者熟悉的概念。

如果用户的主目录不存在,则创建它:

if [[ ! -d $HOME ]]; then
    echo "Creating home directory: ${HOME}..."
    mkdir -p $HOME
    echo "done"
fi

那个 ! 字符是Bash的否定,所以你可以把这个例子的第一行读作如果 NOT (测试) 是一个目录 $HOME,然后……

这里有一个稍微复杂一点的例子。如果用户的主目录不存在,或者 ALWAYSCREATE 变量设置为 yes,则创建主目录:

ALWAYSCREATE=yes
if ! [[ -d $HOME ]] || [[ $ALWAYSCREATE == yes ]]; then
    echo "Creating home directory: ${HOME}..."
    mkdir -p $HOME
    echo "done"
fi

用于字符串测试的有用运算符 -z 是未设置(用于变量) -n 是非零(设置——用于变量) =~ 是一个左操作数匹配正则表达式(右操作数),例如 [[ foobar =~ f*bar ]] 用于文件测试的有用运算符 -d: 一个目录 -e: 存在 -f: 一个普通文件 -S: 一个套接字文件 -w: 可写,从这个Bash进程的角度来看 (( 算术测试 )) 在 (( 测试中评估的算术将把测试的退出值设置为 1 如果表达式评估为 0;否则,它将返回退出状态 0。这使得测试相当直观,使用你在几乎所有其他编程语言中已经知道的运算符:

和 >= – 大于和大于或等于 < 和 <= – 小于和小于或等于 == – 测试相等 (( $SOME_NUMBER == 24 )) 是一个相当直接的算术测试。让我们看看它的工作方式。

对于数字 24:

→ SOME_NUMBER=24 → (( ==24))→ℎSOMENUMBER==24))→echo? 0 “echo $?”命令打印出前一个命令的退出状态,这让我们可以看到算术测试实际上评估到了什么。对于其他值,包括非数字:

→ SOME_NUMBER=foobar → (( ==24))→ℎSOMENUMBER==24))→echo? 1 如果 未设置(例如,[[−SOMENUMBER未设置(例如,[[−zSOME_NUMBER ]] ):

→ unset SOME_NUMBER → (( $SOME_NUMBER == 24 )) zsh: bad math expression: operand expected at `== 24 ' 所以,回顾一下:

(( ==24))如果变量设置为24将评估为0。如果SOMENUMBER==24))如果SOMENUMBER变量设置为24将评估为0。如果SOME_NUMBER 设置为 24 之外的值(包括非数字值),它将评估为 1。 如果 $SOME_NUMBER 未设置,你将得到一个错误,由于你的算术测试没有左操作数用于比较。 条件语句:if/then/else Bash if语句一般以这种形式出现:

if [[ ]];ℎTEST]];thenSTATEMENTS else $OTHER_STATEMENTS fi

在我们看例子之前,记住这种形式的几件事:

if 和 fi 分别开始和结束if块。 ; 在Bash中分隔语句;在测试后立即添加一个。 [[ 和 ]] 限定你的测试表达式。 else子句是可选的。 以下是Bash中if语句的样子:

if [[ -e "example.txt" ]]; then
    echo "The file exists!"
fi

如果你想在这个结构上加上一个else子句,你可以!

if [[ -e "example.txt" ]]; then
    echo "The file exists!"
else
    echo "The file does not exist!"
fi

循环 Bash循环以 / do / done 的一般格式出现。它们还支持 break 和 continue 语句,分别跳出循环和跳到下一次迭代。

C风格循环 Bash支持C风格的循环,有初始化表达式、条件表达式和计数表达式:

for (( i=0; i<=9; i++ ))
do  
  echo "Loop var i is currently $i"
done

for…in 让我们谈谈使用 for…in 循环进行迭代。试着在你的shell中运行以下内容:

for i in 1 2 3 4 5
do
  echo $i
done

这是一个带有一些控制流的循环:

for os in FreeBSD Linux NetBSD "macOS" DragonflyBSD
do
  echo "Checking out ${os}..."
  if [[ "$os" == 'NetBSD' ]]; then
    echo "(I'm pretty sure this would run on my toaster, actually)"
  fi
  sleep 1
done

While 另一个你可能熟悉来自其他编程语言的常见控制结构是 while 循环。在Bash中,这个工作超级类似。要跳出循环,可以使用 break 语句。

以下脚本将逐行读取 lines.txt 文件,直到遇到 STOP 行。最后一行还展示了如何将文件管道到循环中。read 命令将逐行处理文件:

file="lines.txt"
while read line; do
    if [[ $line == "STOP" ]]; then
        echo "Encountered STOP. Exiting loop."
        break
    fi
    echo "Processing: $line"
    # Additional commands to process $line can be added here.
done < "$file"

变量导出 通过在变量名前加上 export 前缀来导出变量,确保从你的脚本进程生成的任何子shell也将能够访问该变量的值。这是一种确保变量被传播到当前shell的变量作用域或命名空间的任何未来子作用域(或子命名空间)的方法。

在你的shell中设置一个变量:

MYDIR=$HOME

创建并运行这个脚本(警告:这将失败!):

#!/usr/bin/env bash
LISTING=$(ls "${MYDIR}/Documents")
echo $LISTING

你会看到一个错误,ls: /Documents: No such file or directory,由于运行这个脚本生成了一个子shell,它无法访问其父shell中未导出的变量(换句话说,就是生成它的shell,即你的交互式shell)。让子shell访问你的变量必须通过 export 关键字明确完成:

export MYDIR=$HOME

目前重新运行示例脚本,目前你已经导出了变量,你会看到它可以访问 MYDIR 变量了。

函数 我们一般提议,当你发现自己需要Bash函数时,你已经找到了另一种语言来编写你不断增长的程序。不过,有时Bash依旧是解决问题的正确语言,我们想向你展示绝对基础,强烈倾向于我们推荐使用它们的方式。

通过使用 function 关键字来定义函数:

function my_great_function {
  $EXPRESSIONS
}

通过简单地调用它们的名称来调用函数:

my_great_function

优先使用局部变量 Bash使用或多或少的全局作用域——更准确地说,是每个(子)shell。许多现代编程语言为你提供了一个单独的函数作用域来使用,所以函数状态在函数退出后不会污染全局状态。

在你的函数中使用局部变量将保护你免受这种情况的影响,我们提议你使用它们:

#!/usr/bin/env bash
important_var=somevalue
function local_var_example() {
    local important_var="changed this locally, don't worry"
    echo "local_var_example: ${important_var}"
}
function bad_example() {
    important_var="this is mutating the global var because I'm bad, and I should feel bad."
    echo "bad_example: ${important_var}"
}
echo "before functions: ${important_var}"
local_var_example
echo
echo "after local_var_example: ${important_var}"
echo
bad_example
echo "after bad_example: ${important_var}"
exit 0

自己运行这段代码,看看使用局部变量有什么不同。

输入和输出重定向 当你运行脚本时,你常常会想要重定向它们的输出:

到另一个程序(通过管道 – | – 见第11章,管道和重定向,了解更多详情) 到一个常规文件(像日志文件) 到一个特殊位置,如 /dev/null,它可以作为你不需要的数据的黑洞 除了管道,这里还有你在野外看到的最常见的输入/输出重定向技巧。

<: 输入重定向 这一般用于从文件中获取输入,而不是从shell启动进程:

grep foobar < stuff.txt

和 >>: 输出重定向 符号会将输出流到你指向的任何地方,如果是普通文件,它将覆盖已经存在的任何内容:

ps aux | grep foo > /var/log/foo_overwrite.log

每次运行此命令时,ps aux | grep foo 的输出将被写入
/var/log/foo_overwrite.log,覆盖任何现有文件内容。

使用 >> 将追加到输出文件,保留任何现有内容。这一般是你希望对日志文件做的:

echo $(date && cat /proc/stat) >> /var/log/kernelstate.log

使用 2>&1 重定向 STDERR 和 STDOUT 有时,你想要将标准输出和标准错误都重定向到一个文件:

consul agent -dev >> /var/log/consul.log 2>&1 &

这条命令以开发模式运行 Hashicorp 的 Consul 并将进程放到后台(最后的 & 符号),将标准输出重定向到日志文件。2>&1 告知 Bash “将文件描述符 2(STDERR)重定向到 1(STDOUT)的同一个地方”——在这个例子中,就是 /var/log/consul.log。

你已经知道文件描述符——STDIN、STDOUT 和 STDERR。如果你只想将标准错误重定向到与标准输出不同的文件怎么办?

变量插值语法——{} 要完成大多数编程语言中所谓的“字符串插值”——用变量的值替换字符串的一部分——你需要 Bash 的变量插值,即 ${}。

自己试试看:

MYNAME=dave
echo "I can't do that, ${MYNAME}."

在 Bash 中还有其他插值变量的方式,但这是我们的最爱,由于它由于意外形状的输入(空格、特殊字符等)破坏程序的可能性最小。

如果你要将变量用作类似字符串的值,使用这种语法——即使该变量本身并不需要插值到另一个字符串中:

NAME="${MYNAME}"

这将防止许多奇怪的 Bug 和 Bash 的行为,所以这是一个值得养成的好习惯。

注意 当使用变量插值时,你几乎总是希望以 -u 选项运行 Bash(要么通过 -u 调用它,要么像我们推荐的那样,在脚本开头使用 set -euo pipefail)。这将防止你在使用变量之前不得不检查零值。

Shell脚本的限制 Bash 有无数的特性,我们这里没有涵盖。如果你需要深入了解 Bash 语言和环境,有许多书籍和大量的网络免费资源。目前你已经入门了,Bash 手册页(man bash)也是一个很好的开始。

我们预计在你的职业生涯中会遇到许多 Bash 脚本。不过,你花在阅读和解释现有脚本上的时间很可能会超过编写大型新 Bash 程序的时间。Bash 超级适合小问题和系统任务,这些任务适合用现有的软件解决,只需要将它们组合成一个解决方案。对于超出将标准 Linux 和 Unix 程序组合在一起的大问题,它一般是一个糟糕的选择。

对于 Bash,我们发现:

小比大好 清晰比机智好 安全比遗憾好 随着它们的成长,用不同的编程语言(一般是 Python)编写的工具替代 Bash 脚本并不罕见。这不是对 Bash 的贬低!它超级适合填补它所占据的生态位,这就是为什么它长期以来如此广泛。如果你偶尔停下来问问自己,“Bash 脚本依旧是解决这个问题的正确方案吗?”你会很好。

结论 这一章是一个直截了当、海量信息的 Bash 脚本速成课程。它很密集,但它涵盖了你成为一个有效的 Bash 脚本编写者所需的所有基础知识。如果需要的话,多过几遍。除了语法,我们还介绍了我们认为是最佳实践的内容,这些实践使编写易读、可维护的实际脚本变得更容易(或至少是可能的)。

练习,练习,再练习——最好是在你有的实际问题上,而不仅仅是玩具示例。没有比这更快的学习方法了。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容