Shell脚本编程最佳实践

68 阅读5分钟

前言

写Shell脚本容易,写好Shell脚本难。随手写的脚本能跑,但换个环境就出问题;脚本越写越长,自己都看不懂;没有错误处理,跑到一半失败了也不知道。

本文整理Shell脚本编程的最佳实践,从代码规范到错误处理,让脚本更健壮、更易维护。


1. 脚本基础规范

1.1 标准模板

#!/bin/bash
#
# 脚本名称: deploy.sh
# 功能描述: 自动化部署脚本
# 作者: your_name
# 创建时间: 2025-01-08
# 使用方式: ./deploy.sh [env] [version]
#

set -euo pipefail

# 全局变量
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log"

# 默认值
ENV="${1:-prod}"
VERSION="${2:-latest}"

# 主函数
main() {
    log "开始执行..."
    # 主逻辑
    log "执行完成"
}

# 日志函数
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

# 执行
main "$@"

1.2 set命令详解

set -e          # 遇到错误立即退出
set -u          # 使用未定义变量报错
set -o pipefail # 管道中任一命令失败则整体失败
set -x          # 调试模式,打印每条命令

# 组合使用
set -euo pipefail

为什么需要这些设置

# 没有 set -e 的问题
rm -rf /important/data   # 假设这里写错了路径
echo "删除成功"           # 即使上面失败,这行还会执行

# 没有 set -u 的问题
echo $UNDEFIND_VAR       # 不报错,只是空值
rm -rf $UNDEFIND_VAR/*   # 危险!可能变成 rm -rf /*

# 没有 set -o pipefail 的问题
cat /nonexistent | grep "test" | wc -l
echo $?  # 返回0,因为wc成功了,但cat其实失败了

1.3 变量使用规范

# 使用大括号包裹变量
echo "${name}"           # 推荐
echo "$name"             # 可以,但不够清晰

# 给变量设置默认值
name="${1:-default}"     # 如果$1为空,使用default
name="${1:=default}"     # 如果$1为空,赋值并使用default
name="${1:?错误信息}"    # 如果$1为空,报错退出

# 字符串操作
file="/path/to/file.txt"
echo "${file%.txt}"      # /path/to/file  去掉后缀
echo "${file##*/}"       # file.txt       只取文件名
echo "${file%/*}"        # /path/to       只取目录

# 只读变量
readonly CONFIG_FILE="/etc/app.conf"

# 局部变量(在函数中)
local temp_var="value"

2. 函数编写

2.1 函数定义规范

# 推荐写法
function_name() {
    local arg1="$1"
    local arg2="${2:-default}"
    
    # 函数逻辑
    
    return 0
}

# 带返回值的函数
get_memory_usage() {
    local usage
    usage=$(free | awk '/Mem:/ {printf "%.1f", ($2-$7)/$2*100}')
    echo "$usage"
}

# 调用
mem=$(get_memory_usage)
echo "内存使用率: ${mem}%"

2.2 参数处理

#!/bin/bash
# 处理命令行参数

usage() {
    cat <<EOF
Usage: $0 [OPTIONS]

Options:
    -e, --env ENV       环境 (dev|test|prod)
    -v, --version VER   版本号
    -f, --force         强制执行
    -h, --help          显示帮助
EOF
    exit 1
}

# 默认值
ENV=""
VERSION=""
FORCE=false

# 解析参数
while [[ $# -gt 0 ]]; do
    case "$1" in
        -e|--env)
            ENV="$2"
            shift 2
            ;;
        -v|--version)
            VERSION="$2"
            shift 2
            ;;
        -f|--force)
            FORCE=true
            shift
            ;;
        -h|--help)
            usage
            ;;
        *)
            echo "未知参数: $1"
            usage
            ;;
    esac
done

# 参数检查
if [[ -z "$ENV" ]]; then
    echo "错误: 必须指定环境"
    usage
fi

2.3 返回值与退出码

# 使用返回值
check_service() {
    if systemctl is-active --quiet "$1"; then
        return 0  # 成功
    else
        return 1  # 失败
    fi
}

