ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Shell脚本进阶核心:文本处理、日期批量操作与避坑实战

Shell脚本进阶核心:文本处理、日期批量操作与避坑实战 写shell脚本写了几年前几篇把语法部分讲得差不多了这篇想聊聊真正让shell脚本“值钱”的地方——文本处理、日期文件操作以及那些让人头疼的坑。很多朋友学到语法就停了觉得if、for、while会用就差不多了。但等你真的拿shell去做事——处理日志、批量改文件名、按日期归档数据——你会发现真正卡住你的不是语法而是三件事怎么高效地从文本里捞信息、怎么把日期和文件名玩出花、怎么绕过脚本里那些莫名其妙的坑。这篇就把这三件事一次性说透。适合刚过完基础语法想进阶的新手也适合写了几个脚本但总被诡异问题折磨的“半熟手”。1. 为什么文本处理和高阶技巧是Shell脚本的分水岭1.1 从基础语法到工程实战的进阶脉络前四篇我们走完了变量、判断、循环、函数这些基础语法但实际用起来你会发现光有语法框架远远不够。我见过不少朋友把shell脚本写成“Java风格”——大量if/else嵌套、变量满天飞跑一次能累死个人。真正的shell脚本核心恰恰不是这些语法而是它与Linux命令行的天然融合grep查文本、sed改文本、awk分析文本、日期处理、批量文件操作。这是shell区别于Python、Perl等脚本语言的根本优势——它不需要额外安装解释器不需要导入库一切都是系统自带跑在任意一台Linux机器上都能直接执行。举个例子很多刚入门的朋友喜欢写一个脚本去循环读取文件里的每一行然后判断是否包含某个关键字再拼计数器统计。这个逻辑用Python写可能看得懂但用shell写出来往往又臭又长还容易踩各种坑。其实一行grep -c就完成了。这就是思路问题。基础语法是“语言”文本处理和命令组合才是“表达方式”。你不熟悉命令就只能笨拙地绕来绕去。到了第五篇我的目标是帮你建立一个关键意识shell脚本不是用来写复杂业务逻辑的它是用来“编排”系统命令的。脚本里90%的代码应该是在调用命令、解析输出、处理文件而不是在写运算和嵌套逻辑。理解了这一点你的脚本会突然变得简洁很多。1.2 为什么选文本三剑客而不是Python有人会问“既然文本处理这么复杂为什么不直接用PythonPython处理文本不是更强吗”这个问题我经常被问到。我的看法是分场景。如果你的任务是写一次性脚本、日常运维自动化、快速处理日志shell三件套grep、sed、awk加管道几乎是不可替代的原因有三。第一是启动开销。Python解释器启动需要几十毫秒在循环里跑几千次就是好几秒而grep是C写的同样的任务几乎是瞬时完成。第二是管道亲和力。shell的一切设计都是为了“把上一个命令的输出交给下一个命令”Python脚本要接入管道还得写sys.stdin解析而grep天然就在管道链中。第三是调试成本。一行grep写错了改一下参数就行Python脚本写错了得打开文件改代码再运行来回折腾时间。做技术选型要清醒不是所有问题都需要重武器。我见过有人写100行Python脚本去处理一个grep -E就能搞定的日志统计纯粹是杀鸡用牛刀。当然如果是复杂的多维统计、JSON解析、多步文本变换用Python也是合理的。但本篇讨论的范围始终是shell能“优雅解决”的问题这也是系列第五篇想传递的核心思路。2. grep在shell脚本中的常见用法详解2.1 从进程退出码开始理解grep很多人用grep只停留在“搜到就打印”的层面完全忽略了grep的退出码。其实退出码才是shell脚本中使用grep的关键。grep的退出码有三种0代表匹配到了1代表没有匹配到2代表文件不存在或参数错误。这个特性让grep可以直接作为if条件使用省去了写管道再判断的麻烦。看这个典型场景——检查进程是否在运行if pgrep -f nginx /dev/null 21; then echo nginx is running else echo nginx is not running fi同理如果你要检查某个配置文件里是否已经包含某项配置if grep -q ^max_connections /etc/my.cnf; then echo 配置已存在跳过添加 else echo 配置缺失需要追加 fi这里有两个细节值得重点说明。第一-q参数quiet让grep不输出任何结果只返回退出码。如果不加-q匹配到的内容会全部打印到终端刷屏不说在脚本里没有意义。第二如果不想看到“文件不存在”之类的错误输出需要把标准错误重定向到/dev/null。这两个小细节组合在一起脚本的执行体验会干净很多。我还建议你在写脚本时把grep的退出码存下来再用而不是直接裸奔到管道链里尤其是当你后面还有复杂的处理逻辑时grep -q keyword file.txt ret$? if [ $ret -eq 0 ]; then echo found else echo not found fi为什么要存下来因为管道链中每一条命令都会更新$?的值一旦你执行了echo或者其他命令上一个grep的退出码就丢了。先存后判是脚本里最保险的写法。2.2 正则表达式与变量引用的坑grep在脚本中最容易翻车的点从来不是正则本身而是正则表达式与变量同时出现时的引用问题。看这个反面教材patternerror.*timeout grep $pattern app.log你觉得自己写得很对实际跑起来却会报“grep: timeout: No such file or directory”。为什么因为变量没有被双引号包裹$pattern在传递给grep之前被shell按照空白字符拆成了两个参数error.*timeout 和 timeout。grep把第一个当作模式把第二个当成文件名去处理了。正确写法是patternerror.*timeout grep $pattern app.log双引号在这里不是可有可无的装饰它决定了变量是被当作“一个整体参数”还是被拆散成多个参数。这个坑在路径带空格的文件上体现得更加明显——我见过有人写for循环遍历带空格的文件名最后把整个脚本都搞崩了。统一原则脚本中所有引用变量的地方除非你明确需要分词否则一律加双引号。除了引用问题grep在脚本中还容易犯第二个错误把扩展正则和基础正则搞混。grep默认使用基础正则BRE、?、|这些字符在基础正则里是普通字符不表示量词或分支。比如grep error|warning app.log你以为在匹配error或warning实际上它是在匹配字面的“error|warning”这几个字符。解决办法有两个一是给grep加-E参数使用扩展正则二是用egrep。推荐前者因为-E是POSIX标准参数行为更可预期。下面是正确示例grep -E error|warning app.log grep -E ^[0-9]{4}-[0-9]{2}-[0-9]{2} app.log第三个常见的坑是在正则中使用通配符时小心shell先展开了。比如你想匹配当前目录下的所有.log文件中的错误信息grep error.log这个没问题因为shell把.log展开成了具体文件名grep拿到的是一串文件。但如果你的正则本身就包含*比如grep err.timeout app.log这行代码没有问题——双引号保护了正则里的不被shell展开。可一旦你写成grep err*timeout app.logshell就会先在当前目录找以err开头的文件并尝试展开典型的结果就是grep收到多个乱七八糟的参数。这种问题排查起来非常隐蔽因为有时候恰好当前目录没有匹配文件shell就原样传参脚本能跑而一旦有人放了个err开头的文件进去脚本立刻行为异常。所以再次强调正则表达式一律用双引号包起来。2.3 日志统计实战一条命令代替十行循环我举个实际工作中的场景。线上有个接口服务每天产生大量访问日志需要统计每个接口的调用次数和失败次数。很多新手拿到这个需求第一反应是写个脚本逐行读文件每行split一下再用关联数组计数。这当然能做但效率低而且代码复杂度上来了。用grep加管道一行就搞定核心统计# 统计每个接口的调用次数 grep -oP GET \K[^ ?] access.log | sort | uniq -c | sort -rn # 统计每个接口的失败次数 grep -E HTTP/1.1 (5[0-9]{2}) access.log | grep -oP GET \K[^ ?] | sort | uniq -c | sort -rn这里用了grep -o只输出匹配的部分加了-P参数启用Perl兼容正则\K表示“从匹配中丢弃之前的内容”。这个组合在日志分析里非常实用——不需要写awk和sed只需要两步先grep把需要的片段“抠”出来再sort和uniq -c做统计。我个人的经验是在shell脚本中能用grep -o解决“从一行中提取字段”的需求就不要轻易上awk。grep -o的好处是语法简洁、可读性强、性能也好。只有当你需要对多个字段做条件运算时awk才是更合适的选择。还有一个小技巧当你发现grep的参数越来越复杂、正则越来越长时不妨想想能不能用多个grep串联。比如要过滤包含“error”且同时包含“timeout”的行不必写一个复杂的正则grep error app.log | grep timeout这段代码的可读性比grep -E error.*timeout|timeout.*error好了不知多少。同理要过滤“error”但不包含“timeout”的行grep error app.log | grep -v timeout-v参数是反向匹配在脚本里做排除条件时特别顺手。这些命令组合起来比你写一个几百行的Python解析器要快得多也更容易维护。3. 日期时间处理与批量文件操作的实战3.1 日期时间转为数字串的几种姿势这个需求太常见了备份文件要带时间戳日志归档要按日期分目录脚本执行要记录耗时。网上搜得最多的就是“shell脚本获取当前日期时间并转换为数字串”。这里我直接总结一套最优解。# 基本格式年月日 date %Y%m%d # 输出示例20260115 # 连带时分秒适合做备份文件名 date %Y%m%d%H%M%S # 输出示例20260115143022 # 带分隔符适合人类阅读 date %Y-%m-%d_%H:%M:%S # 输出示例2026-01-15_14:30:22 # Unix时间戳适合程序间传递 date %s # 输出示例1736922622注意格式字符串里的%Y是大写的年四位%y是小写的年两位。%H是24小时制小时%I是12小时制小时——如果你用%I记得加上%p来区分上午下午。这些细节一旦搞错脚本就会在半夜跑批时给你一个完全错误的文件名。除了获取当前时间处理相对时间也是高频需求。比如要生成昨天、上周、上个月的日期# 昨天的日期 date -d -1 day %Y%m%d # 7天前的日期 date -d -7 day %Y%m%d # 上个月的同一天注意不是所有月份都有31号所以这个慎用 date -d -1 month %Y%m%d # 一分钟前 date -d -1 minute %Y%m%d%H%M%S用-d参数做日期计算时强烈建议先在命令行手工验证一遍你要的边界情况。比如“上个月”的边界处理——你用date -d 2026-01-31 -1 month试试得到的是12月31日但如果今天是3月31日-1 month会得到2月31日吗不会GNU date会自动折算成3月3日2月没有31日。这种边界行为在脚本里一旦发生生成的归档文件名会完全对不上。我的经验是涉及“月”级别的日期加减最好先用case或者日历库去计算而不是依赖date -d的自动折算涉及“天”级别的加减则基本可靠。日期转数字串之后最常见的用途就是拼进文件名。我通常用一个变量把时间戳定下来然后在脚本里反复使用保证同一个脚本里所有生成的文件名时间是相同的TS$(date %Y%m%d%H%M%S) LOG_FILEbackup_${TS}.log TAR_FILEbackup_${TS}.tar.gz注意这里的写法${TS}用花括号做变量边界避免“backup_${TS}.tar.gz”中后面的点被当成变量名的一部分。这个语法细节我刚开始写脚本时经常踩后来养成了习惯只要变量名后面紧跟其他字符一律加花括号。3.2 for循环批量重命名文件很多朋友在Linux下批量重命名文件时第一反应是装rename工具其实纯shell配合for循环mv就能写得很干净。这里分享一个万金油框架for file in *.txt; do mv $file ${file%.txt}.md done这段代码把所有.txt后缀改成.md。循环里的file变量每次拿到一个文件名${file%.txt}是shell的参数扩展语法表示“去掉末尾的.txt”然后拼上新的.md后缀。每次循环执行一次mv简单直接。但真实场景往往比这个复杂。比如文件名是“report_20250101.txt”要改成“report-20250101.txt”把下划线换成短横线for file in report_*.txt; do new_name$(echo $file | sed s/_/-/g) mv $file $new_name done再比如文件名带日期要统一加前缀“backup_”for file in *.log; do mv $file backup_$file done这里有一个新手很容易踩的坑for file in.log当目录下没有.log文件时shell会把.log当作字面字符串传给循环循环体里mv就会尝试改一个名为“*.log”的文件然后报错。我见过有人为了处理这个问题写了很多防御逻辑。其实最简洁的做法是启用nullglob选项shopt -s nullglob for file in *.log; do mv $file backup_$file done开启了nullglob后如果没有匹配的文件glob表达式会展开为空循环自然执行零次不会报错。这个shopt -s nullglob放在脚本开头几乎是无痛的强烈建议批量处理文件时都加上。批量重命名里有个更隐蔽的坑文件名里的空格。如果你的目录下有文件叫“my report.txt”for循环从glob拿到的值是my report.txt如果mv命令写作mv $file newfile那么这个命令行展开后会变成mv my report.txt newfile——mv收到三个参数一定出错。所以强烈、强烈、强烈建议所有引用文件名的变量一律加双引号。# 正确 mv $file ${file%.txt}.md # 错误示范不要模仿 mv $file ${file%.txt}.md3.3 多场景综合示例按日期归档日志把日期处理和for循环结合起来就能写出真正实用的脚本。下面这个场景大家应该都遇到过应用每天产生一个日志文件需要定期把七天前的日志压缩后归档。#!/bin/bash shopt -s nullglob LOG_DIR/var/log/myapp ARCHIVE_DIR/data/archive KEEP_DAYS7 # 1. 计算归档的日期边界 archive_date$(date -d -${KEEP_DAYS} day %Y%m%d) echo 开始归档 ${KEEP_DAYS} 天之前的日志边界日期: ${archive_date} # 2. 遍历所有日志文件 for log_file in ${LOG_DIR}/*.log; do # 提取文件名中的日期部分文件名形如 app-20260101.log file_date$(basename $log_file .log | grep -oE [0-9]{8}) if [ -z $file_date ]; then echo 跳过无法识别日期的文件: $log_file continue fi # 3. 如果文件日期小于归档边界就压缩归档 if [[ $file_date $archive_date ]]; then tar -czf ${ARCHIVE_DIR}/$(basename $log_file).tar.gz $log_file if [ $? -eq 0 ]; then rm $log_file echo 已归档并删除: $log_file else echo 归档失败保留原文件: $log_file fi fi done这里用到的技巧点有几个。一是[[ $file_date $archive_date ]]这种字符串比较——注意文件日期是八位数字串字符串比较的结果和数值比较完全一致所以直接用运算符即可不需要转成数字。如果日期不是这个格式就需要用date -d转成时间戳再比较大小file_ts$(date -d $file_date %s) archive_ts$(date -d $archive_date %s) if [ $file_ts -lt $archive_ts ]; then ... fi二是在删除原文件之前我检查了tar命令的退出码。这个习惯很多新手没有——压缩失败就直接删原文件结果数据没了归档文件还是坏的。先确认归档成功再删是最基本的“数据安全素养”。三是用basename配合参数扩展从路径中提取文件名再用grep -oE抠出日期数字。这两步串联起来实际上是“管道思维”的体现——每个命令负责一小块组合起来形成流水线。这也是通篇想传达的思想shell脚本就是命令的编排。4. Shell脚本中常见的坑与规避技巧4.1 for循环与IFS分隔符for循环是shell里最常用的结构之一也是坑最多的地方。最经典的坑是用for去遍历命令的输出。看这个例子# 假设users.txt内容如下 # alice # bob # charlie for user in $(cat users.txt); do echo user: $user done如果没有特殊符号这段代码没问题。但假如users.txt里有一行是“john smith”$(cat users.txt)输出后会被shell按空白字符重新分词于是“john”和“smith”会被当成两个独立的循环元素程序逻辑立刻就乱了。更麻烦的是如果某行里有通配符比如“test*”shell还会尝试把它展开成文件名列表循环轨迹彻底失控。要正确遍历文件里的行应该使用while read并且给read加上-r参数while read -r user; do echo user: $user done users.txt这里有两个关键点。其一while read是从文件逐行读取天然按换行符划分不会拆散行内空格其二-r参数告诉read不要对反斜杠做转义处理避免路径中的特殊字符被吃掉。如果确实需要遍历命令输出的每一行也有正确姿势while read -r line; do echo $line done (command)这里的(...)是进程替换把命令输出当作一个临时文件读入。注意不要改成command | while read ...的管道形式——虽然这个写法更常见但管道符右侧的while是在子shell里执行的循环里设置的任何变量在循环结束后都会丢失。这个问题排查起来极其隐蔽甚至很多老手都中过招。最稳妥的方案就是统一使用进包替换的写法。那IFS到底是什么它是shell内部字段分隔符Internal Field Separator的缩写默认值为空格、制表符、换行符。for循环的分词、read的字段切分都受IFS影响。如果你要按逗号分隔来遍历CSV字段可以临时修改IFSOLD_IFS$IFS IFS, while read -r field1 field2 field3; do echo $field1 done data.csv IFS$OLD_IFS改IFS的核心原则是先保存旧值用完后恢复。不然整个脚本后续的所有分词行为都会被改变产生连环坑。4.2 变量比较的经典陷阱变量比较是另一个“看似简单、翻车率极高”的地方。最经典的坑是[ ]单方括号和[[ ]]双方括号的区别。看这段代码str if [ $str hello ]; then echo equal fi当$str为空时[ $str hello ]展开后变成[ hello ]shell看到的是一条语法错误的表达式直接报“unexpected operator”。很多人在脚本里遇到莫名其妙的报错根因就是这个。解决办法有两个一是固定用双方括号[[ ]]它不会对变量做词拆分和通配符展开空值也安全str if [[ $str hello ]]; then echo equal else echo not equal fi二是如果非要用单方括号至少给变量加引号if [ $str hello ]; then echo equal fi我个人的建议是在bash脚本中统一使用[[ ]]进行任何字符串判断。[[ ]]支持、、等运算符支持和||还支持正则匹配~功能远强于[ ]。有的朋友在写POSIX sh兼容脚本时不能用[[ ]]那就必须严格遵守“变量加双引号”纪律。数值比较是另一个容易错的地方。、、这些运算符在判断数值时并不做数学比较而是做字符串比较。数字10和9用字符串比较大小结果是10更小因为1小于9。所以数值比较必须使用专用的运算符count10 if [ $count -gt 5 ]; then echo count大于5 fi # 或者使用双括号算术表达式更推荐 if (( count 5 )); then echo count大于5 fi(( ))是bash的算术扩展里面可以直接写、、、这些数学符号变量也可以不加$符号。尤其在循环计数场景下((count))这种写法干净利落。但注意(( ))是bash特性在sh下不兼容写脚本时确认你的shell是bash再放心使用。4.3 空变量与未定义变量的处理默认情况下shell对未定义变量的引用不会报错只会得到一个空值。这在很多场景下是很危险的比如一个路径变量没赋值rm -rf $DIR就变成了rm -rf 而rm -rf空串通常不会删除任何东西但如果是rm -rf $DIR没有引号变量为空时命令变成rm -rf后面没有任何参数有些版本的rm会直接报错有些则可能因为使用不当产生严重后果。要保护自己的脚本最有效的两个手段是开启set -u以及主动给变量提供默认值。#!/bin/bash set -u echo $UNDEFINED_VAR开启set -u后脚本对未定义变量会直接报错“unbound variable”而不是默默用空值继续执行。这个选项几乎所有的生产脚本都该开能拦截掉非常多的低级故障。给变量设默认值的语法是${var:-default}# 如果DIR没传就默认用/tmp DIR${1:-/tmp} echo 使用目录: $DIR # 如果环境变量没有配置给一个默认端口 PORT${APP_PORT:-8080}注意${var:-default}和${var:default}的区别。前者只是“使用”默认值变量本身不被修改后者会“赋值”给变量。在函数里判断参数、在脚本里接收外部配置时${1:-default}是最高频的用法。还有一个小坑是${var:?error message}。如果变量未定义脚本直接退出并输出错误信息: ${MYSQL_PASSWORD:?请设置MYSQL_PASSWORD环境变量}这个组合适合在脚本开头做“前置条件校验”比如连接数据库前确认密码已经设置。我用下来觉得它比手动if判断省事很多而且错误信息一目了然。综合来看每个脚本开头的“防御三件套”就是#!/bin/bash set -euo pipefailset -e让脚本在任意命令失败时立即退出set -u拦截未定义变量pipefail保证管道链中只要有一个命令失败整体就算失败。这三个选项放在一起脚本的健壮性会有质的提升。不过也要注意set -e有时会让脚本“过于敏感”——比如你明明知道某个grep可能匹配不到退出码为1但在if判断里使用就没有问题因为if的条件命令不会被set -e影响。5. 问题排查实录与速查表5.1 典型问题速查表写脚本这几年我把最常见的报错和现象整理成了一个速查表。遇到类似问题可以直接核对省去反复试错的时间。现象根因解决方案脚本提示command not foundPATH没包含命令路径或变量名拼写错误echo $PATH检查用which确认命令位置grep报“No such file or directory”变量未加引号模式被拆散变量引用一律加双引号for循环只执行了一次glob没有匹配时默认原样传参开启shopt -s nullglob文件名带空格mv报错文件名变量未加引号所有变量加双引号循环里设置的变量循环结束后没值管道右侧的while在子shell中执行用进程替换 (cmd)代替管道[ : unary operator expected单方括号遇到空变量改用[[ ]]或给变量加双引号数值比较结果不对用了$var1 $var2做数值比较改用(( ))或-gtdate命令报invalid date日期格式与-d参数不兼容用date -d $(date %F)这类标准格式脚本里rm -rf没生效变量为空导致参数缺失开启set -u或用${var:?}校验这张表里的每一个问题我都踩过而且不止一次。尤其是管道和子shell的问题曾经让我排查了整整半天最后才发现是while read在子shell中运行导致变量丢失。从那以后我写脚本只要涉及“逐行读取并统计”一律使用进程替换再也没出现过这种问题。5.2 一次线上排查实录分享一个印象深刻的案例。某次一个数据同步脚本突然失效日志显示同步进程没有启动。排查过程如下。第一步我手动执行了脚本里的核心同步命令能正常工作。排除命令本身的问题。第二步在脚本里加了set -x把每条命令展开打印出来。结果发现脚本里判断“是否需要同步”的if条件从来没有进入过——因为条件是检查一个标志文件是否存在而文件的路径是从配置读取的。第三步echo打印变量发现配置读取出来的路径末尾多了一个空格导致test -f $CONFIG_FILE永远检查的是“路径加空格”自然找不到文件。这个问题的根因是配置文件中有一行写成“path/tmp/data/ ”末尾留了一个空格脚本读取时又没做trim处理。后来我在所有读取配置文件的地方统一加了去除空白字符的处理# 从配置文件读取并去掉前后空白 CONFIG_FILE$(grep ^path /etc/myapp.conf | cut -d -f2 | xargs)这里用xargs做了一个很有用的技巧xargs默认会去除输入中的前后空白字符同时还能处理空行。用它来清洗从命令行输出中拿到的变量比手动写sed正则要省事得多。这次排查给我的教训是很多看似“诡异”的bug根因往往是最不起眼的角落里多了一个空格、一个没加的引号、一个被误用的语法。shell脚本出问题绝大多数不是逻辑写错了而是“字符层面”出了问题。所以排查问题时第一件事永远是检查变量被展开之后的真实内容——echo或者set -x这两个工具能救你无数次。再补充一个我自己常用的排查技巧遇到复杂管道链出错时不要靠猜把管道拆开、一步一步执行在每一步后面加上echo检查中间结果。比如# 原始管道 cat app.log | grep error | awk {print $1} | sort -u # 排查时先看第一步输出 cat app.log | head -20 # 再看第二步输出 cat app.log | grep error | head -20 # 逐步推进直到找到断点这个“从管道头开始逐步找断点”的方法看起来笨但在排查实际问题时效率远高于盯着代码硬想。毕竟grep、awk这类工具的参数非常多可能出现“命令没报错但结果不符合预期”的情况这种问题只有看真实的中间输出才能定位。我个人写复杂管道时还会故意先跑一遍“只输出、不处理”确认每步结果正确之后再拼完整管道。这个习惯帮我避免了很多次“管道太长找不到错”的痛苦。关于shell脚本说了这么多其实核心就一句话shell是命令的编排语言你掌握的命令越多、理解每个命令的边界行为越深写出来的脚本就越简洁、越稳。第五篇到这里把文本处理、日期文件操作和大部分常见坑都覆盖了。最后再分享一个小技巧写脚本时习惯性地在头部加上set -euo pipefail给所有引用变量的地方加双引号循环读取文件时用进程替换取代管道这三点能帮你挡掉一大半的线上事故。后续的系列文章里我还会专门深入讲解awk与sed的高阶用法以及如何写出可复用、可维护的函数库。希望这篇对你真正有用下次写脚本时能把那些曾经踩过的坑变成你脚本里的“防护栏”。
返回列表