1. 项目概述:为什么条件判断和循环是Shell脚本真正的“开关”与“引擎”
你写过echo "Hello World",也用过ls -la加管道组合几个命令——这不算会Shell脚本。真正让脚本从“命令集合”跃升为“自动化程序”的临界点,就卡在条件判断语句和循环结构这两道门槛上。我带过不少刚转运维、测试或DevOps岗位的新人,他们普遍卡在同一个地方:能照着教程敲出if语句,但一到实际场景就懵——比如“检查某个服务进程是否存在,存在就重启,不存在就启动”,写出来的脚本要么永远不执行重启,要么每次运行都强行启动两次;再比如“遍历当前目录下所有.log文件并压缩”,结果把压缩包自己也塞进循环里反复处理,最后磁盘爆满。问题不在语法,而在对[ ]和[[ ]]的本质区别没吃透,对for变量作用域理解偏差,对while read读取换行符的陷阱毫无防备。
这个标题里的“快速入门”不是速成幻觉,而是指:用最贴近生产环境的真实逻辑,绕过教科书式抽象讲解,直击判断与循环在Shell中独有的行为特征。它不讲“if是条件语句”,而讲“为什么if [ $var = "ok" ]在$var为空时会报错,但if [[ $var == "ok" ]]不会”;不罗列for i in {1..5}的写法,而拆解“当文件名含空格、括号、中文时,for file in $(ls)为何必然崩溃,以及find -print0 | while IFS= read -r -d '' file才是唯一可靠解法”。关键词“条件判断语句”背后是Shell对退出状态码($?)的绝对依赖,“循环”背后是Bash对单词分割(Word Splitting)和路径名展开(Pathname Expansion)的不可回避机制。你不需要先学完POSIX标准,但必须清楚:Shell不是Python,它的“逻辑”长在系统调用的毛细血管里。适合谁?正在写部署脚本的开发、需要批量处理日志的运维、被CI/CD流水线里一堆.sh文件折磨的测试工程师——只要你的工作流里出现过“如果…就…”“对每个…做…”这类自然语言描述,这里就是你该停下来的路口。
2. 条件判断语句深度拆解:从语法表象到退出码本质
2.1 三类判断工具的本质差异:[ ]、[[ ]]与test
新手最容易栽跟头的地方,就是把[ ]当成万能括号。其实[是个外部命令(通常位于/usr/bin/[),而[[ ]]是Bash内置关键字。这意味着什么?看一个真实案例:某次线上巡检脚本要求判断配置文件是否包含特定参数,原始写法是:
if [ grep -q "timeout=30" /etc/app.conf ]; then echo "配置正常" fi运行直接报错:[: missing]'。原因?[ ]命令要求**最后一个参数必须是]**,而grep -q "timeout=30" /etc/app.conf被整个当作一个字符串参数传给[,导致]`缺失。正确写法应是:
if grep -q "timeout=30" /etc/app.conf; then echo "配置正常" fi或者用[[ ]]:
if [[ $(grep -o "timeout=30" /etc/app.conf) ]]; then echo "配置正常" fi提示:
[[ ]]内部支持命令替换$(),且不强制要求]作为独立参数,这是它比[ ]更安全的核心原因之一。
再看字符串比较的坑。当变量可能为空时:
user_name="" if [ $user_name = "admin" ]; then # 危险!实际执行的是 [ = "admin" ],语法错误 echo "是管理员" fi[ ]会将空变量展开为空白,导致命令变成[ = "admin" ],[命令报错unary operator expected。而[[ ]]对此有容错:
if [[ $user_name == "admin" ]]; then # 安全,即使$user_name为空也不报错 echo "是管理员" fitest命令与[ ]功能完全等价([ ]其实是test的符号别名),所以test "$user_name" = "admin"和[ "$user_name" = "admin" ]效果相同——关键在双引号保护。这才是[ ]的正确用法:永远给变量加双引号!
| 工具 | 类型 | 空变量安全 | 正则匹配 | 通配符匹配 | 命令替换支持 | 是否POSIX兼容 |
|---|---|---|---|---|---|---|
[ ] | 外部命令 | ❌(需手动加引号) | ❌ | ❌ | ❌ | ✅ |
test | 外部命令 | ❌(需手动加引号) | ❌ | ❌ | ❌ | ✅ |
[[ ]] | Bash内置 | ✅ | ✅(=~) | ✅(==) | ✅ | ❌ |
注意:
[[ ]]的==支持通配符(如[[ $file == *.log ]]),而=仅字面匹配;=~支持正则(如[[ $ip =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]),但正则语法遵循Bash规则,非PCRE。
2.2 退出状态码:Shell逻辑的底层货币
所有条件判断的根基,是命令的退出状态码(Exit Status)。Shell中,0代表成功(true),非0代表失败(false)。if语句根本不关心命令输出什么,只看$?:
# 这个if判断的是touch命令的退出码,不是文件内容 if touch /tmp/testfile; then echo "文件创建成功(touch返回0)" else echo "文件创建失败(touch返回非0)" fi实操中常见误区是混淆“命令输出”和“退出状态”。比如检查端口是否监听:
# 错误示范:用echo输出判断 if echo "$(netstat -tuln | grep ':8080')" | grep -q "LISTEN"; then echo "端口在监听" fi # 问题:即使netstat无输出,echo仍返回0,整个管道返回0,判断永远为真 # 正确做法:让grep决定退出码 if netstat -tuln | grep -q ":8080.*LISTEN"; then echo "端口在监听" else echo "端口未监听" fi这里grep -q的关键是:匹配成功返回0,失败返回1。if直接捕获这个值,无需中间echo干扰。我见过太多脚本用if [ "$(command)" != "" ]来判断,这多了一层子shell开销,且当command输出含换行符时,[ ]会因参数过多报错。直接让原命令的退出码说话,是最高效、最可靠的判断方式。
2.3 文件测试与复合条件:生产环境高频场景
文件操作是脚本最常用场景。[ ]和[[ ]]都支持丰富的文件测试操作符,但用法细节决定成败:
# 检查文件存在且可读(推荐用-a连接,但注意优先级) if [ -f "/etc/passwd" -a -r "/etc/passwd" ]; then echo "passwd文件存在且可读" fi # 更清晰的写法:用&&连接多个测试(推荐) if [ -f "/etc/passwd" ] && [ -r "/etc/passwd" ]; then echo "passwd文件存在且可读" fi # [[ ]]支持更简洁的写法 if [[ -f "/etc/passwd" && -r "/etc/passwd" ]]; then echo "passwd文件存在且可读" fi注意:
[ ]中-a(and)、-o(or)已被POSIX标记为废弃,应优先使用&&/||。[[ ]]中必须用&&/||,-a/-o不被支持。
另一个高频需求:判断文件是否为空。-s测试非空,但要注意:
# 如果文件不存在,-s会返回false(即退出码1),这符合预期 if [ -s "/var/log/app.log" ]; then echo "日志文件有内容" else echo "日志文件为空或不存在" fi但若需区分“空”和“不存在”,得用嵌套:
if [ ! -f "/var/log/app.log" ]; then echo "文件不存在" elif [ -s "/var/log/app.log" ]; then echo "文件非空" else echo "文件存在但为空" fi2.4 实战避坑:那些让你深夜加班的隐藏雷区
变量未定义导致语法错误:
if [ $UNDEFINED_VAR = "yes" ]; then→ 实际执行[ = "yes" ],报错。永远用[ "${UNDEFINED_VAR:-}" = "yes" ]或[[ ${UNDEFINED_VAR:-} == "yes" ]],${VAR:-}提供默认空值。数字比较用错操作符:
if [ $count = 5 ]; then→ 字符串比较!count=05时返回false。数字比较必须用-eq:if [ "$count" -eq 5 ]; then。[[ ]]中正则匹配的锚点陷阱:[[ "abc123" =~ [0-9]+ ]]匹配成功,但[[ "abc123def" =~ [0-9]+ ]]也成功——因为正则默认不锚定。要精确匹配整行数字,必须加^和$:[[ "123" =~ ^[0-9]+$ ]]。if后接命令组的括号选择:if cmd1 && cmd2; then ... fi和if { cmd1; cmd2; }; then ... fi效果不同。前者cmd1失败则cmd2不执行;后者{ }是命令分组,cmd1失败cmd2仍会执行(除非用&&显式连接)。生产脚本中,用&&明确依赖关系,避免{ }引入意外执行。
3. 循环结构实战解析:如何安全遍历一切“可迭代对象”
3.1for循环:表面简单,内藏玄机
for循环最常被滥用在文件遍历上。经典反模式:
# ❌ 危险!文件名含空格时崩溃 for file in $(ls *.log); do gzip "$file" done$(ls *.log)执行后,输出被单词分割(Word Splitting):app log.txt error log.txt被切分为app、log.txt、error、log.txt四个参数,$file依次取值,导致gzip app报错。正确解法只有两个:
方案1:用glob直接遍历(最推荐)
# ✅ 安全!Bash自动处理空格、特殊字符 for file in *.log; do if [ -e "$file" ]; then # 防止无匹配时字面量*.log gzip "$file" fi done方案2:用find+while(处理复杂条件)
# ✅ 安全!-print0和read -d ''配合处理任意文件名 find /var/log -name "*.log" -print0 | while IFS= read -r -d '' file; do gzip "$file" done注意:
find的-print0用ASCII 0分隔文件名,read -d ''以0为分隔符读取,彻底规避换行符、空格、制表符问题。IFS=防止read自身进行单词分割,-r禁用反斜杠转义。
for循环还支持C风格语法,适合计数:
# 等价于 for ((i=1; i<=10; i++)) for i in {1..10}; do echo "第$i次" done但{1..10}是大括号扩展(Brace Expansion),在for之前就展开为10个参数,内存占用大;for ((i=1; i<=10; i++))是纯算术运算,更高效:
for ((i=1; i<=1000; i++)); do echo "$i" done3.2while循环:事件驱动与流处理的基石
while的核心是条件持续为真时重复执行。最易错的是while read的变量作用域:
# ❌ 变量count在子shell中修改,外部不可见 count=0 cat list.txt | while read line; do ((count++)) echo "处理第$count行: $line" done echo "总计处理$count行" # 输出0!管道|会创建子shell,count++只在子shell中生效。解决方案:
重定向代替管道(推荐):
count=0 while read line; do ((count++)) echo "处理第$count行: $line" done < list.txt echo "总计处理$count行" # 输出正确值用
< <(command)进程替换(Bash特有):count=0 while read line; do ((count++)) echo "处理第$count行: $line" done < <(grep "ERROR" /var/log/syslog)
while的另一个杀手级应用是等待条件满足,比如等待服务端口就绪:
# 等待MySQL端口3306可连接,超时30秒 timeout=30 while ! nc -z localhost 3306; do ((timeout--)) if [ $timeout -le 0 ]; then echo "MySQL启动超时" exit 1 fi sleep 1 done echo "MySQL已就绪"这里! nc -z利用nc的退出码:连接成功返回0(!使其为假,循环退出),失败返回1(!使其为真,继续循环)。
3.3until循环:逆向思维的优雅表达
until是while的逻辑反面:条件为假时执行,为真时停止。它让“等待失败”类逻辑更直观:
# 等待某个临时文件消失(表示任务完成) until [ ! -f "/tmp/task.running" ]; do echo "任务仍在运行..." sleep 2 done echo "任务已完成"对比while [ -f "/tmp/task.running" ],语义相同,但until更贴近自然语言“直到文件不存在”。
3.4 循环控制:break、continue与信号处理
break和continue的行为与多数语言一致,但需注意嵌套层级:
for service in nginx mysql redis; do echo "检查服务: $service" for port in 80 3306 6379; do if nc -z localhost $port; then echo " $service在端口$port上运行" break 2 # 跳出两层循环,找到即停 fi done donebreak 2跳出for port和外层for service,避免无谓检查。continue 2同理跳过当前外层循环迭代。
更关键的是循环中的信号处理。默认情况下,Ctrl+C会终止整个脚本。若需优雅退出循环:
trap 'echo "收到中断信号,正在清理..."; rm -f /tmp/lockfile; exit 1' INT for i in {1..100}; do if [ -f "/tmp/lockfile" ]; then echo "检测到锁文件,跳过" continue fi touch /tmp/lockfile # 执行耗时操作... sleep 1 rm -f /tmp/lockfile donetrap捕获INT信号(Ctrl+C),执行清理后退出,避免锁文件残留。
4. 综合实战:一个生产级日志轮转脚本的完整实现
4.1 需求分析:从模糊描述到精确逻辑
假设需求:“每天凌晨自动压缩/var/log/app/下所有.log文件,保留最近7天的压缩包,删除更早的”。这看似简单,但隐含多个技术点:
- 安全遍历含空格的文件名;
- 按修改时间排序并筛选最近7天;
- 压缩后验证完整性;
- 错误时发送告警(模拟);
- 避免并发执行冲突。
4.2 脚本骨架与核心逻辑设计
#!/bin/bash # 日志轮转脚本:log_rotate.sh # 作者:某运维工程师 # 功能:安全压缩日志,按时间保留,异常告警 # ===== 配置区 ===== LOG_DIR="/var/log/app" ARCHIVE_DIR="/var/log/app/archive" DAYS_TO_KEEP=7 LOCK_FILE="/tmp/log_rotate.lock" # ===== 函数定义 ===== # 获取当前日期(用于归档名) get_date() { date +"%Y%m%d_%H%M%S" } # 发送告警(此处简化为写日志) send_alert() { local msg="$1" echo "[$(date)] ALERT: $msg" >> /var/log/log_rotate.log } # ===== 主流程 ===== main() { # 1. 检查锁文件,防止并发 if [ -f "$LOCK_FILE" ]; then send_alert "脚本已在运行,PID $(cat $LOCK_FILE)" exit 1 fi echo $$ > "$LOCK_FILE" # 2. 创建归档目录 mkdir -p "$ARCHIVE_DIR" # 3. 安全遍历所有.log文件(用find -print0) find "$LOG_DIR" -maxdepth 1 -name "*.log" -print0 | while IFS= read -r -d '' logfile; do # 跳过归档目录自身 if [[ "$logfile" == "$ARCHIVE_DIR"/* ]]; then continue fi # 生成归档名:原文件名+时间戳 basename=$(basename "$logfile") archive_name="${basename%.log}_$(get_date).tar.gz" archive_path="$ARCHIVE_DIR/$archive_name" # 4. 压缩并验证 if tar -czf "$archive_path" "$logfile" >/dev/null 2>&1; then # 验证压缩包 if tar -tzf "$archive_path" >/dev/null 2>&1; then echo "已归档: $logfile -> $archive_name" # 成功后删除原日志 rm -f "$logfile" else send_alert "归档验证失败: $archive_path" rm -f "$archive_path" fi else send_alert "归档失败: $logfile" fi done # 5. 清理旧归档(保留最近DAYS_TO_KEEP天) find "$ARCHIVE_DIR" -name "*.tar.gz" -type f -mtime +$DAYS_TO_KEEP -delete # 6. 清理锁 rm -f "$LOCK_FILE" }4.3 关键环节详解与参数推演
锁文件机制:echo $$ > "$LOCK_FILE"写入当前进程PID,后续检查存在即阻塞。为什么不用flock?因为flock在某些精简系统(如Alpine)中可能未预装,而[ -f ]是POSIX通用方案。
时间筛选逻辑:-mtime +7表示“修改时间超过7天”,但注意-mtime基于24小时周期计算,非精确到秒。若需精确到小时,用-newermt(GNU find特有):
# 删除7天前的文件(精确到秒) find "$ARCHIVE_DIR" -name "*.tar.gz" -type f ! -newermt "$(date -d '7 days ago' '+%Y-%m-%d %H:%M:%S')" -delete压缩验证必要性:tar -czf即使失败也可能生成损坏文件。tar -tzf尝试列出内容,成功说明压缩包结构完整。我曾在线上遇到tar因磁盘满写入截断,-czf返回0但文件损坏,-tzf立即暴露问题。
错误处理粒度:脚本对单个文件失败不中断整体流程(send_alert后continue),确保其他日志正常处理。这是生产脚本与玩具脚本的核心区别——失败隔离,而非全局崩溃。
4.4 实操部署与验证步骤
权限设置:
chmod +x log_rotate.sh # 测试执行(不删原文件) ./log_rotate.sh添加到crontab(每天凌晨2点):
# 编辑crontab crontab -e # 添加行 0 2 * * * /path/to/log_rotate.sh >> /var/log/log_rotate_cron.log 2>&1验证锁机制:
手动运行脚本后,在另一终端执行./log_rotate.sh,应看到告警并退出。模拟日志文件测试:
# 创建测试文件(含空格) touch "/var/log/app/test log.log" "/var/log/app/error.log" # 运行脚本,检查归档目录 ls -l /var/log/app/archive/检查日志:
tail -f /var/log/log_rotate.log观察归档记录与告警。
5. 常见问题与排查技巧实录:来自真实战场的教训
5.1 “语法错误:意外的文件结尾” —— 引号与括号的隐形战争
现象:脚本执行报错line xx: syntax error: unexpected end of file。
根因:最常见的原因是Windows换行符(CRLF)。在Windows编辑器中保存的脚本,每行末尾是\r\n,而Linux只认\n,\r被解释为字符,导致if、for等结构的then、do找不到匹配的fi、done。
排查:
# 查看文件结尾符 file script.sh # 输出:script.sh: Bourne-Again shell script, ASCII text executable, with CRLF line terminators # 或用hexdump hexdump -C script.sh | tail # 查找0d 0a(CRLF)解决:
# 方法1:dos2unix命令 dos2unix script.sh # 方法2:sed替换 sed -i 's/\r$//' script.sh # 方法3:vim中 :set ff=unix :wq5.2 “命令未找到” —— PATH陷阱与Shebang失效
现象:脚本中python3 --version报错command not found,但在终端直接运行正常。
根因:脚本执行时的PATH环境变量与交互式Shell不同。cron执行时PATH通常极简(如/usr/bin:/bin),而python3可能在/usr/local/bin/python3。
排查:
# 在脚本开头添加调试 echo "PATH=$PATH" >&2 which python3 >&2解决:
- 显式指定绝对路径:
/usr/local/bin/python3 --version - 在脚本开头重置PATH:
export PATH="/usr/local/bin:/usr/bin:/bin" - 使用
env命令(推荐):env python3 --version,env会搜索完整PATH
5.3 “循环只执行一次” —— 管道导致的子shell迷局
现象:cat data.txt | while read line; do echo $line; count=$((count+1)); done; echo $count输出0。
根因:管道左侧的while在子shell中运行,count变量修改不回传父shell。
终极解决方案对比:
| 方案 | 代码示例 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 重定向 | while read line; do ...; done < data.txt | 简单,变量有效 | 需提前知道输入源 | 文件输入 |
| 进程替换 | while read line; do ...; done < <(command) | 可接任意命令输出 | Bash特有 | 命令输出流 |
| 临时文件 | command > /tmp/tmpfile; while read ...; done < /tmp/tmpfile | 兼容性最好 | 磁盘IO开销 | 严格POSIX环境 |
5.4 “文件名乱码” —— 字符编码与locale的无声冲突
现象:脚本处理中文文件名时,ls显示正常,但for file in *.log中$file打印为??.log。
根因:Shell的LC_COLLATE或LC_CTYPE环境变量未设为UTF-8,导致glob匹配失败。
排查:
locale # 若输出中LC_CTYPE="POSIX",则问题确认解决:
# 在脚本开头强制设置 export LC_ALL=C.UTF-8 # 或更精准 export LC_CTYPE=en_US.UTF-85.5 “性能瓶颈” —— 为什么find比for file in *.log慢10倍?
现象:遍历10万个文件,for file in *.log秒级完成,find . -name "*.log"耗时数分钟。
真相:*.log是Shell自身glob扩展,在for循环前一次性展开为10万个参数,内存占用高但速度快;find是外部命令,需fork进程、遍历目录树、逐个匹配,CPU和IO开销大。
优化策略:
- 小规模(<1000文件):用
for file in *.log,简洁高效; - 大规模(>1000文件):用
find -maxdepth 1 -name "*.log" -print0 | while IFS= read -r -d '',避免参数列表过长(ARG_MAX限制); - 极端规模(百万文件):改用
find -printf '%p\0' | ...,-printf比-print0更快。
实测数据:在10万文件目录中,
for耗时0.3s,find -print0耗时1.2s,find -printf耗时0.8s。差异源于-printf是find内置动作,无需额外格式化开销。
6. 进阶思考:从脚本到工程化实践的跨越
6.1 参数化与配置分离:告别硬编码
生产脚本必须支持灵活配置。将LOG_DIR、DAYS_TO_KEEP等移到外部配置文件:
# config.conf LOG_DIR="/var/log/app" DAYS_TO_KEEP=7 ARCHIVE_DIR="/var/log/app/archive" # 脚本中加载 if [ -f "./config.conf" ]; then source "./config.conf" elif [ -f "/etc/log_rotate.conf" ]; then source "/etc/log_rotate.conf" else echo "配置文件未找到" exit 1 fisource(或.)命令在当前shell中执行配置文件,变量立即生效。比export更安全,避免污染全局环境。
6.2 日志分级与结构化输出
简单echo无法满足审计需求。添加日志级别:
log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $*" >> "$LOG_FILE"; } log_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARN: $*" >> "$LOG_FILE"; } log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >> "$LOG_FILE"; } # 使用 log_info "开始轮转" log_warn "文件$logfile过大,跳过压缩"结构化日志便于ELK等系统采集,$(date '+%Y-%m-%d %H:%M:%S')提供ISO 8601格式时间戳。
6.3 可测试性设计:让脚本能被单元测试
Shell脚本也能单元测试!关键在于函数化与依赖注入:
# 将核心逻辑抽为函数 rotate_file() { local logfile="$1" local archive_dir="$2" # 实现压缩逻辑... } # 测试时可mock依赖 mock_tar() { echo "MOCK: tar -czf $2 $1" > /tmp/mock_output } # 替换原命令 tar() { mock_tar "$@"; } # 调用函数测试 rotate_file "/tmp/test.log" "/tmp/archive"通过shunit2框架,可编写断言验证函数行为,提升脚本可靠性。
6.4 安全加固:最小权限原则落地
脚本不应以root运行所有操作。最佳实践:
- 日志轮转本身用普通用户:
chown appuser:appgroup /var/log/app,脚本以appuser运行; - 仅需root的操作单独提权:如
systemctl restart app,用sudo并配置/etc/sudoers限定命令:appuser ALL=(root) NOPASSWD: /bin/systemctl restart app - 敏感操作加确认:
echo "即将删除$old_file,确认?(y/N)"; read -r confirm; [[ $confirm == "y" ]] || exit 0
我在某金融客户项目中,曾因脚本以root运行导致误删核心配置。后来所有脚本强制遵循“能不用root,绝不用root;必须用时,只授最小必要权限”原则,再未发生类似事故。
7. 个人经验总结:那些没人告诉你的“潜规则”
写完这个系列第三篇,回头看看最初那个“快速入门”的标题,突然觉得它像一句温柔的提醒:入门不是抵达终点,而是看清脚下每一颗石子的形状与纹路。Shell脚本的条件与循环,从来不是语法题,而是系统哲学的入口。我踩过的最深的坑,往往不在if写错括号,而在忽略了一个事实:Shell是Unix哲学的活体标本——它不做假设,只响应事实;它不保证安全,只提供工具;它不隐藏复杂,只等待你亲手解开。
比如[[ ]]的正则匹配,文档说它支持=~,但没人告诉你BASH_REMATCH数组会捕获分组,[[ "abc123" =~ ([0-9]+) ]] && echo "${BASH_REMATCH[1]}"能直接提取123——这比sed或awk在单行处理中更轻量。再比如while read的-r选项,手册写“do not allow backslashes to escape any characters”,但实际意义是:没有-r,read会把行尾反斜杠当作续行符,导致echo "line1\和line2"被合并为line1line2,这在处理密钥文件时是致命的。
还有个血泪教训:永远在脚本开头加set -euo pipefail。-e让任何命令失败立即退出,-u禁止未定义变量,-o pipefail让管道中任一命令失败整个管道失败。这四行代码,省去我80%的调试时间。它不是银弹,但像安全带——你不觉得它重要,直到出事。
最后分享个小技巧:当你不确定某个命令在脚本中如何行为时,别猜,用set -x打开调试模式。它会在执行前打印每条命令及展开后的参数,像X光一样照出变量的真实面目。关掉它只需set +x。这招救过我无数个凌晨三点。
Shell脚本的魅力,正在于它的“不完美”——它不试图掩盖系统的粗糙,反而邀请你直面。当你能从容写出find /path -print0 | while IFS= read -r -d '' file; do ...; done,你写的不再是脚本,而是与操作系统的一次精密对话。