S8

Shell 基础

点击正文段落选定收藏或划线位置;未选定时使用屏幕内第一段。

课内目录

S6 让你能在终端组织命令、重定向与管道,S7 解释了身份与权限如何影响一次操作。现在把这些操作写成可以保存、检查、反复运行的文件。本课使用 Bash;练习只操作自行建立的临时目录。涉及 stat -c、find -printf、cp -T 的例子按 GNU/Linux 编写,其他系统需要替换对应工具选项。

1 为何要从「敲命令」走到「可重复脚本」

交互操作适合探索:先看目录,再修改参数,看到结果后决定下一步。历史记录保存了敲过的命令,却未必保存当时的工作目录、变量和执行身份。昨天成功的一串命令,今天从另一个目录粘贴执行,可能指向完全不同的数据。可重复脚本要把这些隐含条件变成输入、检查和明确的失败结果。

脚本仍然是一组命令,只是命令放进文件,由解释器按顺序读取。文件可以进入版本控制:审查者能看到本次增加了哪项检查、删除了哪个选项;出问题时也能对照上一个版本。口头说明“照昨天那样跑”缺少这些证据。脚本文件本身不保证可靠,可靠性来自对输入、路径和退出码的约定。

把下面内容保存为 tool.sh:

#!/usr/bin/env bash
printf '开始检查\n'
printf '工作目录:%s\n' "$PWD"

首行叫 shebang。直接执行文件时,系统据此启动解释器;/usr/bin/env 会从当前 PATH 查找 bash。这方便了安装路径不同的环境,也意味着运行环境需要提供可信的 PATH。# 开头的其他普通行通常是注释,适合说明用途和限制,没必要逐行翻译代码。

$ chmod u+x tool.sh
$ ./tool.sh
开始检查
工作目录:/tmp/s8-demo
$ bash tool.sh
开始检查
工作目录:/tmp/s8-demo

chmod u+x 给文件所有者增加执行权限。./tool.sh 明确指定当前目录里的文件,需要执行权限;bash tool.sh 是让已经启动的 Bash 读取文件,不要求脚本本身具有执行位,但需要能读取它。后一种方式明确选择 Bash,文件里的 shebang 不会把它改成另一种解释器。不要为了消除权限报错直接扩大到所有人可写。

执行脚本通常会启动子进程,脚本中的 cd 不会把调用它的终端一起切换目录。不要为了让这个变化留下来,随手把运行方式换成 source tool.sh:source 会在当前 shell 执行文件,变量、选项和 exit 都可能影响当前会话。只有明确设计为加载配置或函数的文件,才采用这种调用约定。区分执行和加载,也能解释为什么脚本里的 export 不会自动改写外层终端环境。

本课会使用数组之外的基础结构,也会用到 Bash 专有的 pipefail、nullglob 和 read -d。因此不要把首行改成 #!/bin/sh 后假定效果相同。POSIX shell 是另一套兼容性约束,等确有跨系统需求时再逐项迁移。

交互探索与脚本复用
交互探索与脚本复用

2 变量与环境:赋值、导出与可见范围

变量给一段值起名字。赋值写成 name=value,等号两边不能放空格。name = value 会被解释为执行名叫 name 的命令,并给它传入参数;它不是排版不同的赋值。值包含空格时,在赋值处加引号,让读者看清整个值的边界。

name='nightly job'
printf '<%s>\n' "$name"
prefix=backup
printf '%s\n' "${prefix}_2026.log"
<nightly job>
backup_2026.log

$name 取出变量值,${name} 用花括号明确变量名在哪里结束。$prefix_2026 会寻找名为 prefix_2026 的变量,并不会自动理解你想追加后缀。花括号负责名字边界,双引号负责展开后的参数边界,这两个作用不能互相替代。

Shell 变量先属于当前 shell。环境变量是被标记为可以传给随后启动的子进程的变量。下面在新 Bash 中运行实验,避免已有变量的导出属性干扰结果;--noprofile --norc 避免读取相应启动配置。

