Skip to content

Shell 编程规范 ​

运维脚本出事,几乎都出在三个地方:变量没引号、错误没检查、变量为空导致路径变成 /。这一节给一套可以直接套用的骨架。

一、脚本头 ​

bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

逐条解释:

设置作用
set -e任何命令返回非 0 就退出
set -u用未定义变量时报错退出
set -o pipefail管道中任一命令失败,整个管道失败
IFS=$'\n\t'分词只按换行和 tab,避免空格拆坏文件名

pipefail 为什么重要:

bash
# 没有 pipefail:grep 失败但 wc 成功,整体返回 0
cat app.log | grep ERROR | wc -l
echo $?      # 0

# 有 pipefail:grep 失败时整体失败

set -e 有三个不生效的场景,必须知道:

bash
# 1. 命令在 if / while / until 的条件里
if grep -q foo file; then ...; fi

# 2. 命令在 && / || 链里(除最后一个)
cmd1 && cmd2        # cmd1 失败不会退出

# 3. 命令前有 !
! grep -q foo file

反过来,这些场景失败也不会让脚本退出——需要显式检查。

set -e 在管道和子 shell 里的行为

set -e 不会传递到子 shell 内部,除非子 shell 也 set -e。(cd /tmp && rm -rf x) 这类写法要小心。

二、变量与引号 ​

bash
NAME="world"
echo "hello, $NAME"
echo 'hello, $NAME'      # 单引号不展开
echo "hello, ${NAME}"    # 推荐加花括号,避免歧义

所有变量都用双引号包起来,除非你明确要分词:

bash
# 错:路径有空格就炸
rm -rf $DIR/

# 对
rm -rf "${DIR:?}"/

${DIR:?} 是"变量为空就报错退出",比 set -u 更精确(set -u 只管"未定义",不管"定义为空字符串")。

常用扩展:

写法含义
${VAR:-default}未设置或为空时用默认值
${VAR:=default}未设置或为空时赋值并返回
${VAR:?msg}未设置或为空时报错退出
${VAR#prefix}去掉最短前缀
${VAR##*/}取文件名(去掉最长前缀到 /)
${VAR%%.*}去掉最长后缀(从第一个 .)
${VAR//a/b}全部替换
${#VAR}长度
bash
DIR="${1:-/data/app}"
FILE="/opt/app/config/app.yml"
echo "${FILE##*/}"      # app.yml
echo "${FILE%%.*}"      # /opt/app/config/app
echo "${FILE%/*}"       # /opt/app/config

三、数组 ​

bash
HOSTS=(10.0.0.31 10.0.0.32 10.0.0.33)
echo "${HOSTS[0]}"
echo "${#HOSTS[@]}"           # 长度
echo "${HOSTS[@]}"            # 所有元素(加引号)

for h in "${HOSTS[@]}"; do
  echo "checking $h"
done

"${HOSTS[@]}" 的引号必须有。${HOSTS[@]} 不加引号会在元素含空格时被拆开。

追加:

bash
HOSTS+=(10.0.0.34)

关联数组(bash 4+):

bash
declare -A MAP
MAP[host1]=10.0.0.31
MAP[host2]=10.0.0.32
echo "${MAP[host1]}"
for k in "${!MAP[@]}"; do echo "$k=${MAP[$k]}"; done

四、条件判断 ​

bash
# 用 [[ ]],不要用 [ ]
if [[ -f /etc/nginx/nginx.conf ]]; then
  echo exists
fi
测试含义
-f / -d / -e文件 / 目录 / 存在
-r / -w / -x可读 / 可写 / 可执行
-s文件非空
-z / -n字符串为空 / 非空
== / !=字符串比较
=~正则匹配([[ ]] 内)
-eq -ne -lt -gt数值比较
&& / ||与 / 或
bash
if [[ "$STATUS" == "ok" && "$COUNT" -gt 0 ]]; then
  echo fine
fi

if [[ "$LINE" =~ ^ERROR[[:space:]]+([0-9]+) ]]; then
  echo "code=${BASH_REMATCH[1]}"
fi

[ ] 和 [[ ]] 的区别

  • [ "$a" == "$b" ] 在某些 shell 里 == 不合法,要用 =。
  • [ $a -gt 1 ] 里 $a 为空会报 integer expression expected,必须加引号或用 [[ ]]。
  • [[ ]] 是 bash 关键字,支持 &&、=~、模式匹配,不会分词,所以更安全。

脚本统一用 [[ ]]。

五、函数 ​

bash
log() {
  local level="$1"; shift
  printf '%s [%s] %s\n' "$(date '+%F %T')" "$level" "$*" >&2
}

die() {
  log ERROR "$*"
  exit 1
}

check_service() {
  local name="$1"
  systemctl is-active --quiet "$name" || die "$name is not running"
}

check_service nginx

规范:

  • 局部变量用 local,避免污染全局。
  • 用 return N 返回状态码,用 echo 返回数据(不要混用)。
  • 参数用 "$1",多参数用 shift 逐个取。
bash
# 返回值 vs 输出
get_port() {
  echo 8080            # 输出:给调用方用 $() 接
}

PORT=$(get_port)

六、错误处理与清理 ​

bash
TMPDIR=$(mktemp -d /tmp/deploy.XXXXXX)

cleanup() {
  local code=$?
  rm -rf "$TMPDIR"
  exit $code
}
trap cleanup EXIT INT TERM

# 主体
cp app.jar "$TMPDIR/"

trap ... EXIT 在脚本任何方式退出时都会执行(正常、exit N、set -e 触发、收到 INT/TERM)。local code=$? 保住原始退出码,否则 exit 会改成 0。

七、参数解析 ​

bash
MODE="daily"
DEPLOY=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    -m|--mode)   MODE="$2"; shift 2 ;;
    -d|--deploy) DEPLOY=true; shift ;;
    -h|--help)
      echo "usage: $0 [-m daily|weekly] [-d]"
      exit 0 ;;
    *) die "unknown option: $1" ;;
  esac
