尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Shell变量规范实战:命名、展开、引号与陷阱

Shell变量规范实战:命名、展开、引号与陷阱 1. 为什么Shell脚本也要讲“规矩”不是洁癖是成本问题先讲个场景。我接手过一个跑批任务脚本几百行缩进混乱变量命名全是a、b、x1、x2这种没有注释没有函数拆分全靠一串串cp、sed、awk堆到底。每次要改个输出路径得全文搜索三次以上才能确认改哪里生怕动错一个变量导致后面全崩。后来实在受不了花了一整天重构加规范、理变量、拆函数从此这种脚本我再没为它加过班。Shell脚本尤其是刚入门时写的那些看起来就是“敲几条命令、跑通就行”。可真留在生产环境里跑上几个月你会发现脚本的质量决定的是后续所有人的时间和心情。这里说的“规范”绝不是为了代码好看搞形式主义它解决的是一笔实打实的成本账。我把它归纳成四个问题可读性也就是“换个人能不能看懂”。变量命名清晰、结构分明的脚本新人接手也能快速定位逻辑反之一个n3的变量你两周后回来自己都未必记得它是什么含义。可调试性也就是“出错时能不能快速锁定问题”。规范下写的脚本变量作用域清楚、函数边界明确报错栈一目了然杂乱脚本的报错信息基本等于没报。可移植性也就是“换个环境还能不能跑”。写死绝对路径、依赖特定用户环境变量、随手用反引号嵌套的脚本换个目录或换台机器就罢工这种经历做运维的同学应该都熟悉。安全性也就是“输入脏数据时会不会出大事”。不加引号的变量、不过滤的外部输入一旦展开成命令所有数据都可能在不知不觉中被执行或篡改。这四个问题一大半都和“变量”强相关。所以我一直觉得想在Shell这条路上走稳先啃透变量再谈其他。这篇文章我就把这段时间沉淀下来的、和变量相关的规范和实战心得一次性整理出来希望能帮你少走点弯路。2. 变量定义与分类先把最基础的边界搞清楚2.1 Shell变量的“弱类型”本质很多从Java、Python转过来学Shell的人一开始都会在变量上栽跟头因为Shell变量的模型跟高级语言差得太远。Shell里所有变量本质上都是字符串。你没看错连数字也是字符串。#!/bin/bash num123 echo $num 1 ? # 输出的是123 1 ? # 要真的做运算必须用 $((...)) echo $((num 1)) # 输出124这个特性决定了你在Shell里处理“变量的加减乘除”时思路必须转变不是“类型转换”而是“让解释器把字符串当作数字去做算术展开”。最常见的计算姿势有三种# 1. $(( )) 算术展开最常用性能好 a5 b3 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_RETRY3、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里${}和$()到底有什么区别”其实这两个东西从根上就不一样。${}是变量展开变量替换。它的作用是把一个变量的值取出来用。nametom echo ${name} # 输出 tom为什么明明$name也能取变量还非要加花括号因为${name}能明确界定变量名的边界。比如nametom # 想输出 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_file3.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 VERBOSE0 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) VERBOSE1 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_ENVproduction export APP_CONFIG/etc/app/config.yml ./deploy_script.sh注意子进程里改这些变量不会影响父进程。这也是很多脚本出现“变量怎么改了没反应”的根源。5. 变量相关的高频“坑”每个我都踩过5.1 赋值等号两边的空格正确varvalue 错误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中的变量修改不生效最经典的场景就是管道加whilecount0 printf a\nb\nc\n | while read -r line; do count$((count 1)) done echo 总行数${count} # 输出是总行数0为什么因为管道右边的while运行在子Shell中所有变量修改都发生在子进程里对父进程的count完全没有影响。这也是Shell面试题里最喜欢挖的一个点。解决方案通常有三种# 方案1用进程替换让 while 在主Shell里执行 count0 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_pathC:\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 VERBOSE0 # ---------- 工具函数 ---------- 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) VERBOSE1 ;; 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_cmdtar -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}兜底不依赖用户机器配置静态检查脚本写完后跑一次shellcheckwarning清零再发。这九条不说有多高深但每一条背后都对应着真实事故和漫长的排错时间。把规范变成每天写脚本的默认输出你才能把更多精力留给真正的业务逻辑而不是和Shell解释器的各种“意外行为”作斗争。
返回列表