常见的 Bash 脚本陷阱及规避方法
通过更安全的错误处理、引号、数组、陷阱和参数解析,避免常见的Bash脚本错误。
常见Bash脚本陷阱及如何避免
Bash脚本陷阱通常出现在脚本遇到真实文件名、缺失变量、命令失败或意外输入时。一个在你的笔记本上运行良好的脚本,如果依赖宽松的默认设置,可能会在CI或生产环境中崩溃。
你不需要让每个Shell脚本都变得复杂。但你需要对扩展进行引号处理,有意识地检查失败,并测试包含空格的文件名。
谨慎设置更安全的默认值
许多脚本以以下内容开头:
#!/usr/bin/env bash
set -euo pipefail
对于许多自动化脚本来说,这是一个良好的基线,但每个选项都有其尖锐的边缘:
set -e在简单命令失败时退出,但在if测试、&&和||列表的部分以及某些命令替换中除外。set -u在展开未设置的变量时退出。set -o pipefail使管道在管道中任何命令失败时失败,而不仅仅是最后一个命令。
当早期失败比继续执行更安全时,使用这些选项。对于预期会失败的命令,显式处理其状态。
if ! grep -q "ready" status.txt; then
echo "服务尚未就绪"
exit 1
fi
对变量扩展使用引号
未加引号的变量是最常见的Bash错误。Bash会对未加引号的扩展执行单词拆分和通配符扩展,因此像 release notes/*.txt 这样的路径可能会变成多个参数或匹配你未预期的文件。
file="release notes.txt"
# 错误:因为值被拆分为两个单词而中断。
rm $file
# 正确:传递一个精确的参数。
rm -- "$file"
在命令支持的情况下,对用户控制的文件名使用 --。这可以防止像 -rf 这样的文件名被解释为选项。
使用数组存储参数列表
不要将带有参数的命令存储在一个字符串中然后运行它。引号处理会很快变得脆弱。
# 错误
flags="-a --exclude node_modules"
rsync $flags "$src" "$dest"
# 正确
flags=(-a --exclude "node_modules")
rsync "${flags[@]}" "$src" "$dest"
数组保留了参数边界。当参数包含空格、通配符字符或以破折号开头的值时,这一点很重要。
优先使用 $(...) 而不是反引号
反引号难以嵌套且容易误读。使用 $(...) 进行命令替换。
current_branch="$(git rev-parse --abbrev-ref HEAD)"
echo "正在构建分支:$current_branch"
除非你故意想要单词拆分,否则保持命令替换加引号。
读取文件而不丢失数据
这种模式看起来无害,但会在空格处中断,并且可能破坏反斜杠:
for line in $(cat hosts.txt); do
echo "$line"
done
使用 while IFS= read -r 代替。
while IFS= read -r host; do
echo "正在检查 $host"
done < hosts.txt
IFS= 保留了前导和尾随空格。-r 防止反斜杠转义被解释。
使用 mktemp 和 trap 处理临时文件
硬编码的临时路径可能会与另一个进程冲突或留下过时文件。创建唯一路径并在退出时清理。
tmp_file="$(mktemp)"
cleanup() {
rm -f "$tmp_file"
}
trap cleanup EXIT
printf '%s\n' "工作数据" > "$tmp_file"
对于目录,使用 mktemp -d 并在清理函数中删除目录。
使用 getopts 解析选项
手动参数解析常常会遗漏边缘情况。对于短选项,Bash内置的 getopts 通常就足够了。
verbose=false
output=""
while getopts ":vo:" opt; do
case "$opt" in
v) verbose=true ;;
o) output="$OPTARG" ;;
:)
echo "选项 -$OPTARG 需要一个参数" >&2
exit 2
;;
\?)
echo "未知选项:-$OPTARG" >&2
exit 2
;;
esac
done
shift "$((OPTIND - 1))"
getopts 处理短标志,如 -v 和 -o file。如果你的脚本需要像 --output 这样的长选项,请编写一个仔细的解析器或使用具有更强参数解析库的语言。
检查可能失败的命令
不要因为命令输出了某些内容就假定它成功了。在使用输出之前检查重要操作。
if ! archive="$(tar -czf app.tar.gz app 2>&1)"; then
echo "归档失败:$archive" >&2
exit 1
fi
对于管道,当中间失败应导致整个管道失败时,启用 pipefail。
set -o pipefail
journalctl -u api.service | grep -i "error"
没有 pipefail,管道状态通常来自最后一个命令。
当可移植性重要时避免使用Bash
如果你的脚本使用数组、[[ ... ]]、mapfile 或 pipefail,那么它是一个Bash脚本。以以下内容开头:
#!/usr/bin/env bash
如果你需要POSIX sh 的可移植性,请避免使用Bash特有的功能,并使用目标系统使用的Shell进行测试。不要编写带有 #!/bin/sh 的Bash脚本并期望它在任何地方行为一致。
要点
改进Bash脚本最快的方法是使用混乱的输入进行测试:文件名中的空格、缺失的变量、空文件和失败的命令。对扩展使用引号,使用数组处理参数列表,使用 trap 清理临时文件,并明确失败路径。未来的你将花更少的时间调试那些只在完美输入下才能工作的脚本。