bash --noprofile --norc <<'BASH'
unset S8_LOCAL S8_SHARED
S8_LOCAL=inside
S8_SHARED=before
printf '父进程:%s / %s\n' "$S8_LOCAL" "$S8_SHARED"
bash -c 'printf "子进程:%s / %s\n" "${S8_LOCAL-未设置}" "${S8_SHARED-未设置}"'
export S8_SHARED
bash -c 'printf "导出后:%s\n" "$S8_SHARED"; S8_SHARED=child'
printf '父进程仍为:%s\n' "$S8_SHARED"
BASH
父进程:inside / before
子进程:未设置 / 未设置
导出后:before
父进程仍为:before

导出是让子进程继承启动时的值,不是建立双向共享变量。子进程把值改成 child,不会反向改写父 shell。${变量-默认值} 在变量未设置时给出默认文字,后面处理可选参数时也能用到。这里通过带引号的 heredoc 标记保留实验文本中的美元符号,让新启动的 Bash 自己展开。

env 可以查看传给进程的环境,但不会列出全部普通 shell 变量。排查时应打印需要的某一项,避免把令牌等敏感值混入日志。还可以写 S8_SHARED=once bash -c 'printf "%s\n" "$S8_SHARED"',只为这次命令提供环境值;它不会把父 shell 中的值永久改成 once。

还要区分“没有设置”和“设置成空字符串”。两者在直接打印时都像什么也没有,但对默认值和严格检查的含义不同。下面的减号形式只在未设置时采用默认值,冒号减号形式则把空值也算进去。它们不会自动改写变量,只改变这次展开的结果。

unset S8_OPTION
printf '未设置:<%s>\n' "${S8_OPTION-fallback}"
S8_OPTION=''
printf '空值:<%s>\n' "${S8_OPTION-fallback}"
printf '空值也默认:<%s>\n' "${S8_OPTION:-fallback}"
未设置:<fallback>
空值:<>
空值也默认:<fallback>

对日志前缀这类可选配置,默认值可以提升便利性;对目标目录这类关键参数,静默填默认值可能把错误输入变成真实写入。应先确定业务含义,再决定默认策略。把必需输入写在参数检查里,通常比等执行到一半才发现值为空更容易排查。

3 引用:无引号、双引号、单引号与空格陷阱

脚本传给命令的是一组参数,不是一张看起来像命令的图片。在通常的命令参数位置,未引用的变量展开结果会按 IFS 分词,随后还可能进行路径名展开。默认分隔字符包括空格、制表符和换行。文件名里的空格因此可能把一个参数变成两个;变量里的 * 还可能匹配当前目录的文件。

用 printf 观察参数,不执行删除操作:

file='report final.txt'
printf '<%s>\n' $file
printf '<%s>\n' "$file"
printf '<%s>\n' '$file'
<report>
<final.txt>
<report final.txt>
<$file>

这几行只有引用方式不同,参数却不同。无引号时得到两个参数;双引号允许变量展开,同时保留一个完整参数;单引号把美元符号也当普通字符。printf 会重复使用格式串,因此每个参数各占一行,适合看清边界。echo 对某些选项和反斜杠的处理有差异,本课优先使用 printf。

危险写法 rm $f 的问题,是作者以为 $f 代表一个文件,实际却可能传入多个路径或匹配更多文件。不要在真实目录里通过删除来验证它。先用上面的参数打印法检查,再在真正需要删除时使用 rm -- "$f";其中引号保留参数边界,-- 防止以短横线开头的文件名被当成选项。-- 是常见工具的约定,使用别的命令时仍需查它是否支持。

引用也影响通配符:printf '%s\n' *.log 让 shell 寻找日志文件;printf '%s\n' '*.log' 打印字面量。若 pattern='*.log',直接展开 $pattern 会重新触发分词与匹配,行为依赖当前位置。脚本应明确写出需要展开的通配符,把读入的文件名始终当数据处理。

