Chapter 07

Shell 脚本编程

从变量到函数,从循环到信号处理——用 Bash 脚本将繁琐任务自动化

脚本基础:shebang 与执行方式

Shebang 行

脚本文件的第一行以 #! 开头,告诉操作系统用哪个解释器来执行这个脚本。这一行称为 shebang(sharp + bang)。

#!/bin/bash          # 指定使用 bash 执行
#!/usr/bin/env bash   # 更可移植:从 PATH 中找 bash(推荐)
#!/usr/bin/env python3  # Python 脚本的 shebang
#!/bin/sh            # POSIX sh(兼容性最强但功能受限)

# 严格模式(推荐所有脚本使用)
#!/usr/bin/env bash
set -euo pipefail
# -e  : 任何命令返回非零退出码时立即退出
# -u  : 使用未定义的变量时报错退出
# -o pipefail : 管道中任一命令失败则整个管道失败
# 执行脚本的三种方式
chmod +x script.sh
./script.sh              # 使用 shebang 指定的解释器
bash script.sh           # 显式用 bash 执行(忽略 shebang)
source script.sh         # 在当前 shell 中执行(变量/函数在当前会话可用)
. script.sh              # source 的简写

变量

# 赋值:等号两边不能有空格!
name="Alice"
count=42
pi=3.14
today=$(date +%Y-%m-%d)    # 命令替换:赋值为命令输出
size=$(du -sh /var | cut -f1)

# 使用变量
echo "$name"               # 加引号(推荐,避免单词分割)
echo "${name}Script"       # 花括号界定变量名边界
echo '$name'               # 单引号:不展开变量,输出字面 $name

# 默认值操作符
echo "${name:-default}"    # 若 name 未设置或为空,使用 default
echo "${name:=default}"    # 若 name 未设置或为空,赋值为 default
echo "${name:?'name is required'}"  # 若未设置则报错退出

# 字符串操作
str="Hello, World!"
echo ${#str}               # 字符串长度:13
echo ${str:7:5}            # 截取:World(从位置7取5个字符)
echo ${str/World/Linux}    # 替换第一个匹配:Hello, Linux!
echo ${str//l/L}           # 替换所有匹配:HeLLo, WorLd!
echo ${str#Hello, }        # 从前缀删除:World!
echo ${str%!}              # 从后缀删除:Hello, World
echo ${str,,}              # 转小写(bash 4.0+)
echo ${str^^}              # 转大写

# 数组
fruits=("apple" "banana" "cherry")
echo ${fruits[0]}          # apple
echo ${fruits[@]}          # 所有元素
echo ${#fruits[@]}         # 数组长度:3
fruits+=("date")           # 追加元素
for f in "${fruits[@]}"; do echo "$f"; done

# 关联数组(字典)
declare -A person
person["name"]="Alice"
person["age"]=30
echo ${person["name"]}
echo ${!person[@]}         # 所有键

条件判断

if / elif / else

# 基本 if
if [ "$name" = "Alice" ]; then
    echo "Hello, Alice!"
elif [ "$name" = "Bob" ]; then
    echo "Hello, Bob!"
else
    echo "Hello, stranger!"
fi

# [[ ]] 是增强版,支持正则匹配和更安全的字符串比较(推荐)
if [[ "$name" == Alice* ]]; then
    echo "Name starts with Alice"
fi

if [[ "$str" =~ ^[0-9]+$ ]]; then
    echo "纯数字"
fi

# 常用测试表达式
# 字符串:= != -z(空)-n(非空)< >(字典序)
[ -z "$var" ]      # 变量为空
[ -n "$var" ]      # 变量非空
[ "$a" = "$b" ]    # 字符串相等
[ "$a" != "$b" ]   # 字符串不等

# 数字:-eq -ne -lt -le -gt -ge
[ "$count" -gt 0 ]       # 大于
[ "$count" -eq 42 ]      # 等于
(( count > 0 ))          # 算术表达式(更直观)

# 文件:-f -d -e -r -w -x -s
[ -f "/etc/hosts" ]      # 是普通文件且存在
[ -d "/tmp" ]            # 是目录
[ -e "/path" ]           # 存在(任何类型)
[ -r "$file" ]           # 可读
[ -w "$file" ]           # 可写
[ -x "$script" ]         # 可执行
[ -s "$file" ]           # 存在且非空

# 逻辑运算
[ -f "$f" ] && [ -r "$f" ]   # AND(-a 或 &&)
[ -z "$a" ] || [ -z "$b" ]   # OR(-o 或 ||)
[[ -f "$f" && -r "$f" ]]     # [[ ]] 内支持 && 和 ||

case 语句

# case:用于多值匹配(比 if-elif 更简洁)
read -p "请输入选项 [start|stop|restart]: " action

case "$action" in
    start)
        echo "启动服务..."
        ;;
    stop)
        echo "停止服务..."
        ;;
    restart|reload)           # 多个模式匹配同一分支
        echo "重启服务..."
        ;;
    [Yy]|[Yy]es)             # 通配符匹配
        echo "确认"
        ;;
    *)                        # 默认分支
        echo "未知选项: $action"
        exit 1
        ;;
