第 12 章

脚本工程化

第12章:Shell 脚本工程化

一行能用的脚本和一个生产就绪的脚本之间,横亘着错误处理、信号捕获、参数解析、单元测试和代码质量工具的完整工程体系。本章系统讲解如何用 set -euo pipefailtrapshellcheckbats 等工具将 Shell 脚本从"能跑"升级为"可信赖的生产工具"。

12.1 Shebang 与脚本头部规范

shebang#!)是脚本第一行,告诉内核用哪个解释器执行此文件。选择正确的 shebang 直接影响脚本的可移植性和调试能力。

#!/usr/bin/env bash   # 推荐:通过 PATH 查找 bash(可移植)
#!/bin/bash           # 固定路径:大多数 Linux 可用,macOS 默认 bash 3.x(过旧)
#!/bin/sh             # POSIX sh:最大可移植性,但功能受限

# 为何推荐 #!/usr/bin/env bash?
# - /usr/bin/env 在几乎所有 Unix 系统上都存在
# - 自动使用 PATH 中第一个 bash(支持用户自定义安装路径)
# - nix/Homebrew 安装的新版 bash 可以被正确找到

# === 调试选项(shebang 行或 set 命令)===
#!/usr/bin/env bash -x   # -x:执行前打印每条命令(xtrace)
#!/usr/bin/env bash -v   # -v:读取时打印每一行(verbose)
#!/usr/bin/env bash -xv  # 两者结合

# 在脚本内部动态开关调试
set -x    # 开启 xtrace
some_command
set +x    # 关闭 xtrace

# 只调试某个函数内部
my_function() {
    set -x
    # ... 需要调试的代码 ...
    set +x
}

# 使用 PS4 定制 xtrace 前缀(显示文件名+行号)
export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'

12.2 set -euo pipefail 严格模式

set -euo pipefail 写在每个生产脚本的顶部,是 Shell 脚本工程化的第一步。这四个选项共同构成"严格模式",将静默失败变成显式错误。

#!/usr/bin/env bash
set -euo pipefail

# === 逐项解析 ===

# -e (errexit):任何命令非零退出时立即终止脚本
# 没有 -e:
false   # 失败但继续
echo "Still running"   # 这行会执行!

# 有 -e:
# false   # 脚本在此行退出,不打印任何内容

# -e 的陷阱:以下情况 -e 不触发
# 1. if/while/until 条件中的命令
if false; then echo "never"; fi   # OK,-e 不触发

# 2. 用 || 连接的命令(右侧运行时左侧失败被容许)
false || echo "fallback"   # OK

# 3. 用 ! 否定的命令
! false   # OK(反转退出码为 0)

# 局部禁用 -e(处理已知可能失败的命令)
set +e
count=$(grep -c "pattern" file.txt)   # grep 未找到时返回 1,不终止脚本
set -e

# 或使用 || true 更简洁
count=$(grep -c "pattern" file.txt) || true

# -u (nounset):引用未定义变量时报错并退出
# 没有 -u:
echo $UNDEFINED_VAR   # 输出空字符串,悄悄失败

# 有 -u:
# echo $UNDEFINED_VAR   # bash: UNDEFINED_VAR: unbound variable

# -u 的陷阱:使用默认值语法可安全访问可能未定义的变量
echo "${UNDEFINED_VAR:-default_value}"   # 安全,输出 default_value
echo "${OPTIONAL_VAR-}"                  # 未定义时输出空字符串(不报错)

# -o pipefail:管道中任何命令失败则整个管道返回非零退出码
# 没有 pipefail:
cat /nonexistent | wc -l   # 整体返回 0(wc 成功),掩盖了 cat 的错误
echo $?   # 0

# 有 pipefail:
# cat /nonexistent | wc -l   # 整体返回非零,-e 触发脚本退出

# -x (xtrace):打印每条执行的命令(调试用,生产环境慎开)
# set -x   # 开启后每行前面会打印 + command