传递脚本的全部参数时,优先使用 "$@",它逐个保留参数边界。"$*" 把所有参数用 IFS 的第一个字符连接成一个参数;未引用的 $* 还会继续分词和匹配。比如 set -- 'a b' c 后,printf '<%s>\n' "$@" 输出 <a b>、<c> 两行,而 "$*" 输出 <a b c> 一行。包装命令的函数应写 some_command "$@",才不会在转交参数时丢失边界。

无引号展开会分词
无引号展开会分词
双引号与单引号如何保留边界
双引号与单引号如何保留边界

4 退出码进入脚本:if 与 set -e 的直觉

S6 中的命令执行完都会留下退出状态。通常 0 表示成功,非零表示失败或其他约定结果;具体含义要看命令。例如 grep 的 1 表示没有匹配,和读文件出错不是同一件事。Shell 用这个状态决定分支,并不扫描输出里有没有“成功”两个字。

if test -d /definitely-missing-s8; then
  printf '目录存在\n'
else
  printf '目录不存在\n'
fi

输出是 目录不存在。if 命令; then 直接执行命令并判断状态,[ 也是用于条件检查的命令形式,末尾需要单独的 ]。不要先执行检查,再插入一条日志,最后才判断 $?:日志命令已经覆盖了原来的状态。

bash -c 'exit 7'
rc=$?
printf '捕获到退出码:%s\n' "$rc"

输出为 捕获到退出码:7。赋值必须紧跟待检查的命令。若需要在开启 set -e 后捕获失败,更清楚的写法是把命令放进 if,在 else 开头保存状态;避免用 if ! cmd 后再取原始状态,因为 ! 已经将状态反转。

if bash -c 'exit 7'; then
  printf '任务成功\n'
else
  rc=$?
  printf '任务失败,状态=%s\n' "$rc" >&2
fi

set -e 让许多未被处理的失败结束脚本。bash -e -c 'false; printf "继续\n"' 不会打印“继续”,退出码为 1。但它不是“任何地方出现非零就退出”的开关。if 的测试命令失败本来就用于选择分支,因此不会触发相同的退出;&&、||、循环条件以及函数所处的调用上下文也有例外。关键操作仍应显式检查,不能把这组选项当作完整的错误处理机制。

set -e:独立失败与 if 测试
set -e:独立失败与 if 测试
set -e:管道失败先定状态
set -e:管道失败先定状态

回到 S6 的管道:默认状态来自最后一个命令。下面中间一步读完输入后退出 7,最后的计数命令依然正常结束:

printf 'data\n' | bash -c 'cat >/dev/null; exit 7' | wc -c
echo $?
set -o pipefail
printf 'data\n' | bash -c 'cat >/dev/null; exit 7' | wc -c
echo $?
set +o pipefail
0
0
0
7

这里没有开启 set -e,所以能打印失败后的状态。开启 pipefail 后,管道取最右侧非零状态,全部成功才返回 0。它不会让后面的命令回滚,也不保证发生失败时所有进程立即停止。若同时开启 set -e,未被条件接住的失败管道可能直接结束脚本,后面的 echo 就不会执行。

管道状态的传播
管道状态的传播

5 循环与批量:for 遍历文件名的安全写法

批量处理先解决“每次循环拿到什么”。下面在独立 Bash 中建立练习目录,目录路径由 mktemp 返回;这里有意保留练习数据,便于检查结果。

bash <<'BASH'
work=$(mktemp -d)
printf '练习目录:%s\n' "$work"
cd -- "$work" || exit 1
touch -- 'app.log' 'error final.log' 'notes.txt'
shopt -s nullglob
for f in *.log; do
  printf '检查 <%s>\n' "$f"
done
BASH
练习目录:/tmp/tmp.A1b2C3d4E5
检查 <app.log>
检查 <error final.log>

