先加这四行安全开头
#!/usr/bin/env bash
set -e # 任一命令失败即退出
set -u # 使用未定义变量时报错
set -o pipefail # 管道中任一环节失败即视为失败
IFS=$'\n\t' # 让分词只按换行与制表符,避免空格踩坑
没有这几行,脚本常常「看起来执行完了」,实则中间某步失败被静默跳过。
引用规则:绝大多数变量要加双引号
| 写法 | 行为 | 建议 |
|---|---|---|
| $var | 会再分词与通配展开 | 几乎不要用 |
| "$var" | 原样作为一个参数 | 默认写法 |
| ${var} | 便于拼接边界,如 "${var}_suffix" | 拼接时用 |
| '$(cmd)' | 单引号内不执行 | 需要字面量时用 |
四个最容易出事的地方
- 路径含空格:未加引号时,一个文件名会被拆成多个参数;
- cd 失败后继续执行:
cd /some/dir && do_something,不要单独一行cd; - rm 与变量:
rm -rf "$DIR"/*在 DIR 为空时可能变成危险的删除,应先判空并用 set -u 兜底; - 管道吞掉错误:
cmd | head即使 cmd 失败,退出码也可能为 0,这正是 pipefail 存在的意义。
可维护脚本的几个习惯
- 参数校验放最前:缺少参数就打印用法并退出;
- 用 trap 做清理:临时目录、锁文件在退出时统一释放;
- 日志带时间且可关:便于排查,也避免污染调用方输出;
- 长脚本加 --dry-run:先打印将要执行的命令,确认无误再真跑。
set -euo pipefail
usage() { echo "usage: $0 <target-dir>"; exit 1; }
[ $# -eq 1 ] || usage
target=$1
[ -d "$target" ] || { echo "not a directory: $target" >&2; exit 1; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT # 无论成功失败都会清理
实战案例:三个线上事故
- “脚本把线上目录清空了”:变量为空、未判空、
rm -rf三者叠加。修复:set -u,删除前显式判空,并给危险操作加确认或 --dry-run。 - “脚本显示成功,实际步骤失败了”:管道或子命令的失败被吞掉。修复:
set -euo pipefail让失败立即暴露。 - “路径带空格就出问题”:未加引号的变量被词分割。修复:所有变量引用都加双引号
"$var"。
常见问题
sh 和 bash 有区别吗?有。数组、[[ ]]、pipefail 都是 bash 特性,脚本里要用就明确 #!/usr/bin/env bash,并避免用 sh script.sh 运行。脚本需要单元测试吗?超过几十行、被多人使用的脚本值得加;至少做一次语法检查与空参数测试。命令找不到怎么办?显式判断依赖是否存在,而不是让错误发生在中途。
可维护脚本的组织方式
脚本从几十行长到几百行时,可维护性就会迅速下降。以下做法能明显延长脚本的寿命。
- 先设定严格模式:让脚本在命令失败、使用未定义变量、管道中某一步失败时立即退出,避免错误被忽略后继续执行造成更大破坏。这是最划算的一行配置。
- 参数解析集中在开头:把所有入参与默认值在一处处理并校验,避免参数散落在脚本各处导致难以阅读与测试。
- 函数化并保持单一职责:一个函数只做一件事,输入输出明确;避免依赖全局变量在函数间传递状态。
- 输出要有区分:正常结果走标准输出,日志与错误走标准错误,这样脚本被管道或重定向时行为仍然正确。
- 善用退出码:让调用方可以通过退出码判断失败原因,并在脚本顶部用注释写清各退出码含义。
- 幂等优先:脚本应可重复执行而不产生副作用,部署与初始化类脚本尤其如此,重跑一次就能修好问题比手工回滚可靠得多。
什么时候该换语言
当脚本开始处理复杂数据结构、需要单元测试或跨平台分发时,继续用脚本语言维护的成本会超过收益。此时应果断迁移到具备类型与包管理能力的语言,而不是把脚本不断加长。
脚本的测试方式
脚本也应有最基本的测试:用固定输入运行并断言输出与退出码。可以借助通用的测试框架,也可以用简单的断言函数自建。哪怕只覆盖主要分支,也能显著减少“改一处坏一处”的情况。