名称: shell-best-practices 用户可调用: false 描述: 在编写遵循现代最佳实践的Shell脚本时使用。涵盖可移植脚本编写、Bash模式、错误处理和安全编码。 允许工具:
- 读取
- 写入
- 编辑
- Bash
- Grep
- Glob
Shell脚本最佳实践
遵循现代最佳实践编写健壮、可维护和安全的Shell脚本的全面指南。
脚本基础
Shebang选择
根据需求选择合适的shebang:
# 可移植bash(推荐)
#!/usr/bin/env bash
# 直接bash路径(更快,可移植性较差)
#!/bin/bash
# POSIX兼容shell(最可移植)
#!/bin/sh
# 特定shell版本
#!/usr/bin/env bash
# 需要Bash 4.0+
严格模式
始终启用严格的错误处理:
#!/usr/bin/env bash
set -euo pipefail
# 这些的作用:
# -e: 命令失败时立即退出
# -u: 将未设置变量视为错误
# -o pipefail: 如果任何命令失败,管道失败
调试时添加:
set -x # 执行时打印命令
脚本头部模板
#!/usr/bin/env bash
set -euo pipefail
# 脚本: script-name.sh
# 描述: 本脚本功能的简要描述
# 用法: ./script-name.sh [选项] <参数>
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
变量处理
始终引用变量
防止单词分割和通配符扩展:
# 好
echo "$variable"
cp "$source" "$destination"
if [ -f "$file" ]; then
# 坏 - 空格或特殊字符可能破坏
echo $variable
cp $source $destination
if [ -f $file ]; then
使用有意义的名称
# 好
readonly config_file="/etc/app/config.yml"
local user_input="$1"
declare -a log_files=()
# 坏
readonly f="/etc/app/config.yml"
local x="$1"
declare -a arr=()
默认值
# 如果未设置则使用默认值
name="${NAME:-default_value}"
# 如果未设置或为空则使用默认值
name="${NAME:-}"
# 如果未设置则分配默认值
: "${NAME:=default_value}"
# 如果未设置则报错(带消息)
: "${REQUIRED_VAR:?错误: REQUIRED_VAR 必须设置}"
只读和局部变量
# 常量
readonly MAX_RETRIES=3
readonly CONFIG_DIR="/etc/myapp"
# 函数局部变量
my_function() {
local input="$1"
local result=""
# ...
}
错误处理
退出码
使用有意义的退出码:
# 标准码
readonly EXIT_SUCCESS=0
readonly EXIT_FAILURE=1
readonly EXIT_INVALID_ARGS=2
readonly EXIT_NOT_FOUND=3
# 带码退出
exit "$EXIT_FAILURE"
陷阱用于清理
cleanup() {
local exit_code=$?
# 清理临时文件
rm -f "${temp_file:-}"
# 如果需要恢复状态
exit "$exit_code"
}
trap cleanup EXIT
# 脚本继续...
temp_file=$(mktemp)
错误消息
error() {
echo "错误: $*" >&2
}
warn() {
echo "警告: $*" >&2
}
die() {
error "$@"
exit 1
}
# 用法
[[ -f "$config_file" ]] || die "未找到配置文件: $config_file"
验证输入
validate_args() {
if [[ $# -lt 1 ]]; then
die "用法: $SCRIPT_NAME <输入文件>"
fi
local input_file="$1"
[[ -f "$input_file" ]] || die "未找到文件: $input_file"
[[ -r "$input_file" ]] || die "文件不可读: $input_file"
}
函数
函数定义
# 文档化函数
# 处理日志文件并提取错误
# 参数:
# $1 - 日志文件路径
# $2 - 输出目录(可选,默认: ./output)
# 返回:
# 0 成功,1 失败
process_log() {
local log_file="$1"
local output_dir="${2:-./output}"
[[ -f "$log_file" ]] || return 1
grep -i "error" "$log_file" > "$output_dir/errors.log"
}
返回值
# 返回状态
is_valid() {
[[ -n "$1" && "$1" =~ ^[0-9]+$ ]]
}
if is_valid "$input"; then
echo "有效"
fi
# 捕获输出
get_config_value() {
local key="$1"
grep "^${key}=" "$config_file" | cut -d= -f2
}
value=$(get_config_value "database_host")
条件语句
使用 [[ ]] 进行测试
# 好 - [[ ]] 更强大且更安全
if [[ -f "$file" ]]; then
if [[ "$string" == "value" ]]; then
if [[ "$string" =~ ^[0-9]+$ ]]; then
# 避免 - [ ] 有局限性
if [ -f "$file" ]; then
if [ "$string" = "value" ]; then
数值比较
# 使用 (( )) 进行算术
if (( count > 10 )); then
if (( a == b )); then
if (( x >= 0 && x <= 100 )); then
# 或在 [[ ]] 中使用 -eq/-lt/-gt
if [[ "$count" -gt 10 ]]; then
字符串比较
# 相等
if [[ "$str" == "value" ]]; then
# 模式匹配
if [[ "$str" == *.txt ]]; then
# 正则表达式匹配
if [[ "$str" =~ ^[a-z]+$ ]]; then
# 空/非空
if [[ -z "$str" ]]; then # 空
if [[ -n "$str" ]]; then # 非空
循环
遍历文件
# 好 - 处理文件名中的空格
for file in *.txt; do
[[ -e "$file" ]] || continue # 如果没有匹配则跳过
process "$file"
done
# 使用find进行递归
while IFS= read -r -d '' file; do
process "$file"
done < <(find . -name "*.txt" -print0)
# 坏 - 空格会破坏
for file in $(ls *.txt); do # 不要这样做
从文件读取行
# 正确 - 保留空白
while IFS= read -r line; do
echo "$line"
done < "$filename"
# 使用进程替换
while IFS= read -r line; do
echo "$line"
done < <(some_command)
带索引迭代
files=("one.txt" "two.txt" "three.txt")
for i in "${!files[@]}"; do
echo "索引 $i: ${files[i]}"
done
数组
声明和用法
# 索引数组
declare -a files=()
files+=("file1.txt")
files+=("file2.txt")
# 访问所有元素
for f in "${files[@]}"; do
echo "$f"
done
# 数组长度
echo "${#files[@]}"
# 关联数组 (Bash 4+)
declare -A config
config[host]="localhost"
config[port]="8080"
echo "${config[host]}"
数组最佳实践
# 引用扩展
"${array[@]}" # 所有元素,单词分割
"${array[*]}" # 所有元素,单个字符串
# 检查是否为空
if [[ ${#array[@]} -eq 0 ]]; then
echo "空数组"
fi
# 检查键(关联)
if [[ -v config[key] ]]; then
echo "键存在"
fi
命令执行
检查命令存在
# 首选方法
if command -v docker &>/dev/null; then
echo "Docker已安装"
fi
# 在条件语句中
require_command() {
command -v "$1" &>/dev/null || die "未找到必需命令: $1"
}
require_command git
require_command docker
捕获输出和状态
# 捕获输出
output=$(some_command)
# 捕获输出和状态
if output=$(some_command 2>&1); then
echo "成功: $output"
else
echo "失败: $output" >&2
fi
# 检查状态而不捕获输出
if some_command &>/dev/null; then
echo "命令成功"
fi
安全命令替换
# 使用 $() 而不是反引号
result=$(command) # 好
result=`command` # 避免
# 嵌套替换
result=$(echo $(date)) # 使用 $() 有效
可移植性
POSIX 与 Bash
| 功能 | POSIX | Bash |
|---|---|---|
| 测试语法 | [ ] |
[[ ]] |
| 数组 | 否 | 是 |
$() |
是 | 是 |
${var//pat/rep} |
否 | 是 |
[[ =~ ]] 正则 |
否 | 是 |
(( )) 算术 |
否 | 是 |
可移植替代
# 代替 [[ ]],使用带引号的 [ ]
if [ -f "$file" ]; then
if [ "$str" = "value" ]; then
# 代替 (( )),使用带 -eq 的 [ ]
if [ "$count" -gt 10 ]; then
# 代替 ${var//pat/rep}
echo "$var" | sed 's/pat/rep/g'
# 代替数组,使用空格分隔的字符串
files="one.txt two.txt three.txt"
for f in $files; do
echo "$f"
done
安全
避免 Eval
# 坏 - 代码注入风险
eval "$user_input"
# 更好 - 使用数组构建命令
cmd=("grep" "-r" "$pattern" "$directory")
"${cmd[@]}"
净化输入
# 验证预期格式
if [[ ! "$input" =~ ^[a-zA-Z0-9_-]+$ ]]; then
die "无效输入格式"
fi
# 转义用于命令
escaped=$(printf '%q' "$input")
临时文件
# 安全临时文件创建
temp_file=$(mktemp) || die "创建临时文件失败"
trap 'rm -f "$temp_file"' EXIT
# 安全临时目录
temp_dir=$(mktemp -d) || die "创建临时目录失败"
trap 'rm -rf "$temp_dir"' EXIT
日志记录
基本日志记录
readonly LOG_FILE="/var/log/myapp.log"
log() {
local level="$1"
shift
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE"
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@" >&2; }
log_error() { log "ERROR" "$@" >&2; }
# 用法
log_info "开始处理"
log_error "连接失败"
详细模式
VERBOSE="${VERBOSE:-false}"
debug() {
if [[ "$VERBOSE" == "true" ]]; then
echo "DEBUG: $*" >&2
fi
}
# 启用方式: VERBOSE=true ./script.sh
完整脚本模板
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# 脚本: example.sh
# 描述: 演示Shell最佳实践的模板
# 用法: ./example.sh [选项] <输入文件>
# =============================================================================
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
# 退出码
readonly EXIT_SUCCESS=0
readonly EXIT_FAILURE=1
readonly EXIT_INVALID_ARGS=2
# 日志函数
log_info() { echo "[INFO] $*"; }
log_error() { echo "[ERROR] $*" >&2; }
# 错误处理
die() {
log_error "$@"
exit "$EXIT_FAILURE"
}
cleanup() {
local exit_code=$?
rm -f "${temp_file:-}"
exit "$exit_code"
}
trap cleanup EXIT
# 参数解析
usage() {
cat <<EOF
用法: $SCRIPT_NAME [选项] <输入文件>
选项:
-h, --help 显示此帮助信息
-v, --verbose 启用详细输出
-o, --output 输出目录 (默认: ./output)
示例:
$SCRIPT_NAME input.txt
$SCRIPT_NAME -v -o /tmp/output input.txt
EOF
}
parse_args() {
local OPTIND opt
while getopts ":hvo:-:" opt; do
case "$opt" in
h) usage; exit "$EXIT_SUCCESS" ;;
v) VERBOSE=true ;;
o) OUTPUT_DIR="$OPTARG" ;;
-) case "$OPTARG" in
help) usage; exit "$EXIT_SUCCESS" ;;
verbose) VERBOSE=true ;;
output=*) OUTPUT_DIR="${OPTARG#*=}" ;;
*) die "未知选项: --$OPTARG" ;;
esac ;;
:) die "选项 -$OPTARG 需要参数" ;;
\?) die "未知选项: -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
if [[ $# -lt 1 ]]; then
usage
exit "$EXIT_INVALID_ARGS"
fi
INPUT_FILE="$1"
}
# 验证输入
validate() {
[[ -f "$INPUT_FILE" ]] || die "未找到文件: $INPUT_FILE"
[[ -r "$INPUT_FILE" ]] || die "文件不可读: $INPUT_FILE"
mkdir -p "$OUTPUT_DIR" || die "无法创建输出目录"
}
# 主逻辑
main() {
# 默认值
VERBOSE="${VERBOSE:-false}"
OUTPUT_DIR="${OUTPUT_DIR:-./output}"
parse_args "$@"
validate
log_info "处理 $INPUT_FILE"
# ... 主逻辑在此 ...
log_info "完成"
}
main "$@"
何时使用此技能
- 从头编写新Shell脚本
- 审查Shell脚本问题
- 重构遗留Shell代码
- 调试脚本失败
- 提高脚本安全性
- 使脚本更可移植
- 设置适当的错误处理