临时路径每次不同。*.log 在循环开始前展开成文件名列表,每个匹配结果天然作为独立的一项,文件名中的空格不会再次拆开。循环体仍要写 "$f",否则变量展开时会重新引入分词问题。若只处理普通文件,可在循环体先写 [ -f "$f" ] || continue,跳过同样以 .log 结尾的目录。

默认情况下,没有匹配时 Bash 会保留字面量 *.log,循环仍执行一次。shopt -s nullglob 让没有匹配的模式消失,循环执行零次。这是 Bash 选项,影响当前 shell 后续的通配符展开;上例把它限制在独立进程。零次执行是否算成功取决于任务,必需存在日志的任务还应统计匹配数,并在零个文件时主动报错。

不要写 for f in $(ls)。ls 的展示输出不是参数列表,命令替换还会删除末尾换行,再按空白分词,遇到空格或换行文件名就失真。当前目录的简单批量任务用通配符;递归任务需要 find 等工具,并通过 NUL 分隔传递文件名。第 9 节会展示这种传递方式,同时指出即使文件名边界安全,错误状态仍可能丢失。

无匹配时的差别可以单独观察,不必猜测:在一个空目录运行 for f in *.log; do printf '<%s>\n' "$f"; done,默认会输出 <*.log>;先启用 nullglob 再执行同一个循环,则没有输出。这种静默跳过对可选归档可能合理,对每日必交的数据则需要报警。判断“有无输入”与判断“每份输入是否处理成功”是两道独立检查,不能用循环正常结束来替代前一道检查。

6 函数、参数与 PATH:脚本怎样找到命令

脚本通过位置参数接收输入,避免每次换目录都修改源码。运行 ./show.sh 'reports 2026' archive 时,$1 是 reports 2026,$2 是 archive,$# 是参数数量 2,$0 通常是调用时使用的脚本名。先检查数量,再读取必需参数;启用未设置变量检查后,直接访问不存在的 $2 会报错。

#!/usr/bin/env bash
show_pair() {
  printf '来源=<%s>\n目标=<%s>\n' "$1" "$2"
}
if [ "$#" -ne 2 ]; then
  printf '用法:%s 来源 目标\n' "$0" >&2
  exit 2
fi
show_pair "$1" "$2"
$ bash show.sh 'reports 2026' archive
来源=<reports 2026>
目标=<archive>
$ bash show.sh reports
用法:show.sh 来源 目标

最后一条命令的状态为 2。函数把可命名的工作组合起来;调用它时,函数内部的 $1、$2 来自函数这次收到的参数,而不是强制绑定脚本入口参数。函数用 return 返回状态,脚本用 exit 结束整个进程。函数内部临时变量可以用 Bash 的 local 限制作用范围,避免误改外层同名变量。

函数的默认返回状态同样来自最后执行的命令。例如函数先调用失败命令,再打印一条成功日志,调用方可能只看到日志的零状态。将多个步骤收进函数并不会自动形成事务,也不会保留每一步错误。应在失败分支 return 非零,或者在函数内累计结果,再明确返回。return 后仍由脚本调用方决定是否继续处理其他输入,这与 exit 直接结束脚本的控制范围不同。

输入参数解决“处理谁”,PATH 解决“执行谁”。命令名包含 / 时按指定路径执行;没有 / 时,Bash 还会考虑函数、内建命令,再对外部命令按 PATH 中的目录顺序查找,查找结果也可能被缓存。command -v 能报告当前 shell 如何解析这个名字,输出不一定是绝对路径,例如内建命令可能只显示名称。

$ command -v bash
/usr/bin/bash
$ command -v printf
printf
$ command -v s8_command_that_does_not_exist
$ echo $?
1

bash 的实际路径随系统而异。当前目录通常不在 PATH 里,所以把 show.sh 放在眼前也不意味着能直接输入 show.sh 运行;./show.sh 表明了路径。不要通过把 . 加到 PATH 来消除这个提示,尤其不要让它优先于可信系统目录。定时任务的环境常比终端精简,应检查依赖命令,必要时设置经过审查的 PATH。

