Shell编程代码规范指南
# Shell编程代码规范指南
本规范参考 Google Shell Style Guide (opens new window) 及 ShellCheck Wiki (opens new window),结合项目实践精简整理。
# 目录
- 01.规范概述
- 02.文件头部规范
- 03.命名规范
- 04.代码格式规范
- 05.注释规范
- 06.变量规范
- 07.函数规范
- 08.条件判断与测试
- [8.1 [[ vs 选型
- 8.2 文件与字符串测试
- 8.3 算术判断
- 8.4 case分支
- 09.循环与数组
- 10.子命令与管道
- 11.字符串与文本处理
- 12.错误处理
- 13.安全与防御
- 14.调试与工具链
- 15.常见反模式
- 16.代码审查清单
- 17.常见陷阱速查
# 01.规范概述
# 1.1 为何需要代码规范
疑惑:Shell 脚本通常就几十行,能跑就行,为什么还要制定规范?
答疑:Shell 是一门"宽容到危险"的语言。不加引号能跑、变量不声明能跑、管道中断能跑——但空格文件名、空变量、子命令失败等边界情况会悄无声息地出错。规范的本质是用防御性写法,让脚本在任何输入下都能安全运行。
# 1.2 核心目标
| 目标 | 说明 |
|---|---|
| 健壮性 | 空格、特殊字符、空值都不会让脚本崩溃 |
| 可读性 | 脚本像文档一样流畅 |
| 一致性 | 整个项目像一个人写的 |
| 安全性 | 避免命令注入、路径遍历、临时文件竞争 |
| 可调试性 | 出问题时能快速定位 |
# 1.3 要求等级
- 必须(Mandatory):必须采用,违反将被 Code Review 驳回。部分规则由
shellcheck强制执行。 - 推荐(Preferable):理应采用,特殊情况可不采用但需注释说明。
- 可选(Optional):自行决定。
# 1.4 Shell与Bash版本
脚本统一使用 Bash 4.0+(macOS 默认 Bash 3.2,需通过 Homebrew 安装新版)。执行环境为 Linux/macOS,不使用 zsh 特有语法。可移植脚本应限制在 POSIX sh。
# 02.文件头部规范
# 2.1 Shebang 【必须】
#!/usr/bin/env bash
# ✅ 用 /usr/bin/env 查找 bash(可移植,兼容不同安装路径)
# ❌ #!/bin/bash — 在某些系统上 bash 不在 /bin
# ❌ #!/bin/sh — 可能指向 dash/ash,不支持 Bash 扩展语法
1
2
3
4
2
3
4
# 2.2 严格模式 【必须】
#!/usr/bin/env bash
set -euo pipefail # 严格模式三件套
# set -e : 任何命令失败(返回非零)立即退出
# set -u : 使用未定义变量时报错(而不是默默用空字符串)
# set -o pipefail : 管道中任一命令失败,整个管道失败
# 这三个选项让脚本在出错时"快速失败",
# 而不是带着错误继续执行导致更隐蔽的问题。
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
# 2.3 可选选项 【推荐】
# set -x : 打印每个命令及展开后的参数(调试用,生产环境注释掉)
# set -v : 打印每行脚本原文(调试用)
# shopt -s nullglob : 无匹配的 glob 展开为空(而非保留字面量 *.txt)
# shopt -s extglob : 启用扩展通配符(@(a|b) 等)
# shopt -s globstar : 启用 ** 递归通配
# shopt -s nocaseglob : 大小写不敏感通配
1
2
3
4
5
6
2
3
4
5
6
# 03.命名规范
# 3.1 命名总表
| 类型 | 规范 | 示例 |
|---|---|---|
| 文件名 | 全小写 + 下划线 | deploy.sh, backup_database.sh |
| 函数名 | 小写 + 下划线 | download_file, parse_args |
| 变量名 | 小写 + 下划线 | file_path, retry_count |
| 常量/环境变量 | 全大写 + 下划线 | MAX_RETRIES, CONFIG_DIR |
| 只读变量 | readonly + 全大写 | readonly SCRIPT_DIR |
| 私有函数 | _ 前缀 | _internal_helper, _validate_input |
| 循环变量 | 简洁但有意义 | i, file, line |
| 全局变量 | 全大写(与局部区分) | TEMP_DIR, LOG_FILE |
# 3.2 正确与错误示例
# ✅ 正确
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly MAX_RETRIES=5
download_file() {
local url="$1"
local dest="$2"
# ...
}
# ✅ 私有函数用 _ 前缀
_validate_url() { [[ "$1" =~ ^https?:// ]]; }
# ❌ 错误
# function downloadFile() — 不用 function 关键字,不用驼峰命名
# local Url=$1 — 变量名用了大写(与环境变量冲突风险)
# MAX_RETRIES=5 — 缺少 readonly
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 3.3 命名反模式
| 反模式 | 示例 | 改进 |
|---|---|---|
| 过度缩写 | dl_f, prs | download_file, parse_args |
| 含义不清 | data, tmp, x | response_body, temp_dir |
| 大写变量名(非环境变量) | local URL="$1" | local url="$1" |
| 拼音命名 | dian_hua | phone_number |
| 无意义的后缀 | download_file_func | download_file |
# 04.代码格式规范
# 4.1 缩进与空格 【必须】
# ✅ 2 个空格缩进
download_file() {
local url="$1"
local dest="$2"
if [[ ! -f "${dest}" ]]; then
curl -fsSL "${url}" -o "${dest}"
fi
}
# ✅ 运算符两侧加空格
result=$((a + b * c))
# ❌ result=$((a+b*c))
# ✅ 分号前无空格、后有空格
if [[ -f "${file}" ]]; then
# ❌ if [[ -f "${file}" ]];then
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 4.2 管道换行 【推荐】
# ✅ 长管道:用 | 结尾换行,每个命令一行
cat "${log_file}" \
| grep "ERROR" \
| awk '{print $3}' \
| sort \
| uniq -c \
| sort -rn \
| head -10
# ✅ 长命令参数:用 \ 折行,参数对齐
curl -fsSL "https://api.example.com" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"name":"Alice"}' \
-o "${output_file}"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 4.3 空行与逻辑分组
#!/usr/bin/env bash
set -euo pipefail
# --- 常量定义 ---
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly CONFIG_FILE="${SCRIPT_DIR}/config.yml"
readonly MAX_RETRIES=3
# --- 私有函数 ---
_validate_input() {
local input="$1"
[[ -n "${input}" ]] || { echo "ERROR: empty input" >&2; exit 1; }
}
# --- 业务函数 ---
download_file() {
# 不同逻辑块之间空一行
local url="$1"
local dest="$2"
if [[ -f "${dest}" ]]; then
echo "Skip: ${dest} already exists"
return 0
fi
echo "Downloading: ${url} -> ${dest}"
curl -fsSL "${url}" -o "${dest}"
}
# --- 入口 ---
main() {
parse_args "$@"
do_work
}
main "$@"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 05.注释规范
# 5.1 核心原则
注释解释"为什么",代码说明"做了什么"。
# ❌ 差的注释:复述代码
count=$((count + 1)) # count 加 1
# ✅ 好的注释:解释意图
count=$((count + 1)) # 跳过 CSV 文件的标题行
# ✅ 记录决策原因
# 使用 --retry 3 而非无限重试,因为第三方 API 在高峰期偶尔超时,
# 但 3 次内必定成功(2023-06 确认的行为)
curl --retry 3 "${url}"
# ❌ 过时注释(危险!)
# 调用 V1 接口
curl "https://api-v2.example.com" # ← 注释和代码不匹配
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
# 5.2 文件头注释
#!/usr/bin/env bash
#
# deploy.sh — 部署指定 tag 到目标服务器。
#
# 用法:
# ./deploy.sh <tag> [--env prod|staging]
#
# 环境变量:
# DEPLOY_KEY - SSH 私钥路径(必需)
# DOCKER_REGISTRY - 镜像仓库地址(默认 docker.io)
#
# 退出码:
# 0 - 部署成功
# 1 - 参数错误
# 2 - 连接失败
# 3 - 部署失败
#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 5.3 函数注释
# Downloads a file from given URL with retry logic.
#
# Globals:
# MAX_RETRIES - max download attempts (default 3)
#
# Arguments:
# url - source URL (required, must be https)
# dest - destination path (required)
#
# Returns:
# 0 if download succeeds
# 1 if all retries exhausted
#
# Outputs:
# Progress info to stdout
# Errors to stderr
#
# Example:
# download_file "https://example.com/data.tar.gz" "/tmp/data.tar.gz"
download_file() {
local url="$1"
local dest="$2"
# ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 5.4 TODO 与 FIXME
# TODO(yc): 添加增量部署支持,预计 v2.0 实现
deploy_full() { ... }
# FIXME(yc): 当镜像 tag 包含特殊字符(如 /)时,docker pull 失败
# 需要增加 tag 合法性校验
pull_image() { ... }
# HACK(yc): macOS 的 sed 与 GNU sed 不兼容,临时方案
# 待统一构建环境后移除(#1234)
sed -i '' 's/old/new/g' "${file}"
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
# 06.变量规范
# 6.1 引号包裹 【必须】
# ✅ 始终用双引号包裹变量引用(防止空格分词和 glob 展开)
echo "Processing: ${file_name}"
cp "${source}" "${dest}"
rm -rf "${temp_dir}"
# ❌ 不加引号的常见后果
# var="file with spaces.txt"
# cp $var /tmp/ → cp file with spaces.txt /tmp/ (被空格拆开)
# cp "$var" /tmp/ → cp 'file with spaces.txt' /tmp/
# ✅ 命令替换也要加引号
local output
output="$(curl -s "${url}")" # ✅
output=$(curl -s "${url}") # ❌ 不加引号:输出中的换行/空格导致单词拆分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
# 6.2 花括号与默认值 【必须】
# ✅ 花括号明确变量边界
echo "${prefix}_suffix" # ✅ 清晰
# echo "$prefix_suffix" # ❌ 会被解释为变量 prefix_suffix
# ✅ 默认值
port="${PORT:-8080}" # PORT 未设置或为空 → 使用 8080
log_level="${LOG_LEVEL:-info}"
# ✅ := 同时赋值
use_tls="${USE_TLS:=true}" # 未设置 → 赋值 true
echo "${USE_TLS}" # 永远有值
# ✅ 必须提供的变量
db_url="${DB_URL:?DB_URL is required}" # 未设置 → 报错退出
# echo "${optional_var:?"未设置"} # ❌ 用 :? 要有充分理由
# ✅ 备选值
fallback="${BACKUP_HOST:-${PRIMARY_HOST:-localhost}}" # 逐级备用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 6.3 变量作用域 【必须】
# ✅ 函数内用 local 限定作用域
process_file() {
local input_file="$1" # 函数局部变量
local temp_file
temp_file="$(mktemp)"
# ...
}
# ✅ 全局变量用 readonly 保护(常量)
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ✅ 需要修改的全局变量用大写(警示这是全局的)
CURRENT_STEP="" # 全大写 = 全局,小心修改
# ❌ 不要在函数内修改全大写全局变量(除非明确这就是设计意图)
# ❌ 函数内不用 local → 变量在全局作用域生效(副作用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 6.4 数组操作 【推荐】
# ✅ 数组定义
fruits=("apple" "banana" "cherry")
# ✅ 遍历数组
for fruit in "${fruits[@]}"; do # "${fruits[@]}" — 每个元素独立加引号
echo "Fruit: ${fruit}"
done
# ❌ ${fruits[*]} — 所有元素合并成一个字符串(空格连接)
# ❌ $fruits — 只引用第一个元素 fruits[0]
# ✅ 数组长度
echo "Count: ${#fruits[@]}"
# ✅ 追加元素
fruits+=("date")
# ✅ 切片
echo "${fruits[@]:1:2}" # banana cherry
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 6.5 readonly 与 declare 【推荐】
# ✅ readonly 保护常量
readonly SCRIPT_DIR
readonly MAX_RETRIES=5
readonly DOCKER_REGISTRY="docker.io/myapp"
# ✅ declare 声明类型
declare -i retry_count=0 # 整型
declare -a items=() # 数组
declare -A users=() # 关联数组(Bash 4.0+)
# ✅ 关联数组
declare -A config
config["timeout"]=30
config["retries"]=3
for key in "${!config[@]}"; do # 遍历 key
echo "${key}=${config[${key}]}"
done
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 07.函数规范
# 7.1 函数定义 【必须】
# ✅ 推荐写法(不使用 function 关键字,添加 () 更清晰)
download_file() {
local url="$1"
local dest="$2"
# ...
}
# ❌ 不推荐的写法
# function download_file { ... } # 多余的关键字
# function download_file() { ... } # 混用,bash 允许但不推荐
# ✅ 简单函数可写成一行(单逻辑)
is_root() { [[ "$(id -u)" -eq 0 ]]; }
is_installed() { command -v "$1" &>/dev/null; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
# 7.2 参数与返回值 【必须】
# ✅ 参数通过 $1, $2, ... 接收,用 local 接住
process_item() {
local item_name="$1"
local item_type="${2:-json}" # 第二个参数可选,默认 json
local force="${3:-false}"
echo "Processing ${item_name} (type=${item_type})"
if [[ "${force}" == "true" ]]; then
echo "Force mode enabled"
fi
}
# ✅ 所有参数:"$@"
print_all() {
for arg in "$@"; do # "$@" — 每个参数独立加引号
echo "Arg: ${arg}"
done
}
# ❌ $* — 所有参数合并成一个字符串
# ✅ 返回码(0 = 成功,非 0 = 失败)
is_valid_email() {
local email="$1"
[[ "${email}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]
return $? # 返回测试结果的退出码
}
# ✅ "返回"字符串:通过 stdout(调用方用 $(...) 捕获)
get_config_value() {
local key="$1"
grep "^${key}:" "${CONFIG_FILE}" | cut -d':' -f2- | sed 's/^[[:space:]]*//'
}
# ✅ 错误信息通过 stderr 输出
download_file() {
local url="$1"
if ! curl -fsSL "${url}" -o "${dest}"; then
echo "ERROR: Failed to download ${url}" >&2
return 1
fi
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 7.3 main 函数入口 【推荐】
# ✅ 用 main 函数作为入口(便于跟踪变量作用域、执行顺序)
main() {
parse_args "$@"
validate_environment
run_tasks
}
main "$@"
# 好处:
# 1. 脚本"骨架"一目了然
# 2. main 内的变量是 local 的
# 3. 可以整个 main 函数放在脚本末尾,与"执行入口"合一
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
# 7.4 函数库组织 【推荐】
# ✅ 引用公共函数库
# source 用绝对路径或相对脚本路径
source "${SCRIPT_DIR}/lib/utils.sh"
source "${SCRIPT_DIR}/lib/logging.sh"
# ❌ source ./utils.sh — 相对路径依赖当前工作目录
# ❌ . utils.sh — 同上
# ✅ 函数库中的函数应设计为无副作用(不修改全局状态)
# ✅ 如果函数库需要在被 source 时做初始化,用条件变量防止重复执行
if [[ -z "${_UTILS_INITIALIZED:-}" ]]; then
readonly _UTILS_INITIALIZED="true"
readonly _UTILS_TEMP_DIR="$(mktemp -d)"
fi
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
# 08.条件判断与测试
# 8.1 [[ vs [ 选型 【必须】
# ✅ [[ ]] — Bash 内置,更安全(不用转义 < > ( ) 等字符)
if [[ "${name}" == "admin" ]]; then
echo "Welcome admin"
fi
# ✅ [[ ]] 支持正则匹配
if [[ "${email}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "Valid email"
fi
# ✅ [[ ]] 支持 && || 组合条件
if [[ "${name}" == "admin" ]] && [[ -f "${config}" ]]; then
start_service
fi
# ❌ [ ] — 仅在需要 POSIX 兼容时使用(需小心转义和空格)
# ❌ [[ -f $file ]] — 变量没加引号
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 8.2 文件与字符串测试
# ✅ 文件测试
[[ -f "${file}" ]] # 普通文件存在(非目录)
[[ -d "${dir}" ]] # 目录存在
[[ -x "${script}" ]] # 可执行
[[ -r "${file}" ]] # 可读
[[ -s "${file}" ]] # 文件存在且非空
[[ ! -f "${file}" ]] # 文件不存在
# ✅ 字符串测试
[[ -z "${var}" ]] # 字符串为空
[[ -n "${var}" ]] # 字符串非空
[[ "${a}" == "${b}" ]] # 字符串相等
[[ "${a}" != "${b}" ]] # 字符串不等
# ✅ 多个条件组合
if [[ -d "${dir}" && -w "${dir}" ]]; then
write_to_dir
fi
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 8.3 算术判断 【推荐】
# ✅ (()) — 算术判断(支持 > < >= <= == !=)
if (( count > 0 )); then
echo "count is positive: ${count}"
fi
if (( retries <= MAX_RETRIES )); then
retry
fi
# ✅ 算术表达式赋值
local result=$((a + b * c))
# 等价于:(( result = a + b * c ))
# ❌ 不要用 [[ ]] 做算术比较
# [[ "${count}" -gt 0 ]] — 在 Bash 中也工作,但 (()) 更清晰
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 8.4 case 分支 【推荐】
# ✅ case 处理多分支输入(比多个 if-elif 更清晰)
case "${action}" in
start)
start_service
;;
stop)
stop_service
;;
restart)
stop_service
start_service
;;
status)
show_status
;;
*)
echo "Usage: $0 {start|stop|restart|status}" >&2
exit 1
;;
esac
# ✅ case + 通配符(匹配模式)
case "${file}" in
*.tar.gz|*.tgz) tar -xzf "${file}" ;;
*.tar.bz2) tar -xjf "${file}" ;;
*.zip) unzip "${file}" ;;
*) echo "Unknown archive format" >&2 ;;
esac
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 09.循环与数组
# 9.1 while read 逐行处理 【推荐】
# ✅ while IFS= read -r:保留行内空格,不解释反斜杠
while IFS= read -r line; do
echo "Line: ${line}"
done < "${input_file}"
# IFS= → 不 trim 行首行尾空白
# -r → 不解释反斜杠转义
# < file → 通过重定向(而非管道)避免子 shell 变量丢失
# ✅ 管道场景用 process substitution
while IFS= read -r line; do
process "${line}"
done < <(grep "ERROR" "${log_file}")
# done < <(cmd) → 进程替换:cmd 的输出作为文件输入,不创建子 shell
# ❌ cmd | while read ... → while 在子 shell 中,变量修改无法传到外部
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 9.2 遍历文件与 find 【推荐】
# ✅ find -print0 + while read -d '':正确处理文件名中的空格/换行
while IFS= read -r -d '' file; do
process "${file}"
done < <(find . -name "*.md" -print0)
# -print0 → 用 NUL 字符分隔文件名(文件名中唯一不可能出现的字符)
# -d '' → 以 NUL 字符为分隔符
# ❌ 不要用 for 遍历 ls 输出(空格分词问题)
# for file in $(ls *.md); do ... # 文件名有空格 → 被拆开
# for file in *; do process "$file"; done # ✅ 正确的 glob 遍历
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
# 9.3 数组遍历 【推荐】
fruits=("apple" "banana" "cherry")
# ✅ 遍历所有值
for fruit in "${fruits[@]}"; do
echo "Fruit: ${fruit}"
done
# ✅ 遍历索引
for i in "${!fruits[@]}"; do
echo "Index ${i}: ${fruits[${i}]}"
done
# ✅ C 风格 for(有明确起始/终止条件时)
for (( i = 0; i < MAX_RETRIES; i++ )); do
if try_connect "${i}"; then
break
fi
done
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 10.子命令与管道
# 10.1 命令替换 【必须】
# ✅ $() 而非反引号(支持嵌套,易读)
current_dir="$(pwd)"
file_count="$(ls -1 | wc -l)"
# ❌ `cmd` — 反引号,嵌套困难,与引号混淆
# nested=`echo \`date\``
# ✅ 嵌套命令替换
latest_file="$(find "$(get_download_dir)" -name "*.tar.gz" | tail -1)"
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
# 10.2 管道与重定向
# ✅ 管道:串联命令
# 注意:每个命令的运行结果是独立的退出码
# 需要 set -o pipefail 来让管道第一个失败的命令成为整个管道的退出码
cat "${logfile}" | grep "ERROR" | sort | uniq -c | sort -rn | head -10
# ✅ 标准输出重定向
echo "log" > "${log_file}" # 覆盖
echo "append" >> "${log_file}" # 追加
# ✅ 标准错误重定向
echo "error" >&2 # 输出到 stderr
command 2>/dev/null # 丢弃 stderr
command &>"${log_file}" # stdout 和 stderr 合并写入文件
# ✅ here document
cat > "${config_file}" <<EOF
server {
host: ${host}
port: ${port}
}
EOF
# <<EOF → 变量展开
# <<'EOF' → 不展开变量
# <<-EOF → 去掉前导 tab(不能是空格)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 10.3 进程替换与 here 文档 【推荐】
# ✅ 进程替换:一个命令的输出作为另一个命令的"文件"输入
diff <(sort file1.txt) <(sort file2.txt)
# 绕过临时文件,直接比较两个排序结果
# ✅ here 文档变量展开 / 不展开
cat <<EOF
Host: ${HOSTNAME} # 变量会展开
Date: $(date) # 命令会执行
EOF
cat <<'EOF'
Host: ${HOSTNAME} # 字面量输出,不展开
Date: $(date) # 字面量输出,不执行
EOF
# ✅ here string(将字符串作为 stdin)
grep "pattern" <<< "${input_string}"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 11.字符串与文本处理
# 11.1 参数扩展 【推荐】
filename="backup-2024-01-15.tar.gz"
# ✅ 获取文件名(去掉目录)
basename="${filename##*/}" # backup-2024-01-15.tar.gz
# ✅ 获取扩展名
extension="${filename##*.}" # gz
# ✅ 获取文件名(去掉扩展名)
name="${filename%.*}" # backup-2024-01-15.tar
name="${filename%%.*}" # backup-2024-01-15(去掉全部后缀)
# ✅ 替换
no_spaces="${filename// /_}" # 全部空格替换为下划线
first_only="${filename/ /_}" # 只替换第一个
# ✅ 长度
echo "Length: ${#filename}"
# ✅ 子串
date_part="${filename:7:10}" # 2024-01-15(从第 7 个字符取 10 个)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 11.2 文本处理工具链 【推荐】
# ✅ awk / sed / grep 组合
# 提取访问最多的 10 个 IP
awk '{print $1}' access.log \
| sort \
| uniq -c \
| sort -rn \
| head -10
# ✅ sed 替换
sed -i "s/{{VERSION}}/${version}/g" "${config_file}"
# ✅ grep 常用选项
grep -r "TODO" src/ # 递归
grep -v "SKIP" # 排除匹配行
grep -i "error" # 忽略大小写
grep -c "error" # 只输出匹配计数
grep -n "error" # 显示行号
grep -A 3 "error" # 匹配行及后 3 行
grep -B 2 "error" # 匹配行及前 2 行
# ✅ jq:JSON 处理(首选)
echo "${response}" | jq -r '.data.user.name'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 12.错误处理
# 12.1 set 选项详解 【必须】
#!/usr/bin/env bash
set -euo pipefail
# set -e 的例外(某些命令失败是正常的):
# ✅ 条件语句中:if ! command; then ...
# ✅ while/until 条件中:while ! ping -c1 "${host}"; do ...
# ✅ || 短路:command || true(明确允许失败)
# ✅ && 的左侧:cmd1 && cmd2(cmd1 失败则整体失败)
# ✅ 临时关闭 set -e
set +e
some_fallible_command
exit_code=$?
set -e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
# 12.2 退出码约定 【必须】
# ✅ 明确退出码(0 = 成功,非 0 = 失败)
# 约定分配:
# 0 - 成功
# 1 - 一般错误
# 2 - 参数/用法错误
# 3 - 网络/连接错误
# 4 - 权限错误
# 5 - 配置错误
# ✅ 检查关键命令
if ! mkdir -p "${dir}"; then
echo "ERROR: Cannot create directory: ${dir}" >&2
exit 1
fi
# ✅ 使用 exit_code 变量统一管理
exit_code=0
task_a || exit_code=$?
task_b || exit_code=$?
exit "${exit_code}"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 12.3 trap 清理资源 【必须】
# ✅ trap:注册 EXIT/INT/TERM 信号的清理函数
# EXIT — 脚本正常结束或 exit 时
# INT — Ctrl+C
# TERM — kill 默认信号
cleanup() {
local exit_code=$?
echo "Cleaning up..." >&2
# 清理临时文件
[[ -d "${TEMP_DIR:-}" ]] && rm -rf "${TEMP_DIR}"
# 释放锁
[[ -f "${LOCK_FILE:-}" ]] && rm -f "${LOCK_FILE}"
# 杀后台进程
[[ -n "${BG_PID:-}" ]] && kill "${BG_PID}" 2>/dev/null
exit "${exit_code}" # 保留原始退出码
}
trap cleanup EXIT INT TERM
# ✅ 每条 trap 只能注册一个 handler,需要时用列表管理
declare -a _CLEANUP_FUNCS=()
add_cleanup() { _CLEANUP_FUNCS+=("$1"); }
run_cleanup() {
for func in "${_CLEANUP_FUNCS[@]}"; do
"${func}"
done
}
trap run_cleanup EXIT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 12.4 错误日志 【推荐】
# ✅ 统一的日志函数
readonly LOG_FILE="/var/log/myapp/deploy.log"
log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO $*" | tee -a "${LOG_FILE}"; }
log_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARN $*" | tee -a "${LOG_FILE}" >&2; }
log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR $*" | tee -a "${LOG_FILE}" >&2; }
die() { # fatal error + exit
log_error "$@"
exit 1
}
# ✅ 使用
log_info "Starting deployment..."
if ! deploy; then
die "Deployment failed: ${?}"
fi
log_info "Deployment complete"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 13.安全与防御
# 13.1 输入验证 【必须】
# ✅ 所有外部输入必须验证
validate_url() {
local url="$1"
if [[ ! "${url}" =~ ^https?:// ]]; then
echo "ERROR: Invalid URL: ${url}" >&2
return 1
fi
}
validate_number() {
local num="$1"
if [[ ! "${num}" =~ ^[0-9]+$ ]]; then
echo "ERROR: Not a number: ${num}" >&2
return 1
fi
}
# ✅ 防止命令注入:不直接拼接用户输入到命令字符串
# ❌ eval "echo ${user_input}" — 危险!
# ❌ ssh host "echo ${user_input}" — 如果 input 含 "; rm -rf / " →
# ✅ 用参数传递,让命令自己处理引号
ssh host echo "${user_input}" # ✅ ssh 自动转义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 13.2 路径遍历防御 【必须】
# ✅ 检查路径是否包含 ..(父目录引用)
validate_path() {
local path="$1"
if [[ "${path}" =~ \.\. ]]; then
echo "ERROR: Path traversal detected in: ${path}" >&2
return 1
fi
# 进一步:要求路径必须在指定基础目录内
# local real_path
# real_path="$(realpath "${path}" 2>/dev/null)" || return 1
# if [[ "${real_path}" != "${BASE_DIR}"/* ]]; then
# echo "ERROR: Path outside allowed directory" >&2
# return 1
# fi
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 13.3 临时文件安全 【必须】
# ✅ mktemp:安全创建临时文件/目录
temp_file="$(mktemp)" # /tmp/tmp.XXXXXX
temp_dir="$(mktemp -d)" # /tmp/tmp.XXXXXX/
temp_file="$(mktemp /tmp/myapp.XXXXXX)" # 指定模板
# ❌ 不要手动构造临时文件名(可预测 → 竞态条件)
# temp_file="/tmp/myapp_$$" # PID 可预测,不安全
# ✅ 自动清理
cleanup() { rm -f "${temp_file}"; rm -rf "${temp_dir}"; }
trap cleanup EXIT
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
# 13.4 依赖检查 【推荐】
# ✅ 运行前检查依赖
check_dependency() {
local cmd="$1"
if ! command -v "${cmd}" &>/dev/null; then
echo "ERROR: '${cmd}' is required but not installed" >&2
exit 1
fi
}
check_dependency curl
check_dependency jq
check_dependency docker
# ✅ 检查依赖版本
check_version() {
local cmd="$1"
local min_version="$2"
# 例如检查 bash 版本
if (( BASH_VERSINFO[0] < 4 )); then
echo "ERROR: Bash >= 4.0 required, found ${BASH_VERSION}" >&2
exit 1
fi
}
check_version bash 4.0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 13.5 权限检查 【推荐】
# ✅ 需要 root 时明确检查
if [[ "$(id -u)" -ne 0 ]]; then
echo "ERROR: This script must be run as root" >&2
exit 4
fi
# ✅ 检查文件权限
if [[ ! -r "${config_file}" ]]; then
echo "ERROR: Cannot read ${config_file}" >&2
exit 4
fi
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
# 14.调试与工具链
# 14.1 调试模式
# ✅ 逐行打印:set -x(生产环境注释掉)
# set -x
# echo "Current user: $(whoami)"
# set +x
# ✅ 定向调试输出到文件
exec 3>/tmp/debug.log
BASH_XTRACEFD=3 # 将 set -x 的输出重定向到 fd 3
set -x
# ✅ 条件调试
if [[ "${DEBUG:-}" == "true" ]]; then
set -x
fi
# ✅ 手动打印调用栈
print_stack() {
local frame=0
while caller "${frame}"; do
((frame++))
done
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 14.2 shellcheck 静态检查
# shellcheck:Shell 脚本静态分析工具(必装)
# 安装:brew install shellcheck (macOS)
# apt install shellcheck (Ubuntu)
shellcheck myscript.sh # 检查单个文件
shellcheck scripts/*.sh # 批量检查
shellcheck -e SC2034 myscript.sh # 排除特定规则
# 常用被排除的规则:
# SC2034 - 变量未使用(有时故意保留)
# SC1090 - 无法解析 source 路径
# SC1091 - 未找到 sourced 文件
# SC2155 - declare/export 与 local 混用
1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
# 14.3 shfmt 格式化
# shfmt:Shell 格式化工具
# 安装:brew install shfmt
shfmt -w myscript.sh # 原地格式化
shfmt -w -i 2 -ci scripts/*.sh # -i 2 缩进,-ci case 缩进
shfmt -d myscript.sh # 只显示 diff,不修改
shfmt -l scripts/*.sh # 列出需要格式化的文件
1
2
3
4
5
6
7
2
3
4
5
6
7
# 14.4 bats 测试框架 【可选】
# bats:Bash 自动化测试框架
# 安装:brew install bats-core
# deploy_test.bats
setup() {
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
source "${BATS_TEST_DIRNAME}/../deploy.sh"
}
@test "deploy fails without tag argument" {
run deploy_main ""
assert_failure
assert_output --partial "tag is required"
}
@test "deploy succeeds with valid tag" {
run deploy_main "v1.2.3"
assert_success
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 14.5 CI 集成
# GitHub Actions 示例
- name: Shell Code Quality
run: |
# 格式检查
shfmt -d scripts/ # 只显示 diff
# 静态分析
shellcheck scripts/*.sh
# 单元测试(如果有)
bats tests/
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
# 15.常见反模式
| 反模式 | 问题 | 改进 |
|---|---|---|
| 变量不加引号 | 空格导致单词拆分 | "${var}" |
用 ls 遍历文件 | 空格文件名出错 | find -print0 或 shell glob |
cat file \| cmd | 无用进程(UUOC) | cmd < file 或 cmd file |
反引号 `cmd` | 嵌套困难、与引号混淆 | $(cmd) |
| 硬编码路径 | 不可移植 | 变量或 mktemp |
eval 拼接命令 | 命令注入风险 | 用数组传参 |
| 忽略命令返回值 | 错误被静默 | 加 || exit_code=$? |
管道中的 while read | 变量修改在子 shell 中丢失 | 进程替换 while ... done < <(cmd) |
echo 输出任意数据 | echo "-n" 被解释为选项 | printf "%s\n" "${data}" |
[ ] 中不引用变量 | [ $var = "" ] 当 var 为空 → 语法错误 | [ "${var}" = "" ] |
# 16.代码审查清单
每次 Code Review 时,按以下清单逐项检查:
## 头部
- [ ] Shebang 为 #!/usr/bin/env bash
- [ ] set -euo pipefail 存在
- [ ] 文件头注释完整(用途、用法、退出码)
## 命名
- [ ] 函数/变量 snake_case,常量/全局变量 SCREAMING_SNAKE
- [ ] 私有函数 _ 前缀
- [ ] 无拼音、含义模糊的命名
## 变量
- [ ] 变量引用全部加双引号("${var}")
- [ ] 函数内用 local 声明变量
- [ ] 常量用 readonly 保护
- [ ] 数组引用用 "${arr[@]}"
## 函数
- [ ] 参数用 local 接住
- [ ] 返回值明确(return 0/非0)
- [ ] 错误信息输出到 stderr(>&2)
## 条件与循环
- [ ] 用 [[ ]] 而非 [ ]
- [ ] for 循环中的变量加引号
- [ ] while read 保留 IFS= read -r
- [ ] 无 for i in $(ls ...) 模式
## 命令与管道
- [ ] 命令替换用 $() 而非反引号
- [ ] 需要 pipefail 的场景 set -o pipefail
- [ ] 无 UUOC(cat file | cmd)
## 错误处理
- [ ] 关键命令执行结果检查(|| exit_code=$?)
- [ ] trap 清理临时资源(EXIT INT TERM)
- [ ] exit 退出码有意义
## 安全
- [ ] 外部输入(参数、环境变量、文件内容)有校验
- [ ] 无 eval 拼接用户输入
- [ ] 临时文件用 mktemp 创建
- [ ] 无路径遍历风险
- [ ] 依赖命令有 check_dependency
## 可维护性
- [ ] 通过 shellcheck 检查
- [ ] 通过 shfmt 格式化
- [ ] 复杂逻辑有注释说明"为什么"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# 17.常见陷阱速查
以下陷阱即使在有经验的开发者中也常见,值得定期回顾。
# 17.1 变量与引号陷阱
| # | 陷阱 | 描述 | 正解 |
|---|---|---|---|
| 1 | 变量不加引号 | rm $file 当 file 含空格 → 删错文件 | rm "${file}" |
| 2 | $* vs $@ | $* 把所有参数合并成一个字符串 | "$@" — 每个参数独立引号 |
| 3 | echo 处理 -n | echo "${var}" 当 var="-n" → 被吃掉 | printf "%s\n" "${var}" |
| 4 | IFS 影响词分割 | 修改 IFS 后 $var 的行为变化 | 尽量不用未引用变量 |
| 5 | 空变量测试 | [ $x = "" ] 当 x 为空 → 语法错误 | [ "${x}" = "" ] 或 [[ -z ${x} ]] |
# 17.2 流程控制陷阱
| # | 陷阱 | 描述 | 正解 |
|---|---|---|---|
| 1 | set -e 被 if 等吞掉 | if cmd; then — cmd 失败不会触发 set -e | 理解 set -e 的例外情况 |
| 2 | 管道中 while 在子 shell | cmd | while read; do var=1; done; echo $var — 为空 | while ... done < <(cmd) |
| 3 | [ ] 缺少空格 | [ -f"${file}" ] → 语法错误 | [ -f "${file}" ](各处加空格) |
| 4 | == 在 [ ] 中不可移植 | [ "${a}" == "${b}" ] 在 POSIX sh 中不工作 | [ "${a}" = "${b}" ] 或用 [[ ]] |
# 17.3 性能陷阱
| # | 陷阱 | 描述 | 正解 |
|---|---|---|---|
| 1 | 循环中执行子命令 | for f in $(find ...); do → 子进程开销大 | find -print0 \| while read 或用进程替换 |
| 2 | cat file \| grep pattern | 多余 cat 进程(UUOC) | grep pattern file |
| 3 | 循环中频繁重定向 | 每次循环打开/关闭文件 | 在循环外 exec 3>file 复用 fd |
| 4 | 大文件用 while read + echo 逐行 | 每个 echo 一个 write 系统调用 | 用 awk 或 sed 批量处理 |
# 17.4 安全陷阱
| # | 陷阱 | 描述 | 正解 |
|---|---|---|---|
| 1 | eval 拼接用户输入 | eval "echo ${input}" → 命令注入 | 永远不要 eval 用户输入 |
| 2 | 临时文件名可预测 | temp=/tmp/myapp_$$ → 竞态条件 | mktemp |
| 3 | curl \| bash 不验证 | 下载并执行远程脚本 → 中间人攻击 | 验证 HTTPS + 校验 SHA256 |
| 4 | 密码在命令行中 | mysql -p"${PASSWORD}" → ps 可见 | 用配置文件、环境变量或 stdin |
本文档将随 Shell 实践演进持续更新,欢迎通过 GitHub issues (opens new window) 反馈问题和建议。
上次更新: 2026/06/17, 11:39:29