09. August 2020 · 2 comments · Categories: Uncategorized · Tags: ,

Bash 大概是程序员们既是最熟悉的而又是最陌生的。说熟悉,Bash 几乎存在于程序员身边的绝大多数电脑上。说陌生,Bash 作为最常用的脚本语言,很少有程序员们了解 Bash 的特性和语法,而且因为不了解还产生了很多的误解。比如什么 Bash 作为一个脚本语言,其语法怪异且不严禁,仅适合编写简单的脚本,我们应该用 Python 代替 Bash。其实这完全是错误的理解。甚至很多程序员并不知道他们当前使用的 Bash 版本是多少,用的是 3.x?还是 4.x?还是 5.x?你知道你用的是哪个版本吗?

其实 Bash 的语法是图灵完备的,也就是说理论上我们可以用 Bash 实现任何其他语言可以实现的功能。话虽如此,但不同的语言有其业务场景的适用性,我们当然应该把 Bash 用在最适合的场景下,这才能发挥出 Bash 的强大威力。

完全没有道理用自己熟悉的编程语言去比较其他语言,如果不符合自己熟悉的习惯就评价为怪异。作为程序员一定要保持开放的心态去学习。

这篇文章介绍了大家对 Bash 的常见误解,看看你知道多少?

Bash 的 if 语句后面其实不是括号

这应该是被人误解最多的,吐槽最多的一点。比如 Bash 的语法非常怪异,if 后面的括号竟然是方括号,而且括号的前后都要有空格!

事实上,Bash 的 if 语法定义上其实并没有定义括号,而且命令序列。执行命令 help if 可以看到 if 的使用方法

if COMMANDS; then COMMANDS; 
[ elif COMMANDS; then COMMANDS; ]... 
[ else COMMANDS; ] 
fi

