脚本工程化与规范

从脚本走向工程:讲解 set -euo pipefail 严格模式、函数库与代码组织、ShellCheck 静态检查、日志规范、单元测试与 CI 集成的完整实践。

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无谓 catgrep x file 不用 `cat file
SC2034变量未使用删除或注明
SC2164cd 未检查`cd dir
SC1090source 路径不可解析加 # 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
阶段工具门禁
lintshellcheckerror 级 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
lintshellcheck -S error
单测bats tests/
版本--version 有输出
权限chmod +x 可执行
依赖command -v 前置检查

一句话总结: 脚本进入"长期维护"阶段后,版本号、CHANGELOG、CI 门禁一个都不能少——它已经是产品的一部分,不再是临时胶水。

8. 总结

环节要点
结构shebang + 严格模式 + 配置区 + 函数区 + main
严格模式set -euo pipefail,容错显式 || true
函数库BASH_SOURCE[0] 定位,前缀命名防撞
lintshellcheck 强制门禁,SC2086 红线
日志stdout 数据、stderr 日志、带等级时间戳
测试bats 单测 + bash -n + CI 集成
发布版本号、CHANGELOG、权限与依赖检查

工程化的本质是把"靠记忆的脚本"变成"可评审、可测试、可交接的资产"。严格模式 + shellcheck + bats 这套组合投入小、回报大。调试技巧与安全防护,则是让这套工程跑在"坑"上也不翻车的保障。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 脚本性能优化实战
  2. 网络请求与诊断实战
  3. 定时任务调度实战