esac

循环

# for 循环——遍历列表
for fruit in apple banana cherry; do
    echo "水果: $fruit"
done

# for 循环——遍历数组
files=("a.txt" "b.txt" "c.txt")
for f in "${files[@]}"; do
    echo "处理: $f"
done

# for 循环——C 风格数字
for (( i=1; i<=5; i++ )); do
    echo "第 $i 次"
done

# for 循环——seq 生成数字序列
for i in $(seq 1 5); do echo $i; done
for i in $(seq 0 10 2); do echo $i; done  # 步长为 2:0 2 4 6 8 10

# for 循环——遍历目录文件
for f in /var/log/*.log; do
    echo "日志文件: $f($(wc -l < "$f") 行)"
done

# while 循环
count=0
while [ $count -lt 5 ]; do
    echo "count=$count"
    (( count++ ))
done

# while read——逐行读取文件/标准输入
while IFS= read -r line; do
    echo "行内容: $line"
done < /etc/hosts

# while read——处理命令输出
ps aux | while read -r user pid cpu mem rest; do
    if (( $(echo "$cpu > 50" | bc) )); then
        echo "高 CPU 进程: $pid($cpu%)"
    fi
done

# until 循环(条件为假时继续)
until systemctl is-active nginx &>/dev/null; do
    echo "等待 nginx 启动..."
    sleep 2
done
echo "nginx 已启动!"

# break 和 continue
for i in {1..10}; do
    [ $i -eq 5 ] && break      # 遇到 5 退出循环
    [ $i -eq 3 ] && continue   # 跳过 3
    echo $i
done

函数

# 函数定义(两种语法均可)
function greet() {
    local name="$1"            # local 声明局部变量
    echo "Hello, $name!"
}

backup() {
    local src="$1"
    local dst="${2:-/tmp/backup}"
    
    if [ ! -f "$src" ]; then
        echo "错误: 文件 $src 不存在" >&2
        return 1               # 函数内用 return 返回退出码
    fi
    
    cp "$src" "$dst/$(basename $src).$(date +%Y%m%d)"
    echo "备份完成"
    return 0
}

# 调用函数
greet "Alice"
backup /etc/nginx/nginx.conf /var/backups

# 捕获函数返回值(退出码)
if backup /etc/hosts; then
    echo "备份成功"
else
    echo "备份失败"
fi

# 函数返回字符串(通过 echo + 命令替换)
get_timestamp() {
    echo "$(date +%Y%m%d_%H%M%S)"
}
ts=$(get_timestamp)
echo "时间戳: $ts"

# 递归函数
factorial() {
    local n=$1
    if (( n <= 1 )); then
        echo 1
    else
        local sub=$(factorial $(( n - 1 )))
        echo $(( n * sub ))
    fi
}
echo "5! = $(factorial 5)"

位置参数与特殊变量

#!/usr/bin/env bash
# 调用: ./deploy.sh production v1.2.3 --dry-run

echo "脚本名: $0"        # ./deploy.sh
echo "第1个参数: $1"     # production
echo "第2个参数: $2"     # v1.2.3
echo "参数个数: $#"      # 3
echo "所有参数: $@"      # production v1.2.3 --dry-run
echo "所有参数: $*"      # 与 $@ 类似,但引号行为不同
echo "上一命令退出码: $?"
echo "当前进程 PID: $$"
echo "最近后台 PID: $!"

# 遍历所有参数(推荐用 $@)
for arg in "$@"; do
    echo "参数: $arg"
done

# shift:移除第一个参数($2 变成 $1,以此类推)
while [ $# -gt 0 ]; do
    case "$1" in
        --env|-e)
            ENV="$2"
            shift 2          # 移除 --env 和其值
            ;;
        --dry-run)
            DRY_RUN=true
            shift
            ;;
        *)
            echo "未知参数: $1"
            exit 1
            ;;
    esac
done

读取用户输入:read

# 基本读取
read username              # 读取一行到变量 username
read -p "请输入用户名: " username    # 显示提示语
read -s -p "请输入密码: " password  # -s 静默模式(不显示输入)
read -t 10 -p "10秒内确认 [y/N]: " confirm  # -t 超时秒数
read -n 1 -p "按任意键继续..." key  # -n 只读取N个字符

# 读取多个变量(按空格分割)
read first last <<< "Alice Smith"
echo "$first $last"    # Alice Smith

# 处理用户确认
read -p "确认执行?[y/N] " confirm
case "${confirm,,}" in   # ,, 转小写
    y|yes)
        echo "继续执行..."
        ;;
    *)
        echo "已取消"
        exit 0
        ;;
esac

退出码与错误处理

# 退出码:0=成功,1-255=各种错误
exit 0     # 正常退出
exit 1     # 通用错误
exit 2     # 用法错误(参数不对)

# 检查上一命令的退出码
cp source.txt dest.txt
if [ $? -ne 0 ]; then
    echo "复制失败" >&2
    exit 1
fi

# 更简洁的写法
cp source.txt dest.txt || { echo "复制失败" >&2; exit 1; }

# 自定义错误函数
die() {
    echo "错误: $*" >&2
    exit 1
}

[ -f "$config" ] || die "配置文件 $config 不存在"

# 检查必要命令是否存在
require_cmd() {
    command -v "$1" >/dev/null 2>&1 || die "需要安装 $1"
}
require_cmd docker
require_cmd kubectl

trap:信号处理与清理

# trap 用于捕获信号,常用于脚本退出时的清理工作

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

TMPDIR=$(mktemp -d)     # 创建临时目录

# 注册退出时的清理函数
cleanup() {
    echo "清理临时文件..."
    rm -rf "$TMPDIR"
    echo "清理完成"
}
trap cleanup EXIT       # EXIT:脚本以任何方式退出时触发
trap cleanup INT TERM   # INT=Ctrl+C,TERM=kill 信号

# 常用 trap 信号
trap 'echo "收到 Ctrl+C,退出..."' INT
trap 'echo "错误发生在第 $LINENO 行"' ERR
trap 'echo "脚本结束"' EXIT

# 实际脚本示例:带清理的部署脚本
deploy() {
    local tmpdir
    tmpdir=$(mktemp -d)
    
    trap "rm -rf '$tmpdir'" RETURN   # 函数返回时清理
    
    echo "下载到临时目录: $tmpdir"
    curl -sL https://example.com/app.tar.gz | tar -xz -C "$tmpdir"
    
    echo "部署..."
    cp -r "$tmpdir/app" /opt/
    
    echo "部署完成!"
}

综合示例:实用备份脚本

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

# 配置
BACKUP_DIR="/var/backups"
RETENTION_DAYS=7
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

# 工具函数
log()  { echo "[$(date +%H:%M:%S)] $*"; }
die()  { echo "错误: $*" >&2; exit 1; }

# 参数处理
[ $# -lt 1 ] && die "用法: $0 <源目录> [备份目录]"

SRC="${1}"
DST="${2:-$BACKUP_DIR}"

# 检查
[ -d "$SRC" ]  || die "源目录不存在: $SRC"
[ -d "$DST" ]  || mkdir -p "$DST"
command -v tar >/dev/null || die "需要安装 tar"

# 备份
ARCHIVE="$DST/$(basename $SRC)_$TIMESTAMP.tar.gz"
log "开始备份 $SRC → $ARCHIVE"
tar -czf "$ARCHIVE" -C "$(dirname $SRC)" "$(basename $SRC)"
log "备份完成:$(du -sh $ARCHIVE | cut -f1)"

# 清理旧备份
OLD_COUNT=$(find "$DST" -name "*.tar.gz" -mtime +$RETENTION_DAYS | wc -l)
if [ "$OLD_COUNT" -gt 0 ]; then
    log "清理 $OLD_COUNT 个超过 ${RETENTION_DAYS} 天的旧备份"
    find "$DST" -name "*.tar.gz" -mtime +$RETENTION_DAYS -delete
fi

log "当前备份列表:"
ls -lh "$DST"/*.tar.gz 2>/dev/null || echo "  (无)"
Shell 脚本调试技巧
本章小结

Shell 脚本编程的核心要点:① 始终在脚本第一行加 set -euo pipefail——-e 遇错即退,-u 未定义变量报错,-o pipefail 管道失败不被忽略,这三个选项是安全脚本的基础;② 变量赋值等号两边不能有空格,引用变量始终加双引号 "$var" 防止单词分割和通配符展开;③ 用 local 声明函数局部变量,防止污染全局作用域;④ [[ ]][ ] 更安全(不需要引号防护,支持正则 =~);⑤ while IFS= read -r line 是逐行读取文件的标准写法,-r 防止反斜杠转义,IFS= 保留行首尾空格;⑥ trap cleanup EXIT 确保脚本无论正常还是异常退出都能清理临时资源;⑦ bash -x 和 ShellCheck 是调试脚本的两个必备工具。