「速通Shell」字符串处理与正则匹配

如果说循环是脚本的发动机,那字符串处理就是它的方向盘。日常写 shell 脚本,绕不开两类事情:一是处理半结构化的文本——日志、配置、CSV、URL、文件名;二是从一堆乱七八糟的字符里挑出我们想要的部分。前者靠 shell 自带的字符串操作和变量展开就能搞定,后者就得靠正则。两个东西合起来,构成了 shell 文本处理的核心。

这一篇我们把字符串处理和正则匹配一起讲清楚。重点不是罗列语法,而是让你看完之后知道什么场景该用什么工具。

一、字符串基础操作

在 shell 里,字符串的地位比大多数语言都高——所有变量在你不显式声明类型的情况下,默认就是字符串。所以搞懂 shell 字符串,是写好脚本的必修课。

1.1 单引号、双引号、不加引号的区别

这个话题被讲烂了,但它就是字符串处理的入口,不说不行。

#!/bin/bash
name="张三"
age=25
​
echo '$name 今年 $age 岁'
echo "$name 今年 $age 岁"
echo $name 今年 $age 岁

输出:

$name 今年 $age 岁
张三 今年 25 岁
张三
今年
25
岁

三种写法的差异一目了然:

  • 单引号 '...':所见即所得,里面所有字符都原样输出,变量不会被替换,转义字符也无效。
  • 双引号 "...":里面的变量会被替换,转义字符(如 \n\t)也生效。
  • 不加引号:等同于双引号,但会按 IFS 切分。如果字符串里有空格,又没加引号,就会被当成多个参数传给 echo。上面第三个 echo 之所以变成五行,就是因为 $name 后面那个空格被当成了分隔符。

实操经验:除非你确定字符串里没有特殊字符,任何时候都加双引号。这是 shell 编程的"第一铁律",能避免 80% 莫名其妙的 bug。

1.2 字符串拼接

shell 里没有专门的字符串拼接运算符,直接挨着写就行:

#!/bin/bash
first="Hello"
last="World"
greeting="$first, $last!"
echo $greeting

输出:Hello, World!。注意这里 $first, 的逗号和后面的空格都是字面字符,会原样出现在结果里。如果你想要更明确的"分隔",也可以这样:

greeting="${first}, ${last}!"

${first}$first 更稳——花括号明确告诉 shell 变量的边界,避免它把 first, 当成一个未定义的变量名(虽然不会报错,但会留空格)。

1.3 获取字符串长度

#!/bin/bash
str="hello world"
echo "长度:${#str}"

