← 返回文章列表

Shell 脚本要点:安全开头、引用规则与四个常见错误

命令行避坑

先加这四行安全开头

#!/usr/bin/env bash
set -e          # 任一命令失败即退出
set -u          # 使用未定义变量时报错
set -o pipefail # 管道中任一环节失败即视为失败
IFS=$'\n\t'    # 让分词只按换行与制表符,避免空格踩坑

没有这几行,脚本常常「看起来执行完了」,实则中间某步失败被静默跳过。

引用规则:绝大多数变量要加双引号

写法行为建议
$var会再分词与通配展开几乎不要用
"$var"原样作为一个参数默认写法
${var}便于拼接边界,如 "${var}_suffix"拼接时用
'$(cmd)'单引号内不执行需要字面量时用

四个最容易出事的地方

  1. 路径含空格:未加引号时,一个文件名会被拆成多个参数;
  2. cd 失败后继续执行:cd /some/dir && do_something,不要单独一行 cd;
  3. rm 与变量:rm -rf "$DIR"/* 在 DIR 为空时可能变成危险的删除,应先判空并用 set -u 兜底;
  4. 管道吞掉错误: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   # 无论成功失败都会清理

实战案例:三个线上事故

  1. “脚本把线上目录清空了”:变量为空、未判空、rm -rf 三者叠加。修复:set -u,删除前显式判空,并给危险操作加确认或 --dry-run。
  2. “脚本显示成功,实际步骤失败了”:管道或子命令的失败被吞掉。修复:set -euo pipefail 让失败立即暴露。
  3. “路径带空格就出问题”:未加引号的变量被词分割。修复:所有变量引用都加双引号 "$var"。

常见问题

sh 和 bash 有区别吗?有。数组、[[ ]]、pipefail 都是 bash 特性,脚本里要用就明确 #!/usr/bin/env bash,并避免用 sh script.sh 运行。脚本需要单元测试吗?超过几十行、被多人使用的脚本值得加;至少做一次语法检查与空参数测试。命令找不到怎么办?显式判断依赖是否存在,而不是让错误发生在中途。

可维护脚本的组织方式

脚本从几十行长到几百行时,可维护性就会迅速下降。以下做法能明显延长脚本的寿命。

  1. 先设定严格模式:让脚本在命令失败、使用未定义变量、管道中某一步失败时立即退出,避免错误被忽略后继续执行造成更大破坏。这是最划算的一行配置。
  2. 参数解析集中在开头:把所有入参与默认值在一处处理并校验,避免参数散落在脚本各处导致难以阅读与测试。
  3. 函数化并保持单一职责:一个函数只做一件事,输入输出明确;避免依赖全局变量在函数间传递状态。
  4. 输出要有区分:正常结果走标准输出,日志与错误走标准错误,这样脚本被管道或重定向时行为仍然正确。
  5. 善用退出码:让调用方可以通过退出码判断失败原因,并在脚本顶部用注释写清各退出码含义。
  6. 幂等优先:脚本应可重复执行而不产生副作用,部署与初始化类脚本尤其如此,重跑一次就能修好问题比手工回滚可靠得多。

什么时候该换语言

当脚本开始处理复杂数据结构、需要单元测试或跨平台分发时,继续用脚本语言维护的成本会超过收益。此时应果断迁移到具备类型与包管理能力的语言,而不是把脚本不断加长。

脚本的测试方式

脚本也应有最基本的测试:用固定输入运行并断言输出与退出码。可以借助通用的测试框架,也可以用简单的断言函数自建。哪怕只覆盖主要分支,也能显著减少“改一处坏一处”的情况。