Bash 脚本往往从很小开始,并随着时间推移不断变大。你花五分钟写来复制几个文件的辅助脚本,最终可能会跑在 cron 定时任务、部署流水线或服务器启动脚本里。因此,从一开始就遵循一些最佳实践,远比事后返工重要。
本指南介绍一些实用的 Bash 最佳实践,可让脚本保持可预测、可安全重跑,也便于他人阅读。
以清晰的 Shebang 行开头
每个 Bash 脚本都应以正确的 shebang 行开头。对于 Bash 而言,可移植的选择是:
#!/usr/bin/env bash
使用 /usr/bin/env bash 可以让脚本通过 PATH 找到 Bash,这在 Bash 安装在 /bin 之外的系统上(例如使用 Homebrew 的 macOS)很重要。如果你依赖仅在特定版本中存在的特性,可以锁定具体版本,例如 /usr/bin/bash。
启用严格模式
默认情况下,Bash 会忽略许多错误。某条命令可能失败、某个变量可能未设置,而脚本仍会若无其事地继续运行。在脚本顶部启用严格模式会改变这种默认行为:
#!/usr/bin/env bash
set
-euo pipefail
下面说明每个标志的作用:
- -e — 任何命令返回非零状态时立即退出。
- -u — 将未设置的变量视为错误并退出。
- -o pipefail — 只要管道中的任意命令失败,整个管道就失败,而不仅仅是最后一条命令。
一个常见的额外设置是 IFS=$'\n\t',它可以防止按空格进行单词分割,并避免在遍历含空格的文件名时出现意外。
#!/usr/bin/env bash
set
-euo pipefail
IFS
=
$'\n\t'
严格模式并非万能。它无法捕获逻辑错误,而且在有些脚本中,你需要通过 || true 局部地关闭某些允许失败的命令。其意义在于把默认行为从“静默继续”翻转为“立即失败”,这样问题会尽早暴露出来。
始终为变量加上引号
在 Bash 中,未加引号的变量会先被展开,然后再进行单词分割。如果变量包含空格、通配符,或为空,结果可能和你期望的完全不同。
file
=
"My Report.txt"
printf
'<%s>\n'
$file
由于 $file 没有加引号,Bash 会把两个参数传给 printf:My 和 Report.txt。加上引号即可修复,并将文件名作为一个整体值保留下来:
printf
'<%s>\n'
"
$file
"
同样的规则也适用于测试、循环和命令替换之中。拿不准时,就加引号。唯一常见地故意不加引号的情况,是你确实需要单词分割(例如把字符串拆成多个参数),但这种情况很少见。
使用 $() 而非反引号
反引号 `command` 和 $(command) 都能运行命令并替换其输出,但一旦脚本超出单行范围,反引号就会带来麻烦。
第一个问题是嵌套。反引号本身不能嵌套,所以每一层内部的反引号都需要转义:
echo
"`basename \`dirname /var/log/syslog\``"
log
如果忘了加反斜杠,Bash 会在错误的位置关闭第一对反引号,于是 basename 在没有参数的情况下运行,而 dirname /var/log/syslog 则作为普通文本残留下来。而 $(...) 形式无需任何转义即可嵌套:
echo
"
$(
basename
"
$(
dirname /var/log/syslog
)
"
)
"
log
引号是第二个问题。在反引号内部,Bash 会在解析内层命令前先去掉一层反斜杠,因此 \\a 会以 \a 的形式传入,echo 也只会打印出 a。而在 $(...) 内部,反斜杠会原样传递,保持不变。
反引号能做到的,$(...) 全部都能做到,因此请默认使用 $(...)。同时也要给结果加上引号,因为命令替换和任何其他展开一样,也会进行单词分割。
优先使用 [[ ]] 而非 [ ]
Bash 支持两种测试语法:较旧的 [ ](即 test 命令的同义词)和较新的 [[ ]] 关键字。对于 Bash 脚本,请优先使用 [[ ]]:
if
[[
-f
"
$config
"
&&
"
$env
"
==
"production"
]]
;
then
echo
"Loading production config"
fi
[[ ]] 即使不加引号也能安全处理空变量,支持使用 == 进行模式匹配、使用 =~ 进行正则匹配,并允许在测试内部使用 && 和 || 这样的逻辑运算符。只有在纯 POSIX shell(如 dash)中才退回到 [ ]。
用函数封装重复逻辑
一旦脚本的步骤超过几个,就应当把相关的命令归组到函数中。函数能让脚本更易读、更易测试,也更易重构。
log
()
{
printf
'[%s] %s\n'
"
$(
date +
'%F %T'
)
"
"
$*
"
}
backup_dir
()
{
local
src
=
"
$1
"
local
dst
=
"
$2
"
log
"Backing up
$src
to
$dst
"
rsync -a
"
$src
/"
"
$dst
/"
}
backup_dir
"/var/www"
"/backup/www"
在函数内部使用 local 声明变量,避免它们泄漏到脚本的其他部分。把输入作为位置参数传入,并通过 echo 或 printf 返回输出,而不是使用全局变量,因为全局变量难以追踪。
关于参数、返回值与作用域的更多细节,请参阅 Bash 函数相关文档。
用数组构建命令参数
引号保护的是单个值,但脚本常常需要构建一整列参数并交给某个命令处理。把这一列参数塞进一个普通字符串是行不通的,因为字符串会被单词分割:
opts
=
"--exclude 'My Report.txt'"
printf
'<%s>\n'
$opts
<--exclude>
<'My>
<Report.txt'>
文件名被拆成了两段,而单引号也作为字面字符被带了进来。使用数组则可以让每个参数都保持完整:
opts
=(
--exclude
"My Report.txt"
)
printf
'<%s>\n'
"
${
opts
[@]
}
"
<--exclude>
<My Report.txt>
使用 "${opts[@]}" 展开会为数组中的每个元素产生一个独立的 shell 词,无论其中含有什么内容。只要标志是条件性的,就该使用这种模式:
args
=(
-a --delete
)
if
[[
"
$verbose
"
==
"yes"
]]
;
then
args
+=(
--verbose
)
fi
rsync
"
${
args
[@]
}
"
"
$src
/"
"
$dst
/"
引号和下标 @ 都要保留。"${opts[*]}" 会把所有元素合并成单个词,而未加引号的 ${opts[@]} 又会再次进行单词分割,让你回到原点。关于数组的更多细节,请参阅 Bash 数组文档。
显式处理错误
严格模式会在出错时停止脚本,但它不会告诉用户出了什么问题。在 ERR 上添加一个 trap,你就拥有了一个统一记录失败的地方。
#!/usr/bin/env bash
set
-Eeuo pipefail
on_error
()
{
local
exit_code
=
$?
local
line
=
$1
echo
"Error on line
$line
(exit code
$exit_code
)"
>
&
2
exit
"
$exit_code
"
}
trap
'on_error $LINENO'
ERR
-E 选项让 ERR 陷阱能够继承到函数和子 shell 中。如果没有它,辅助函数内部的失败可能会在退出脚本的同时,不运行你的错误处理程序。
对于清理临时文件之类的清理任务,请在 EXIT 上使用 trap:
tmp_dir
=
$(
mktemp -d
)
trap
'rm -rf "$tmp_dir"'
EXIT
无论脚本是正常退出、在 set -e 下遇到错误,还是被 Ctrl+C 中断,EXIT 陷阱都会运行。这是保证清理的最安全方式。
尽早校验输入
接收参数的脚本,应当在参数为缺失或无效时快速失败。在执行任何实际工作之前先检查它们:
if
[[
$#
-lt
2
]]
;
then
echo
"Usage:
$0
<environment> <version>"
>
&
2
exit
1
fi
env
=
"
$1
"
version
=
"
$2
"
case
"
$env
"
in
staging
|
production
)
;;
*
)
echo
"Unknown environment:
$env
"
>
&
2
exit
1
;;
esac
对于带多个标志的脚本,请使用 getopts 而非手动解析 $@。它能以一致的方式处理短选项、选项参数和错误报告。
避免解析 ls 的输出
初学者常犯的一个错误是遍历 ls 的输出:
for
f in
$(
ls /var/log
)
;
do
echo
"
$f
"
done
这在文件名含有空格、换行符或特殊字符时会出问题。请改用 glob 或 find:
for
f in /var/log/*
;
do
echo
"
$f
"
done
对于递归遍历,请使用 find 配合 -print0 与 xargs -0,或者使用 IFS= 和 -d '' 的 while read 循环,以正确处理异常文件名。
尽量保持脚本的幂等性
一个可以安全重跑的脚本,比一个第二次运行就出错的脚本要好用得多。应尽量做到幂等行为:在创建目录之前先检查,使用 mkdir -p 而非 mkdir,使用……
mkdir -p /opt/app/config
同样的模式也适用于软件包安装、用户创建和配置修改。像 Ansible 这类工具在设计上就强制保证了幂等性;而在 Bash 中,你需要自己加上这些检查。
使用 ShellCheck
ShellCheck 是面向 shell 脚本的静态分析器。它能在你把脚本投入生产环境之前,就捕获未加引号的变量、不安全的 for 循环等众多常见错误。
通过包管理器安装:
sudo apt install shellcheck
然后针对你的脚本运行它:
shellcheck script.sh
修复它报告的警告,或者用行内注释说明为何要忽略某条特定的检查。把 shellcheck 接入你的编辑器或 pre-commit 钩子,可以从根本上消除一整类 Bash 错误。
可读性优先于技巧性
Bash 充满各种快捷方式和晦涩的展开语法。它们能让脚本更短,但也会让六个月后的阅读变得更困难。即便要多写几个字符,也请选择更清晰的写法:
if
[[
-z
"
$name
"
]]
;
then
name
=
"anonymous"
fi
几乎总比精简压缩后的等价写法更易维护:
:
"
${
name
:=anonymous
}
"
目标是写出同事读一遍就能理解的脚本,而不是需要查阅参考手册的脚本。
快速参考
关于可打印的快速参考,请参阅 Bash 速查表。
在提交 Bash 脚本之前,请确认以下几点:
- 已存在 #!/usr/bin/env bash shebang 行。
- 已启用 set -euo pipefail。
- 所有变量展开都已加引号。
- 命令替换使用 $(...) 而非反引号。
- 测试使用 [[ ]] 而非 [ ]。
- 函数替代了冗长的重复代码块,并使用 local 变量。
- 参数列表以数组构建,并以 "${arr[@]}" 展开。
- trap 负责处理错误与清理。
- 工作开始前已校验输入。
- 不解析 ls 的输出。
- shellcheck 不报告警告,或警告已被显式忽略。
常见问题
set -e 单独使用就够了吗?不够。set -e 会在很多错误时停止,但它会漏掉管道中的失败,并且忽略未设置的变量。应将其与 -u 和 -o pipefail 组合,以覆盖这些情况。
严格模式会让现有脚本出问题吗?它往往会暴露本来就存在的缺陷,例如未设置的变量和被忽略的错误。修复方法是对变量加引号、使用像 ${VAR:-} 这样的默认值,以及……
我应该用 Bash 写脚本,还是改用 Python?对于主要调用其他命令的简短“胶水”脚本,Bash 是不错的选择。一旦脚本增长到几百行以上、涉及复杂的数据结构……
在哪里可以学习更多安全的脚本编写模式?在编写脚本时,请保持 ShellCheck 维基页面打开,并通读官方 Bash 手册中关于 shell 内建命令的部分。两者都解释了每条规则背后的原理。
总结
严格模式、变量加引号,再加上几处恰当的陷阱(trap),就能覆盖 Bash 脚本中的大部分风险。把这些实践作为你的默认习惯,在每次改动时都运行 shellcheck,你的脚本就会在本地、预发和生产环境中表现一致。