# 推荐的严格模式组合写法
set -euo pipefail
IFS=$'\n\t'   # 修改字段分隔符,防止空格分词引起的 bug
选项 作用 陷阱
-e 命令非零退出时终止 if/
-u 未定义变量报错 用 ${VAR:-default} 绕过
-o pipefail 管道失败时传播错误 与 grep/head 等"正常失败"命令冲突
-x 打印每条执行命令 可能泄露密码到日志

12.3 trap 完整指南:信号捕获与清理

trap 是 Shell 的信号处理机制。生产脚本必须注册 EXIT 陷阱来清理临时文件和资源,无论脚本因成功、错误还是用户中断(Ctrl+C)而退出,清理函数都会被调用。

#!/usr/bin/env bash
set -euo pipefail

# === 基本 trap 语法 ===
# trap 'COMMAND' SIGNAL [SIGNAL...]
# trap FUNCTION_NAME SIGNAL [SIGNAL...]

# === 最重要:EXIT 陷阱(无论如何退出都执行)===
TMPDIR_WORK=""

cleanup() {
    local exit_code=$?   # 捕获原始退出码
    echo "[cleanup] Cleaning up... (exit code: $exit_code)" >&2
    # 安全删除临时目录(检查是否为空再删)
    if [[ -n "${TMPDIR_WORK}" && -d "${TMPDIR_WORK}" ]]; then
        rm -rf "${TMPDIR_WORK}"
        echo "[cleanup] Removed temp dir: ${TMPDIR_WORK}" >&2
    fi
    # 释放锁文件
    [[ -f /var/run/myscript.pid ]] && rm -f /var/run/myscript.pid
    exit "$exit_code"   # 保持原始退出码
}

trap cleanup EXIT   # 注册 EXIT 陷阱

# 创建临时目录(trap 注册后创建,确保能被清理)
TMPDIR_WORK=$(mktemp -d)
echo "Working in: $TMPDIR_WORK"

# 脚本主体...
cp /etc/hosts "$TMPDIR_WORK/"
# 即使这里发生错误,cleanup 也会被调用

# === INT / TERM 陷阱 ===
interrupted=false

handle_sigint() {
    echo "" >&2
    echo "[signal] Caught SIGINT (Ctrl+C), shutting down gracefully..." >&2
    interrupted=true
    # 不需要显式调用 cleanup,EXIT 陷阱会自动执行
    exit 130   # 标准:128 + signal_number(2)
}

handle_sigterm() {
    echo "[signal] Caught SIGTERM, shutting down..." >&2
    exit 143   # 标准:128 + 15
}

trap handle_sigint  INT
trap handle_sigterm TERM

# === ERR 陷阱:在命令失败时调用(配合 -e 使用)===
handle_error() {
    local exit_code=$?
    local line_no=${BASH_LINENO[0]}
    echo "[ERROR] Command failed at line ${line_no} with exit code ${exit_code}" >&2
    echo "[ERROR] Command: ${BASH_COMMAND}" >&2
}
trap handle_error ERR

# === 忽略信号(子脚本不被中断)===
trap '' SIGINT   # 忽略 Ctrl+C
sleep 60        # 这期间 Ctrl+C 不起作用
trap - SIGINT   # 恢复默认处理

# === trap 在子 shell 中的行为 ===
trap 'echo parent EXIT' EXIT
(
    trap 'echo child EXIT' EXIT   # 子 shell 有自己的 trap
    echo "in subshell"
)
# 输出:child EXIT(子 shell 退出时),然后父 shell 正常运行

# === DEBUG 陷阱(高级调试)===
# trap 'echo "Line $LINENO: $BASH_COMMAND"' DEBUG   # 每条命令前执行

12.4 错误处理模式

除了 set -e 自动退出,生产脚本还需要提供清晰的错误信息和统一的错误码规范。以下是经过实战检验的错误处理工具函数:

#!/usr/bin/env bash
set -euo pipefail

# === 工具函数:die / err / log ===
readonly SCRIPT_NAME=$(basename "$0")

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]  $*" >&2
}

err() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2
}

die() {
    err "$@"
    exit 1
}

# 使用示例
log "Script started"
err "Something went wrong"
die "Fatal: cannot continue"   # 打印错误并退出

# === || 模式:命令失败时显示错误 ===
cp source.txt dest.txt || die "Failed to copy source.txt to dest.txt"