实际上所谓的方括号 [ 其实是一个命令,这个命令既是 Bash 的内建命令,也是一个外部命令,根据系统的不同可能位于 /bin 或者 /usr/bin 目录下。比如 macOS 上执行命令 ls -l “/bin/[” 就可以找到。

当我们知道了方括号 [ 是个命令后,这些怪异的语法就变得很容易理解了。因为 if 后面是命令,所以当然也可以用方括号 [ 命令了。既然这是一个命令,那命令的参数和命令本身自然也需要有空格来做分割了。事实上方括号 [ 命令等同于 test 命令,但是要求命令的最后一个参数必须是 ]。哈哈,如果一个括号只有开头而没有结尾,这才是最怪异的呢。

不过还有一种常见的是双方括号 [[,这个是 Bash 的内建关键字。虽然可以把关键字当作命令来看到,但是 Bash 会对关键字做特殊处理。比如在 [[ 与其结尾参数 ]] 之间是可以使用 &&、|| 这样的逻辑操作的,如果我们把 [[ 1 -eq 1 && 2 -eq 2 ]] 当作两条命令来看到,第二个命令是 2,这肯定会执行失败了。这就是 Bash 对关键字做的一个特殊处理。而 [ 是一个命令,不管是内建命令也好外部命令也好,[ 1 -ne 0 && 2 -ne 3 ] 一定会出错的,因为这将执行两条命令,第一条 [ 1 -ne 0 将会因为 [ 命令的最后一个参数不是 ] 而出错,第二条命令 2- ne 3 ] 可能将会因为在 PATH 环境变量中没有找到一个名为 2 的命令而出错。

说到这里,我们可以想想看。假如有一个 Java 程序员竟然对 Java 的 if 语句的语法不熟悉,这个程序员怎么可能写出好的 Java 程序?把 Java 换成 Bash 也是一样的。

真的无法阻止 Bash 中的函数修改全局的变量吗?

这应该是第二个被人误解最多的一个 Bash 特性,也常被用来证明 Bash 的语言多么不可靠。原因是如果在函数中没有使用 local 关键字来声明变量,那函数里的变量将会对全局产生影响。或许看起来有些绕口,请看如下的例子:

#!/usr/bin/env bash
x=42
bar=answer

function foo() {
  local x=999
  bar=world
  echo foo: x=$x bar=$bar
}

foo
echo x=$x bar=$bar

执行的结果是

foo: x=999 bar=world
x=42 bar=world

在函数 foo 中用 local 关键字声明了变量 x,这使得函数内变量 x 的作用域限于函数 foo 内,不同于外部的全局变量 x。而函数内使用变量 bar 时没有用 local 关键字声明,这使得函数内对变量 bar 的赋值实际上是修改了函数之外的变量 bar。这可能会造成我们在编写函数的时候,必须显式的使用 local 关键字来声明局部变量,否则会发生无意中使用了全局的同名变量,导致该全局变量被无意中修改的问题。

这个问题真的很严重,也的确是有些人吐槽 Bash 不严谨的地方之一。事实上,这也恰巧说明了我们可能并不了解 Bash 中定义函数的语法。

大家都知道,在 Bash 中定义函数时,把函数体放到花括号中。看上去这点和很多编程语言也非常类似,比如 C、Java 等。但却不会把变量的作用域限制在花括号的范围内。其实 Bash 中声明函数时,其语法要求函数体是一个组合命令即可。所以,花括号并不是必须的,我们还可以用圆括号。请看下面的例子中函数 foo 的函数体用了圆括号:

#!/usr/bin/env bash
x=42
bar=answer

function foo() ( # <--
  local x=999
  bar=world
  echo foo: x=$x bar=$bar
) # <--

foo
echo x=$x bar=$bar

执行的结果是:

foo: x=999 bar=world
x=42 bar=answer

在第二个例子中,我们把函数体放到了圆括号里面,这使得这个函数将会在子进程中执行。而子进程是无法修改父进程中的任何变量的。所以,即使在函数中没有用 local 关键字声明变量 bar,对这个变量的变更也不会影响到全局的同名变量。

因为在定义 Bash 的函数时需要的是组合命令,所以下面的函数定义也是合法的:

function foo()
for param; do
  echo "=> $param";
done

执行 foo a b c 会得到

=> a
=> b
=> c

是的,出乎大家的意料,定义 Bash 函数的时候连什么括号都可以不要。

所以,我想再吐槽一次。如果有人连 Bash 的语法都不了解不熟悉,有很多的误解。他们是如何得出 Bash 的语法不严禁,功能不如其他语言这些结论的?

Bash 也支持直接执行目录名来切换到该目录中

很多使用 Zsh 的用户在举例 Zsh 与 Bash 的区别时,这是一个常用来对比的特性。很可惜,这不是 Zsh 的独特特性,Bash 其实也支持。实际上 Zsh 下这个特性也是默认关闭的,只是很多人都会使用 Oh My Zsh 这个知名的配置工程,而 Oh My Zsh 默认开启了这个特性。在 Bash 下开启这个特性的命令是 shopt -s autocd,开启之后我们就可以直接执行目录名来切换到该目录了。

Bash 其实也支持 ** 通配符来匹配多级子目录

这个特性也是有些人会误认为 ** 通配符是 Zsh 比 Bash 好用的一个特性,但其实 Bash 也是支持的。

开启命令是 shopt -s globstar

然后我们就可以用 ls **/*.txt 来列出当前目录及其子目录下的所有 *.txt 文件了。

所以,以后也请不要那这个特性来对比 Zsh 和 Bash 了。

Bash 在命令行上其实也支持撤销、粘贴等操作

当然有很多人知道在命令行下如何快速跳转光标、删除单词或者删除到行尾等常用操作,但很少有人会知道的 Bash 在命令行也支持撤销、粘贴等操作,而且粘贴功能事实上比操作系统默认的更强大。

撤销的快捷键是 Ctrl-/,反复按下会继续撤销之前的操作。

粘贴的快捷键是 Ctrl-y。当在命令行使用了 Ctrl-k 删除到行尾、Ctrl-w 删除前一个单词、Alt-d 删除下一个单词、Ctrl-u 删除整行等操作后,Bash 会将删除掉的内容放到自己的剪贴板中。要注意的是这个 Bash 的剪贴板不同于操作系统的剪贴板。删除之后,可以按下 Ctrl-y 在当前光标处粘贴最后一次删除的内容,重复按下会重复粘贴。

如果删除过多次,按下 Ctrl-y 粘贴出最后一次删除的内容后,再紧接着按下 Alt-y 就会替换为前一次的内容,重复按下 Alt-y 会继续替换更早之前删除的历史内容。

Bash 中使用 I/O 重定向的时候,重定向可以不用放在命令的最后

我们经常使用 >、>>、<、<< 来把命令结果保存到文件、或者从文件中读取内容。通常我们都是把重定向放在命令的最后。比如 ls -ld * >/tmp/files.txt

实际上,重定向可以位于一条命令的任何位置,放在中间或者最前面都是可以的。比如以下命令都是一样的

>/tmp/files.txt ls -ld *

ls >/tmp/files.txt -ld *

有时候不把重定向放在命令的最后会给我们快速复用并修改之前的命令非常方便。比如我们只需要快速搜索到之前执行的命令 >/tmp/files.txt ls -ld *,就可以很容易的修改最后一个参数了。

顺便说以下,看上去 Bash 中的 I/O 重定向很简单、很易使用。但事实上Bash 的 I/O 重定向比绝大多数人认为的更强大且复杂。或许我会在另外的文章中来详细介绍一下。

Bash 支持直接访问网络

Bash 的网络功能是很多人不知道的一个特性,不需要什么 nc、socat 这些命令就可以访问网络资源。只需要使用 I/O 重定向直接访问设备文件 /dev/tcp/<host>/<port> 即可,把 <host> 和 <port> 换成你希望访问的网络地址和端口。比如 /dev/tcp/baidu.com/80 就是访问主机 baidu.com 的 80 端口。

请看下面的命令,用 Bash 的内建命令实现了一个简单的 HTTP 客户端功能,访问 baidu.com 并打印出响应。

#!/usr/bin/env bash
exec 6<>/dev/tcp/baidu.com/80;
printf "GET / HTTP/1.1\nHOST: baidu.com\nConnection: close\n\n" >&6;
while read -r -u 6 line; do
  echo "$line";
done;
exec 6>&-

执行结果

HTTP/1.1 200 OK
Date: Sat, 08 Aug 2020 23:31:36 GMT
Server: Apache
Last-Modified: Tue, 12 Jan 2010 13:48:00 GMT
ETag: "51-47cf7e6ee8400"
Accept-Ranges: bytes
Content-Length: 81
Cache-Control: max-age=86400
Expires: Sun, 09 Aug 2020 23:31:36 GMT
Connection: Close
Content-Type: text/html

<html>
<meta http-equiv="refresh" content="0;url=http://www.baidu.com/">
</html>

其他的 Shell,比如 Zsh、Sh 等可能不支持这种直接访问网络的方式,但是 Zsh 的网络支持其实更强大,只是方式不同。

Bash 的受限模式让自动化运维更安全

在启动 Bash 的时候,使用选项 -r 可以让 Bash 进入受限模式。在受限模式下,会有很多的限制,比如:

  • 不能用 cd 命令切换目录
  • 不能修改 PATH 环境变量
  • 不可以通过指定路径的方式来运行命令
  • 不能使用 I/O 重定向
  • ……

Bash 受限模式的一个常见的使用场景是自动化运维。想想看,如果系统会根据一些用户的请求来自动生成并执行一些命令,在这些命令中可能会使用一些用户的输入,这可能会产生类似 SQL 注入的漏洞攻击。再比如,在一些自动化运维的时候难免要临时切换到特权用户下执行命令。为了让系统更安全,在临时的特权用户环境里面只能执行一些特定的命令或者脚本,以尽量减少系统被攻击的可能性。使用 Bash 的受限模式就可以避免非期望的命令或者脚本被执行,让系统更加安全。

有些「Bash 命令」其实和 Bash 无关

或许有的人会把 ls、rm、mkdir、…… 等命令称为 Bash 命令,其实这些命令与 Bash 并没有关系。比如在 GNU/Linux 上,这些命令属于 GNU Coreutils 这个项目。而 Bash 命令应该是 Bash 的内建命令,比如 alias、exec、printf 等等。有些 Coreutils 中的命令因为使用的很多,为了避免无谓的进程创建开销,所以 Bash 也将其内建了,比如 echo、test 等。不过要注意的是,Bash 的内建命令的选项与 Coreutils 中的同名命令可能不同。

比如执行下列命令可能会得到不同的结果:

echo --help
/bin/echo --help

# macOS 上用 brew install coreutils 后的默认路径
/usr/local/opt/coreutils/libexec/gnubin/echo --help

我们也能够为 Bash 写单元测试

Bash 还有一个最易被人诟病的一点就是,很难去测试一个 Bash 脚本。所以这也是很多人说应该用 Python 代替 Bash 的一个理由,因为我们可以为 Python 写单元测试,而不能为 Bash 写单元测试。虽然已经有了一些 Bash 的测试框架,比如 Batsshunit2 等。但这些测试框架都是集成测试框架。这意味着,为了执行 Bash 脚本的测试用例,我们需要事先安装好被测脚本中依赖的各种工具,配置好所需的目录、配置等。比如在我们的脚本中使用了 Bazel 或者 CMake,如果没有安装 Bazel 或者 CMake,很显然这些测试都将失败。

如果可以为 Bash 脚本写单元测试,这就意味着这些测试可以在任意时候、任意的机器上乱序执行任意的测试都应该在秒级的时间内得到相同的执行结果。无论是在 Windows 上执行 GNU/Linux 的 Bash 脚本的单元测试,还是在一个全新的极简的环境中执行一个使用了很多第三方工具的复杂的 Bash 脚本的单元测试,都应该得到稳定的测试结果。

现在有一个名为 Bach 的测试框架(Bach Testing Framework),这是目前唯一的一个真正可以为 Bash 脚本编写单元测试的框架。Bach 测试框架的独特之处在于它不会执行任何在 PATH 环境变量中的命令,而且还提供了丰富的模拟命令用于模拟其它命令的行为。Bach 测试框架非常适合于测试 Bash 脚本的执行逻辑,而不用实现安装并配置好被测脚本中使用到的各个工具。

请看下面的例子:

#!/usr/bin/env bash
set -euo pipefail
source bach.sh

test-rm-rf() {
    # Write your test case

    project_log_path=/tmp/project/logs
    sudo rm -rf "$project_log_ptah/" # 这里写错了变量名,导致了一个严重的错误
}
test-rm-rf-assert() {
    # Verify your test case
    sudo rm -rf /   # 这就是将要执行的命令
                    # 别慌!Bach 不会让这个命令真的执行!
}

Bach 测试框架的每一个测试用例默认是由两个函数来组成,一个是用于编写测试用例,比如上例中的 test-rm-rf,与之对应的是用于验证测试结果的测试验证函数 test-rm-rf-assert。Bach 测试框架会分别运行这两个函数,并对比执行中调用的命令序列是否一致。

2 Comments

  1. […] Bash 脚本很难维护,在《关于 Bash 的 10 个常见误解》里面有提到,其实是很多人对于 Bash 的语法就根本不熟悉。连最常用的 if […]

  2. qfh says:

    什么时候来片重定向的文章呀

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.