依赖检查也要把结果变成状态约定。例如 if command -v stat >/dev/null 2>&1; then printf 'stat 可用\n'; else printf '缺少 stat\n' >&2; exit 2; fi,在提供该工具的机器输出 stat 可用。但命令存在并不保证选项兼容:某系统的 stat 可能没有 -c。对固定部署环境应记录工具版本,对跨系统脚本应提供明确分支,别把选项报错误判成目录权限拒绝。

命令名怎样解析
命令名怎样解析

7 常见坑:未引用变量、管道掩盖失败、依赖当前目录

这几类故障往往在“手工运行成功”后才出现。终端里恰好只有简单文件名,未引用变量没有暴露问题;生产数据出现空格或星号后,同一行命令处理的参数数量就变了。复现时应打印带边界的参数,而不是只打印拼接后的整行文字。Bash 的 printf '%q\n' "$f" 能给出便于观察的转义表示,但它只是排查输出,不应拿来再拼接执行命令。

管道的问题是另一个维度。producer | consumer 即使正确保留了全部文件名,生产者失败也可能被成功的消费者遮住。先问哪一步失败,再问状态怎样传到脚本入口。使用 pipefail 能发现管道成员的非零状态,但如果成员内部忽略错误并返回零,外层也无从发现。第 9 节将把这两个层次分别修复。

相对路径则依赖进程的工作目录。cat ./config.txt 中的点不是脚本所在目录。交互 shell、cron 和 systemd 启动服务时,工作目录可能不同;某些系统服务从 / 开始。不要假定所有 cron 都从 / 开始,也不要以为脚本会自动切换到自身位置。可以从 / 启动脚本作对照,观察它是否仍能找到约定的输入。

如果配置明确与脚本放在一起,可以在 Bash 脚本中建立基准目录:

script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 1
config="$script_dir/config.txt"
if [ ! -r "$config" ]; then
  printf '配置不可读:%s\n' "$config" >&2
  exit 1
fi

这个写法适合普通脚本路径,不负责追踪符号链接到最终目标。若部署通过符号链接组织文件,需要另定资源定位约定。另一种更容易审查的方案是要求调用者传入绝对配置路径。无论选哪一种,都应在日志中记录实际使用的路径,并在路径无效时停止,而不是悄悄换用当前目录里的另一个文件。

另一个容易被误判为“命令没报错”的问题来自重定向顺序。Shell 在执行命令前先处理重定向,并按从左到右的顺序修改文件描述符。1 是标准输出,2 是标准错误;2>&1 的含义是让标准错误指向标准输出此刻指向的位置,不是让两者永远绑定。下面在临时目录里比较两种顺序:

bash <<'BASH'
work=$(mktemp -d) || exit 1
bash -c 'printf "result\n"; printf "error\n" >&2' > "$work/both.log" 2>&1
bash -c 'printf "result\n"; printf "error\n" >&2' 2>&1 > "$work/out.log"
printf '合并日志:\n'
cat -- "$work/both.log"
printf '仅输出日志:\n'
cat -- "$work/out.log"
BASH
error
合并日志:
result
error
仅输出日志:
result

第 2 次调用先把标准错误复制到原标准输出,再把标准输出改到文件,所以那条 error 仍送往调用方的标准输出。若调用方是终端,它会显示在那里;若调用方又接了管道,它可能进入下一条命令。自动任务只收集 out.log 时,就可能漏看失败原因。排查时应同时核对命令状态与输出去向,不能把日志文件没有错误当成执行成功。

> 还会在命令启动前创建或截断目标文件。因此 cat "$config" > "$config" 会先清空配置,再让 cat 读取空文件,甚至返回成功。引号只能保护路径边界,不能阻止截断。需要转换配置时,应写入另一个临时文件,检查转换状态和生成内容后再替换原文件。若重定向本身因父目录不存在或权限不足而失败,目标命令不会执行,整条简单命令返回非零;这种失败也应由调用处显式接住。