if check_service nginx; then
    echo "nginx运行中"
else
    echo "nginx未运行"
fi

# 自定义退出码
readonly EXIT_SUCCESS=0
readonly EXIT_INVALID_ARGS=1
readonly EXIT_FILE_NOT_FOUND=2
readonly EXIT_PERMISSION_DENIED=3

[[ -f "$config_file" ]] || exit $EXIT_FILE_NOT_FOUND

3. 错误处理

3.1 trap捕获信号

#!/bin/bash
set -euo pipefail

# 临时文件
TEMP_FILE=$(mktemp)

# 清理函数
cleanup() {
    local exit_code=$?
    rm -f "$TEMP_FILE"
    echo "清理完成,退出码: $exit_code"
    exit $exit_code
}

# 捕获退出信号
trap cleanup EXIT
trap 'echo "收到中断信号"; exit 130' INT TERM

# 主逻辑
echo "正在处理..."
# ... 脚本逻辑 ...

3.2 错误处理函数

#!/bin/bash

# 错误处理
error_handler() {
    local line_no=$1
    local error_code=$2
    echo "错误发生在第 $line_no 行,退出码: $error_code"
    # 可以在这里发送告警
}

trap 'error_handler ${LINENO} $?' ERR

# 日志函数
log_error() {
    echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}

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

log_warn() {
    echo "[WARN] $(date '+%Y-%m-%d %H:%M:%S') $*"
}

3.3 重试机制

# 带重试的函数
retry() {
    local max_attempts=$1
    local delay=$2
    shift 2
    local cmd="$@"
    
    local attempt=1
    while [[ $attempt -le $max_attempts ]]; do
        echo "尝试第 $attempt 次: $cmd"
        if eval "$cmd"; then
            return 0
        fi
        
        if [[ $attempt -lt $max_attempts ]]; then
            echo "失败,${delay}秒后重试..."
            sleep "$delay"
        fi
        ((attempt++))
    done
    
    echo "达到最大重试次数,失败"
    return 1
}

# 使用
retry 3 5 curl -f http://example.com/health

4. 常用技巧

4.1 安全的文件操作

# 安全删除(先检查)
safe_rm() {
    local target="$1"
    
    # 防止误删根目录
    if [[ "$target" == "/" ]] || [[ -z "$target" ]]; then
        echo "危险操作,拒绝执行"
        return 1
    fi
    
    # 检查是否存在
    if [[ ! -e "$target" ]]; then
        echo "目标不存在: $target"
        return 1
    fi
    
    rm -rf "$target"
}

# 安全的目录切换
cd_safe() {
    cd "$1" || {
        echo "无法进入目录: $1"
        exit 1
    }
}

# 创建目录(如果不存在)
mkdir -p "$target_dir"

4.2 并行执行

#!/bin/bash
# 并行处理

# 方法1:后台进程
for server in server1 server2 server3; do
    ssh "$server" 'uptime' &
done
wait  # 等待所有后台进程完成

# 方法2:xargs并行
echo "server1 server2 server3" | tr ' ' '\n' | \
    xargs -P 3 -I {} ssh {} 'uptime'

# 方法3:GNU parallel(需安装)
parallel -j 4 ssh {} uptime ::: server1 server2 server3 server4

4.3 锁机制防止重复执行

#!/bin/bash
# 使用flock防止脚本重复执行

LOCK_FILE="/tmp/$(basename "$0").lock"

exec 200>"$LOCK_FILE"

if ! flock -n 200; then
    echo "脚本已在运行中"
    exit 1
fi

# 主逻辑
echo "开始执行..."
sleep 60
echo "执行完成"

4.4 配置文件读取

#!/bin/bash
# 读取配置文件

CONFIG_FILE="${1:-/etc/app.conf}"

# 检查文件存在
[[ -f "$CONFIG_FILE" ]] || {
    echo "配置文件不存在: $CONFIG_FILE"
    exit 1
}

# 方法1:source(注意安全风险)
# source "$CONFIG_FILE"

