Bash 脚本编写教程 / 20 - 最佳实践
20 - 最佳实践
20.1 脚本模板
每个新脚本都应包含以下基础结构:
#!/usr/bin/env bash
# ============================================================
# 脚本名称: script.sh
# 描述: 脚本用途说明
# 用法: ./script.sh [选项] <参数>
# 作者: 作者名
# 日期: 2026-05-10
# ============================================================
set -euo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME=$(basename "$0")
readonly SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
readonly SCRIPT_VERSION="1.0.0"
# ---- 日志函数 ----
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2; }
warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ⚠️ $*" >&2; }
error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ❌ $*" >&2; }
die() { error "$@"; exit 1; }
# ---- 清理函数 ----
cleanup() {
local exit_code=$?
# 清理临时资源
[[ -n "${TMP_DIR:-}" && -d "$TMP_DIR" ]] && rm -rf "$TMP_DIR"
exit "$exit_code"
}
trap cleanup EXIT INT TERM
# ---- 参数解析 ----
usage() {
cat << EOF
用法: $SCRIPT_NAME [选项] <参数>
选项:
-h, --help 显示帮助
-v, --version 显示版本
-q, --quiet 静默模式
EOF
}
QUIET=false
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--version) echo "$SCRIPT_VERSION"; exit 0 ;;
-q|--quiet) QUIET=true; shift ;;
--) shift; break ;;
-*) die "未知选项: $1" ;;
*) break ;;
esac
done
ARGS=("$@")
}
# ---- 主逻辑 ----
main() {
parse_args "$@"
TMP_DIR=$(mktemp -d)
# 业务逻辑
[[ "$QUIET" != true ]] && log "开始执行..."
# ...
[[ "$QUIET" != true ]] && log "执行完成"
}
main "$@"
20.2 代码规范
命名规范
| 元素 | 风格 | 示例 |
|---|---|---|
| 常量 | 全大写+下划线 | MAX_RETRY, CONFIG_FILE |
| 全局变量 | 小写+下划线 | log_file, target_dir |
| 局部变量 | 小写+下划线 | local count=0 |
| 函数 | 小写+下划线 | validate_input() |
| 私有函数 | 前缀 _ | _internal_helper() |
| 模块函数 | 模块::函数 | log::info(), file::exists() |
格式规范
# ✅ 良好的格式
process_file() {
local input_file="$1"
local output_file="$2"
local verbose="${3:-false}"
if [[ ! -f "$input_file" ]]; then
error "文件不存在: $input_file"
return 1
fi
if [[ "$verbose" == true ]]; then
log "处理文件: $input_file -> $output_file"
fi
# 业务逻辑
sed 's/old/new/g' "$input_file" > "$output_file"
}
# ❌ 不良的格式
process_file(){
local input_file=$1
local output_file=$2
if [[ ! -f $input_file ]];then
error "文件不存在: $input_file"
return 1
fi
sed 's/old/new/g' $input_file > $output_file
}
代码组织
#!/usr/bin/env bash
set -euo pipefail
# 1. 常量定义
readonly VERSION="1.0.0"
# 2. 全局变量
VERBOSE=false
# 3. 工具函数
log() { echo "[$(date '+%H:%M:%S')] $*" >&2; }
# 4. 业务函数
validate() { :; }
process() { :; }
# 5. 参数解析
parse_args() { :; }
# 6. 主入口
main() { :; }
main "$@"
20.3 ShellCheck 规则精要
必须修复的规则
# SC2086: 变量未加引号 —— 高危!
# ❌ 错误
rm $file
files=$(find . -name "*.txt")
cd $dir
# ✅ 正确
rm "$file"
files=$(find . -name "*.txt")
cd "$dir"
# SC2046: 命令替换未加引号
# ❌ 错误
rm $(find /tmp -name "*.log")
# ✅ 正确
find /tmp -name "*.log" -exec rm {} +
# SC2155: declare 和赋值应分开
# ❌ 错误
local var=$(some_command)
# ✅ 正确
local var
var=$(some_command)
# SC2164: cd 缺少错误处理
# ❌ 错误
cd /some/dir
# ✅ 正确
cd /some/dir || exit 1
# SC2034: 变量定义但未使用
# 可能是拼写错误或应删除
unused_var="hello" # ShellCheck 会警告
# SC2162: read 未使用 -r
# ❌ 错误
read line
# ✅ 正确
read -r line
ShellCheck 配置文件
# .shellcheckrc 文件
# 全局启用
enable=avoid-nullary-conditions
# 全局禁用
disable=SC2034 # unused variables (common in sourced files)
# 指定 Shell 方言
shell=bash
# 外部源文件路径
source-path=lib/
20.4 性能优化
避免不必要的子 Shell
# ❌ 慢:每次循环都创建子 Shell
while read -r line; do
echo "$line"
done < <(command)
# ❌ 更慢:管道创建两个子 Shell
cat file.txt | grep "pattern"
# ✅ 快:直接重定向
while IFS= read -r line; do
echo "$line"
done < file.txt
# ✅ 更快:直接 grep
grep "pattern" file.txt
内建命令优于外部命令
| 操作 | 慢(外部命令) | 快(内建命令) |
|---|---|---|
| 字符串长度 | echo "$str" | wc -c | ${#str} |
| 大小写转换 | echo "$str" | tr 'a-z' 'A-Z' | ${str^^} |
| 子串提取 | echo "$str" | cut -c1-5 | ${str:0:5} |
| 路径提取 | echo "$path" | xargs dirname | ${path%/*} |
| 去除前缀 | echo "$str" | sed 's/^prefix//' | ${str#prefix} |
| 算术运算 | expr $a + $b | $((a + b)) |
| 默认值 | if [ -z "$var" ]; then var="default"; fi | ${var:-default} |
减少循环中的外部命令
# ❌ 慢:循环中调用外部命令
for file in *.txt; do
count=$(wc -l < "$file")
echo "$file: $count lines"
done
# ✅ 快:一次处理
while IFS= read -r file; do
wc -l "$file"
done < <(find . -name "*.txt" -type f)
# ✅ 更快:使用 find -exec
find . -name "*.txt" -type f -exec wc -l {} +
# ✅ 并行处理
find . -name "*.txt" -type f | xargs -P "$(nproc)" wc -l
缓存重复计算
# ❌ 每次调用都重新计算
get_config() {
grep "^$1=" /etc/myapp/config.ini | cut -d= -f2
}
host=$(get_config host) # 读文件
port=$(get_config port) # 又读文件
# ✅ 一次性加载到关联数组
declare -A CONFIG
load_config() {
while IFS='=' read -r key value; do
[[ -n "$key" && "$key" != \#* ]] && CONFIG["$key"]="$value"
done < /etc/myapp/config.ini
}
load_config
host="${CONFIG[host]}"
port="${CONFIG[port]}"
20.5 安全最佳实践
输入验证
# 永远不要信任用户输入
validate_input() {
local input="$1"
# 检查是否为空
[[ -z "$input" ]] && { echo "输入为空" >&2; return 1; }
# 检查长度
[[ ${#input} -gt 255 ]] && { echo "输入过长" >&2; return 1; }
# 检查非法字符(根据场景调整)
if [[ "$input" =~ [[:cntrl:]] ]]; then
echo "包含非法控制字符" >&2
return 1
fi
# 白名单验证(推荐)
if [[ "$input" =~ ^[a-zA-Z0-9._-]+$ ]]; then
return 0
else
echo "输入包含不允许的字符" >&2
return 1
fi
}
安全的临时文件
# ❌ 不安全:可预测的文件名
tmpfile="/tmp/myapp_$$.tmp"
# ✅ 安全:使用 mktemp
tmpfile=$(mktemp /tmp/myapp.XXXXXX)
tmpdir=$(mktemp -d /tmp/myapp.XXXXXX)
# 设置安全的临时目录
export TMPDIR=/tmp
umask 077 # 只有 owner 可读写
避免命令注入
# ❌ 危险:直接拼接用户输入
filename="$1"
eval "cat $filename" # 如果 filename="; rm -rf /"
# ❌ 危险:未加引号
rm $filename # 如果 filename="a b"会删除两个文件
# ✅ 安全:加引号
cat "$filename"
rm "$filename"
# ✅ 安全:使用数组传递参数
args=("-name" "*.txt" "-type" "f")
find . "${args[@]}"
# ✅ 安全:避免 eval
# ❌ eval "$command"
# ✅ 直接调用函数
敏感信息处理
# ❌ 硬编码密码
DB_PASSWORD="secret123"
# ✅ 从环境变量读取
DB_PASSWORD="${DB_PASSWORD:?数据库密码未设置}"
# ✅ 从文件读取(权限 600)
DB_PASSWORD=$(cat /etc/myapp/db_password)
# ✅ 使用 secret 管理工具
DB_PASSWORD=$(vault kv get -field=password secret/myapp/db)
# 不要在日志中输出敏感信息
log() {
local msg="$*"
# 脱敏处理
msg=$(echo "$msg" | sed -E 's/password=[^ ]*/password=****/g')
echo "$msg" >&2
}
20.6 可移植性
POSIX 兼容性
# 如果需要兼容 sh/dash,避免以下 Bash 特性:
# ❌ [[ ]] —— 使用 [ ]
[ -f "$file" ] && echo "存在"
# ❌ ${var,,} —— 使用 tr
echo "$var" | tr '[:upper:]' '[:lower:]'
# ❌ 数组 —— 使用位置参数
set -- a b c
for item; do echo "$item"; done
# ❌ $(( )) 算术 —— 使用 expr 或 test
result=$(expr $a + $b)
# ❌ function 关键字 —— 使用 name() 语法
my_func() { echo "hello"; }
# ❌ <<< Here String —— 使用 echo |
echo "hello" | read -r var
# ❌ process substitution <()
diff <(ls /tmp) <(ls /var)
跨平台检测
# 检测操作系统
detect_os() {
case "$(uname -s)" in
Linux*) echo "linux" ;;
Darwin*) echo "macos" ;;
CYGWIN*) echo "cygwin" ;;
MINGW*) echo "mingw" ;;
FreeBSD*) echo "freebsd" ;;
*) echo "unknown" ;;
esac
}
# 跨平台兼容的命令
case "$(detect_os)" in
linux)
SED_INPLACE=(sed -i)
OPEN_URL=(xdg-open)
;;
macos)
SED_INPLACE=(sed -i '')
OPEN_URL=(open)
;;
esac
"${SED_INPLACE[@]}" 's/old/new/g' file.txt
20.7 代码审查清单
| 类别 | 检查项 | 优先级 |
|---|---|---|
| 安全 | 变量是否加引号 | 🔴 必须 |
| 安全 | 是否有命令注入风险 | 🔴 必须 |
| 安全 | 敏感信息是否硬编码 | 🔴 必须 |
| 安全 | 临时文件是否安全 | 🟡 建议 |
| 健壮性 | 是否设置 set -euo pipefail | 🔴 必须 |
| 健壮性 | 是否有错误处理 | 🔴 必须 |
| 健壮性 | 是否有清理逻辑 (trap) | 🟡 建议 |
| 健壮性 | 输入是否验证 | 🟡 建议 |
| 可读性 | 变量名是否有意义 | 🟡 建议 |
| 可读性 | 是否有注释说明 | 🟡 建议 |
| 可读性 | 函数是否单一职责 | 🟡 建议 |
| 可维护性 | ShellCheck 是否通过 | 🔴 必须 |
| 可维护性 | 是否有测试 | 🟡 建议 |
| 性能 | 循环中是否有不必要的外部命令 | 🟢 优化 |
| 性能 | 是否缓存重复计算 | 🟢 优化 |
| 可移植性 | 是否使用了 Bash 特有特性 | 🟡 视情况 |
20.8 快速参考卡
常用一行命令
# 查找并替换文件内容
find . -name "*.txt" -exec sed -i 's/old/new/g' {} +
# 统计代码行数
find . -name "*.sh" -exec cat {} + | wc -l
# 批量重命名
for f in *.JPG; do mv "$f" "${f%.JPG}.jpg"; done
# 查找大文件
find / -type f -size +100M -exec ls -lh {} \; 2>/dev/null
# 监控文件变化
inotifywait -m -r /path/to/watch
# 提取 URL
grep -oE 'https?://[^ ]+' file.txt
# 去重保留顺序
awk '!seen[$0]++' file.txt
# CSV 列求和
awk -F',' '{sum+=$3} END {print sum}' data.csv
# JSON 解析(无 jq 时)
grep -o '"key": *"[^"]*"' file.json | cut -d'"' -f4
# 并行压缩
find . -name "*.log" | xargs -P 4 gzip
20.9 扩展阅读
- Google Shell Style Guide
- The Art of Shell Programming
- ShellCheck
- Bash Pitfalls
- Advanced Bash-Scripting Guide
- Bash Hackers Wiki
🎉 恭喜! 你已完成全部 20 章的 Bash 脚本编写教程。
记住:实践是最好的老师。将这些知识应用到你的日常工作中,从写一个小工具开始,逐步构建更复杂的脚本。