名称: shell-scripting
描述: 编写健壮、可移植的 Shell 脚本。适用于解析参数、正确处理错误、编写 POSIX 兼容脚本、管理临时文件、并行运行命令、管理后台进程或为脚本添加 --help 帮助信息。
元数据: {"clawdbot":{"emoji":"🐚","requires":{"bins":["bash"]},"os":["linux","darwin","win32"]}}
编写可靠、可维护的 Bash 脚本。涵盖参数解析、错误处理、可移植性、临时文件、并行执行、进程管理和脚本自文档化。
#!/usr/bin/env bash
set -euo pipefail
# 描述:本脚本的功能(一行概括)
# 用法:script.sh [选项] <必需参数>
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "$0")"
# 默认值
VERBOSE=false
OUTPUT_DIR="./output"
usage() {
cat <<EOF
用法:$SCRIPT_NAME [选项] <输入文件>
描述:
处理输入文件并生成输出。
选项:
-o, --output DIR 输出目录(默认:$OUTPUT_DIR)
-v, --verbose 启用详细输出
-h, --help 显示此帮助信息
示例:
$SCRIPT_NAME data.csv
$SCRIPT_NAME -v -o /tmp/results data.csv
EOF
}
log() { echo "[$(date '+%H:%M:%S')] $*" >&2; }
debug() { $VERBOSE && log "DEBUG: $*" || true; }
die() { log "ERROR: $*"; exit 1; }
# 解析参数
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) OUTPUT_DIR="$2"; shift 2 ;;
-v|--verbose) VERBOSE=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) die "未知选项:$1" ;;
*) break ;;
esac
done
INPUT_FILE="${1:?$(usage >&2; echo "错误:需要输入文件")}"
[[ -f "$INPUT_FILE" ]] || die "文件未找到:$INPUT_FILE"
# 主逻辑
main() {
debug "输入:$INPUT_FILE"
debug "输出:$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
log "正在处理 $INPUT_FILE..."
# ... 执行实际工作 ...
log "完成。输出位于 $OUTPUT_DIR"
}
main "$@"
set -e # 任何命令失败时立即退出
set -u # 使用未定义变量时报错
set -o pipefail # 管道中任意命令失败则整个管道失败
set -x # 调试:执行前打印每条命令(输出较多)
# 组合使用(建议每个脚本都加上)
set -euo pipefail
# 临时禁用,以允许某些命令失败
set +e
some_command_that_might_fail
exit_code=$?
set -e
# 退出时清理(任何退出:成功、失败或信号)
TMPDIR=""
cleanup() {
[[ -n "$TMPDIR" ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT
TMPDIR=$(mktemp -d)
# 可自由使用 $TMPDIR —— 它会自动清理
# 捕获特定信号
trap 'echo "已中断"; exit 130' INT # Ctrl+C
trap 'echo "已终止"; exit 143' TERM # kill
# 使用前检查命令是否存在
command -v jq >/dev/null 2>&1 || die "需要 jq 但未安装"
# 提供默认值
NAME="${NAME:-default_value}"
# 必需变量(未设置则失败)
: "${API_KEY:?错误:需要 API_KEY 环境变量}"
# 重试命令
retry() {
local max_attempts=$1
shift
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
"$@" && return 0
log "第 $attempt/$max_attempts 次尝试失败。正在重试..."
((attempt++))
sleep $((attempt * 2))
done
die "命令在 $max_attempts 次尝试后失败:$*"
}
retry 3 curl -sf https://api.example.com/health
# 手动解析(无依赖)
FORCE=false
DRY_RUN=false
while [[ $# -gt 0 ]]; do
case "$1" in
-f|--force) FORCE=true; shift ;;
-n|--dry-run) DRY_RUN=true; shift ;;
-o|--output)
[[ -n "${2:-}" ]] || die "--output 需要一个值"
OUTPUT="$2"; shift 2 ;;
--output=*)
OUTPUT="${1#*=}"; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;; # 选项结束
-*) die "未知选项:$1" ;;
*) break ;; # 位置参数开始
esac
done
# 剩余参数为位置参数
FILES=("$@")
[[ ${#FILES[@]} -gt 0 ]] || die "至少需要一个文件"
while getopts ":o:vhf" opt; do
case "$opt" in
o) OUTPUT="$OPTARG" ;;
v) VERBOSE=true ;;
f) FORCE=true ;;
h) usage; exit 0 ;;
:) die "选项 -$OPTARG 需要一个参数" ;;
?) die "未知选项:-$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
# 创建临时文件(自动保证唯一性)
TMPFILE=$(mktemp)
echo "data" > "$TMPFILE"
# 创建临时目录
TMPDIR=$(mktemp -d)
# 使用自定义前缀/后缀创建临时文件
TMPFILE=$(mktemp /tmp/myapp.XXXXXX)
TMPFILE=$(mktemp --suffix=.json) # 仅限 GNU
# 始终使用 trap 清理
trap 'rm -f "$TMPFILE"' EXIT
# 可移植模式(适用于 macOS 和 Linux)
TMPDIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'myapp')
trap 'rm -rf "$TMPDIR"' EXIT
# 并行运行 4 个命令
cat urls.txt | xargs -P 4 -I {} curl -sO {}
# 并行处理文件(每次 4 个)
find . -name "*.csv" | xargs -P 4 -I {} ./process.sh {}
# 带进度指示器的并行处理
find . -name "*.jpg" | xargs -P 8 -I {} sh -c 'convert {} -resize 800x600 resized/{} && echo "完成:{}"'
# 在后台运行任务,等待所有完成
pids=()
for file in data/*.csv; do
process_file "$file" &
pids+=($!)
done
# 等待所有任务并检查结果
failed=0
for pid in "${pids[@]}"; do
wait "$pid" || ((failed++))
done
[[ $failed -eq 0 ]] || die "$failed 个作业失败"
# 使用 8 个并行作业处理文件
parallel -j 8 ./process.sh {} ::: data/*.csv
# 带进度条
parallel --bar -j 4 convert {} -resize 800x600 resized/{/} ::: *.jpg
# 管道输入行
cat urls.txt | parallel -j 10 curl -sO {}
# 在后台启动
long_running_command &
BG_PID=$!
# 检查是否仍在运行
kill -0 $BG_PID 2>/dev/null && echo "运行中" || echo "已停止"
# 等待它完成
wait $BG_PID
echo "退出码:$?"
# 脚本退出时终止
trap 'kill $BG_PID 2>/dev/null' EXIT
# 运行命令,如果退出则重启
run_with_restart() {
local cmd=("$@")
while true; do
"${cmd[@]}" &
local pid=$!
log "已启动 PID $pid"
wait $pid
local exit_code=$?
log "进程退出,代码 $exit_code。5 秒后重启..."
sleep 5
done
}
run_with_restart ./my-server --port 8080
# 30 秒后终止命令
timeout 30 long_running_command
# 使用自定义信号(SIGTERM 失败后发送 SIGKILL)
timeout --signal=TERM --kill-after=10 30 long_running_command
# 可移植方法(无 timeout 命令)
( sleep 30; kill $$ 2>/dev/null ) &
TIMER_PID=$!
long_running_command
kill $TIMER_PID 2>/dev/null
# sed:macOS 要求 -i ''(空备份扩展名)
# Linux:
sed -i 's/old/new/g' file.txt
# macOS:
sed -i '' 's/old/new/g' file.txt
# 可移植:
sed -i.bak 's/old/new/g' file.txt && rm file.txt.bak
# date:不同标志
# GNU(Linux):
date -d '2026-02-03' '+%s'
# BSD(macOS):
date -j -f '%Y-%m-%d' '2026-02-03' '+%s'
# readlink -f:macOS 上不存在
# 可移植替代方案:
real_path() { cd "$(dirname "$1")" && echo "$(pwd)/$(basename "$1")"; }
# stat:不同语法
# GNU:stat -c '%s' file
# BSD:stat -f '%z' file
# grep -P:macOS 默认不可用
# 使用 grep -E 替代,或安装 GNU grep
# 使用 printf 替代 echo -e(echo 行为不一致)
printf "第 1 行\n第 2 行\n"
# 使用 $() 替代反引号
result=$(command) # 推荐
result=`command` # 不推荐(已弃用,嵌套有问题)
# 测试时使用 [[ ]](bash)或 [ ](POSIX sh)
[[ -f "$file" ]] # Bash(更安全,无单词分割)
[ -f "$file" ] # POSIX sh
# 数组检查(仅限 bash,非 POSIX)
if [[ ${#array[@]} -gt 0 ]]; then
echo "数组有元素"
fi
# 简单方法:加载 key=value 文件
# config.env:
# DB_HOST=localhost
# DB_PORT=5432
# 加载前验证(安全:检查命令)
if grep -qP '^[A-Z_]+=.*[;\`\$\(]' config.env; then
die "配置文件包含不安全字符"
fi
source config.env
# config.ini:
# [database]
# host = localhost
# port = 5432
# [app]
# debug = true
parse_ini() {
local file="$1" section=""
while IFS='= ' read -r key value; do
[[ -z "$key" || "$key" =~ ^[#\;] ]] && continue
if [[ "$key" =~ ^\[(.+)\]$ ]]; then
section="${BASH_REMATCH[1]}"
continue
fi
value="${value%%#*}" # 去除行内注释
value="${value%"${value##*[![:space:]]}"}" # 去除尾部空白
printf -v "${section}_${key}" '%s' "$value"
done < "$file"
}
parse_ini config.ini
echo "$database_host" # localhost
echo "$app_debug" # true
confirm() {
local prompt="${1:-确定吗?}"
read -rp "$prompt [y/N] " response
[[ "$response" =~ ^[Yy]$ ]]
}
confirm "删除 /tmp/data 中的所有文件?" || die "已中止"
rm -rf /tmp/data/*
# 简单计数器
total=$(wc -l < file_list.txt)
count=0
while IFS= read -r file; do
((count++))
printf "\r正在处理 %d/%d..." "$count" "$total" >&2
process "$file"
done < file_list.txt
echo "" >&2
LOCKFILE="/tmp/${SCRIPT_NAME}.lock"
acquire_lock() {
if ! mkdir "$LOCKFILE" 2>/dev/null; then
die "另一个实例正在运行(锁:$LOCKFILE)"
fi
trap 'rm -rf "$LOCKFILE"' EXIT
}
acquire_lock
# ... 安全继续,只有一个实例运行 ...
# 从文件参数或标准输入读取
input="${1:--}" # 默认为 "-"(标准输入)
if [[ "$input" == "-" ]]; then
cat
else
cat "$input"
fi | while IFS= read -r line; do
process "$line"
done
set -euo pipefail 开头。它能捕获 80% 的静默错误。trap cleanup EXIT 处理临时文件。切勿依赖脚本末尾的清理代码。"$var" 而非 $var。未引用的变量会在空格和通配符处出错。[[ ]] 而非 [ ]。它能更好地处理空字符串、空格和模式匹配。shellcheck 是 Shell 脚本的最佳检查工具。运行:shellcheck myscript.sh。如果可用请安装它。readonly 防止意外覆盖:readonly DB_HOST="localhost"。usage() 函数,并在 -h/--help 或缺少必需参数时调用它。未来的用户(包括你自己)会感谢你。printf 而非 echo。bash -n script.sh 测试脚本语法。