# 方法2:逐行解析(更安全)
while IFS='=' read -r key value; do
    # 跳过注释和空行
    [[ "$key" =~ ^#.*$ ]] && continue
    [[ -z "$key" ]] && continue
    
    # 去掉空格
    key=$(echo "$key" | xargs)
    value=$(echo "$value" | xargs)
    
    # 赋值
    declare "$key=$value"
done < "$CONFIG_FILE"

echo "DB_HOST: $DB_HOST"
echo "DB_PORT: $DB_PORT"

4.5 颜色输出

#!/bin/bash
# 颜色定义

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m'  # No Color

echo_red()    { echo -e "${RED}$*${NC}"; }
echo_green()  { echo -e "${GREEN}$*${NC}"; }
echo_yellow() { echo -e "${YELLOW}$*${NC}"; }
echo_blue()   { echo -e "${BLUE}$*${NC}"; }

# 使用
echo_green "[OK] 服务正常"
echo_red "[FAIL] 服务异常"
echo_yellow "[WARN] 磁盘空间不足"

5. 调试技巧

5.1 调试模式

#!/bin/bash
# 调试开关

DEBUG=${DEBUG:-false}

debug() {
    if [[ "$DEBUG" = true ]]; then
        echo "[DEBUG] $*" >&2
    fi
}

# 使用
debug "变量值: name=$name"

# 执行时开启调试
# DEBUG=true ./script.sh

5.2 详细执行跟踪

#!/bin/bash

# 在需要调试的代码段前后开关
set -x  # 开启
# ... 需要调试的代码 ...
set +x  # 关闭

# 或者指定调试输出位置
exec 5>/tmp/debug.log
BASH_XTRACEFD=5
set -x

5.3 shellcheck静态检查

# 安装
# yum install shellcheck 或 apt install shellcheck

# 检查脚本
shellcheck script.sh

# 常见问题示例
# SC2086: Double quote to prevent globbing and word splitting
# SC2046: Quote this to prevent word splitting
# SC2034: Variable appears unused

6. 实战示例

6.1 服务部署脚本

#!/bin/bash
#
# deploy.sh - 服务部署脚本
#

set -euo pipefail

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_NAME="myapp"
readonly DEPLOY_DIR="/opt/${APP_NAME}"
readonly BACKUP_DIR="/opt/backup/${APP_NAME}"

# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'

log_info()  { echo -e "${GREEN}[INFO]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }

usage() {
    cat <<EOF
Usage: $0 <version>

Example:
    $0 v1.2.3
EOF
    exit 1
}

# 参数检查
VERSION="${1:-}"
[[ -z "$VERSION" ]] && usage

# 检查环境
check_env() {
    log_info "检查环境..."
    
    # 检查权限
    [[ $EUID -eq 0 ]] || {
        log_error "需要root权限"
        exit 1
    }
    
    # 检查目录
    mkdir -p "$DEPLOY_DIR" "$BACKUP_DIR"
}

# 备份
backup() {
    log_info "备份当前版本..."
    
    if [[ -d "$DEPLOY_DIR/current" ]]; then
        local backup_name="${APP_NAME}_$(date +%Y%m%d_%H%M%S)"
        cp -r "$DEPLOY_DIR/current" "$BACKUP_DIR/$backup_name"
        log_info "备份到: $BACKUP_DIR/$backup_name"
    fi
}

# 下载
download() {
    log_info "下载版本: $VERSION"
    
    local download_url="https://releases.example.com/${APP_NAME}/${VERSION}.tar.gz"
    local temp_file=$(mktemp)
    
    curl -fsSL "$download_url" -o "$temp_file" || {
        log_error "下载失败"
        rm -f "$temp_file"
        exit 1
    }
    
    # 解压
    mkdir -p "$DEPLOY_DIR/$VERSION"
    tar -xzf "$temp_file" -C "$DEPLOY_DIR/$VERSION"
    rm -f "$temp_file"
}

# 切换版本
switch_version() {
    log_info "切换到新版本..."
    
    # 更新软链接
    ln -sfn "$DEPLOY_DIR/$VERSION" "$DEPLOY_DIR/current"
}

# 重启服务
restart_service() {
    log_info "重启服务..."
    
    systemctl restart "$APP_NAME" || {
        log_error "重启失败,尝试回滚"
        rollback
        exit 1
    }
    
    # 健康检查
    sleep 5
    if ! curl -sf http://localhost:8080/health > /dev/null; then
        log_error "健康检查失败,尝试回滚"
        rollback
        exit 1
    fi
}

# 回滚
rollback() {
    log_info "回滚到上一版本..."
    
    local latest_backup=$(ls -t "$BACKUP_DIR" | head -1)
    if [[ -n "$latest_backup" ]]; then
        ln -sfn "$BACKUP_DIR/$latest_backup" "$DEPLOY_DIR/current"
        systemctl restart "$APP_NAME"
    fi
}

# 清理旧版本
cleanup() {
    log_info "清理旧版本..."
    
    # 保留最近5个备份
    cd "$BACKUP_DIR" && ls -t | tail -n +6 | xargs -r rm -rf
}

# 主函数
main() {
    check_env
    backup
    download
    switch_version
    restart_service
    cleanup
    
    log_info "部署完成: $VERSION"
}

main

6.2 批量服务器执行脚本

#!/bin/bash
#
# batch_exec.sh - 批量服务器执行命令
#

set -euo pipefail

# 服务器列表(可以从文件读取)
SERVERS=(
    "10.10.0.1"
    "10.10.0.2"
    "10.10.0.3"
)

# SSH选项
SSH_OPTS="-o ConnectTimeout=5 -o StrictHostKeyChecking=no"

usage() {
    cat <<EOF
Usage: $0 <command>

Example:
    $0 "uptime"
    $0 "df -h"
    $0 "systemctl status nginx"
EOF
    exit 1
}

COMMAND="${1:-}"
[[ -z "$COMMAND" ]] && usage

# 执行
for server in "${SERVERS[@]}"; do
    echo "====== $server ======"
    ssh $SSH_OPTS "$server" "$COMMAND" 2>&1 || echo "执行失败"
    echo ""
done

如果服务器分布在不同网络,可以先用组网工具(WireGuard、ZeroTier、星空组网等)把机器串起来,脚本里直接用虚拟IP,不用关心实际网络环境。


7. 常见问题

7.1 空格和特殊字符

# 错误:变量没加引号
for file in $files; do   # 如果文件名有空格会出问题

# 正确:加引号
for file in "$files"; do

# 处理文件名有空格的情况
while IFS= read -r -d '' file; do
    echo "处理: $file"
done < <(find . -name "*.txt" -print0)

7.2 数组操作

# 定义数组
arr=("item1" "item2" "item3")

# 遍历
for item in "${arr[@]}"; do
    echo "$item"
done

# 数组长度
echo "长度: ${#arr[@]}"

# 追加元素
arr+=("item4")

# 索引访问
echo "第一个: ${arr[0]}"

7.3 字符串比较

# 字符串比较用 [[ ]]
if [[ "$str1" == "$str2" ]]; then
    echo "相等"
fi

# 正则匹配
if [[ "$str" =~ ^[0-9]+$ ]]; then
    echo "是数字"
fi

# 数值比较用 (( )) 或 -eq
if (( num1 > num2 )); then
    echo "num1更大"
fi

if [[ $num1 -gt $num2 ]]; then
    echo "num1更大"
fi

总结

类别最佳实践
基础set -euo pipefail,变量加引号
变量使用 ${} 包裹,设置默认值
函数使用 local 声明局部变量
错误处理trap 捕获信号,清理临时文件
日志带时间戳,区分级别
调试使用 shellcheckset -x
安全防止误删,检查参数合法性

写Shell脚本的原则:

  1. 健壮性:考虑各种异常情况
  2. 可读性:清晰的命名和注释
  3. 可维护性:模块化,避免重复
  4. 安全性:防止误操作,谨慎使用 rm

更多运维技术文章,欢迎关注公众号:北平的秋葵