# Shell 脚本通用要点

适用于所有用 shell 完成的工作：部署和运维脚本、构建脚本、一次性的命令行操作。示例以 bash 为准。

## 一、动手前

- **先确认目标和流程。** 写下脚本要从什么状态到什么状态、中间有哪些步骤、哪一步之后不能回头。流程有多种做法时，先和使用者确认，从最简单的那种开始。
- **先看一眼要改动的对象。** 删除、覆盖、移动之前，确认目标路径、分支、主机和自己设想的一致。
- **能用现成工具就不自己造。** 优先用系统自带的命令，只有确实需要时才调用应用代码或额外的运行时。

## 二、失败要安全

- **出错就停。** 开头写 `set -Eeuo pipefail`，再加 `trap 'printf "第 %s 行失败（退出码 %s）\n" "$LINENO" "$?" >&2' ERR`，失败时能看到是哪一行。
- **操作要可逆，影响范围要小。** 改名保留，少用删除。改动前先备份，备份放在脚本固定管理的位置。`rm -rf --` 只用于脚本自己创建、路径固定的目录，路径绝不能来自可能为空的变量。
- **半途失败要回到能用的状态。** 在改动线上状态之前注册 `trap ... EXIT`，用来撤回改动或恢复旧服务。到达“不能自动撤回”的那一步时，用 `trap - EXIT` 取消。
- **不能自动撤回的失败，给出下一步命令。** 例如“启动失败，执行 `bash xxx rollback`”。会丢数据的恢复由人决定，不自动执行。
- **可以重复执行。** 第二次运行不会破坏第一次的结果：已经完成的步骤要么跳过，要么明确拒绝并说明原因。

## 三、检查要准

- **检查的必须是真正关心的东西。** “接口返回 200”比“进程存在”更能说明服务可用，“读得到数据库的接口返回 200”又比“首页返回 200”更可靠。
- **在不会变化的数据上做严格比对。** 比对前后状态时，先让数据停止变化（停服务、取快照），再要求完全相等。不要在一直变化的数据上设容忍规则。
- **不写死只对一次运行成立的值。** 具体的版本号、行数、条目数、文件内容标记，下一次运行就会误报。
- **有差异就列出明细。** 打印“前 → 后”，再由人确认。不确定时默认取消。
- **能自动查到的事实，不让人回答。** 只有判断本身需要人来做时才询问。

## 四、交互

- **确认词按后果分级。** 只是提醒、可以继续的用 `y`；停机、删除、改数据用 `yes`。不提供跳过所有确认的开关。
- **非交互时默认取消。** `read -r -p '…' answer || true`，空答案当作取消。
- **输出分层。** 步骤、警告、错误各用固定的前缀；警告和错误写到 stderr；错误以非零状态退出。每一步结束时告诉使用者下一步做什么。
- **不打印凭据。** 密码、令牌、私钥、Cookie 不出现在输出、日志和命令行参数里，需要时引用文件路径。

## 五、写法

- **常量放在顶部。** 路径、服务名、端口写成大写常量。函数里的变量用 `local`。
- **只调用一次的逻辑直接内联。** 被多处调用的才写成函数。不要为了“看起来整齐”拆出只用一次的小函数。
- **子命令少而完整。** 每个子命令完成一件完整的事。不带参数时打印用法，并以 2 退出。
- **依赖在入口处检查。** 用 `command -v` 检查要用的命令，缺少时直接告诉使用者怎么安装，不要等到中途才失败。
- **注释只写原因。** 代码已经说明了在做什么，注释解释为什么这样做。
- **变量一律加引号。** `"$var"`、`"${arr[@]}"`。路径参数前加 `--`，防止以 `-` 开头的值被当成选项。

## 六、常见陷阱

- **`&&`、`||` 链里的失败不会触发 `set -e`。** 必须失败即停的链写成 `a && b || die '原因'`。
- **`local x=$(cmd)` 会吞掉退出码。** 先写 `local x`，再写 `x=$(cmd)`。
- **命令替换直接作为参数时，失败不会让脚本停下。** 例如 `tool "$(生成参数)"`。先赋给变量，再使用。
- **`pipefail` 下，`cmd | grep -q` 可能因为 SIGPIPE 得到错误的退出码。** 先 `out=$(cmd)`，再用 `case` 或 `grep` 处理这个变量。
- **函数或循环的最后一行是 `[ 条件 ] && 动作` 时，条件为假会让返回值非零。** 改用 `if`。
- **通配符没有匹配时会原样保留。** 使用前先用 `[ -e ]` 判断，或者开启 `nullglob`。
- **相对路径依赖当前目录。** 给函数和外部命令传完整路径。很多工具遇到不存在的路径会默默新建，例如 sqlite3 会新建一个空库，检查反而“通过”。
- **`cd` 会影响后面的所有命令。** 需要时放进子 shell：`(cd dir && cmd)`。
- **`sudo cmd > file` 的重定向不在 sudo 里执行。** 改用 `cmd | sudo tee file`。
- **`printf '%-10s'` 按字节计算宽度。** 中文等多字节字符会对不齐，改用“名称：值”的格式。
- **heredoc 是否展开变量取决于结束标记的引号。** `<<'EOF'` 不展开，`<<EOF` 展开。嵌入其他语言的代码时用带引号的形式。
- **Windows 编辑的文件可能带 CRLF。** 脚本和要比较的命令输出都要确认没有 `\r`。

## 七、验证

- **`bash -n` 只能检查语法。** 能装 shellcheck 就跑一遍，但它也代替不了实际运行。
- **没有真实环境时搭沙箱。** 把路径常量改到临时目录，在 `PATH` 前面放桩命令代替 `systemctl`、`curl`、`sudo` 等，核心逻辑用真实依赖执行。
- **逐个场景运行。** 正常流程、使用者取消、每一种检查失败、恢复、重复执行。每个场景结束后检查服务状态、目录和文件内容，不只看退出码。
- **测试本身也要可信。** 一项检查从来没失败过时，先确认它真的检查到了东西。
- **写清楚没验证的部分。** 哪些靠桩代替、哪些只能在真实环境确认，都要明确写出来，不要把沙箱结果当作真实环境的结果。