1. 为什么Shell脚本也要讲“规矩”:不是洁癖,是成本问题
先讲个场景。我接手过一个跑批任务脚本,几百行,缩进混乱,变量命名全是a、b、x1、x2这种,没有注释,没有函数拆分,全靠一串串cp、sed、awk堆到底。每次要改个输出路径,得全文搜索三次以上才能确认改哪里,生怕动错一个变量导致后面全崩。后来实在受不了,花了一整天重构,加规范、理变量、拆函数,从此这种脚本我再没为它加过班。
Shell脚本,尤其是刚入门时写的那些,看起来就是“敲几条命令、跑通就行”。可真留在生产环境里跑上几个月,你会发现:脚本的质量,决定的是后续所有人的时间和心情。
这里说的“规范”,绝不是为了代码好看搞形式主义,它解决的是一笔实打实的成本账。我把它归纳成四个问题:
- 可读性,也就是“换个人能不能看懂”。变量命名清晰、结构分明的脚本,新人接手也能快速定位逻辑;反之,一个
n=3的变量,你两周后回来自己都未必记得它是什么含义。 - 可调试性,也就是“出错时能不能快速锁定问题”。规范下写的脚本,变量作用域清楚、函数边界明确,报错栈一目了然;杂乱脚本的报错信息基本等于没报。
- 可移植性,也就是“换个环境还能不能跑”。写死绝对路径、依赖特定用户环境变量、随手用反引号嵌套的脚本,换个目录或换台机器就罢工,这种经历做运维的同学应该都熟悉。
- 安全性,也就是“输入脏数据时会不会出大事”。不加引号的变量、不过滤的外部输入,一旦展开成命令,所有数据都可能在不知不觉中被执行或篡改。
这四个问题,一大半都和“变量”强相关。所以我一直觉得,想在Shell这条路上走稳,先啃透变量,再谈其他。这篇文章我就把这段时间沉淀下来的、和变量相关的规范和实战心得一次性整理出来,希望能帮你少走点弯路。
2. 变量定义与分类:先把最基础的边界搞清楚
2.1 Shell变量的“弱类型”本质
很多从Java、Python转过来学Shell的人,一开始都会在变量上栽跟头,因为Shell变量的模型跟高级语言差得太远。Shell里所有变量,本质上都是字符串。你没看错,连数字也是字符串。
#!/bin/bash num=123 echo "$num + 1 = ?" # 输出的是:123 + 1 = ? # 要真的做运算,必须用 $((...)): echo $((num + 1)) # 输出:124这个特性决定了你在Shell里处理“变量的加减乘除”时,思路必须转变:不是“类型转换”,而是“让解释器把字符串当作数字去做算术展开”。最常见的计算姿势有三种:
# 1. $(( )) 算术展开:最常用,性能好 a=5 b=3 echo $((a * b)) # 2. expr 外部命令:老古董,在脚本里尽量少用 # echo $(expr $a + $b) # 3. let 命令:更老一点,也不建议在新脚本里用 # let "c = a + b"另外注意,Shell里没有“浮点数”,要用bc或awk处理小数,这也是一个新手很容易踩的盲区。
我在自己的规范里定了一条硬性要求:凡是参与算术运算的变量,定义时就要在注释里写明“这是数字”,防止后续维护的人拿字符串逻辑去处理它。
2.2 变量命名规范
命名这件事,最容易被当成“小事”,但其实它直接决定脚本能不能被长期维护下去。我现在执行的核心原则有三个:
第一,同一个脚本内风格必须统一。我推荐用snake_case给普通变量命名(比如file_path、backup_dir),用全大写UPPER_SNAKE给只读常量或环境变量命名(比如MAX_RETRY=3、CONFIG_PATH)。一句话:普通变量小写带下划线,常量全部大写。
第二,名字要能说出“业务含义”,而不是“数据结构含义”。我曾见过有人写list1、list2、arr3,这种命名完全是在给下一个接手的人挖坑。正确示范:
# 差:含义模糊 arr1=(/data/logs /data/tmp /opt/app) for d in ${arr1[@]}; do ...; done # 好:业务名词一目了然 backup_roots=(/data/logs /data/tmp /opt/app) for backup_root in "${backup_roots[@]}"; do ...; done第三,避开关键字,也避开特殊字符。if、then、for、while、function、local这些都是Shell保留的关键字,不能当变量名,这个大家都知道了。但还要留意:变量名里只用字母、数字、下划线,且不能以数字开头。用-或.做连接符,变量名直接报废。
2.3 环境变量、内部变量与退出码
Shell变量按作用域和用途,我还习惯把它分成三类来记忆。
用户自定义变量,就是上面的普通变量,生命周期只存在于当前Shell或脚本进程。
环境变量,用export导出的变量,会传递给当前进程的所有子进程。这里有个关键知识点:环境变量的传递是单向继承的——子进程能读取,但子进程里修改了,父进程收不到这个改动。这是Shell里最简单、也最容易被忽视的模型。
内部变量,是Shell预定义好的一些特殊变量,我列了一个高频清单:
| 变量 | 含义 | 经典用法 |
|---|---|---|
$0 | 当前脚本的文件名 | echo "usage: $0 <arg1>" |
$1、$2... | 位置参数 | 函数和脚本参数解析 |
$# | 参数个数 | 判断参数数量是否满足要求 |
$@ | 所有参数,每个参数独立 | 遍历处理每个参数 |
$* | 所有参数,合并成一个整体 | 整体传递,不常用 |
$? | 上一条命令的退出码 | 判断上一步执行是否成功 |
$$ | 当前脚本的PID | 生成临时文件名,防冲突 |
$! | 最后一个后台任务的PID | 配合wait使用 |
这几个变量每个脚本几乎都会用上,尤其是$?,我几乎在每个关键命令后都会条件判断一次。
#!/bin/bash if cp "$SRC_FILE" "$DST_DIR/"; then echo "复制成功" else echo "复制失败,退出码:$?" exit 1 fi3. 变量展开是一等一的技术活:${}、$() 与引号
3.1 ${} 和 $() 的区别,一个必须刻进脑子里的知识点
网上高频出现的问题:“Shell里${}和$()到底有什么区别?”其实这两个东西从根上就不一样。
${}是变量展开(变量替换)。它的作用是把一个变量的值取出来用。
name="tom" echo "${name}" # 输出 tom为什么明明$name也能取变量,还非要加花括号?因为${name}能明确界定变量名的边界。比如:
name="tom" # 想输出 tom_suffix,如果没有花括号: echo "$name_suffix" # 解释器会去找变量 name_suffix,结果为空 echo "${name}_suffix" # 正确输出 tom_suffix$()是命令替换,它的作用是把括号里命令的输出结果当作一个值来用。
today=$(date +%Y-%m-%d) echo "今天是 ${today}" # 输出:今天是 2025-xx-xx再强调一遍它们的性质:${}里放的是变量名,$()里放的是一条命令。一个是“从内存里取东西”,一个是“把命令运行结果拿过来”。这两者的混用,是我见过初级脚本里最典型的硬伤之一。
3.2 参数展开:变量处理的高级玩法
${}的种类远不止“取变量值”这么简单。用不同的修饰符,你能做到取默认值、给空值赋值、截断子串、按模式删前缀后缀……这些能力在实际脚本中非常实用,比你自己写一堆if或者调用sed要省太多事。
我整理一个高频速查表,照着用就行。
| 写法 | 作用 | 示例 |
|---|---|---|
${var:-default} | 变量为空或未定义时,使用默认值 | ${PORT:-8080} |
${var:=default} | 变量为空或未定义时,把默认值赋给变量 | ${LOGDIR:=/var/log} |
${var:?message} | 变量为空或未定义时,报错并退出 | ${MANDATORY:?缺少必填参数} |
${#var} | 获取字符串长度 | ${#filename} |
${var:offset:length} | 截取子串 | ${str:1:3} |
${var#pattern} | 从头删除最短匹配前缀 | ${url#*://} |
${var##pattern} | 从头删除最长匹配前缀 | ${url##*/} |
${var%pattern} | 从尾删除最短匹配后缀 | ${file%.*} |
${var%%pattern} | 从尾删除最长匹配后缀 | ${path%%/*} |
${var/old/new} | 替换第一个匹配 | ${version/-SNAPSHOT/} |
${var//old/new} | 替换全部匹配 | ${str// /_} |
这一块我是建议把它练成“肌肉记忆”的,因为效率提升太明显了。比如最经典的提取文件名和扩展名:
fullname="/data/backup/app_20250218.tar.gz" # 文件名(去掉路径) filename=${fullname##*/} echo "$filename" # app_20250218.tar.gz # 去掉最后一个后缀(.tar.gz → .tar) basename_part=${filename%.*} echo "$basename_part" # app_20250218.tar # 拿扩展名 ext=${filename##*.} echo "$ext" # gz这些操作,如果走basename、cut、awk的外部命令路线,也能实现,但启动外部进程的开销、管道处理带来的引号问题、以及代码可读性,都不如参数展开干净利落。
3.3 引号规则:双引号、单引号和不加引号
“什么时候该加引号?”这个问题被问得最多,却也是规范里最能扯皮的一点。我的经验可以浓缩成一句话:凡是变量展开结果要作为“一个完整值”使用时,就加双引号。
不加引号时,Shell会做“分词”和“路径通配符展开”。这意味着:
path="/tmp/my dir" ls -l $path # 实际执行的是:ls -l /tmp/my dir # 而系统里面 /tmp/my 和 dir 是两个不同的路径 ls -l "$path" # 正确,把 "my dir" 当作完整路径处理再比如文件名里带星号或问号的情况,不加引号还会触发通配符扩展:
target="*.log" find . -name $target # 通配符被展开,实际找的是当前目录下匹配到的文件,完全不是你想表达的意思 find . -name "$target" # 才是在找所有 .log 结尾的文件单引号则完全不同。单引号内部的所有字符都失去特殊含义,$、反引号、通配符、转义符统统变成普通字符。需要纯字面量的时候才用单引号,例如:
echo '路径是 $HOME' # 字面输出:路径是 $HOME还有一个常见场景是混合使用:外层双引号,里面某个固定前缀用单引号包一层,这在sed指令里特别常见:
sed -i "s|${old_ip}|${new_ip}|g" "$config_file"3.4 数组变量:不只是“一个变量存一串值”
Shell的数组分为两类:下标数组和关联数组(需要bash 4及以上)。规范使用数组的关键点,也是我在团队里反复强调的:
第一,遍历数组永远记得加双引号、用[@],用${#数组[@]}取长度。
fruits=("apple" "banana" "cherry") echo "${fruits[0]}" # apple echo "${#fruits[@]}" # 3 for fruit in "${fruits[@]}"; do echo "当前水果是 $fruit" done注意,如果不加引号,${fruits[@]}里元素含空格时会被拆成一个一个词,遍历结果直接错乱。把"${arr[@]}"当固定写法记忆,不要改动它。
第二,按行读文件到数组,是处理配置文件的利器。
mapfile -t lines < "$config_file" for line in "${lines[@]}"; do echo "处理行: $line" donemapfile(也叫readarray)从bash 4开始成为内置命令,-t会自动去掉每行末尾的换行符。这个写法比cat + for in更安全,因为for in会受分词和通配符展开的影响,而mapfile是按行精确分割的。
4. 变量在参数处理与循环中的规范用法
4.1 用for循环处理多个文件路径
Shell里最常用的循环基本就是for,而变量在循环中的用法决定了循环的可靠程度。最基本的文件遍历:
#!/bin/bash config_dir="/etc/app" for conf in "${config_dir}"/*.conf; do # 注意:这里必须要判断一下文件是否存在 # 如果目录下没有 .conf 文件,conf 会变成字面量 /etc/app/*.conf if [ -f "$conf" ]; then echo "读取配置:$conf" fi done这里有一个我在自己规范里特别标注的坑:当通配符没有匹配到任何文件时,循环变量会保留原始的带星号字符串。所以在循环内先判断[ -e "$conf" ]或[ -f "$conf" ],比先处理再报错要稳妥得多。
如果是循环参数列表,规范操作是这样:
#!/bin/bash # 用法: ./script.sh file1 file2 file3 for input_file in "$@"; do if [ ! -r "$input_file" ]; then echo "错误: 文件不可读 $input_file" >&2 continue fi echo "正在处理:$input_file" done4.2 shift:参数搬运的好帮手
shift命令做的事情是“把所有位置参数左移一位”,$2变$1,$3变$2,同时$#减一。它是手写命令行参数解析的核心工具。规范化解析一个“带选项和值的参数”时,shift非常高效。
#!/bin/bash OUTPUT_DIR="" VERBOSE=0 while [ $# -gt 0 ]; do case "$1" in -o|--output) # 如果没跟参数,直接报错 if [ $# -lt 2 ]; then echo "错误: $1 需要一个路径参数" >&2 exit 1 fi OUTPUT_DIR="$2" shift 2 # 同时跳过选项名和值 ;; -v|--verbose) VERBOSE=1 shift ;; -h|--help) echo "用法:$0 [-o <目录>] [-v]" exit 0 ;; *) echo "未知选项:$1" >&2 exit 1 ;; esac done echo "输出目录:${OUTPUT_DIR}"这套模式在Linux工具里到处可见,你自己的脚本如果也这样做参数解析,使用习惯是统一的,别人接手零成本。
4.3 函数内变量:local与export的边界
Shell函数和高级语言函数最大的不同是:默认情况下,函数内定义的变量是全局变量。也就是说,你在函数里给一个变量赋了值,函数外也能读到、也能被改。这既是方便,也是混乱的根源。
我的规范只有一条:函数内所有临时变量,一律用local声明。
#!/bin/bash process_file() { local file_path="$1" local base_name base_name=$(basename "${file_path}") # 这个 local_file 只在本函数内有效,不会污染全局 local local_file="/tmp/${base_name}.tmp" echo "处理临时文件:${local_file}" } # 全局环境 result="外部值" process_file "/etc/hosts" echo "函数执行后,外部变量:${result}"而export是给“要传递给子进程的变量”用的。写成规范,就是默认不加export,只有明确要被子进程读取的变量才加。比如你要调用一个外部脚本:
export APP_ENV="production" export APP_CONFIG="/etc/app/config.yml" ./deploy_script.sh注意,子进程里改这些变量,不会影响父进程。这也是很多脚本出现“变量怎么改了没反应”的根源。
5. 变量相关的高频“坑”:每个我都踩过
5.1 赋值等号两边的空格
正确:var=value 错误:var = value # 这会触发“命令找不到 var” 错误:var= value # 等价于执行一个名为 var 的空环境变量的命令这大概是Shell新手入坑第一天就会遇到的错误。但别笑,当你从别的语言转过来时,很容易肌肉记忆地打成带空格。我自己在写长一点的赋值语句时,偶尔也会被编辑器格式强迫症带偏。
5.2 未初始化变量的默认行为
默认情况下,Shell会把未定义变量当“空字符串”处理,不报错。比如:
echo "路径是:${unknown_var}" # 输出“路径是:”如果这个变量名拼写错了,你还以为它是个合法空值,排查起来非常痛苦。规范做法是在脚本头部加这么一行:
#!/bin/bash set -uset -u一旦开启,任何未定义变量的展开都会报错并退出脚本。这在调试阶段是救命级别的功能。完整的“严格模式”是我现在所有脚本默认开启的组合:
#!/bin/bash set -euo pipefail-e:任何命令返回非零退出码时立即退出;-u:未定义变量直接报错;-o pipefail:管道中任何一环节失败,整条管道返回失败。
这套组合极大压缩了“脚本悄悄跑错”的概率。但有一点你要清楚:set -e并不是银弹。在if条件、while条件、函数内部的某些场景下,非零退出码可能不会导致脚本退出,代码逻辑仍需要自己判断。
5.3 变量值中有空格、通配符、换行时被拆散
这是“不加引号”引发的一组灾难,我再列一个真实案例。假设你要处理一批文件名,从某个列表文件里读到的路径带空格,不引号就全乱。
#!/bin/bash # 场景:一个列表文件内每一行是一个带空格的文件路径 # paths.txt 内容: # /data/my files/backup_1.tar # /data/my files/backup_2.tar while IFS= read -r p; do ls -l "$p" # 必须加引号 done < paths.txtread -r的-r参数是防止反斜杠转义,IFS=是防止行首尾空格被吃掉。读文件行的标准姿势,也是规范里必须固定的一个模板。
5.4 子Shell中的变量修改不生效
最经典的场景就是管道加while:
count=0 printf "a\nb\nc\n" | while read -r line; do count=$((count + 1)) done echo "总行数:${count}" # 输出是:总行数:0为什么?因为管道右边的while运行在子Shell中,所有变量修改都发生在子进程里,对父进程的count完全没有影响。这也是Shell面试题里最喜欢挖的一个点。
解决方案通常有三种:
# 方案1:用进程替换,让 while 在主Shell里执行 count=0 while read -r line; do count=$((count + 1)) done < <(printf "a\nb\nc\n") echo "总行数:${count}" # 方案2:把结果用命令替换收集,再解析 count=$(printf "a\nb\nc\n" | wc -l) echo "总行数:${count}" # 方案3:直接改用 mapfile 数组 mapfile -t lines < <(printf "a\nb\nc\n") echo "总行数:${#lines[@]}"这个坑我在早期脚本里不知踩了多少次。现在的规范是:除非明确要开子进程,否则我用进程替换< <( ... )代替管道来保持主Shell的变量状态。
5.5 反斜杠转义在变量展开中的表现
在双引号内,反斜杠只有对$、反引号、"、\和换行符才有效。如果变量本身存了一个带反斜杠的Windows路径(虽然Shell脚本里少见,但自动化场景会遇到):
win_path="C:\Users\Admin\AppData" echo "$win_path" # 双引号内反斜杠基本原样输出,除非后面跟的是 $ 或 ` 等字符这会带来一些诡异的展示问题。规范做法是:凡是路径类变量,在定义时就把它规范化,不要留反斜杠歧义,或者用单引号包住字面量。
5.6 环境变量污染与清理
写脚本时,我不建议依赖用户机器上的环境变量来做核心逻辑判断,因为那个变量的值在别人的机器上可能是另外一回事。比如HOME、USER、PATH,每个系统都可能有差异。我自己坚持三条:
- 脚本需要的关键配置,全都从脚本自己的命令行参数、配置文件或脚本内默认值来,不猜环境;
- 必须读取环境变量时,使用
${VAR:-default}给足默认值,避免“空值引发连锁反应”; - 跑完的临时环境变量,能不用全局就用局部,能
unset就unset,别污染当前Shell。
6. 把规范落到模板里:一个可直接抄作业的脚本骨架
6.1 基础脚本模板
关于规范,光讲原则不够,必须落到一个能直接拿来用的模板。我把自己现在所有新脚本默认套用的结构贴出来。它不长,但每个位置都是按规矩来的。
#!/usr/bin/env bash # # 脚本名称: backup_logs.sh # 功能描述: 按天备份指定目录下的日志文件到备份目录 # 用法: ./backup_logs.sh -s <源目录> -d <目标目录> [-v] # set -euo pipefail # ---------- 常量定义 ---------- readonly DEFAULT_BACKUP_ROOT="/data/backup" readonly DATE_SUFFIX=$(date +%Y%m%d_%H%M%S) # ---------- 全局变量(尽量少,尽量只在这里声明) ---------- SOURCE_DIR="" BACKUP_DIR="" VERBOSE=0 # ---------- 工具函数 ---------- log_info() { echo "[INFO] $(date '+%F %T') $*" } log_error() { echo "[ERROR] $(date '+%F %T') $*" >&2 } usage() { cat <<EOF 用法: $0 -s <源目录> -d <目标目录> [-v] 选项: -s 必选,要备份的源目录 -d 必选,备份存放目录 -v 可选,输出详细日志 -h 显示帮助信息 EOF exit 0 } # ---------- 参数解析 ---------- while getopts "s:d:vh" opt; do case "$opt" in s) SOURCE_DIR="$OPTARG" ;; d) BACKUP_DIR="$OPTARG" ;; v) VERBOSE=1 ;; h) usage ;; *) usage ;; esac done # ---------- 入口校验 ---------- if [ -z "${SOURCE_DIR}" ] || [ -z "${BACKUP_DIR}" ]; then log_error "源目录和目标目录都是必填项" usage exit 1 fi if [ ! -d "${SOURCE_DIR}" ]; then log_error "源目录不存在: ${SOURCE_DIR}" exit 1 fi mkdir -p "${BACKUP_DIR}" # ---------- 主逻辑 ---------- log_info "开始备份: ${SOURCE_DIR} -> ${BACKUP_DIR}" tar_cmd="tar -czf" [[ "${VERBOSE}" -eq 1 ]] && tar_cmd="${tar_cmd}v" # 注意 ${tar_cmd} 这个变量,在最终执行时要用 eval 吗? # 不,更规范的做法是用函数或直接拆分写 backup_file="${BACKUP_DIR}/logs_${DATE_SUFFIX}.tar.gz" if tar -czf "$backup_file" -C "$(dirname "${SOURCE_DIR}")" "$(basename "${SOURCE_DIR}")"; then log_info "备份完成: ${backup_file}" else log_error "备份失败" exit 1 fi这里用getopts而不是手写while case解析参数,是考虑到getopts支持选项合并、参数值附加等标准行为,代码更紧凑也更好扩展。当然,如果选项很复杂,也可以用上一节写的while shift手写,二者都是规范做法,选一种保持一致即可。
6.2 加一道静态检查:shellcheck
规范再熟,人总会手滑。所以我现在写完脚本,发布到任何环境之前,都会跑一遍shellcheck。它是一套开源的Shell静态检查工具,能抓出绝大多数变量相关的隐性问题。
安装方式很简单:
# Debian/Ubuntu sudo apt install shellcheck # CentOS/RHEL sudo yum install shellcheck # macOS brew install shellcheck跑一个脚本:
shellcheck backup_logs.sh它会告诉你类似“这个变量在这里引用了但没定义”“双引号里的$需要转义”“这个函数名和系统命令重名”等信息。我常常在提交给自动化平台前,把输出里的warning清零再发。它抓出来的很多问题,恰恰就是我在第5章列的那些坑——只是人在疲劳时真的会忘。
6.3 把规范变成习惯:小清单
最后分享一份我自己长期贴在终端上方的小清单,每次写脚本前过一遍脑子,能避免90%的变量问题。
- 变量命名:普通变量
snake_case,常量UPPER_SNAKE,一眼能看懂含义; - 定义变量:等号两侧不空格,字符串有空格就加双引号;
- 展开变量:默认加
"${var}",除非你明确需要分词和通配符展开; - 严格模式:
set -euo pipefail固定写在脚本头部; - 函数变量:全部用
local声明,避免污染全局;需要暴露给子进程的才用export; - 参数处理:坚持用
"$@"遍历外部输入,不要用$*; - 数组遍历:固定写
for item in "${arr[@]}",别偷懒丢引号; - 环境变量:读取时都用
${VAR:-default}兜底,不依赖用户机器配置; - 静态检查:脚本写完后跑一次
shellcheck,warning清零再发。
这九条不说有多高深,但每一条背后都对应着真实事故和漫长的排错时间。把规范变成每天写脚本的默认输出,你才能把更多精力留给真正的业务逻辑,而不是和Shell解释器的各种“意外行为”作斗争。