1. 脚本结构规范
一句话总结: 一个工程化脚本应该有固定骨架:shebang、严格模式、配置区、函数区、主逻辑,让读者一分钟找到任何东西。
写脚本和写业务代码一样需要结构。推荐布局:
#!/usr/bin/env bash
# ------------------------------------------------------------------
# deploy.sh - 发布脚本
# 用法: ./deploy.sh [-e env] <app>
# 作者: devops@example.com
# ------------------------------------------------------------------
set -euo pipefail
# ---------- 配置区 ----------
readonly APP_DIR="/opt/apps"
readonly LOG_FILE="/var/log/deploy.log"
# ---------- 函数区 ----------
log() { printf '%s %s\n' "$(date '+%F %T')" "$*"; }
die() { log "[ERROR] $*" >&2; exit 1; }
# ---------- 主逻辑 ----------
main() {
local env="${1:?用法: $0 <env>}"
log "开始部署 $env"
# ...
}
main "$@"
1.1 工程化结构清单
| 区块 | 内容 | 目的 |
|---|---|---|
| shebang | #!/usr/bin/env bash | 可移植解释器 |
| 头部注释 | 用途/用法/作者 | 交接文档 |
| 严格模式 | set -euo pipefail | 早失败 |
| 配置区 | readonly 常量集中 | 一处改全局生效 |
| 函数区 | 纯函数 + 副作用隔离 | 可复用可测试 |
| 主逻辑 | main "$@" | 入口清晰 |
一句话总结: 脚本要"能进代码评审",先做到三点:头部有用法说明、常量集中在配置区、入口收敛到 main 函数。
2. set -euo pipefail 严格模式
一句话总结:
-e出错即退、-u未定义变量即退、-o pipefail管道段失败即退,三个开关让脚本在错误发生后立刻停下。
# 三个开关逐项拆解
set -e # 任何命令失败立即退出(rc 非 0)
set -u # 引用未定义变量立即报错
set -o pipefail # 管道中任一段失败,整体失败
# 一行全开(推荐写法)
set -euo pipefail
2.1 严格模式的行为对照
| 场景 | 关闭时 | 开启后 |
|---|---|---|
cp a b 失败 | 继续往下跑 | 立即退出 |
| 用了未定义变量 | 得到空串 | 报 unbound variable |
false | true | 整体算成功 | 退出码非 0 |
| 通配符无匹配 | 保留字面量 | 报错(可用 nullglob 缓解) |
# 需要"容忍失败"的场合显式豁免
rm -rf "$tmp" || true
grep -q "ok" file || echo "未找到 ok"
# 判断类命令不能因 -e 退出
if grep -q "x" file; then
echo "有 x"
fi
# 管道里明确不检查失败的段
set +o pipefail
echo "end" | cat
set -o pipefail
一句话总结: 严格模式是脚本的安全带。但
-e不懂"哪些失败可以容忍",所有容错都要显式|| true或放进 if 条件,这正是可控性的体现。
3. 代码组织与函数库
一句话总结: 把通用函数抽到 lib 文件用 source 复用,函数命名带模块前缀,参数显式校验,形成可维护的代码库。
# lib/log.sh - 日志函数库
#!/usr/bin/env bash
log_info() { printf '[INFO ] %s\n' "$*"; }
log_warn() { printf '[WARN ] %s\n' "$*" >&2; }
log_error() { printf '[ERROR] %s\n' "$*" >&2; }
# 主脚本引用
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/log.sh
source "$SCRIPT_DIR/lib/log.sh"
log_info "加载配置"
3.1 函数设计原则
| 原则 | 做法 |
|---|---|
| 前缀命名 | db_connect / net_ping 避免撞名 |
| 单一职责 | 一个函数只干一件事 |
| 显式入参 | 入口处 local x="${1:?}" |
| 显式返回 | return 0/1 表达成功失败 |
| 副作用收敛 | 打印走参数/全局输出变量 |
# 纯函数 + 返回值传递
parse_version() {
local input="$1"
[[ "$input" =~ ([0-9]+)\.([0-9]+) ]] || return 1
echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
}
read -r major minor < <(parse_version "v1.24" || echo "0 0")
echo "major=$major minor=$minor"
一句话总结: 函数库的引用路径要用
BASH_SOURCE[0]推导,别用$0——被 source 时$0是父脚本路径,会找错目录。
4. ShellCheck 与代码质量
一句话总结: ShellCheck 是 Shell 脚本的 linter,能抓到未加引号、
$拼接、误用[ ]等几百类问题,建议进 CI 强制门禁。
# 安装(macOS)
brew install shellcheck
# 运行
shellcheck myscript.sh
# 只看错误级别
shellcheck -S error myscript.sh
# 忽略某条规则
shellcheck -e SC2086 myscript.sh
# 指定 shell 方言
shellcheck -s bash myscript.sh
4.1 高频规则速查
| 编号 | 问题 | 修法 |
|---|---|---|
| SC2086 | 变量未加引号 | 加 "$var" |
| SC2002 | 无谓 cat | grep x file 不用 `cat file |
| SC2034 | 变量未使用 | 删除或注明 |
| SC2164 | cd 未检查 | `cd dir |
| SC1090 | source 路径不可解析 | 加 # shellcheck source=... |
| SC2155 | 声明与赋值同行 | 拆成 local x; x=... |
# 常见修复示例
# 坏:cd /tmp && rm -f x
cd /tmp || die "无法进入 /tmp" # SC2164
# 坏:local x=$(cmd)
local x
x=$(cmd) # SC2155
一句话总结: ShellCheck 不是摆设:把
-e SC2086当红线,未加引号的变量一律不让过。它发现的每一条都对应一次线上事故的可能性。
5. 日志与输出规范
一句话总结: 日志分等级、带时间戳、stderr 归错误、stdout 归数据,结构化的输出才能被采集与检索。
# 统一日志函数(带等级与时间)
LOG_LEVEL=${LOG_LEVEL:-INFO}
log() {
local level="$1"; shift
printf '[%s] %s %s\n' "$level" "$(date '+%F %T')" "$*"
}
log_info() { log INFO "$*"; }
log_warn() { log WARN "$*" >&2; }
log_error() { log ERROR "$*" >&2; }
5.1 输出规范对照
| 通道 | 用途 | 例子 |
|---|---|---|
| stdout | 机器可消费的数据 | 结果、JSON、列表 |
| stderr | 日志与错误 | INFO/WARN/ERROR |
| 日志文件 | 长期留存 | 重定向 >> app.log 2>&1 |
# 数据走 stdout,日志走 stderr
log_info "查询开始"
result=$(db_query)
printf '%s\n' "$result" # stdout 只有结果
log_info "查询结束"
# 重定向到文件(含 stderr)
exec >> /var/log/app.log 2>&1
一句话总结: 凡是"要被别的程序消费"的输出只进 stdout,凡是"给人看的"日志进 stderr。混淆二者是脚本集成时最常见的 bug。
6. 测试与 CI 集成
一句话总结: Shell 脚本也能单测:函数级用例用断言验证输出与退出码,bats 框架 + GitHub Actions 能实现自动化回归。
# 用 bats 写单元测试
# test/parse_test.bats
#!/usr/bin/env bats
load '../lib/parse.sh'
@test "parse_version 返回主次版本" {
result="$(parse_version 'v1.24')"
[ "$result" = "1 24" ]
}
@test "parse_version 非法输入返回非零" {
run parse_version "abc"
[ "$status" -ne 0 ]
}
6.1 CI 最小配置
# .github/workflows/shell.yml
name: shell-check
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: shellcheck -S error lib/*.sh *.sh
- run: |
for f in tests/*.bats; do bats "$f"; done
| 阶段 | 工具 | 门禁 |
|---|---|---|
| lint | shellcheck | error 级 0 告警 |
| 单测 | bats | 全部通过 |
| 语法 | bash -n | 无语法错误 |
| 集成 | 脚本跑真实流程 | 退出码 0 |
# 本地快速自查
bash -n deploy.sh && echo "语法 OK"
shellcheck -S error deploy.sh
一句话总结: 别等 CI 报错才想起规范。本地先跑
bash -n语法检查 +shellcheck -S error,两个命令能挡掉大部分低级问题。
7. 版本控制与发布
一句话总结: 脚本纳入版本管理、跟随版本号、变更记录留痕,是脚本工程化的收尾一环。
# 脚本自带版本
readonly VERSION="1.4.0"
[[ "$1" == "--version" ]] && { echo "deploy.sh $VERSION"; exit 0; }
[[ "$1" == "--help" ]] && { usage; exit 0; }
# 发布流程要点
# 1. tag 对应版本:git tag v1.4.0
# 2. CHANGELOG 记录破坏性变更
# 3. 配置文件与代码分离,不放仓库里
7.1 发布检查清单
| 检查项 | 命令/做法 |
|---|---|
| 语法 | bash -n |
| lint | shellcheck -S error |
| 单测 | bats tests/ |
| 版本 | --version 有输出 |
| 权限 | chmod +x 可执行 |
| 依赖 | command -v 前置检查 |
一句话总结: 脚本进入"长期维护"阶段后,版本号、CHANGELOG、CI 门禁一个都不能少——它已经是产品的一部分,不再是临时胶水。
8. 总结
| 环节 | 要点 |
|---|---|
| 结构 | shebang + 严格模式 + 配置区 + 函数区 + main |
| 严格模式 | set -euo pipefail,容错显式 || true |
| 函数库 | BASH_SOURCE[0] 定位,前缀命名防撞 |
| lint | shellcheck 强制门禁,SC2086 红线 |
| 日志 | stdout 数据、stderr 日志、带等级时间戳 |
| 测试 | bats 单测 + bash -n + CI 集成 |
| 发布 | 版本号、CHANGELOG、权限与依赖检查 |
工程化的本质是把"靠记忆的脚本"变成"可评审、可测试、可交接的资产"。严格模式 + shellcheck + bats 这套组合投入小、回报大。调试技巧与安全防护,则是让这套工程跑在"坑"上也不翻车的保障。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。