ARTICLE DETAIL

资讯详情

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

Shell正则实战:grep、sed、awk避坑指南

Shell正则实战:grep、sed、awk避坑指南 别的不说就光grep、sed、awk这三个命令我敢说凡是写过三个月以上Shell脚本的人绝对都跟它们打过交道。但是你有没有过这种经历网上搜了一段正则直接粘到脚本里结果要么匹配不到要么把文件内容给替换得乱七八糟甚至有时候sed直接报错你盯着那一堆反斜杠完全不知道问题出在哪。其实问题往往不在正则本身而在Shell环境对正则的“二次加工”。这篇文章我不会给你铺一堆理论而是直接告诉你正则表达式在Shell里到底是怎么被处理的写脚本时该怎么用才能不踩坑。不管你是刚学Linux的小白还是写过一阵子脚本但总被正则搞晕的开发者我相信这篇都能帮你把这块拼图补完整。1. 正则表达式基础与Shell环境分析1.1 元字符速览先建立“语言直觉”正则表达式说白了就是一套描述字符串模式的“小语言”。它的核心不是背语法而是建立一种直觉看到一段文本能下意识判断“这个能不能用一条正则描述出来”。我先把最常用、出现频率最高的元字符列出来这组字符是你在Shell里写任何正则都会反复用到的元字符含义匹配示例.匹配任意单个字符换行符除外a.c匹配abc、a1c*匹配前一个字符零次或多次ab*c匹配ac、abc、abbc匹配前一个字符一次或多次需EREabc匹配abc不匹配ac?匹配前一个字符零次或一次需EREab?c匹配ac、abc^匹配字符串开头^abc匹配以abc开头的行$匹配字符串结尾abc$匹配以abc结尾的行[abc]字符集合匹配其中任意一个[0-9]匹配任意数字[^abc]反向字符集合匹配不在其中的字符[^0-9]匹配非数字\转义字符\.匹配字面量点号|ERE为|或逻辑BRE写法不同a|b匹配a或b\{m,n\}ERE为{m,n}匹配前一个字符m到n次a\{2,3\}匹配aa、aaa注意一个关键点上面表格里我特别标注了“需ERE”或“BRE写法不同”。这就是Shell正则最坑的地方——同一个元字符在“基本正则”BRE和“扩展正则”ERE里的表现不一样。比如在BRE里是普通字符只有写成\才表示“一次或多次”而{m,n}在BRE里必须写成\{m,n\}在ERE里直接写{m,n}就行。1.2 Shell环境里的正则家族BRE、ERE与PCRE我工作里见过太多人在这上面栽跟头。你以为写的是“正则”但实际grep、sed、awk对正则的解析规则并不完全一样这就导致了同一段正则换个工具就失效。简单梳理一下三者的关系BRE基本正则最古老的一套规则。元字符只有.*[]^$其他如、?、|、{}都需要加反斜杠才能获得特殊含义。忠于POSIX标准的grep不带任何参数、sed默认用的就是BRE。ERE扩展正则把、?、|、()、{}直接当作元字符不需要反斜杠。grep -E、egrep、sed -E、awk用的都是ERE。PCREPerl兼容正则这是最接近现代编程语言比如Python、Java、JavaScript的一套规则支持\d、\w、\s这类快捷字符还支持环视、非贪婪匹配等高级特性。grep -P在GNU grep里就是走PCRE。我给个实际对比假设要匹配“至少包含一个数字”的字符串流派写法说明BRE[0-9][0-9]*手动写“一个数字再加零个或多个数字”ERE[0-9]直接表示一次或多次PCRE\d用\d快捷字符如果你在grep里直接写grep [0-9] file它用的BRE被当作普通字符结果就是匹配所有包含“数字后跟一个加号”的行压根达不到你想要的“匹配含数字的行”的效果。正确写法是grep -E [0-9] file或者grep [0-9][0-9]* file。1.3 单引号、双引号与不加引号Shell对正则的“预处理”这是Shell正则最容易被忽略、却又最致命的一环。正则表达式是在命令行里作为参数传给grep、sed、awk的而这个参数是先经过Shell解析再传给命令的。Shell不是透明的它会先对参数里的变量、通配符、特殊符号做一套自己的解释。我总结出三条铁律能用单引号就用单引号单引号里的内容Shell完全不做任何处理原封不动传给命令。这是最安全的方式正则里的$、*、!、\都不会被Shell误解析。双引号会做变量替换和命令替换如果正则里有$比如你想匹配行尾的$符号在双引号里$会被Shell当成变量标志直接变成空字符串或者报错。不加引号极度危险正则里的*会被Shell当作通配符展开可能把当前目录下的文件名都给展开传进去。举个例子我想用grep查找所有以err开头的行# 正确写法 grep ^err /var/log/app.log # 错误写法不加引号 grep ^err /var/log/app.log不加引号时Shell把^err当作一个普通单词在某些情况下可能没问题但一旦正则里出现*、?、[这些字符Shell就会尝试做路径名展开glob。比如你想匹配包含数字的行写成grep [0-9] file如果当前目录下恰好有个文件叫0Shell会把[0-9]展开成0然后命令就变成grep 0 file完全不是你想要的效果。另外一个容易踩的坑是在双引号里写正则关键词含!时在交互式Shell里会被历史展开机制吃掉。比如grep foo! file在bash里!后面跟字符可能触发历史命令替换直接给你报错。我统一建议正则两边永远套单引号除非你要在正则里用变量做动态匹配。2. 核心工具实战grep、sed、awk 的正则正确打开方式2.1 grep筛选是基本功但“提取”才是进阶grep是Shell里最常用的正则工具但很多人对它理解停留在“搜索包含某关键字的行”。实际上在正则的配合下grep完全可以做到“提取出想要的内容而不是整行输出”。常用参数我整理成一张表参数作用实战场景-E使用ERE扩展正则避免了、?、-o只输出匹配到的部分而不是整行从日志里抠出IP、时间戳-v反向匹配输出不包含模式的行过滤掉注释行、空行-P使用PCRE支持\d、环视等需要复杂断言时用-f从文件读取模式每行一个正则批量匹配多个关键词-c计数匹配行数快速统计错误次数-n输出行号定位日志位置-i忽略大小写不区分大小写的搜索实际工作中我经常用-o配合正则来抠数据。比如要从访问日志里提取所有IPv4地址grep -oE ([0-9]{1,3}\.){3}[0-9]{1,3} access.log | sort | uniq -c | sort -rn这条命令的意思-o只输出匹配部分-E用扩展正则正则本体匹配“1到3位数字加点号重复三次再接1到3位数字”。管道接sort | uniq -c | sort -rn还能统计每个IP出现的次数并降序排列这个组合在分析日志时非常好用。再比如系统里有多个服务的PID文件我想提取所有正在监听的端口号ss -tlnp | grep -oE :[0-9] | tr -d : | sort -n | uniq这里grep -oE :[0-9]会把类似:8080的部分抠出来后面的tr -d :去掉冒号再排序去重。用-o的灵活之处就在于正则只描述你关心的那部分内容输出也不会被无关信息干扰。有一点需要提醒grep -P虽然支持\d这种简洁写法但某些精简版系统或macOS自带的BSD grep可能不支持-P参数。我写脚本时为了可移植性一般优先用-E用[0-9]代替\d这样在Linux和macOS上都不会出问题。2.2 sed替换和删除离不开正则的分组与引用sed是流编辑器逐行读取、处理、输出。它最有价值的功能就是利用正则做替换s命令和删除d命令。很多人背了sed s/foo/bar/g这个模板但遇到更复杂的需求就卡壳。先说替换命令的结构sed s/正则/替换内容/标志。三个关键点正则部分用BRE默认需要给、?、{、}、|、(、)加反斜杠。想用简洁的ERE写法加-E参数sed -E s/正则/替换/g。替换内容里用\1、\2引用正则分组捕获的内容。末尾的g表示全局替换不加的话每行只替换第一个匹配。我举个例子假设有这样一个文本文件users.txt每行格式是“姓名:电话:邮箱”我想去掉中间的电话只保留姓名和邮箱sed -E s/^([^:]):[^:]:(.*)$/\1:\2/ users.txt拆解一下^([^:])用分组捕获开头的姓名连续的非冒号字符接着:[^:]:匹配冒号、电话、冒号最后(.*)$捕获邮箱。替换部分用\1:\2把两组捕获重新拼起来。这个例子典型展示了正则分组捕获的实际价值——它不只是“找出来”还能“拆开重组”。还有个常用技巧是删除空行和注释行# 删除空行含只包含空格的行 sed -E /^[[:space:]]*$/d config.conf # 删除以#开头的注释行 sed /^#/d config.conf注意第一行里的[[:space:]]这是POSIX字符类匹配空格、制表符等空白字符比写[ \t]更规范而且在Linux和macOS上行为一致。sed做文件内替换时有个经典坑sed -i在LinuxGNU sed和macOSBSD sed上语法不一样。GNU的写法是sed -i s/foo/bar/g fileBSD则要求sed -i s/foo/bar/g file必须加一个备份后缀参数空字符串表示不备份。我写跨平台脚本时一般会先检测系统类型或者干脆用临时文件加mv的方式避开这个差异。2.3 awk正则匹配字段列超越grep的存在awk不只是一个文本处理工具它更像是“面向行的数据引擎”。它把每一行按分隔符拆成多个字段默认按空格拆分然后你可以在每个字段上做正则匹配和逻辑操作。最基本的用法是awk 正则 {动作} file。不带字段时正则匹配整行带了字段就只在指定字段上匹配。比如/etc/passwd文件每行用冒号分隔我想找出所有使用bash作为Shell的用户awk -F: /bash$/ {print $1} /etc/passwd-F:指定冒号为分隔符/bash$/这个正则匹配行尾是bash的行print $1输出第一个字段用户名。但awk真正的优势是可以在字段上用“正则比较”。比如我只想匹配第二列包含“error”的行awk -F, $2 ~ /error/ {print $1, $2} data.csv这里的~是“匹配”运算符含义是“第二个字段匹配正则/error/”。反过来!~表示“不匹配”。再分享一个我在处理监控数据时经常用的组合统计日志中各错误类型的数量。awk {for(i1;iNF;i) if($i ~ /^ERROR:/) count[$i]} END {for(k in count) print count[k], k} app.log | sort -rn这个脚本遍历每行所有字段只要字段以ERROR:开头就把它作为键存到count数组里计数最后输出统计结果。整个过程只用了一条awk命令配合正则做到了从日志到统计报表的“一站式处理”。awk的正则用的是ERE所以、?、|直接写就可以不需要反斜杠。这点跟grep -E一致和sed不加-E时相反写的时候要区分清楚。2.4 三剑客选择指南什么时候用哪个很多人总是纠结“这个需求该用grep还是sed还是awk”。我的经验法则很简单只要筛选行用grep。速度最快写法最简单。要改内容、按规则替换或删除用sed。它在流式处理上优势明显。要做统计、按字段逻辑处理、多条件判断用awk。它是三者中编程能力最强的。组合使用grep先筛行sed再改格式awk最后统计这是非常常用的管道流水线。3. Shell脚本里的正则动态构造变量传递与循环配合3.1 引号层次问题当正则遇上Shell变量在命令行里写正则是一回事在Shell脚本里写正则又是另一回事。最大的区别是脚本里你常常需要把变量拼进正则里做动态匹配。举个例子写一个脚本统计日志里某个IP出现了多少次IP存在变量ip里ip192.168.1.100 grep -c $ip access.log这样写没问题因为grep的第一个参数被双引号包裹Shell会把$ip展开成实际的IP字符串。但如果你把双引号换成单引号grep -c $ip access.log单引号里$ip不会被展开grep就会去搜索字面量$ip结果肯定是0。这就是所谓的“引号层次”问题——单引号内不能展开变量双引号内可以。但如果IP里带了正则元字符呢比如你要匹配“以192.168开头”的IP几种写法效果完全不同# 错误把.当成正则元字符会匹配“192x168”这种 grep -c $ip access.log # 正确先用sed转义正则里的点 escaped_ip$(echo $ip | sed s/\./\\./g) grep -c $escaped_ip access.log我专门写了一个regex_escape函数放在脚本库里凡是外部输入的变量要进正则先过一遍转义regex_escape() { # 把正则里的特殊字符统一加反斜杠 printf %s $1 | sed -E s/([.*?^$()\[\]{}|\\])/\\\1/g }使用场景比如用户输入一个关键词你希望它只按字面匹配不要被解释成正则read -p 请输入要搜索的关键词: keyword escaped$(regex_escape $keyword) grep $escaped data.txt这个细节极其重要否则用户输入一个a.b你本意是搜字母a、任意字符、字母b结果搜索出来的内容可能完全超出预期。3.2 正则与for循环批量处理多个文件模式Shell脚本里for循环配合正则最常见的用法有两种一是遍历文件列表二是循环处理匹配结果。第一种场景批量重命名文件。比如把当前目录下所有.txt后缀改成.mdfor file in *.txt; do newname$(echo $file | sed -E s/\.txt$/\.md/) mv $file $newname done这里sed的正则\.txt$匹配结尾的.txt\.md是替换内容。注意点在$符号前面必须加反斜杠转义点号不然点号会匹配任意字符。第二种场景循环处理grep的输出。比如读取日志里所有的ERROR行逐行提取时间戳和错误消息grep ERROR app.log | while IFS read -r line; do timestamp$(echo $line | grep -oE ^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}) message$(echo $line | sed -E s/^.*ERROR: //) echo 时间: $timestamp, 错误: $message done这里用了while read而不是for是因为for按空格拆分会破坏包含空格的日志行while IFS read -r line能保证整行作为一个变量传入。这也是Shell脚本里处理带空格文本的标准姿势。还有个小技巧for循环里经常需要去掉文件扩展名或获取路径中的目录部分正则结合参数扩展会更高效for file in /var/log/nginx/access.log.*; do # 用正则提取日志文件名中间的日期部分 date_part$(echo $file | grep -oE [0-9]{8}) echo $date_part done3.3 与其他命令联动find、expect、Android调试场景正则表达式在Shell里的第三大价值是“粘合剂”能把各种命令串成一条流水线。这里举几个我实际用过的场景。场景一find配合-regex按模式找文件默认的find按通配符*.log找文件是不够用的比如我想找所有“包含backup_和后缀是.tar.gz或.zip”的文件find /data -regex .*backup_.*\.\(tar\.gz\|zip\)$注意find -regex匹配的是“完整路径”不是像grep那样匹配行内任意位置所以正则开头结尾都要写.*。场景二expect里用正则匹配交互式输出写自动化脚本时expect经常用来交互式登录并执行命令。它的expect子命令支持正则匹配expect -c spawn ssh userhost expect { password: { send mypassword\r } -re Permission denied.* { exit 1 } } 这里-re表示后面的模式是正则表达式.*表示任意字符用于捕获登录失败的情况。场景三Android ADB调试里的常见操作如果你做Android开发或测试adb shell里也经常用正则处理设备信息。比如查看电池状态中的某个字段adb shell dumpsys battery | grep -E level:|status:再比如用adb shell pm list packages列出包名然后grep -E筛选包含特定关键词的应用包adb shell pm list packages | grep -E com\.(google|tencent|alibaba)\.这些场景里的正则用法和普通Linux环境完全一致核心思路都是“先取回文本再用正则过滤、提取”。3.4 动态生成正则和性能陷阱在脚本里动态生成正则时最容易出问题的不是正则本身而是转义层级。比如你在sed -E的替换内容里又想嵌一个正则分组又想输出字符会碰到多层转义问题。我举一个最典型的例子用sed给http://链接自动换成https://sed -E s|http://|https://|g file.txt注意这里我用了|作为分隔符而不是默认的/因为http://里面本身就含有斜杠再用/当分隔符就会产生歧义。同理替换路径时也建议切换分隔符# 把/usr/bin改成/usr/local/bin sed -E s|/usr/bin|/usr/local/bin|g path.txt关于性能正则匹配在数据量大时会成为瓶颈。最常见的性能陷阱是“灾难性回溯”尤其是(a)这种嵌套量词在匹配失败时会发生指数级回溯导致脚本卡死。在Shell工具里遇到这种情况优先用更具体的字符类来限定匹配范围尽量避免连续的模糊量词。还有一个实用经验能用grep先筛掉大部分数据再给sed或awk做精细化处理性能会好很多。比如处理100万行日志先grep ERROR把候选行缩到几千行再交给awk做统计比直接awk处理全量快一个数量级。4. 常见问题与排查技巧实录4.1 高频错误对照表写Shell正则时踩过的坑我整理成一个速查表基本覆盖了90%的报错和异常常见现象根本原因解决方案grep匹配不到结果但模式明明是对的忘记加-E或?被当成普通字符用grep -E或把写成\sed -i在macOS上报错BSD sed的-i参数要求显式指定备份后缀用sed -i s/foo/bar/g file正则里的$被当成变量替换为空参数加了双引号Shell把$当成了变量标志外层改用单引号或用\$转义匹配*号没效果正则写成*Shell对双引号内的*做了路径名展开单引号包裹写成*并配合前面的模式awk匹配变量时用//包变量匹配不到//里的内容不会被Shell展开用$变量 ~ /regex/或$变量 ~ patterngrep -o输出为空正则没分组或者-o在BSD和GNU下行为差异先确认正则在grep -P下能匹配再换-Esed替换内容里的变成整行在替换部分有特殊含义表示整个匹配串需要输出字面时写成\匹配中文字符时结果不对区域设置或字符编码不一致用字符类[一-龥]或设置LC_ALLC.UTF-8正则匹配了不想要的行没加行首行尾锚点^和$模糊匹配改成精确锚定4.2 调试正则的三个实用技巧正则写出来不生效别反复猜用对调试手段十分钟内能定位问题。技巧一用echo加grep做最小测试不要在完整脚本里调试先单独验证正则本身echo 192.168.1.100:8080 | grep -oE ([0-9]{1,3}\.){3}[0-9]{1,3}看到输出结果再往脚本里放。这个方法简单粗暴但能快速判断到底是正则错了还是变量传递错了。技巧二用grep -P配合环视测试复杂需求PCRE的环视lookahead/lookbehind是验证边界条件的好工具。比如提取IP但不包含端口号echo 192.168.1.100:8080 | grep -oP ^[0-9.](?:)(?:)表示“后面跟着冒号”的断言但断言本身不参与匹配。测试通过后如果环境不支持-P再换成-E配合分组捕获的写法。技巧三在脚本里打印正则参数调试脚本时最常见的情况是“命令看不出错但输出不对”。这时候在调用grep、sed前加一行调试输出打印出最终传进去的正则是什么echo DEBUG: pattern[$pattern] grep $pattern $file很多次我都是靠这个发现变量里混进了换行符或空格。尤其在从配置文件读取变量时一不小心把换行符带进正则匹配自然全盘失败。4.3 关于正则可读性的个人习惯最后说点脚本维护的体会。正则表达式是出了名的“写起来爽读起来难”三个月后回看自己写的脚本看着那一长串特殊字符经常要当场重新推导一遍。我自己有几个强制要求写进团队规范里了一是复杂正则必须加注释。Shell本身不支持正则内嵌注释但可以在正则前面用变量名说明意图# 匹配形如 2024-01-15 14:30:00 的时间戳 ts_pattern[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} grep -E $ts_pattern app.log二是分段构造。一个超长正则很难看懂拆成几个变量再拼接ip_part([0-9]{1,3}\.){3}[0-9]{1,3} time_part[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} pattern^${time_part} .* ${ip_part} .*ERROR grep -E $pattern app.log三是正则的缩进和空格。Shell的正则不支持松散模式x模式但通过变量拼接至少可以保证每个片段都有一行说明。5. 写在后面再分享一个正则调试的小工具习惯做了这么多年Shell脚本我越来越觉得正则这东西本质上不是“背出来的”而是“试出来的”。你不可能一次性记住所有元字符的优先级和边界行为但如果你掌握了一套快速验证的方法任何复杂需求都能在几分钟内跑通。我自己的调试工作流是这样的在本地环境里开一个临时脚本文件比如/tmp/re.sh内容就是一行echo $1 | grep -E $2然后反复用不同的测试文本去试。这样调试完再把验证过的正则挪进正式脚本里非常省事。另外再提一个点正则的“贪婪匹配”是新手最容易忽略的。.和*组合在一起默认是贪婪的它会尽可能多地匹配。比如用sed -E s/.*//g删除HTML标签结果会把一整行全删光。这时候就要用非贪婪写法但BRE和ERE里并没有原生的非贪婪语法常见的替代方案是精确限定字符范围比如用[^]*代替.*来匹配标签内部内容。这个道理放到更大的层面也成立正则不是越“炫”越好而是越“贴切”越好。能用字符类精确描述的就不要用通配符扫能锚定开头结尾的就不要让正则去猜边界。写Shell脚本时多花几分钟把正则打磨精准后面数据处理的结果才会稳定可靠。
返回列表