git pull origin main || {
    err "Git pull failed"
    err "Check network connectivity and repository access"
    exit 1
}

# === 检查依赖(脚本开头)===
check_deps() {
    local deps=("$@")
    for dep in "${deps[@]}"; do
        if ! command -v "$dep" &>/dev/null; then
            die "Required command not found: '$dep'. Please install it first."
        fi
    done
}
check_deps curl jq git docker

# === 检查 root 权限 ===
check_root() {
    if [[ $EUID -ne 0 ]]; then
        die "This script must be run as root (use sudo)"
    fi
}

# === 版本检查 ===
check_bash_version() {
    local required_major=4
    local required_minor=2
    if (( BASH_VERSINFO[0] = ${required_major}.${required_minor} required (found: ${BASH_VERSION})"
    fi
}
check_bash_version

# === 错误码规范 ===
# 0   : 成功
# 1   : 通用错误
# 2   : 参数错误(命令行解析失败)
# 126 : 权限不足(命令不可执行)
# 127 : 命令不存在
# 128+N : 被信号 N 终止(130=SIGINT, 143=SIGTERM)

12.5 参数解析:getopts vs getopt

getopts 是 bash 内置命令,支持 POSIX 短选项(-h-v),无需安装额外工具,可移植性最佳。getopt 是外部程序(GNU 版本),支持长选项(--help--verbose),但可移植性略差。

getopts:内置短选项解析

#!/usr/bin/env bash
set -euo pipefail

# getopts 完整示例
VERBOSE=false
OUTPUT_FILE=""
DRY_RUN=false

usage() {
    cat 

Process a file with various options.

Options:
  -h          Show this help message and exit
  -v          Enable verbose output
  -n          Dry run (do not modify files)
  -o FILE     Output file path (default: stdout)

Examples:
  $(basename "$0") -v input.txt
  $(basename "$0") -o output.txt -v input.txt
  $(basename "$0") -n -v input.txt
EOF
}

# getopts 语法:选项字符串中,: 表示该选项需要参数
while getopts ":hvno:" opt; do
    case $opt in
        h)
            usage
            exit 0
            ;;
        v)
            VERBOSE=true
            ;;
        n)
            DRY_RUN=true
            ;;
        o)
            OUTPUT_FILE="$OPTARG"   # 选项的参数值通过 $OPTARG 获取
            ;;
        :)
            echo "Error: Option -${OPTARG} requires an argument" >&2
            usage >&2
            exit 2
            ;;
        \?)
            echo "Error: Unknown option -${OPTARG}" >&2
            usage >&2
            exit 2
            ;;
    esac
done

# 移除已解析的选项,$@ 变为位置参数
shift $((OPTIND - 1))

# 检查必须的位置参数
if [[ $# -lt 1 ]]; then
    echo "Error:  is required" >&2
    usage >&2
    exit 2
fi

INPUT_FILE="$1"

$VERBOSE && echo "[verbose] Input: $INPUT_FILE, Output: ${OUTPUT_FILE:-stdout}, DryRun: $DRY_RUN"

getopt:GNU 长选项解析

#!/usr/bin/env bash
set -euo pipefail

# getopt 支持长选项(--help、--verbose、--output FILE)
# 需要 GNU getopt(Linux 默认,macOS 需安装 gnu-getopt)

VERBOSE=false
OUTPUT_FILE=""
DRY_RUN=false

usage() {
    cat 

Options:
  -h, --help           Show this help
  -v, --verbose        Verbose output
  -n, --dry-run        Dry run
  -o, --output FILE    Output file
EOF
}

# 解析长选项和短选项
PARSED=$(getopt \
    --options hvno: \
    --longoptions help,verbose,dry-run,output: \
    --name "$(basename "$0")" \
    -- "$@") || { usage >&2; exit 2; }

eval set -- "$PARSED"

while true; do
    case "$1" in
        -h|--help)    usage; exit 0 ;;
        -v|--verbose) VERBOSE=true; shift ;;
        -n|--dry-run) DRY_RUN=true; shift ;;
        -o|--output)  OUTPUT_FILE="$2"; shift 2 ;;
        --)           shift; break ;;
        *)            echo "Internal error" >&2; exit 2 ;;
    esac
