
Shell编程培训实战:用条件判断和循环处理批量任务
Shell 脚本擅长把命令行工具串起来:检查条件、遍历一组输入、对每项执行操作,再根据执行结果决定继续还是退出。它常用于批量重命名、日志检查、文件归档、部署前校验和定时任务等场景。
批量处理的难点往往不在循环本身,而在边界:文件名可能包含空格和换行,命令可能失败,用户输入可能不符合预期,通配符也可能匹配不到任何文件。本文以 Bash 为例,逐步构造可运行的脚本,并说明何时需要更谨慎地处理输入和失败。
一、先明确脚本的运行环境
Shell 有多种实现,例如 Bash、Dash 和 Zsh。条件表达式、数组和部分扩展语法并不完全通用。下面的代码以 Bash 为目标,脚本通常用下面的 shebang 启动:
#!/usr/bin/env bash
保存为 batch.sh 后,可这样运行:
chmod +x batch.sh
./batch.sh
也可以显式使用 Bash 执行:
bash batch.sh
不要把 Bash 脚本当作通用 POSIX Shell 脚本运行。比如 [[ ... ]]、数组和 =(...) 等语法不是所有 Shell 都支持。若确实需要兼容 POSIX Shell,应使用其共同语法,并在目标环境中测试。
二、条件判断:根据状态选择动作
Shell 的条件判断通常围绕两类信息展开:字符串或数字的比较,以及命令的退出状态。命令退出状态为 0 表示成功,非零表示失败;这和许多编程语言中“真为非零”恰好相反。if 可以直接检查命令,不必先把结果转换成布尔值。
1. 检查文件和目录
常见文件测试操作符包括:
-e 路径:路径存在(Bash 支持;POSIX 常用-e,但部分旧环境存在差异)。-f 路径:是普通文件。-d 路径:是目录。-r 路径、-w 路径、-x 路径:当前进程具有可读、可写、可执行权限。-s 路径:存在且大小大于零。
下面的脚本检查输入目录和输出目录是否具备基本条件:
#!/usr/bin/env bash
input_dir=${1:-}
output_dir=${2:-}
if [[ -z $input_dir || -z $output_dir ]]; then
printf '用法:%s 输入目录 输出目录\n' "$0" >&2
exit 2
fi
if [[ ! -d $input_dir ]]; then
printf '输入目录不存在或不是目录:%s\n' "$input_dir" >&2
exit 1
fi
if [[ ! -d $output_dir ]]; then
printf '输出目录不存在或不是目录:%s\n' "$output_dir" >&2
exit 1
fi
if [[ ! -r $input_dir || ! -x $input_dir ]]; then
printf '无法遍历输入目录:%s\n' "$input_dir" >&2
exit 1
fi
printf '目录检查通过\n'
-r 和 -x 是权限检查,不等同于“后续操作必然成功”。权限、挂载状态、文件是否被其他进程移动等情况都可能在检查之后发生变化。因此,真正执行读写时仍要检查命令结果。
2. 字符串与数字比较
在 Bash 的 [[ ... ]] 中,-z 字符串 表示字符串为空,-n 字符串 表示字符串非空。数字比较常用 -eq、-ne、-lt、-le、-gt、-ge。字符串比较则可用 ==、!=。
count=12
if [[ $count -gt 0 ]]; then
printf '待处理数量:%s\n' "$count"
fi
mode=${1:-}
if [[ $mode == dry-run ]]; then
printf '当前为预览模式,不执行写操作\n'
fi
变量扩展通常应加双引号,例如 "$mode"。在 Bash 的 [[ ... ]] 中,变量不会像传统的未引用展开那样进行词拆分和路径名展开;但保持正确的引用习惯能减少迁移到其他语境时的错误。若使用 POSIX 风格的 [ ... ],变量引用尤其重要:
if [ -n "$mode" ]; then
printf '模式为:%s\n' "$mode"
fi
不要用字符串比较来代替数字比较。例如 [[ 10 < 2 ]] 比较的是字符串顺序,不是数值大小。处理外部传入的数字时,还应先验证格式,避免把空字符串或非数字内容交给算术表达式。
三、参数处理:先校验,再执行
脚本参数通过 $1、$2 等位置参数访问,$# 表示参数个数。使用 "$@" 可以把每个参数原样作为独立参数传递;使用未引用的 $* 或 $@,可能导致空格和通配符产生意外拆分。
例如,下面的脚本要求恰好两个参数,并在参数数量不正确时返回状态码 2:
#!/usr/bin/env bash
if [[ $# -ne 2 ]]; then
printf '用法:%s 源文件 目标文件\n' "$0" >&2
exit 2
fi
source_file=$1
target_file=$2
if [[ ! -f $source_file ]]; then
printf '源文件不存在或不是普通文件:%s\n' "$source_file" >&2
exit 1
fi
if ! cp -- "$source_file" "$target_file"; then
printf '复制失败:%s -> %s\n' "$source_file" "$target_file" >&2
exit 1
fi
printf '复制完成\n'
这里的 -- 用于让支持该约定的命令停止解析选项,避免以连字符开头的路径被误认为选项。不是每个命令都支持 --,应查看相应命令的帮助文档。即使使用了 --,仍应给路径加双引号,以保留空格和通配符等字符。
参数数量的边界:Shell 可以接收多个参数,但操作系统对单次执行时的参数和环境总量设有上限(通常由 ARG_MAX 等机制约束,具体数值随系统和环境而变)。不能假定任意大的参数列表都能一次传给外部命令。对于海量文件,优先考虑逐项处理、xargs 的分批调用,或支持标准输入的工具。
此外,Unix 路径名不能包含 NUL 字节,但可以包含空格、制表符、通配符,甚至换行符。若脚本要处理可能含换行的路径,不要用按行读取的方式收集文件名;应使用 NUL 分隔的接口。
四、循环处理:从简单列表到真实文件名
1. 遍历明确的值列表
当输入值由脚本明确给出时,Bash 的 for 循环简洁易读:
#!/usr/bin/env bash
for env_name in dev test staging; do
printf '检查环境:%s\n' "$env_name"
done
这里的列表只有脚本写死的三个值,不涉及用户输入或文件名拆分,因此风险较低。若列表来自变量,通常应使用数组保存元素,而不是拼接成一串空格分隔的文本。
#!/usr/bin/env bash
files=("report one.csv" "report two.csv" "summary.csv")
for file in "${files[@]}"; do
printf '文件:%s\n' "$file"
done
"${files[@]}" 会保留每个数组元素的边界。相反,"${files[*]}" 在双引号中会把所有元素合并成一个词,通常不适合逐项处理。
2. 用通配符遍历目录中的文件
下面示范处理当前目录的 .log 文件。启用 nullglob 后,没有匹配项时通配符展开为空,而不是保留字面上的 *.log:
#!/usr/bin/env bash
shopt -s nullglob
files=(./*.log)
if (( ${#files[@]} == 0 )); then
printf '当前目录没有 .log 文件\n'
exit 0
fi
for file in "${files[@]}"; do
if [[ -f $file ]]; then
printf '普通文件:%s\n' "$file"
fi
done
需要注意,./*.log 通常只匹配当前目录这一层,不会递归进入子目录。通配符默认也不会匹配以点开头的隐藏文件。若需要递归、处理隐藏文件或控制符号链接行为,应明确设计匹配范围,并使用相应工具或选项,不要假定一个通配符能覆盖所有需求。
使用通配符展开时,文件名中的空格和换行会作为同一个数组元素保留,因为每个匹配结果直接进入数组。不要把结果先写进一个普通字符串,再按空格拆开处理。
3. 遍历任意合法文件名:find 与 NUL 分隔
对于需要递归处理、且必须正确覆盖包含换行符的文件名的场景,可使用支持 NUL 分隔的 find 与 Bash 的 read -d '':
#!/usr/bin/env bash
root=${1:-.}
if [[ ! -d $root ]]; then
printf '目录不存在:%s\n' "$root" >&2
exit 1
fi
while IFS= read -r -d '' file; do
printf '发现文件:%s\n' "$file"
done < <(find "$root" -type f -name '*.log' -print0)
-print0 用 NUL 字节分隔路径,read -d '' 以 NUL 作为记录结束符;IFS= 和 -r 分别避免默认字段拆分和反斜杠转义。此写法使用 Bash 进程替换,不是 POSIX Shell 语法。
进程替换让循环在当前 Shell 中执行,但 find 的退出状态不一定能通过这个循环直接可靠地检查。若必须把“遍历命令失败”与“处理某个文件失败”分别报告,应采用能够显式保存并检查 find 状态的设计,例如先写入受控的临时文件,再读取并校验,或使用其他适合目标环境的接口。临时文件也要妥善创建和清理。
五、实战:批量检查并压缩日志
下面的脚本递归查找指定目录下的 .log 普通文件,对每个文件调用 gzip 压缩。它展示了参数校验、循环、错误累计和输出提示。
#!/usr/bin/env bash
set -u
if [[ $# -ne 1 ]]; then
printf '用法:%s 日志目录\n' "$0" >&2
exit 2
fi
root=$1
if [[ ! -d $root ]]; then
printf '目录不存在或不是目录:%s\n' "$root" >&2
exit 1
fi
if ! command -v find >/dev/null 2>&1; then
printf '找不到 find 命令\n' >&2
exit 1
fi
if ! command -v gzip >/dev/null 2>&1; then
printf '找不到 gzip 命令\n' >&2
exit 1
fi
failed=0
found=0
while IFS= read -r -d '' file; do
found=$((found + 1))
if [[ ! -r $file ]]; then
printf '跳过不可读文件:%s\n' "$file" >&2
failed=1
continue
fi
printf '压缩:%s\n' "$file"
if ! gzip -- "$file"; then
printf '压缩失败:%s\n' "$file" >&2
failed=1
fi
done < <(find "$root" -type f -name '*.log' -print0)
if (( found == 0 )); then
printf '没有找到 .log 文件\n'
fi
if (( failed != 0 )); then
printf '处理结束,但有文件未能处理\n' >&2
exit 1
fi
printf '处理完成,共检查 %d 个文件\n' "$found"
该示例的 gzip 通常会生成压缩文件并移除原文件,具体行为应以目标系统中的工具版本和选项为准。生产使用前应确认目标目录、备份策略、保留要求和磁盘空间。若不允许删除原文件,应选择相应选项或先写入另一个目标位置,并验证工具行为。
示例使用 set -u,让未设置的变量在部分使用方式下触发错误,但没有启用 set -e。脚本显式检查了关键命令的状态,并在出错时继续处理后续文件。是否遇错立即停止,要根据任务语义决定:批量报告常希望处理完所有项;部署或数据迁移则可能需要遇到首个错误就中止。
六、while 与计数循环:有条件地重复
while 会在条件成功时重复执行。下面的例子读取清单中的一行一行文本,适用于行本身不需要表达任意文件名的情况:
#!/usr/bin/env bash
list=${1:-}
if [[ ! -r $list ]]; then
printf '清单不可读:%s\n' "$list" >&2
exit 1
fi
while IFS= read -r line || [[ -n $line ]]; do
[[ -z $line ]] && continue
printf '记录:%s\n' "$line"
done < "$list"
read -r 防止反斜杠被当作转义符;IFS= 保留行首和行尾空白;条件末尾的 || [[ -n $line ]] 用来处理没有以换行符结束的最后一行。空行在这个示例中被跳过,但是否跳过应由清单格式决定。
如果清单记录的是任意路径,按换行读取就不安全,因为合法路径名本身可以含换行。此时应使用 NUL 分隔格式,或直接用 find -print0 产生文件列表。
需要固定次数时,可使用 Bash 算术循环:
for ((i = 1; i <= 5; i++)); do
printf '第 %d 次检查\n' "$i"
done
算术循环适合计数任务,不适合拿来遍历含有特殊字符的路径列表。对于外部输入的计数值,应先校验是否为预期的整数,并设置合理上限,避免意外执行过多次或让算术语法处理非预期输入。
七、break、continue 与退出状态
continue 跳过当前轮剩余语句,进入下一轮;break 结束当前循环。嵌套循环中,两者默认只影响最内层循环。
for file in "${files[@]}"; do
if [[ ! -f $file ]]; then
printf '跳过非普通文件:%s\n' "$file" >&2
continue
fi
if ! process_file "$file"; then
printf '处理失败,停止批次:%s\n' "$file" >&2
break
fi
done
process_file 需要是脚本中定义的函数或系统中可用的命令。函数或命令的返回状态由退出状态决定;通常以 return 0 表示成功,以非零值表示失败。外部命令的状态应紧接执行位置检查,避免后续命令覆盖 $?。
若脚本需要区分多种错误原因,可以约定不同的退出码,但要在文档中说明含义。对于仅供人阅读的错误信息,通常输出到标准错误(>&2);成功结果或处理清单则输出到标准输出,便于管道进一步处理。
八、常见错误与排查方法
1. 未引用变量,导致拆词和通配符展开
错误写法:
cp $source $target
如果路径包含空格,Shell 会把它拆成多个参数;路径中包含 * 等字符时,也可能触发路径名展开。通常应写成:
cp -- "$source" "$target"
2. 用命令输出拼接文件列表
下面的写法对含空格或换行的文件名不可靠:
for file in $(find "$root" -type f); do
process_file "$file"
done
命令替换会去除末尾换行,随后结果还会发生字段拆分和通配符展开。文件列表应通过数组、NUL 分隔流或工具自带的批量接口传递,不要把路径列表当作普通空格分隔文本。
3. 忘记处理通配符没有匹配项
在默认 Bash 设置下,未匹配的 *.log 可能保留为字面文本。循环接下来可能把名为 *.log 的字符串当作路径。可以使用 nullglob 后检查数组长度,也可以按脚本需求显式测试匹配结果。若启用 failglob,未匹配会导致错误,更适合希望立即失败的场景。
4. 把测试和执行当作原子操作
“先检查文件存在,再操作”无法避免检查后文件被其他进程替换、删除或修改。检查有助于给出清楚的提示,但不能消除竞争条件。对重要数据,应依赖工具操作本身的错误状态,并采取备份、临时文件加原子替换、权限隔离等措施;这些措施的适用性取决于具体文件系统和业务流程。
5. 不恰当地启用 set -e
set -e 试图在某些命令失败时让脚本退出,但它在条件语句、管道、函数和子 Shell 等上下文中的行为有例外,也容易让脚本在没有预期的地方中止。它不是完整的错误处理方案。对关键操作,使用 if 命令; then ...; else ...; fi 显式处理,通常更容易阅读和维护。若使用 set -o pipefail,它会影响管道的退出状态判断,也应在目标脚本中确认效果。
6. 将用户输入直接交给 Shell 再解释
不要用 eval 执行由用户输入拼出的字符串,也不要把未经设计的输入放进新的 Shell 命令文本中。应将输入作为独立参数传递,并校验允许的值和路径范围。双引号可以防止常见的拆词与通配符扩展,但不能替代授权检查、输入校验或最小权限原则。
九、安全用途的边界
正确引用参数、避免 eval、限制可处理的目录,可以显著降低 Shell 注入和误操作风险,但不能单凭几处引号就宣称脚本“安全”。脚本还可能受到符号链接、目录权限、并发修改、特权运行、环境变量、命令搜索路径以及外部工具实现差异等因素影响。
尤其要谨慎地以 root 或其他高权限账户批量删除、覆盖或移动文件。先以低风险方式验证目标集合,例如输出将要操作的路径;明确限制根目录;避免对未经校验的路径执行递归破坏性操作;必要时先备份,并在隔离环境中测试。预览输出可以帮助发现错误,但预览与正式执行之间仍可能发生文件系统变化,因此它不是强一致性保证。
处理不可信输入时,应区分“参数被安全地传递”与“操作本身被授权”。例如,rm -- "$path" 可以避免路径被当成选项解析,但并不能证明删除这个路径是合理的。对文件类型、目录归属、允许的操作和访问权限,仍需独立验证。
十、批量脚本上线前的检查清单
- 脚本声明的解释器和使用的语法一致,在目标系统上实际运行过。
- 参数数量、必填项、目录和数字范围都有校验,错误时给出用法提示。
- 变量和路径按需要加双引号;外部命令支持时使用
--分隔选项。 - 文件列表不会经过不安全的命令替换和空格拆分;需要处理任意路径时使用 NUL 分隔。
- 空列表、隐藏文件、子目录、符号链接等行为已有明确约定。
- 每项任务的失败如何处理已定义:跳过并累计、立即停止,还是继续并在末尾返回失败。
- 破坏性操作经过预览和低风险环境测试,并具备合适的备份或恢复方案。
- 海量任务考虑了参数长度、运行时间、磁盘空间和日志规模限制。
结语
Shell 条件判断和循环的基本语法很少,但可靠的批量处理需要更完整的思路:明确运行环境,区分成功与失败,保留参数边界,正确表达文件列表,并预先定义异常时的行为。简单列表适合 for,固定次数适合算术循环,逐行文本可用 while read,而任意文件名应采用 NUL 分隔等可靠接口。
从一个小批次开始,在临时目录里验证路径含空格、空匹配、权限不足和单项失败等情况,再逐步扩大处理范围。这样写出的脚本不只是“能跑”,也更容易排查、复用和安全维护。







