我在服务器上敲了快十年的Shell,带过的新人也有两位数了。每次讲到Shell编程,我都会先跟他们说同一句话:这不是一门语言,这是一套“和操作系统对话”的规矩。命令、流程控制、函数、输入输出重定向,这四样东西单独拎出来都不难,难的是它们组合在一起时发生的那些“你以为”和“实际上”。所以这篇不打算给你堆一个几百行的命令大全,我想按我自己踩坑的顺序,把这四个主题挨个讲透,顺便把常规文档里不会写的坑一并交代清楚。如果你只写过几行脚本、总在某些莫名其妙的地方翻车,那这篇正好对症;就算你完全零基础,也能看,因为每个原理我都会掰开揉碎讲,不绕弯子。
1. 命令的“查找-执行”链路:为什么你敲的命令有时不是你以为的那条
先聊命令。很多初学者会把“Shell编程”理解成“写一堆命令拼在一起”,这个理解不算错,但只对了一半。真正写脚本的时候,你不仅要会敲命令,还得理解Shell到底是怎么把一个命令串执行起来的。我见过一个特别典型的例子:同事在工位前排查了半天,明明刚执行了pip install --upgrade,再敲pip还是旧版本,最后发现是Shell的hash表把旧命令路径缓存了。这种现象在Shell里太常见,搞懂命令的查找机制,等于给后面的所有脚本打地基。
1.1 用type自检:命令是别名、内建、函数还是外部程序
当你敲下vim、git、sqlmap这些命令时,Shell并不是直接去执行某个文件,而是走一套查找流程。它会先判断你输入的是不是一个别名,比如很多人习惯alias ll='ls -l',那ll就走别名分支;接着判断是不是内建命令,比如cd、echo、read这些功能写在bash二进制内部,不需要去文件系统里找;如果前面都不是,才会按PATH环境变量里列出的目录顺序,从前往后寻找对应名字的可执行文件。
这套流程里最有用的排查命令是type。type -a vim会告诉你一个命令被解析成了什么:是别名、内建、还是外部程序,以及它在哪个路径。当你觉得“我改了配置但命令没生效”时,第一件事永远是用type确认你敲的命令到底指向了哪个文件,而不是盲目去网上搜。Windows上经常有人报“pip无法识别”“make无法识别”,本质上也是同一个问题:可执行文件所在的目录不在PATH里,或者在PowerShell里还有命令别名和执行策略的干扰。
还有一个顺序问题值得记住:别名、函数、内建、外部命令是按优先级解析的。在交互Shell里,别名最优先;如果你定义了一个和外部命令同名的函数,函数会优先于外部命令。当年有人为了让git走代理还是不走代理,写了个同名函数,结果把整个git调用链搅得乱七八糟。所以排查命令行为异常时,先跑type,很多问题当场就明白了。
提示:在脚本里,别名默认是不生效的。不要试图在
.sh文件里用alias偷懒,那只会让脚本在不同环境中表现不一致。
1.2 hash缓存引发的“装完新命令还是旧命令”的错觉
再来讲PATH和hash缓存。因为每次外部命令查找都要遍历PATH里的多个目录,bash为了性能,会把成功找到的命令路径缓存起来。这个缓存是会话级的,问题也随之而来:你升级了某个工具,或者把某个命令从/usr/bin搬到了/usr/local/bin,新开的终端理论上应该找到新路径,但bash的hash表里还记着旧路径,于是你执行到的还是老版本。
解决方式很简单:执行hash -r清空整个缓存,或者直接重开一个终端。如果你写脚本时想确保拿到的命令路径是准确的,可以用command -v或which动态确认,避免缓存造成误判。这里有一个更隐蔽的坑:脚本里如果用绝对路径调用命令,比如/usr/bin/git,那路径写死之后,系统升级或迁移时脚本就会悄悄指向不存在的文件。我一般建议脚本里直接写命令名,配合set -euo pipefail和command -v的启动校验,比写死绝对路径可靠得多。
2. 流程控制:五个语法之外,还有五个隐藏坑
流程控制是Shell从“命令行”走向“编程”的分水岭。if、for、while、case,单词量不大,但几乎每个语法都埋了坑。这些坑不是语法本身的复杂度造成的,而是Shell特有的“分词”“子Shell”“退出码”这些底层机制在捣乱。我一个个讲,讲完你就明白为什么自己写的脚本总在某些看似不会出错的地方翻车。
2.1 for循环的分词陷阱:从文件名带空格说起
先看一个最常见的写法:
for file in $(ls *.txt); do echo "$file" done这个写法在目录里只有a.txt b.txt时一切正常,一旦出现my notes.txt这种带空格的文件名,for循环就会把它拆成my和notes.txt两个词,然后循环体就开始犯迷糊:明明只有一个文件,循环却跑了两遍,而且echo "$file"打出来的还是不带空格的碎片。
根因是命令替换$(...)输出的文本会经历一轮“词分割”,默认按空格、tab、换行拆分。解决办法是不要用ls,直接用通配符展开:
for file in *.txt; do echo "$file" done通配符展开的结果不会经历分词,文件名里的空格会原样保留。这是个经典错误,很多老手也偶尔翻车。再补一个C风格for循环的写法,方便在确定次数时使用:
for ((i = 0; i < 10; i++)); do echo "$i" done如果要在循环里判断文件是否存在,惯用模式是[[ -e "$file" ]] || continue。因为当通配符没有任何匹配时,它会保持字面量,比如*.txt这个字符串本身,循环还是会执行一次,这时候用-e判断就能安全跳过。
2.2 if的退出码心智模型:真/假不是你以为的真/假
新手最容易搞反的是if的条件判断。在别的语言里,if (condition)跟的是一个布尔表达式,而在Shell里,if后面跟的其实是一个“命令”,判断依据是命令的退出码:0为真,非0为假。所以if [[ -f /etc/passwd ]]的真正意思是:执行[[ -f /etc/passwd ]]这个条件测试命令,如果它退出码为0,就进门。
这也意味着grep、ping、curl这些命令可以直接放在if里,不需要用$?手动判断:
if curl -s http://example.com/api >/dev/null; then echo "接口存活" else echo "接口挂了" >&2 fi注意curl -s把进度条关了,>/dev/null把响应体丢了,if只看退出码。这里就体现出“一切皆命令、命令皆退出码”的Shell哲学。
那[[ ]]和[ ]怎么选?在bash里我强烈建议用[[ ]],它是bash的关键字而不是外部命令,支持正则=~、模式匹配==、&&和||,而且变量不加引号也不会分词。[ ]是test命令的别名形式,本质是一个命令,变量必须加引号,-eq和=的语义也更绕。如果你写的是#!/bin/sh,只能用[ ];但只要你的脚本明确是bash,就大胆用[[ ]],代码更干净,出错概率更低。
这里把[ ]和[[ ]]的区别列个表,方便你记:
| 对比项 | [ ] | [[ ]] |
|---|---|---|
| 本质 | 命令(test) | bash关键字 |
| 变量引号 | 必须加引号,否则易被分词 | 可以不加,安全 |
&&和` | ` | |
| 正则匹配 | 不支持 | 支持=~ |
| 模式匹配 | 不支持 | 支持==和!= |
另一个常见问题是set -e。如果脚本开头写了set -e,一个命令非0退出会直接终止脚本,但在if、while、until条件中出现的命令不受这个限制。理解了这个机制,你才会明白为什么“在if里调用一个可能失败的函数”是安全的,而“在一个独立语句行里调用同一个函数”却可能让脚本瞬间退出。
2.3 while read + 管道 = 变量丢失,谁在偷偷开子Shell
再来看while。最常用的场景是从文件里逐行处理:
count=0 while read line; do count=$((count + 1)) done < /etc/passwd echo "$count"这里重定向是< file,while是在当前Shell进程里跑的,count变量能保留。但如果你图方便用管道:
cat /etc/passwd | while read line; do count=$((count + 1)) done echo "$count" # 输出还是0你以为循环里改了count,结果还是0,原因在于管道每一段默认都在子Shell里执行。while循环体在子Shell里,循环结束子Shell也结束,它对父Shell里的count毫无影响。
解决方案之一是进程替换:
while read line; do count=$((count + 1)) done < <(cat /etc/passwd)进程替换让cat在子进程运行,而while仍然在当前Shell里,循环里的变量修改能保留下来。另一个方案是shopt -s lastpipe,让管道最后一段在当前Shell运行,但这依赖bash版本和作业控制状态,不太通用。我建议把“while read + 重定向/进程替换”写成一个肌肉记忆,不要轻易在管道末尾接while。
这条规则不光影响while,也影响for、函数调用里所有子Shell边界。判断一段代码会不会影响父Shell,你只需要问一句:它所在的进程,是当前Shell的子进程还是同一个进程?只要在子进程里,怎么改变量都白搭。
2.4 case模式匹配与set -e的微妙关系
case其实是流程控制里最不容易出错的,但有一个理解门槛:它的模式匹配用的是“通配符”而不是正则。*能匹配任意串,[abc]匹配集合,?匹配单个字符,|用来分隔多个模式。比如:
case "$1" in *.log) echo "这是日志文件" ;; *.txt|*.md) echo "这是文本文件" ;; [0-9]*) echo "以数字开头" ;; *) echo "未知类型" >&2 ;; esac注意*.log只匹配文件名以.log结尾,不会像正则那样匹配任意字符.*;[0-9]*表示开头是数字。这样写的好处是直观、执行快,根本不需要拉起grep这样外部命令。
真正让我吃过亏的是set -e和case的组合。假如某个分支里的命令失败,而这个命令刚好是分支的最后一条语句,那么整个case表达式的返回值会变成非0。如果case被放在一个更大的if判断里,或者脚本开了set -e,就可能出现“我以为分支执行完没事,结果脚本退出了”的灵异事件。解决思路很简单:在需要兜底的分支末尾加一个true或:,确保分支一定返回0:
case "$1" in start) systemctl start myservice ;; stop) systemctl stop myservice ;; *) echo "未知操作" >&2 true ;; esac这个技巧在写初始化脚本、服务管理脚本时非常实用。说实话,case的坑不多,但只要踩到一次,就足够让你记住“命令退出码”这个底层逻辑无处不在。
3. 函数:别把Shell函数当其他语言的函数用
函数这块,我最早也栽了不少跟头。最大的认知误区就是:Shell函数既没有“返回值表达式”,也没有真正的“局部变量”,所有变量默认都是全局的。这不代表它没用,恰恰相反,脚本一旦超过100行,没有函数来组织逻辑,你会乱到连自己写的代码都不想读。但用函数之前,你必须接受它的几个“反直觉”设定。
3.1 定义与调用:function关键字到底用不用
定义一个函数有这几种写法:
foo() { echo "foo"; } function foo { echo "foo"; } function foo() { echo "foo"; }三种写法在bash里都合法,但我习惯用foo()这种,理由只有一个:可移植性好,sh和dash也认。function关键字是bash的扩展,如果你写的脚本指定了#!/usr/bin/env bash倒无所谓,但万一哪天你把脚本拷到别的环境,可能就报语法错误。为了少一份担心,统一用POSIX风格的foo()更省心。
调用的时候,函数名后面不需要加括号,直接foo即可。函数定义一定要在调用之前,bash不会像其他语言那样先预扫描整个文件。这也是为什么脚本通常会先把一堆工具函数码在开头,主逻辑放在后面。如果你在函数定义之前就调用了它,bash会报“command not found”,这种低级错误其实很常见。
3.2 返回值不能超过255:用echo和命令替换传递“字符串返回值”
Shell函数的“返回值”通过return指定,但它只能是一个0到255的整数,一般约定0表示成功,非0表示失败。如果你想从函数里拿回一段字符串,用return是做不到的,标准做法是echo输出加命令替换:
get_prefix() { local dir=$1 if [[ "$dir" == /* ]]; then echo "root" else echo "relative" fi } result=$(get_prefix "$mydir") echo "$result"但这里有两个很深的坑。第一,命令替换捕获的是函数的所有stdout,如果你在函数里随便echo "调试信息",那些调试信息会混进返回值里。解决办法是把日志打到stderr,这样它们只会出现在屏幕上,不会污染返回值:
get_prefix() { echo "[debug] 处理 $1" >&2 local dir=$1 if [[ "$dir" == /* ]]; then echo "root" else echo "relative" fi }第二,函数里如果调用外部命令,外部命令的stdout也会混进命令替换的结果。如果你想丢弃这些输出,记得重定向到/dev/null或stderr。所以我的经验法则是:函数内“给调用者看的数据”一律走stdout,“给人类看的日志”一律走stderr。这个习惯从第一天写函数就要养成,否则后面一定会被某个莫名多出前缀的字符串折腾半天。
3.3 变量作用域与export:子Shell、子进程、环境变量三兄弟
再谈作用域。Shell函数里的变量默认是全局的,哪怕你是在函数里随手赋的值,函数退出后依然存在。想隔离就用local:
foo() { local tmp=/tmp/x bar=$tmp }local只能用在函数内部,这就是bash的局部变量机制。但很多人搞不清export到到底有什么用,经常会问:“我在脚本里export了,为什么父Shell还是看不到?”
核心逻辑是这样的:export的意思是,把当前Shell进程里的变量复制到环境表里,之后当前Shell启动的任何子进程,都能通过环境表继承这个变量。注意,这里有两个关键词:子进程、继承。你在终端里export FOO=bar,然后从当前Shell启动bash,新的bash能看到$FOO;但你在另一个终端里打开一个全新的bash,是看不到这个变量的,因为它们是两个独立的进程,环境表互不相通。
同理,如果脚本是作为独立程序执行的,脚本里无论怎么export,脚本退出后,父Shell的环境一点都不会改变。要让脚本里的变量和export影响父Shell,唯一途径是用source来执行脚本,相当于把脚本内容“粘贴”到当前Shell里逐行执行。但这也带来了副作用:脚本里的cd会改变父Shell的目录,exit会直接退出父Shell。所以source一个脚本要非常谨慎,只能用于那些你确实想“注入当前环境”的场景,比如配置文件、虚拟环境激活脚本。
这里把三类变量的关系再理一遍:
| 变量类型 | 可见范围 | 是否被子进程继承 |
|---|---|---|
| 普通变量 | 当前Shell进程 | 否,除非手动在启动子命令前赋值 |
| export变量 | 当前Shell进程及其所有子进程 | 是,通过环境表复制 |
| local变量 | 仅当前函数 | 否,函数一退出就销毁 |
还有一个细节:在执行VAR=value command时,这个变量赋值只对该命令进程生效,不会影响当前Shell。这也是“临时环境变量”的标准写法:
LANG=C sort -t: -k1 /etc/passwd这行命令里的LANG=C只对sort进程生效,当前Shell的语言设置不变。理解了这一点,你对Shell进程模型的理解就上升了一个台阶。
3.4 shift、$@、$*:参数队列的滑动窗口
函数参数与脚本参数都靠位置参数传递:$0 $1 $2 ... $@ $* $#。其中shift是我见过最被低估的命令,它的作用是让参数队列整体左移:原来的$2变成$1,$3变$2,$#减1。最典型的用法是手写参数解析循环:
while [ $# -gt 0 ]; do case "$1" in -v|--verbose) verbose=1; shift ;; -f|--file) file=$2; shift 2 ;; -h|--help) usage; exit 0 ;; --) shift; break ;; *) echo "未知参数: $1" >&2; exit 1 ;; esac done这里的shift 2是一次移两个参数,因为-f后面通常还跟着一个路径值,一次把两个都吃掉。想象一下,你站在参数队列的顶端,每处理完一组参数就从队列头部剥掉一组,这就是shift的工作方式。
还有$@和$*的区别。"$@"会把每个参数当作独立的词保留,参数里的空格不会被破坏;"$*"会把所有参数合并成一个字符串,中间以空格分隔。在绝大多数场景下,你都应该用"$@",尤其是需要把参数原封不动传给另一个命令时:
forward_args() { run_command "$@" }如果这里写成"$*",意味着run_command只会收到一个长字符串,内部空格全被揉在一起,原来的参数边界就丢了,这是很多“参数传递后行为异常”的根源。
4. 输入输出重定向:文件描述符才是真正的底层操作
重定向是Shell编程里最像“魔法”的部分。很多初学者只会> file和< file,但真正遇到2>&1、&>>、exec、<<<的时候就发懵。想一眼看懂重定向,最好的方法是回到“文件描述符”这个底层概念。
4.1 0、1、2到底指向哪,重定向前先想明白
每个进程启动时,操作系统默认给它三个文件描述符:0是标准输入stdin,1是标准输出stdout,2是标准错误stderr。在终端里,它们分别指向你的键盘输入和终端屏幕。在脚本里,它们默认继承自父进程。重定向的本质,就是把这些描述符的指向从“终端”改到“文件”或其他描述符。
举个例子:
echo "hello" > result.txt这句不是简单的“把输出写到result.txt”,而是先由Shell把文件描述符1的指向改为result.txt,再执行echo,echo仍然只管往fd 1写。理解了这一点,你就能明白为什么有些命令支持/dev/stderr这种路径——它直接引用了标准错误的设备文件。
同样地,< file是把fd 0的指向从键盘改成文件,之后read、grep等命令从fd 0读取时,读到的就是文件内容。很多命令的“交互式输入”在生产环境里不可用,就是因为没有给它重定向stdin,导致它一直等待键盘输入。给命令喂数据的正确做法,就是操作stdin。
4.2 操作符速查:> >> < 2>&1 &> 的连带关系
先把常用操作符列一个速查表:
| 操作符 | 含义 | 典型示例 |
|---|---|---|
> file | stdout重定向到file,覆盖 | ls > out.txt |
>> file | stdout重定向到file,追加 | echo x >> out.txt |
< file | stdin从file读取 | grep foo < input.txt |
2> file | stderr重定向到file | ls /nope 2> err.txt |
2>&1 | stderr合并到stdout当前指向的位置 | cmd > all.log 2>&1 |
&> file | stdout和stderr同时重定向(bash扩展) | cmd &> all.log |
<<< "str" | 字符串作为stdin输入 | bc <<< "1+2" |
表里最容易被忽略的是2>&1的顺序问题。cmd > all.log 2>&1和cmd 2>&1 > all.log结果完全不同。前者是先让stdout指向all.log,再让stderr指向“stdout当前指向的位置”,也就是all.log,所以两者都进文件;后者是先让stderr指向终端(此时stdout也还是终端),接着再把stdout指向all.log,最终stderr仍然打印在终端上。
我曾经在部署脚本里把顺序写反过,结果服务日志看不到任何报错,所有错误全打在控制台上,排了半天才发现是重定向顺序问题。如果你不想记顺序,就用bash扩展的&> all.log,它把两个流一起收进去,不会因为顺序而踩坑。
4.3 括号分组与exec:把整个脚本的输出“装进口袋”
如果想一次性把一组命令的输出统一重定向,不需要给每条命令都附加>,可以用分组。圆括号(cmd1; cmd2)表示在子Shell中执行,花括号{ cmd1; cmd2; }表示在当前Shell中执行。两者配合重定向都能把组内所有命令的stdout收拢到一个地方:
{ echo "开始备份" tar czf backup.tar.gz /data echo "备份完成" } > backup.log 2>&1圆括号的好处是组内的变量修改不会影响父Shell,隔离副作用;花括号则相反,适合你想在当前环境里临时调整输出、又要保留状态的情况。注意花括号}后面必须跟分号或换行,否则解析器会认为你还在写函数体。
比分组更“狠”的是exec。exec > file会直接把当前Shell进程的stdout永久重定向到该文件,后续所有命令的输出默认都写进去,不需要每条命令再带重定向:
exec > record.log 2>&1 # 从这里开始,所有echo、命令的输出都进record.log这在脚本开头做运行日志时很好用,但它有一个大风险:如果在终端里误敲了exec > record.log,整个会话的输出就都被收进文件了,你再也看不到任何提示。所以exec重定向只适合在明确的脚本里用,而且最好配合trap做退出恢复。
4.4 heredoc与here string:喂给命令的多行文本
heredoc是我日常用得最频繁的重定向技巧之一。它能把多行文本作为stdin喂给命令,最常见的用途是生成文件或给交互式命令喂参数:
cat <<EOF > inventory.txt # 主机清单 10.0.0.1 web 10.0.0.2 db EOF<<EOF让Shell把直到单独一行EOF为止的内容作为stdin。这里有两个容易忽略的细节。第一,heredoc里的$变量和反引号会被展开,如果你不想展开,把定界符加引号写成<<'EOF';第二,默认情况下heredoc内容里的制表符会原样保留,使用<<-EOF可以忽略行首的tab,方便在缩进代码里写。
我在写需要内嵌配置模板的脚本时,几乎总是用<<'EOF',这样模板里的$不会被Shell拦一道,避免出现“明明写的是变量,结果被展开成一个空字符串”的诡异问题。
here string写作<<<"字符串",省去了echo再管道的麻烦:
grep error <<<"$log" bc <<< "$x * 2"它算简易版的heredoc,适合单行文本的输入场景。比如你想快速测试某个命令,手边没有文件,用here string最方便。
5. 一个完整脚本的拆解:把命令、流程控制、函数、重定向串起来
理论讲完,来一个实战。下面这个脚本是我早年间写的一个批量重命名工具的简化版,正好覆盖了前面所有主题:命令查找、for循环、if判断、case、函数、变量作用域、参数处理、重定向。看完这段代码和解读,你可以直接照着写一个属于自己的版本。
5.1 需求与设计:批量重命名脚本需要哪些能力
需求很简单:把一个目录下所有.txt文件批量重命名,统一加上前缀。功能包括参数校验、过滤非文件、生成新文件名、处理重名覆盖、输出分级日志。设计两个接口参数:
rename_txt.sh 目录 [前缀]如果目录不存在或没传参,打印错误并退出非0。前缀默认backup_。脚本头部使用#!/usr/bin/env bash和set -euo pipefail,宁可让问题在早期暴露,也不要让脚本半死不活跑完。
5.2 完整代码与逐段解读
完整代码如下:
#!/usr/bin/env bash set -euo pipefail usage() { echo "用法: $0 <目录> [前缀]" >&2 exit 1 } log() { local level=$1 shift case "$level" in info) echo "[INFO] $*" ;; warn) echo "[WARN] $*" >&2 ;; error) echo "[ERROR] $*" >&2; exit 1 ;; esac } rename_one() { local old=$1 local new=$2 if [[ -e "$new" ]]; then log warn "跳过 $old:目标 $new 已存在" return 1 fi mv -v "$old" "$new" } main() { local dir=${1:-} local prefix=${2:-backup_} if [[ -z "$dir" ]]; then usage fi if [[ ! -d "$dir" ]]; then log error "$dir 不是目录" fi local count=0 for file in "$dir"/*.txt; do if [[ ! -f "$file" ]]; then continue fi base=$(basename "$file") new="$dir/${prefix}${base}" if rename_one "$file" "$new"; then ((count += 1)) fi done log info "完成,共处理 $count 个文件" } main "$@"逐段解读:
set -euo pipefail:未定义变量直接报错(-u)、任何命令失败立即退出(-e)、管道中任一命令失败都算失败(-o pipefail)。但注意,在if条件里、&&/||右侧、以及函数被if rename_one调用时,-e不会触发,所以rename_one返回1时脚本不会崩,只会影响if判断。这正是我们想要的行为。usage和log是两个工具函数:一个把用法打到stderr并退出,一个带分级日志。log warn不退出,log error直接退出。所有普通信息走stdout,所有错误走stderr,呼应了函数章节说的“日志与数据分离”。rename_one:先检查目标是否已存在,存在就返回1。这是“重命名工具该有的底线”,避免覆盖文件造成不可逆损失。调用它的位置正好在if条件里,所以返回值被安全消费。for file in "$dir"/*.txt:用通配符而不是$(ls ...),文件带空格也安全。如果目录下没有匹配项,通配符保持字面量,所以用[[ ! -f "$file" ]] && continue来跳过。base=$(basename "$file"):命令替换,获取不带路径的文件名。local在main函数里声明局部变量,((count += 1))是算术扩展,避免了expr外部命令。整体只用了bash内建能力,没有依赖多余的第三方工具。
5.3 运行效果、边界情况与后续扩展
运行效果大致是这样:
$ ./rename_txt.sh /tmp/data docs_ mv: 重命名 '/tmp/data/a.txt' -> '/tmp/data/docs_a.txt' mv: 重命名 '/tmp/data/b.txt' -> '/tmp/data/docs_b.txt' [INFO] 完成,共处理 2 个文件mv -v的详细输出会原样显示,因为rename_one里执行mv -v时,它的stdout直接流向脚本的stdout,没有被命令替换捕获,也没有被重定向到文件。如果你不想看到mv -v的详细输出,可以把-v去掉,或者把mv -v的输出重定向到/dev/null。
一个边界情况:如果目录里没有.txt文件,for file in "$dir"/*.txt会把整个字符串当作一个不存在的路径传入循环体,[[ ! -f "$file" ]]判断为真,于是continue,脚本最后输出“完成,共处理 0 个文件”,不会报错。这个模式以后可以到处复用。
再提一个被很多人忽略的边界:如果前缀导致目标名和某个已有文件重名,脚本会跳过并给warn,但不会中断整体流程。这就是“单文件处理失败不影响批量任务”的正确姿势,在真实运维场景里非常有用。
往后扩展的思路也很清晰:前缀里插入日期,用$(date +%F);支持递归子目录,就把for file in "$dir"/*.txt换成find "$dir" -name '*.txt'配合while read;更严格的重名策略,可以用mv -i交互确认,但非交互环境不推荐,因为没人点确认。再往后,把参数解析部分换成getopts,就可以写成标准的命令行工具,挂到/usr/local/bin下直接用了。
最后分享一个我自己坚持了多年的习惯:从第一天写脚本开始,就把“所有调试日志走stderr、所有真正的数据走stdout”刻在脑子里。这个习惯在你第一次用函数返回字符串时会救命。Shell编程不是背命令大全,而是建立一套关于进程、文件描述符、退出码的合作直觉。多写几次批量脚本,你会慢慢找到那种“命令像积木一样听话”的感觉,到那时候,什么花哨的脚本对你来说都只是顺手的事。