8 案例:一键检查目录权限的小脚本

S7 的身份与权限现在成为脚本的检查条件。下面的 check-dir.sh 接收一个目录,以当前进程身份检查读取和搜索权限,再输出 GNU stat 的模式信息。目录的读权限用于列出条目,搜索权限影响访问目录内的路径,因此不能只看到 [ -r ] 成功就断言里面的文件都能打开。

#!/usr/bin/env bash
if [ "$#" -ne 1 ]; then
  printf '用法:%s 目录\n' "$0" >&2
  exit 2
fi
dir=$1
if [ ! -d "$dir" ]; then
  printf '拒绝:不是目录:%s\n' "$dir" >&2
  exit 1
fi
if [ ! -r "$dir" ] || [ ! -x "$dir" ]; then
  printf '拒绝:目录不可读取或搜索:%s\n' "$dir" >&2
  exit 1
fi
if ! stat -c '模式=%A (%a) 所有者=%U:%G' -- "$dir"; then
  printf '拒绝:无法获取目录元数据:%s\n' "$dir" >&2
  exit 1
fi
printf '通过:%s\n' "$dir"
exit 0

这是 20 行脚本。这里显式处理每个关键失败,便于初学者看到状态从哪里来;没有依赖 set -e 自动推断流程。日志与错误放在标准错误,正常结果放在标准输出,调用者可以分别保存。没有 GNU stat 时可用 ls -ld -- "$dir" 展示元数据,但输出格式也随系统不同,别把展示文本当稳定的数据接口。

fixture=$(mktemp -d)
mkdir -- "$fixture/readable" "$fixture/denied"
chmod 700 -- "$fixture/readable"
chmod 000 -- "$fixture/denied"
bash check-dir.sh "$fixture/readable"
echo $?
bash check-dir.sh "$fixture/denied"
echo $?
chmod 700 -- "$fixture/denied"
模式=drwx------ (700) 所有者=learner:learner
通过:/tmp/tmp.F6g7H8j9K0/readable
0
拒绝:目录不可读取或搜索:/tmp/tmp.F6g7H8j9K0/denied
1

路径、用户名和组名按实际环境变化。这份权限失败输出要求以普通用户运行;特权身份可能绕过相应访问限制,不能拿它验证普通用户的拒绝场景。无论身份如何,传入不存在的路径都会走“不是目录”分支并返回 1。使用不同身份验证正是 S7 的延伸,不要为了得到期望输出而临时切换到更高权限。

检查通过只证明检查当时、当前身份下满足这些条件。目录可能随后改变,里面某个文件也可能另有访问限制。因此真正读写文件的命令还要检查自己的返回值。这个脚本适合作为入口体检,不能替代后续操作的错误处理,更不能单凭权限位推断整个备份一定成功。

9 排查案例:脚本「看起来成功」却漏处理半份数据

夜间任务报告 backup.sh 退出 0,目标目录却只有一半普通文件。先保留日志与现场,再缩小到可重复样本。缺文件可能涉及发现范围、复制失败或计数口径;没有写入错误的证据,不能把原因直接归为磁盘已满。下面构造文件类型冲突,让失败不依赖当前用户有没有特权。

case_dir=$(mktemp -d)
mkdir -p -- "$case_dir/src" "$case_dir/dst"
for n in 01 02 03 04; do
  printf 'record %s\n' "$n" > "$case_dir/src/$n.txt"
done
mkdir -- "$case_dir/dst/01.txt" "$case_dir/dst/02.txt"
printf '案例目录:%s\n' "$case_dir"

源目录有 4 个普通文件,目标位置的 01.txt、02.txt 却是目录。将下面代码保存为 backup.sh。这是供诊断的故障版本,不用于真实备份:

#!/usr/bin/env bash
src=$1
dst=$2
find "$src" -maxdepth 1 -type f -print0 |
  while IFS= read -r -d '' f; do
    cp -T -- "$f" "$dst/${f##*/}"
  done
rc=$?
printf 'pipeline=%s\n' "$rc"
printf 'backup finished\n'

-print0 与 read -r -d '' 使用 NUL 分隔,能保留空格、反斜杠和换行文件名;IFS= 避免剥除开头结尾空白。${f##*/} 去掉最长的目录前缀,得到文件名。GNU cp -T 要求把第二个路径视作确切目标,禁止把文件悄悄复制进同名目录。这个例子只复制源目录第一层普通文件,不声称实现完整备份语义。

先运行,再立即打印脚本状态。以下输出省略了临时路径的随机后缀;错误信息按英文工具环境展示:

$ LC_ALL=C bash backup.sh "$case_dir/src" "$case_dir/dst"
cp: cannot overwrite directory '/tmp/tmp.CASE/dst/01.txt' with non-directory
cp: cannot overwrite directory '/tmp/tmp.CASE/dst/02.txt' with non-directory
pipeline=0
backup finished
$ echo $?
0

复制失败是真的,完成文字和零状态也是真的。先加入 set -x,或用 bash -x backup.sh ... 开启跟踪。跟踪可能泄露参数,排查完应关闭;这里只使用无敏感信息的练习数据。下面是相关片段,具体遍历顺序由文件系统决定:

+ cp -T -- /tmp/tmp.CASE/src/01.txt /tmp/tmp.CASE/dst/01.txt
cp: cannot overwrite directory '/tmp/tmp.CASE/dst/01.txt' with non-directory
+ IFS=
+ read -r -d '' f
+ cp -T -- /tmp/tmp.CASE/src/03.txt /tmp/tmp.CASE/dst/03.txt
+ IFS=
+ read -r -d '' f
+ cp -T -- /tmp/tmp.CASE/src/04.txt /tmp/tmp.CASE/dst/04.txt
+ IFS=
+ read -r -d '' f
+ rc=0
+ printf 'pipeline=%s\n' 0

这段记录对应最后处理的是成功文件的那次运行。失败的 cp 没有让循环停止,后面的成功覆盖了循环体状态;while 最终返回最后一次执行循环体的状态。默认管道又取最后一个成员,即整个循环的状态,所以紧跟管道的 $? 为 0。即使某次最后处理的文件失败,脚本末尾成功的 printf 仍可能让整个脚本退出 0。因此不能依赖遍历顺序碰巧暴露错误。

只加 pipefail 修不好这个例子:当循环自己已经返回 0 时,它看不到循环里面较早的失败。应在循环内累计失败并显式退出,再让 pipefail 捕获管道其他成员的失败。把 backup.sh 替换为下面版本:

#!/usr/bin/env bash
set -o pipefail
if [ "$#" -ne 2 ]; then
  printf '用法:%s 源目录 目标目录\n' "$0" >&2
  exit 2
fi
src=$1
dst=$2
if [ ! -d "$src" ] || [ ! -d "$dst" ]; then
  printf '源和目标必须是已存在的目录\n' >&2
  exit 2
fi
if find "$src" -maxdepth 1 -type f -print0 |
  (
    failed=0
    while IFS= read -r -d '' f; do
      if cp -T -- "$f" "$dst/${f##*/}"; then
        printf '已复制:%s\n' "$f"
      else
        failed=$((failed + 1))
        printf '复制失败:%s\n' "$f" >&2
      fi
    done
    printf '失败数=%s\n' "$failed" >&2
    [ "$failed" -eq 0 ]
  ); then
  printf 'backup finished\n'
else
  rc=$?
  printf 'backup failed,状态=%s\n' "$rc" >&2
  exit "$rc"
fi

括号明确建立子 shell,计数和最后的判断都发生在里面,避免试图在管道外读取循环里修改的变量。[ "$failed" -eq 0 ] 让任何复制失败都变成这个成员的非零状态;pipefail 则让 find 自己失败时也能影响整条管道。管道被放在 if 中,失败可以进入明确的日志和退出分支。这个版本允许继续尝试剩余文件,但最终报告整体失败。

在冲突尚未修复时运行,会记录 失败数=2、backup failed,状态=1,脚本状态为 1。接着核对数量,不能只相信新日志的措辞。下面按“第一层普通文件”计数,用每个文件一个点避免文件名里的换行扰乱行数:

count_files() {
  find "$1" -maxdepth 1 -type f -printf '.' | wc -c
}
count_files "$case_dir/src"
count_files "$case_dir/dst"
4
2

已经得到同名目录导致复制失败的证据。仅在这份练习夹具中,把冲突目录移到旁边保留,再重跑修复版:

mkdir -- "$case_dir/conflicts"
mv -- "$case_dir/dst/01.txt" "$case_dir/dst/02.txt" "$case_dir/conflicts/"
bash backup.sh "$case_dir/src" "$case_dir/dst"
echo $?
count_files "$case_dir/src"
count_files "$case_dir/dst"
for n in 01 02 03 04; do
  cmp -- "$case_dir/src/$n.txt" "$case_dir/dst/$n.txt" || exit 1
done

末尾结果为 失败数=0、backup finished,随后状态和数量依次为 0、4、4;cmp 全部成功时没有输出。复制日志的顺序可能不同,标准错误与标准输出合并后的显示顺序也可能不同。数量一致还不足以证明内容一致,逐个比较进一步确认这份夹具的数据。真实备份还需另行约定覆盖、元数据、恢复验证和并发策略,不能把这个教学复制器直接视为完整备份产品。

最后补做一次失败验收:保持目标目录正常,把源路径换成夹具内不存在的路径,修复版应打印 源和目标必须是已存在的目录 并返回 2。再恢复正确路径重跑,状态应为 0,目标数量仍为 4,内容比较仍通过。这分别验证了入口拒绝和重复运行,避免只用顺利路径证明修复。真实现场若要搬动冲突目录,必须先核对其用途与数据归属;练习中的移动步骤不能自动成为线上处置方案。

放进方案里

把运维脚本纳入方案时,应让审查者能回答:谁执行、从哪里执行、接收什么输入、失败如何被调用方看见。文件首行明确 Bash,用途说明写清依赖工具与适用系统;版本控制保存变更。检查参数数量,并约定空输入、无匹配文件和部分成功分别意味着什么。运行身份延续 S7 的权限要求,日志记录实际路径,便于复现当时的判断。

检查单至少包含这些项目:

验收用自建夹具目录:放入普通文件、带空格文件、空目录和同名类型冲突;以普通用户补测拒绝访问。记录成功与失败的输出及状态,再从 / 调用脚本验证路径约定。对预演比较前后目录清单,对正式运行比较文件数量与内容,并重复执行检查是否符合覆盖规则。把命令、期望结果和实测记录随脚本提交,审查者才能重跑这些证据。验收通过后,再决定接入定时任务的身份、日志位置和告警条件。

10 小结:走向网络分层(S13)

脚本把输入、参数边界、命令查找和失败传播连成一份可检查的执行约定。能重复执行只是起点,遇到空格、权限拒绝或局部失败时仍能给出可信结果,才足以交给自动任务。

学习路径接下来走向 S13 网络分层。网络排查同样需要区分阶段:命令是否找到、配置是否读到、连接在哪一层失败。保留本课的显式检查与状态记录习惯,才能让网络诊断脚本给出可定位的证据。

下一课转向 S13 网络分层模型:把本章验收过的结论带进后续主题,写成可检查的方案约束。

同阶段相关课

AI 答疑

默认关闭。启用后,只发送本课标题、最近阅读的屏幕内正文摘录(最多 6000 字)与本次问题。

配置自己的服务