输出:长度:11${#var} 是 shell 内置的"取长度"语法,比调用 expr length "$str" 或者 echo -n "$str" | wc -c 快得多。

1.4 子字符串提取

shell 也内置了子串提取,语法是 ${var:offset:length}

#!/bin/bash
str="hello world"
echo "${str:0:5}"    # 从第 0 个字符开始,取 5 个
echo "${str:6}"      # 从第 6 个字符开始,取到末尾
echo "${str: -5}"    # 倒数 5 个字符
echo "${str:6:5}"    # 从第 6 个字符开始,取 5 个

输出:

hello
world
world
world

注意 str: -5 前面有一个空格——这个空格是语法的一部分,用来和"从正数位置开始"区分开。少了这个空格,shell 会把 0 当作负数起点,结果可能不是你要的。

提取子串是 shell 的强项之一。比起 cutawk 这些外部命令,这种纯字符串展开速度更快、依赖更少。

二、变量展开

如果说字符串基础是入门,那变量展开就是 shell 字符串处理的核心。shell 提供了一组基于 ${var...} 的展开语法,能完成"删除前后缀"、“替换”、"默认值"等操作,写起来非常紧凑,也是和 Python、Go 这类语言最大的区别之一。

2.1 删除前后缀

这类操作在处理文件名、URL、配置项的时候特别有用。语法有四个:

  • ${var#pattern}:从左边开始,删除最短匹配
  • ${var##pattern}:从左边开始,删除最长匹配
  • ${var%pattern}:从右边开始,删除最短匹配
  • ${var%%pattern}:从右边开始,删除最长匹配

光看描述容易晕,直接看例子:

#!/bin/bash
path="/usr/local/bin/bash"
​
echo "${path#*/}"      # 删左最短
echo "${path##*/}"     # 删左最长
echo "${path%/*}"      # 删右最短
echo "${path%%/*}"     # 删右最长

输出:

usr/local/bin/bash
bash
/usr/local/bin
(empty)

解释一下:

  • ${path#*/}:从左边开始,删掉第一个 / 以及它之前的所有内容。结果留下 usr/local/bin/bash
  • ${path##*/}:从左边开始,删掉最后一个 / 之前的所有内容。结果留下 bash——这就是取文件名的经典写法。
  • ${path%/*}:从右边开始,删掉第一个 / 以及它之后的所有内容。结果留下 /usr/local/bin——取目录的标准姿势。
  • ${path%%/*}:从右边开始,删掉最后一个 / 之后的所有内容。因为路径以 / 开头,所以把全部内容都删了,结果是空。

这四个语法是处理路径的"四件套",务必熟练。一个真实的例子,批量改文件名:

#!/bin/bash
for file in *.txt
do
    [ -e "$file" ] || continue
    newname="${file%.txt}.md"
    mv "$file" "$newname"
    echo "$file -> $newname"
done

${file%.txt} 是从右边删掉最短匹配 .txt,得到不带后缀的文件名,再拼上 .md。这比 $(echo $file | sed 's/.txt$//') 快得多,可读性也更好。

通配符 * 匹配任意字符串,记住一个规律:

  • ### 后面接的模式,是"我不要的左半部分"
  • %%% 后面接的模式,是"我不要的右半部分"

2.2 字符串替换

shell 也支持变量内替换,语法是 ${var/old/new}

#!/bin/bash
str="hello world, hello shell"
​
echo "${str/hello/hi}"        # 只替换第一个
echo "${str//hello/hi}"       # 替换所有
echo "${str/#hello/hi}"       # 只替换开头
echo "${str/%shell/hi}"       # 只替换结尾

输出:

hi world, hello shell
hi world, hi shell
hi world, hello shell
hello world, hi hi

四种写法的区别:

  • 一个 /:只替换第一个匹配
  • 两个 ///):替换所有匹配
  • #:只匹配开头
  • %:只匹配结尾

注意 ${str/#hello/hi} 要求 hello 在开头才替换,而 ${str/hello/hi} 是任意位置的第一个。如果 old 包含特殊字符(.*[),它们在 shell 展开里被当作通配符,不是正则。要做真正的正则替换,用 sed

2.3 默认值与变量检查

shell 提供了一套"如果变量未定义或为空就怎样"的语法,是写健壮脚本的关键:

语法含义
${var:-default}var 未定义或为空,返回 default(var 不变)
${var:=default}var 未定义或为空,把 default 赋给 var 并返回
${var:+alt}var 已定义且非空,返回 alt;否则返回空
${var:?msg}var 未定义或为空,打印 msg 并退出脚本

实战中 :-:= 用得最多。一个典型的场景是读环境变量:

#!/bin/bash
# 如果 ENV 没设置,使用默认值
: "${ENV:=production}"
echo "当前环境:$ENV"

: "${ENV:=production}" 里的 : 是 shell 的空命令(啥也不干),它配合 ${...} 用作"赋值"语句。这行代码的副作用是:如果 $ENV 没被设置,就把它设为 production。比写 if 判断简洁多了。

${var:?msg} 在写脚本时也很实用,可以用来检查必填参数:

#!/bin/bash
: "${1:?用法: $0 <文件名>}"
echo "处理文件:$1"

如果调用脚本时没传参数,shell 会直接报错退出:

用法: ./script.sh <文件名>

这种"快速失败"的写法比在脚本开头写一堆 if 判断要优雅得多。

三、字符串比较

字符串操作说完了,接下来是比较。shell 里字符串比较有两种写法:经典的 test 命令和更现代的 [[ ]] 复合命令。

3.1 用 test 命令比较

#!/bin/bash
a="hello"
b="world"
​
[ "$a" = "$b" ] && echo "相等" || echo "不相等"
[ "$a" != "$b" ] && echo "不等" || echo "等"
[ -z "$a" ] && echo "a 为空" || echo "a 非空"
[ -n "$a" ] && echo "a 有内容" || echo "a 没内容"

输出:

不相等
不等
a 非空
a 有内容

[ 实际上是 test 命令的简写,两边必须有空格,里面的运算符两侧也必须有空格。这是 shell 字符串比较最常见的语法。

四个常用操作符:

  • =:相等
  • !=:不等
  • -z:字符串长度为零
  • -n:字符串长度非零

注意 ===[ ] 里是等价的,但 ==[[ ]] 里是更"现代"的写法,逻辑上更清晰。

3.2 用 [[ ]] 复合命令

[[ ]] 是 bash 提供的增强版条件判断,它解决了一些 [ ] 的老问题:

#!/bin/bash
file="report.log"
​
# 经典写法
[ -e "$file" ] && echo "文件存在"
​
# 增强写法
[[ -e "$file" ]] && echo "文件存在"
[[ "$file" == *.log ]] && echo "是日志文件"
[[ "$file" =~ .log$ ]] && echo "以 .log 结尾"

[[ ]] 的几个优势:

  • 不需要对变量加双引号(内部不会做 word splitting)
  • 支持 == 通配符匹配*.log 会被当作模式而不是字面字符串)
  • 支持 =~ 正则匹配(这才是重点,我们后面会细讲)

如果你的脚本只在 bash 下跑,优先用 [[ ]] ;如果需要兼容 sh、dash,就老老实实用 [ ]

四、正则匹配

[[ ]] 里的 =~ 是 bash 给我们的小礼物——它让你不用调用 grepsed 就能做正则匹配。这对"判断一个字符串是否符合某种模式"特别有用。

4.1 基础匹配

#!/bin/bash
email="user@example.com"
​
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$ ]]
then
    echo "邮箱格式正确"
else
    echo "邮箱格式错误"
fi

[[ "$email" =~ ^[a-zA-Z0-9._%+-]+@...$ ]] 的意思是:用右边的正则去匹配 $email,匹配成功就返回 0。=~ 默认使用的是扩展正则(ERE),不是基础正则(BRE),所以 +?{n,m} 这些元字符可以直接用,不需要反斜杠转义。

注意正则两边的引号:当正则里没有空格时,可以不加引号;但有空格时必须加引号,否则会被 shell 切碎。

4.2 捕获组

=~ 还有一个杀手级特性——它会把正则里的捕获组自动存到数组 BASH_REMATCH 里:

#!/bin/bash
log="2024-01-15 10:23:45 [ERROR] 用户登录失败"
​
if [[ "$log" =~ ^([0-9-]+)\ +([0-9:]+)\ +[([A-Z]+)]\ +(.*)$ ]]
then
    echo "日期:${BASH_REMATCH[1]}"
    echo "时间:${BASH_REMATCH[2]}"
    echo "级别:${BASH_REMATCH[3]}"
    echo "消息:${BASH_REMATCH[4]}"
fi

输出:

日期:2024-01-15
时间:10:23:45
级别:ERROR
消息:用户登录失败

BASH_REMATCH[0] 是整个匹配的字符串,[1][2]… 是各个捕获组。这个特性在解析日志、URL、配置文件时极其方便,完全不用起 awk 子进程。

4.3 一个完整的例子:校验密码强度

#!/bin/bash
read -p "请输入密码: " -s pwd
echo
​
if [[ ${#pwd} -lt 8 ]]
then
    echo "密码太短,至少 8 位"
    exit 1
fi
​
if [[ ! "$pwd" =~ [A-Z] ]]
then
    echo "密码必须包含大写字母"
    exit 1
fi
​
if [[ ! "$pwd" =~ [a-z] ]]
then
    echo "密码必须包含小写字母"
    exit 1
fi
​
if [[ ! "$pwd" =~ [0-9] ]]
then
    echo "密码必须包含数字"
    exit 1
fi
​
echo "密码强度合格"

这段脚本用 =~ 做了四层校验,每一层都是单独的正则匹配。比起调用 grep 一次次匹配,纯 bash 实现既快又干净。

五、grep日志分析

[[ =~ ]] 适合"在 shell 脚本里嵌入简单判断",但要做复杂的文本分析,还得靠 grepsedawk 三大文本处理工具。先看 grep

5.1 最常用的选项

# 在 file.log 里找包含 "ERROR" 的行
grep "ERROR" file.log
​
# 忽略大小写
grep -i "error" file.log
​
# 显示行号
grep -n "ERROR" file.log
​
# 反向匹配(不包含 "DEBUG" 的行)
grep -v "DEBUG" file.log
​
# 显示匹配行后 2 行(-A: After)
grep -A 2 "异常" file.log
​
# 显示匹配行前 2 行(-B: Before)
grep -B 2 "异常" file.log
​
# 显示匹配行前后各 2 行(-C: Context)
grep -C 2 "异常" file.log
​
# 统计匹配的行数
grep -c "ERROR" file.log
​
# 只输出匹配到的部分,不输出整行
grep -o "[0-9]+.[0-9]+.[0-9]+.[0-9]+" file.log

5.2 基础正则 vs 扩展正则

grep 默认用基础正则(BRE),想用扩展正则(ERE)需要 -E。两者的主要区别在于元字符是否需要反斜杠:

# BRE:+ 需要转义
grep "ERROR+" file.log
grep "ERROR+" file.log
​
# ERE:+ 不需要转义
grep -E "ERROR+" file.log

其他常见的 BRE/ERE 差异:

字符BREERE
+++
???
()()()
{}{}{}
``|

实操建议:日常用 grep -E 或者 egrep(两者等价),可读性更好。除非你在写非常底层的脚本,否则没必要和反斜杠较劲。

5.3 递归搜索

# 在当前目录所有 .log 文件里找 "ERROR"
grep -rn "ERROR" --include="*.log" .
​
# 排除某些目录
grep -rn "ERROR" --exclude-dir={.git,node_modules} .
​
# 排除某些文件
grep -rn "ERROR" --exclude="*.tmp" .

-r 递归,-n 显示行号,--include--exclude 限定文件类型。日常查日志基本就是这几条命令的组合。

5.4 用 grep 提取字段

-o(only matching)配合 -P(Perl 风格正则)能实现相当复杂的字段提取:

# 从日志里提取所有 IP 地址
grep -oP "\d+.\d+.\d+.\d+" access.log
​
# 用 \K 排除掉不想要的部分(Perl 风格特有)
# 下面这行只输出引号里的内容,不包括引号本身
echo 'name="张三"' | grep -oP 'name="\K[^"]+'

-Pgrep 使用 PCRE(Perl Compatible Regular Expressions),支持 \d\s\K 这些更现代的语法。代价是某些精简环境不支持,需要 GNU grep。

六、sed流编辑器

sed 是 “stream editor” 的缩写,它的核心思想是"按行处理,每行执行一组命令"。如果说 grep 是"找东西",那 sed 就是"改东西"。

6.1 最常用:替换

# 把每行第一个 "foo" 替换成 "bar"
sed 's/foo/bar/' file.txt
​
# 把所有 "foo" 替换成 "bar"(加 g 标志)
sed 's/foo/bar/g' file.txt
​
# 只替换第 3 行
sed '3s/foo/bar/' file.txt
​
# 替换第 2 到 5 行
sed '2,5s/foo/bar/g' file.txt
​
# 直接修改原文件(慎用)
sed -i 's/foo/bar/g' file.txt

sed 's/old/new/'sed 最常用的命令。s 是 substitute 的意思,后面跟三个斜号分隔的部分:被替换的内容、替换成什么、标志(g 表示全局替换)。注意默认只替换每行的第一个匹配,要替换所有必须加 g

-i 是 “in-place” 修改,会直接改原文件。生产环境用之前最好先不带 -i 跑一遍,确认输出符合预期再 sed -i

6.2 删除行

# 删除第 3 行
sed '3d' file.txt
​
# 删除第 2 到 5 行
sed '2,5d' file.txt
​
# 删除空行
sed '/^$/d' file.txt
​
# 删除以 # 开头的注释行
sed '/^#/d' file.txt

d 是 delete,配合正则可以做条件删除。/^$/ 表示空行(行首紧跟行尾),/^#/ 表示以 # 开头。

6.3 插入和追加

# 在第 3 行前插入
sed '3i\插入的新行' file.txt
​
# 在第 3 行后追加
sed '3a\追加的新行' file.txt
​
# 替换整行
sed '3c\整行替换' file.txt

i 是 insert,a 是 append,c 是 change。这些在写配置文件模板时偶尔会用。

6.4 多个命令组合

sed 一次可以执行多条命令,用 -e 或者分号分隔:

# 同时删除空行和注释行
sed -e '/^$/d' -e '/^#/d' file.txt
​
# 等价写法
sed '/^$/d; /^#/d' file.txt

6.5 例子:批量修改配置

# 把所有 yaml 文件里的 image: nginx 改成 image: nginx:1.25
find . -name "*.yaml" -exec sed -i 's/image: nginx$/image: nginx:1.25/' {} ;

find 找文件,-execsed 对每个文件执行替换。这是 K8s 运维、CI/CD 脚本里非常常见的模式。

七、awk报表生成

如果说 grep 是"找",sed 是"改",那 awk 就是"算"。awk 是一门完整的脚本语言,但日常用得最多的是它的字段处理和统计能力。

7.1 基础语法

awk 'pattern { action }' file
  • pattern 是过滤条件,匹配的行才执行 action
  • action 是要执行的操作,默认动作是 print $0(打印整行)

7.2 字段处理

# 打印文件第 3 列(默认空格分隔)
awk '{ print $3 }' file.txt
​
# 打印第 1 和第 3 列
awk '{ print $1, $3 }' file.txt
​
# 自定义分隔符
awk -F: '{ print $1 }' /etc/passwd
​
# 多个分隔符(用 | 或正则)
awk -F'[,:]' '{ print $1 }' file.txt
​
# 输出分隔符(OFS)
awk -F: -v OFS=' -> ' '{ print $1, $7 }' /etc/passwd

$1$2… 是第 N 个字段,$0 是整行,NF 是字段总数,NR 是当前行号。这几个内置变量是 awk 最常用的"接口"。

7.3 条件过滤

# 只处理第 3 列大于 100 的行
awk '$3 > 100 { print $1, $3 }' file.txt
​
# 匹配包含 "ERROR" 的行
awk '/ERROR/ { print }' file.txt
​
# 范围匹配(从 "开始" 到 "结束")
awk '/START/,/END/' file.txt

7.4 BEGIN 和 END

awk 有两个特殊模式 BEGINEND,分别在处理开始前和处理结束后执行:

awk 'BEGIN { print "开始统计" }
     { sum += $1; count++ }
     END { print "总和:", sum, "平均:", sum/count }' numbers.txt

BEGIN 块常用来打印表头、初始化变量,END 块常用来输出统计结果。这两个块的存在让 awk 在做汇总统计时比 sed 顺手得多。

7.5 例子:分析访问日志

# 统计 access.log 里每个 IP 的访问次数,按次数降序
awk '{ count[$1]++ } END {
    for (ip in count) print count[ip], ip
}' access.log | sort -rn | head -10

这段组合拳输出访问次数最多的 10 个 IP。awk 负责计数(count[$1]++ 是关联数组的经典用法),sort -rn 负责排序,head 负责取前 10。

八、字符串处理与正则的组合实战

理论和命令分开讲都比较碎,最后给几个综合例子,把前面学的东西串起来。

8.1 解析 URL

#!/bin/bash
url="https://www.example.com:8080/path/to/resource?id=123&name=test#section"
​
# 用 =~ 解析
if [[ "$url" =~ ^https?://([^:/]+):?([0-9]*)(/[^?#]*)??([^#]*)(#.*)?$ ]]
then
    host="${BASH_REMATCH[1]}"
    port="${BASH_REMATCH[2]}"
    path="${BASH_REMATCH[3]}"
    query="${BASH_REMATCH[4]}"
    frag="${BASH_REMATCH[5]}"
    echo "协议: https"
    echo "主机: $host"
    echo "端口: ${port:-443}"
    echo "路径: $path"
    echo "查询: $query"
    echo "片段: $frag"
fi

输出:

协议: https
主机: www.example.com
端口: 8080
路径: /path/to/resource
查询: id=123&name=test
片段: #section

这种"一个正则搞定 URL 解析"的方式比开 python -c "import urlparse..." 要快得多,也更符合 shell 脚本的"轻量"定位。

8.2 提取文件名、扩展名、目录

#!/bin/bash
filepath="/home/user/docs/report.tar.gz"
​
# 完整文件名
filename="${filepath##*/}"          # report.tar.gz
​
# 不带扩展名的文件名
basename="${filename%.*}"           # report.tar
​
# 真正的"主名"(去掉最后一个扩展名)
mainname="${filepath##*/}"          # 先取文件名
mainname="${mainname%.*}"           # 再去最后一个扩展名
echo "主名: $mainname"               # report
​
# 最后一个扩展名
ext="${filepath##*.}"               # gz
​
# 目录
dir="${filepath%/*}"                # /home/user/docs
​
# 当前目录
dir="$( cd "$( dirname "$filepath" )" && pwd )"
​
echo "文件名: $filename"
echo "主名: $mainname"
echo "扩展名: $ext"
echo "目录: $dir"

这种"前后缀删除"的组合是 shell 脚本里的常用模式。##% 用好了,能省掉很多 sedawk 调用。

8.3 日志分析:找出耗时最长的请求

#!/bin/bash
# 假设 access.log 每行格式: 时间 状态 耗时(ms) 路径
​
awk '$3 > 1000 { 
    print $3, $4
}' access.log | sort -rn | head -20

或者用 [[ =~ ]] 做更精细的过滤:

#!/bin/bash
while IFS=' ' read -r time status duration path
do
    # 用正则判断 path 是不是 API 请求
    if [[ "$path" =~ ^/api/ ]] && [ "$duration" -gt 1000 ]
    then
        echo "慢请求: $path 耗时 ${duration}ms"
    fi
done < access.log

8.4 校验输入格式

#!/bin/bash
validate_ip() {
    local ip=$1
    if [[ ! "$ip" =~ ^([0-9]{1,3}.){3}[0-9]{1,3}$ ]]
    then
        return 1
    fi
    # 检查每个段是否在 0-255
    IFS='.' read -r a b c d <<< "$ip"
    for seg in $a $b $c $d
    do
        if [ "$seg" -gt 255 ] || [ "$seg" -lt 0 ]
        then
            return 1
        fi
    done
    return 0
}
​
read -p "请输入 IP: " ip
if validate_ip "$ip"
then
    echo "IP 合法"
else
    echo "IP 非法"
fi

<<< 是 here-string,把字符串当作标准输入传给 read。配合 IFS='.' 就能按点切分 IP。

九、总结

字符串处理和正则匹配是 shell 脚本的"日常工具箱",熟练之后能解决 90% 的文本问题。我们这一篇覆盖的内容回顾一下:

  • 字符串基础:单/双引号区别、拼接、长度、子串提取
  • 变量展开###%%% 删前后缀;/// 替换;:-:=:+:? 默认值
  • 字符串比较:test 命令 vs [[ ]] 复合命令
  • 正则匹配:bash 内置的 =~BASH_REMATCH
  • 三剑客:grep 查找、sed 改写、awk 统计
  • 实战场景:URL 解析、文件路径处理、日志分析、输入校验

下个主题我们顺着这条线往更深入走——聊一聊 shell 里的数组、关联数组和 mapfile 这些"容器",以及它们和循环、字符串处理怎么配合起来写更复杂的数据处理脚本。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柒号华仔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值