done

[[ $# -lt 1 ]] && { echo "Error: input_file required" >&2; exit 2; }
INPUT_FILE="$1"

$VERBOSE && echo "verbose=$VERBOSE dry_run=$DRY_RUN input=$INPUT_FILE output=${OUTPUT_FILE:-stdout}"

12.6 shellcheck:Shell 脚本静态分析

shellcheck 是 Shell 脚本的"编译器警告"。它能在运行前发现常见 bug、不安全用法和可移植性问题,是任何 Shell 工程化项目的必备工具。

# === 安装 ===
sudo apt install -y shellcheck          # Ubuntu/Debian
sudo dnf install -y ShellCheck          # Fedora/RHEL
brew install shellcheck                 # macOS

# === 基本使用 ===
shellcheck script.sh                    # 检查单个文件
shellcheck *.sh                         # 检查所有 .sh 文件
shellcheck -s bash script.sh            # 明确指定 shell 类型

# === 常见警告及含义 ===

# SC2086: Double quote to prevent globbing and word splitting
# 问题:
echo $VAR            # 如果 VAR 包含空格或通配符,会被展开
ls $DIRECTORY        # 可能导致意外的文件名拆分
# 修复:
echo "$VAR"
ls "$DIRECTORY"

# SC2046: Quote this to prevent word splitting
# 问题:
command $(generate_args)    # 命令替换结果按空格分割
# 修复:
command "$(generate_args)"  # 或用数组

# SC2006: Use $(..) instead of legacy `..`
# 问题:
result=`command`            # 反引号嵌套困难,已废弃
# 修复:
result=$(command)

# SC2034: VAR appears unused
# 可以用 _ 前缀表明这是故意未使用的变量
_unused_var="intentional"

# SC2155: Declare and assign separately to avoid masking return values
# 问题:
local result=$(possibly_failing_command)  # local 使返回码永远为 0
# 修复:
local result
result=$(possibly_failing_command)

# SC2164: Use 'cd ... || exit' in case cd fails
# 问题:
cd /some/dir
rm -rf *    # 如果 cd 失败,会删除当前目录下的文件!
# 修复:
cd /some/dir || exit 1
rm -rf *

# === .shellcheckrc 配置文件(项目根目录)===
cat > .shellcheckrc 
  
## 12.7 shfmt:Shell 代码自动格式化


  
**shfmt** 是 Shell 脚本的 `gofmt`——自动格式化代码风格,消除团队内的格式争议。它支持 bash/sh/mksh,能与编辑器和 CI 无缝集成。


  
```bash
# === 安装 ===
# 从 GitHub Releases 下载二进制
VERSION="3.7.0"
curl -LO "https://github.com/mvdan/sh/releases/download/v${VERSION}/shfmt_v${VERSION}_linux_amd64"
install -m755 "shfmt_v${VERSION}_linux_amd64" /usr/local/bin/shfmt

# 或通过包管理器
brew install shfmt           # macOS

# === 基本使用 ===
shfmt script.sh              # 打印格式化后的结果到 stdout
shfmt -w script.sh           # 原地修改文件(write)
shfmt -d script.sh           # 显示差异(diff 模式,不修改)
shfmt -l *.sh                # 列出需要格式化的文件

# === 常用格式化选项 ===
shfmt -i 2 script.sh         # 使用 2 空格缩进(默认 tab)
shfmt -i 4 script.sh         # 使用 4 空格缩进
shfmt -ci script.sh          # case 语句中的 ) 也缩进
shfmt -bn script.sh          # 二元运算符放在行尾(不换行前)
shfmt -sr script.sh          # 重定向操作符后加空格

# === 推荐的 CI 检查命令(验证格式,不修改)===
shfmt -d -i 2 -ci scripts/

# === 编辑器集成 ===
# VS Code: 安装 "shell-format" 扩展
# Vim: 安装 vim-shfmt 插件
# Neovim: 通过 null-ls / conform.nvim 集成
# 示例 Vim 配置:
# autocmd FileType sh setlocal formatprg=shfmt\ -i\ 2

# === 格式化前后对比示例 ===
# 格式化前(混乱的缩进和间距):
# if [ "$x" = "1" ];then
# echo "yes"
# fi

# 格式化后(shfmt -i 2):
# if [ "$x" = "1" ]; then
#   echo "yes"
# fi

12.8 bats:Shell 脚本单元测试框架

bats(Bash Automated Testing System)是专为 Shell 脚本设计的测试框架,语法简洁,输出清晰,支持 TAP 协议,可以无缝集成到 CI 流水线。

# === 安装 bats-core ===
git clone https://github.com/bats-core/bats-core.git
cd bats-core && sudo ./install.sh /usr/local

# 或通过包管理器
brew install bats-core        # macOS
sudo apt install bats         # Ubuntu(可能版本较旧)

# 安装辅助库(推荐)
git clone https://github.com/bats-core/bats-support.git test/test_helper/bats-support
git clone https://github.com/bats-core/bats-assert.git  test/test_helper/bats-assert

# === 被测试的脚本:lib/utils.sh ===
# is_number() { [[ "$1" =~ ^[0-9]+$ ]]; }
# greet() { echo "Hello, $1!"; }

# === bats 测试文件:test/utils.bats ===
cat > test/utils.bats 
  
## 12.9 logger:写入系统日志


  
`logger` 命令将消息写入系统日志(syslog/journald),使脚本日志与操作系统日志基础设施集成,方便集中管理和监控。


  
```bash
# === logger 基本用法 ===
logger "Hello from shell script"   # 写入 syslog
journalctl -t logger --since "1 min ago"  # 查看(systemd 系统)

# === 常用选项 ===
logger -t "myscript" "Application started"   # -t:设置标签(tag)
logger -p local0.info "Info message"         # -p:facility.level(优先级)
logger -p local0.warning "Warning message"
logger -p local0.err "Error occurred"
logger -s "Also print to stderr"             # -s:同时输出到 stderr
logger -i "Include PID in message"           # -i:包含进程 ID

# === 日志级别(level)===
# debug &2; }
log_error()   { logger -t "$SCRIPT_TAG" -p local0.err     "$*"; echo "[ERROR] $*" >&2; }

log_info "Deployment started by user: $USER"
log_warning "Config file missing, using defaults"
log_error "Database connection failed"

# === 查看日志 ===
journalctl -t "deploy-v2"           # 按 tag 过滤(systemd)
journalctl -t "deploy-v2" -f        # 实时跟踪
grep "deploy-v2" /var/log/syslog    # 传统 syslog

# === rsyslog 自定义 facility 配置 ===
# 在 /etc/rsyslog.d/myapp.conf 中添加:
# local0.*    /var/log/myapp.log
# 重启 rsyslog:sudo systemctl restart rsyslog
# 之后 local0 的所有消息都会写入 /var/log/myapp.log

12.10 脚本最佳实践

以下是经过生产环境检验的 Shell 脚本工程化最佳实践,每一条都有明确的"为什么":

#!/usr/bin/env bash
set -euo pipefail

# === 1. 幂等性设计:脚本运行多次结果相同 ===
# 坏:
mkdir /opt/myapp             # 第二次运行报错:directory exists
# 好:
mkdir -p /opt/myapp          # -p 允许目录已存在

# 坏:
useradd myapp                # 第二次运行报错:user exists
# 好:
id myapp &>/dev/null || useradd --system --no-create-home myapp

# === 2. 原子操作:mktemp + mv 防止半写状态 ===
# 坏:直接写入目标文件(崩溃会留下损坏文件)
generate_config > /etc/myapp/config.yaml

# 好:写到临时文件,完成后原子替换
tmp=$(mktemp /etc/myapp/config.yaml.XXXXXX)
generate_config > "$tmp"
chmod 640 "$tmp"
mv "$tmp" /etc/myapp/config.yaml   # mv 在同一文件系统上是原子操作

# === 3. 加锁防止并发运行 ===
LOCKFILE="/var/run/myscript.lock"

# 方法 1:flock(推荐,自动清理)
(
    flock -n 9 || { echo "Another instance is running"; exit 1; }
    # --- 受保护的代码块 ---
    echo "Running with lock"
    sleep 5
) 9>"$LOCKFILE"

# 方法 2:PID 文件
PIDFILE="/var/run/myscript.pid"
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
    echo "Already running (PID $(cat "$PIDFILE"))" >&2
    exit 1
fi
echo $$ > "$PIDFILE"
trap 'rm -f "$PIDFILE"' EXIT

# === 4. 配置文件加载 ===
# 默认配置
CONFIG_FILE="${CONFIG_FILE:-/etc/myapp/myapp.conf}"
[[ -f "$CONFIG_FILE" ]] && source "$CONFIG_FILE"

# 允许用户覆盖
[[ -f ~/.myapp.conf ]] && source ~/.myapp.conf

# === 5. 目录变量:基于脚本位置确定路径 ===
# 不要依赖当前工作目录(cd 后路径会变)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

source "$SCRIPT_DIR/lib/utils.sh"
CONFIG="$PROJECT_ROOT/config/settings.yaml"

# === 6. 数组处理路径中的空格 ===
# 坏:空格路径被拆分
files=$(find /path -name "*.txt")
for f in $files; do ...   # 空格导致路径被截断!

# 好:用 mapfile(readarray)或 while read
mapfile -t files 
  
## 12.11 完整生产级脚本模板


  
以下是一个融合了本章所有最佳实践的生产级脚本模板,可直接复制使用。每个部分都有注释说明其作用和原因。


  
```bash
#!/usr/bin/env bash
# ==============================================================================
# script_name.sh — 一句话描述这个脚本的功能
# Author: Your Name 
# Version: 1.0.0
# Created: 2026-04-25
# Usage: ./script_name.sh [OPTIONS] 
# ==============================================================================

# === 严格模式:失败快、失败早、失败清晰 ===
set -euo pipefail
IFS=$'\n\t'

# === 常量:只读,全大写 ===
readonly SCRIPT_NAME=$(basename "${BASH_SOURCE[0]}")
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_VERSION="1.0.0"
readonly TIMESTAMP=$(date +%Y%m%d_%H%M%S)

# === 日志文件 ===
readonly LOG_DIR="${LOG_DIR:-/var/log}"
readonly LOG_FILE="${LOG_DIR}/${SCRIPT_NAME%.sh}_${TIMESTAMP}.log"

# === 全局状态变量 ===
TMPDIR_WORK=""
LOCKFILE="/var/run/${SCRIPT_NAME%.sh}.lock"

# === 颜色输出(自动检测 tty)===
if [[ -t 2 ]]; then
    RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; NC='\033[0m'
else
    RED=''; YELLOW=''; GREEN=''; NC=''
fi

# === 日志函数 ===
log()  { echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] ${GREEN}[INFO]${NC}  $*" | tee -a "$LOG_FILE" >&2; }
warn() { echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] ${YELLOW}[WARN]${NC}  $*" | tee -a "$LOG_FILE" >&2; }
err()  { echo -e "[$(date '+%Y-%m-%d %H:%M:%S')] ${RED}[ERROR]${NC} $*" | tee -a "$LOG_FILE" >&2; }
die()  { err "$@"; exit 1; }

# === 清理函数(trap EXIT 保证必定执行)===
cleanup() {
    local exit_code=$?
    log "Cleanup started (exit code: ${exit_code})"
    # 删除临时工作目录
    if [[ -n "${TMPDIR_WORK:-}" && -d "${TMPDIR_WORK}" ]]; then
        rm -rf "${TMPDIR_WORK}"
        log "Removed temp dir: ${TMPDIR_WORK}"
    fi
    # 释放锁
    [[ -f "${LOCKFILE}" ]] && rm -f "${LOCKFILE}"
    if [[ $exit_code -eq 0 ]]; then
        log "Script completed successfully."
    else
        err "Script exited with errors. Check log: ${LOG_FILE}"
    fi
    exit "$exit_code"
}
trap cleanup EXIT

# 捕获 SIGINT / SIGTERM
trap 'err "Interrupted by user (SIGINT)"; exit 130'  INT
trap 'err "Terminated by signal (SIGTERM)"; exit 143' TERM

# === 依赖检查 ===
check_deps() {
    local missing=()
    for dep in "$@"; do
        command -v "$dep" &>/dev/null || missing+=("$dep")
    done
    if [[ ${#missing[@]} -gt 0 ]]; then
        die "Missing required commands: ${missing[*]}"
    fi
}

# === 参数解析 ===
VERBOSE=false
DRY_RUN=false
OUTPUT_FILE=""

usage() {
    cat 

Description here.

Options:
  -h, --help        Show this help and exit
  -v, --verbose     Enable verbose output
  -n, --dry-run     Dry run — show what would happen but do nothing
  -o, --output FILE Write output to FILE (default: stdout)
  --version         Print version and exit

Examples:
  ${SCRIPT_NAME} -v input.txt
  ${SCRIPT_NAME} -n --output result.txt input.txt
EOF
}

parse_args() {
    local parsed
    parsed=$(getopt \
        --options hvno: \
        --longoptions help,verbose,dry-run,output:,version \
        --name "$SCRIPT_NAME" \
        -- "$@") || { usage >&2; exit 2; }
    eval set -- "$parsed"
    while true; do
        case "$1" in
            -h|--help)    usage; exit 0 ;;
            --version)    echo "${SCRIPT_NAME} v${SCRIPT_VERSION}"; exit 0 ;;
            -v|--verbose) VERBOSE=true; shift ;;
            -n|--dry-run) DRY_RUN=true; shift ;;
            -o|--output)  OUTPUT_FILE="$2"; shift 2 ;;
            --)           shift; break ;;
            *)            die "Internal error parsing arguments" ;;
        esac
    done
    [[ $# -lt 1 ]] && { err "Missing required argument: "; usage >&2; exit 2; }
    INPUT_FILE="$1"
}

# === 主逻辑 ===
main() {
    # 检查依赖
    check_deps curl jq

    # 初始化日志目录
    mkdir -p "$(dirname "$LOG_FILE")"
    log "=== ${SCRIPT_NAME} v${SCRIPT_VERSION} started ==="
    log "Log file: ${LOG_FILE}"
    $VERBOSE && log "Verbose mode enabled"
    $DRY_RUN && warn "DRY RUN — no changes will be made"

    # 加锁防止并发运行
    (
        flock -n 9 || die "Another instance of ${SCRIPT_NAME} is already running"

        # 创建临时工作目录(cleanup 会自动删除)
        TMPDIR_WORK=$(mktemp -d "/tmp/${SCRIPT_NAME%.sh}.XXXXXX")
        log "Working directory: ${TMPDIR_WORK}"

        # --- 业务逻辑开始 ---
        log "Processing: ${INPUT_FILE}"

        if $DRY_RUN; then
            log "[dry-run] Would process ${INPUT_FILE}"
        else
            cp "${INPUT_FILE}" "${TMPDIR_WORK}/"
            log "File copied to work dir"
            # ... 更多处理步骤 ...
        fi

        # 输出结果
        if [[ -n "${OUTPUT_FILE}" ]]; then
            # 原子写入
            local tmp_out
            tmp_out=$(mktemp "${OUTPUT_FILE}.XXXXXX")
            echo "result data" > "$tmp_out"
            mv "$tmp_out" "${OUTPUT_FILE}"
            log "Output written to: ${OUTPUT_FILE}"
        else
            echo "result data"
        fi
        # --- 业务逻辑结束 ---

    ) 9>"${LOCKFILE}"
}

# === 入口 ===
parse_args "$@"
main

章节总结: 本章构建了 Shell 脚本工程化的完整知识体系:set -euo pipefail 消除静默失败;trap EXIT 保证资源清理;getopts/getopt 规范化参数接口;shellcheck 在提交前发现 bug;shfmt 统一代码风格;bats 为脚本提供单元测试保障;logger 将脚本日志纳入系统日志基础设施。上述工具和模板组合起来,足以将任何脚本提升为生产就绪的可信赖工具。

  上一章
  ← 第11章:管道与文件描述符


  下一章
  第13章:systemd →
本章评分
4.6  / 5  (24 评分)

💬 留言讨论