done

比 getopts 更直观,支持长选项。注意 shift 2 和 shift 的区别。

八、完整骨架 ​

bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="${LOG_FILE:-/var/log/app-deploy.log}"

log() {
  local level="$1"; shift
  printf '%s [%s] %s\n' "$(date '+%F %T')" "$level" "$*" | tee -a "$LOG_FILE" >&2
}

die() {
  log ERROR "$*"
  exit 1
}

require_cmd() {
  command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
}

main() {
  local src="${1:?usage: $0 <jar>}"
  [[ -f "$src" ]] || die "file not found: $src"

  require_cmd java
  require_cmd systemctl

  log INFO "deploying $src"
  install -m 0644 "$src" /opt/app/app.jar
  systemctl restart app
  systemctl is-active --quiet app || die "app failed to start"
  log INFO "done"
}

main "$@"

几个细节:

  • readonly 防止后续被改。
  • ${1:?} 缺参数时自动打印用法。
  • command -v 检查命令存在(比 which 可靠)。
  • install -m 一步完成复制 + 设权限。
  • main "$@" 把参数完整传进去。

九、静态检查 ​

bash
shellcheck deploy.sh
text
In deploy.sh line 12:
rm -rf $DIR/
       ^-- SC2086 (info): Double quote to prevent globbing and word splitting.

shellcheck 能查出引号、未定义变量、不可达代码、[ ] 误用等几百类问题。提交前跑一遍。

bash
# 检查所有脚本
find . -name '*.sh' -exec shellcheck {} +

十、踩过的坑 ​

现象根因处置
脚本删错目录变量为空 + 没引号"${DIR:?}"
某个命令失败但脚本继续没有 set -e 或在 && 链里显式检查 || exit 1
管道失败没被发现没有 pipefailset -o pipefail
文件名带空格被拆开没加引号 / IFS 不对加引号,IFS=$'\n\t'
函数里改了全局变量没用 local加 local
trap 里 $? 总是 0先执行了别的命令第一行 local code=$?
[[ $a -gt 1 ]] 报错$a 非数字先校验,用 =~ ^[0-9]+$
脚本在 cron 里失败PATH 不同绝对路径 + 显式设 PATH
cd 失败但继续执行cd 在 && 链里cd "$DIR" || die