数据与 IO 处理
# 第 3 章 数据与 IO 处理
# 目录介绍
# 3.1 数组操作
# 3.1.1 索引数组——定义/访问/遍历
Bash 支持一维索引数组——索引从 0 开始,用 () 定义、空格分隔:
#!/bin/bash
# ===== 定义方式 =====
# 方式 1:直接赋值
fruits=("苹果" "香蕉" "橘子" "葡萄")
nums=(1 2 3 4 5)
mixed=("hello" 42 "world") # 可以混类型(但不推荐)
# 方式 2:逐个赋值
fruits[4]="西瓜" # 追加到索引 4
# 方式 3:从命令输出构造
files=($(ls *.txt)) # 把 ls 输出的每行作为数组元素
# ===== 访问元素 =====
echo "${fruits[0]}" # 苹果
echo "${fruits[1]}" # 香蕉
echo "${fruits[-1]}" # 西瓜(最后一个——bash 4.2+)
# ===== 遍历数组 =====
echo "--- 遍历全部元素 ---"
echo "${fruits[@]}" # 苹果 香蕉 橘子 葡萄 西瓜
echo "${fruits[*]}" # 苹果 香蕉 橘子 葡萄 西瓜
echo "--- for 循环遍历 ---"
for fruit in "${fruits[@]}"; do # 安全方式——保留元素内空格
echo " 水果:$fruit"
done
echo "--- 带索引遍历 ---"
for i in "${!fruits[@]}"; do # ${!array[@]} 展开为所有索引
echo " [$i] = ${fruits[$i]}"
done
# ===== 添加到末尾 =====
fruits+=("芒果" "草莓") # 追加多个元素
echo "${fruits[@]}" # 苹果 香蕉 橘子 葡萄 西瓜 芒果 草莓
🔑 ${array[@]} vs ${array[*]}——引号下的关键差异:
#!/bin/bash
items=("a b" "c" "d e")
echo "=== @ ==="
for item in "${items[@]}"; do # 每个元素独立——正确
echo " [$item]"
done
# 输出:
# [a b]
# [c]
# [d e]
echo "=== * ==="
for item in "${items[*]}"; do # 所有元素合并成一个字符串
echo " [$item]"
done
# 输出:
# [a b c d e] ← 全拼在一起了!
📌 铁律:遍历数组永远用
"${array[@]}"(双引号 + @)。
# 3.1.2 关联数组——Shell 的"字典"
Bash 4.0+ 支持关联数组——类似 Python 的 dict,需要 declare -A 声明:
#!/bin/bash
# ===== 声明与赋值 =====
declare -A student # 必须先 declare -A!
student["name"]="张三"
student["age"]="25"
student["city"]="深圳"
# 一次性赋值
declare -A config=(
["host"]="localhost"
["port"]="8080"
["user"]="admin"
)
# ===== 访问 =====
echo "姓名:${student[name]}" # 张三(key 可以加引号也可以不加)
echo "年龄:${student[age]}" # 25
# ===== 遍历键值对 =====
for key in "${!student[@]}"; do # ${!array[@]} → 所有键
echo " $key = ${student[$key]}"
done
# 输出:
# name = 张三
# age = 25
# city = 深圳
# ===== 判断 key 是否存在 =====
if [[ -v "student[phone]" ]]; then # -v 检查变量/键是否存在(bash 4.2+)
echo "有手机号"
else
echo "没有手机号"
fi
# ===== 删除某个键 =====
unset "student[age]"
echo "删除后:${!student[@]}" # name city
🔑 关联数组实战:统计单词频率:
#!/bin/bash
text="the quick brown fox jumps over the lazy dog the fox"
declare -A freq
for word in $text; do
((freq[$word]++)) # 自动计数——第一次访问时值=0,++后=1
done
for word in "${!freq[@]}"; do
echo " $word → ${freq[$word]}"
done
# 输出:
# over → 1
# quick → 1
# the → 3
# ...
# 3.1.3 数组长度、切片与 CRUD
#!/bin/bash
fruits=("苹果" "香蕉" "橘子" "葡萄" "西瓜" "芒果" "草莓")
# ===== 数组长度 =====
echo "元素个数:${#fruits[@]}" # 7
echo "索引个数:${#fruits[*]}" # 7
echo "第一个元素长度:${#fruits[0]}" # 2("苹果"占 2 个字符)
# ===== 切片 =====
echo "${fruits[@]:1:3}" # 香蕉 橘子 葡萄(索引1开始,取3个)
echo "${fruits[@]:3}" # 葡萄 西瓜 芒果 草莓(索引3到最后)
echo "${fruits[*]:2:2}" # 橘子 葡萄
# ===== 替换元素 =====
fruits[1]="火龙果" # 替换索引 1
echo "${fruits[@]}" # 苹果 火龙果 橘子 葡萄 西瓜 芒果 草莓
# ===== 删除元素 =====
unset fruits[4] # 删除索引 4(西瓜)
echo "${fruits[@]}" # 苹果 火龙果 橘子 葡萄 芒果 草莓
echo "索引列表:${!fruits[@]}" # 0 1 2 3 5 6 ← 索引不连续了
# ===== 清空数组 =====
fruits=() # 全部清空
echo "清空后:${#fruits[@]}" # 0
# ===== 合并数组 =====
a=(1 2 3)
b=(4 5 6)
combined=("${a[@]}" "${b[@]}")
echo "${combined[@]}" # 1 2 3 4 5 6
🔑 数组 CRUD 速查表:
| 操作 | 语法 | 说明 |
|---|---|---|
| 定义 | arr=(a b c) | 小括号 + 空格分隔 |
| 访问 | "${arr[i]}" | 索引从 0 开始 |
| 遍历 | for e in "${arr[@]}" | 安全遍历 |
| 长度 | "${#arr[@]}" | 元素个数 |
| 追加 | arr+=("x") | 自动追加到末尾 |
| 切片 | "${arr[@]:i:n}" | 从索引 i 取 n 个 |
| 删除 | unset arr[i] | 删除后索引不重排 |
| 清空 | arr=() | 全部清空 |
| 拷贝 | copy=("${arr[@]}") | 复制数组 |
# 3.2 字符串处理
# 3.2.1 长度/截取/替换/删除
Shell 的 ${} 提供了丰富的字符串内建操作——不需要调用外部命令:
#!/bin/bash
str="Hello, World! Welcome to Shell."
# ===== 1. 字符串长度 =====
echo "长度:${#str}" # 30
echo "长度:$(echo -n "$str" | wc -c)" # 30(外部命令方式——慢)
# ===== 2. 子串截取 =====
echo "${str:0:5}" # Hello(从 0 取 5 个字符)
echo "${str:7:5}" # World(从 7 取 5 个)
echo "${str:7}" # World! Welcome to Shell.(从 7 到结尾)
echo "${str:(-6)}" # Shell.(从末尾往前 6 个)
echo "${str:(-6):3}" # She(从末尾往前 6 个取 3 个)
# ===== 3. 字符串替换 =====
# 替换第一个匹配
echo "${str/World/宇宙}" # Hello, 宇宙! Welcome to Shell.
# 替换所有匹配
text="foo foo foo bar"
echo "${text//foo/bar}" # bar bar bar bar
# 行首替换
echo "${str/#Hello/Hi}" # Hi, World! Welcome to Shell.
echo "${str/#Hi/Hi}" # 不匹配——原样输出
# 行尾替换
echo "${str/%Shell/Bash}" # Hello, World! Welcome to Bash.
echo "${str/%Bash/Bash}" # 不匹配——原样输出
# ===== 4. 字符串删除(模式匹配)=====
file="backup_2025-06-07.tar.gz"
# 从开头删除最短匹配(#)
echo "${file#backup_}" # 2025-06-07.tar.gz
echo "${file#*/}" # 如果文件有路径...不匹配,原样
# 从开头删除最长匹配(##)
echo "${file##*_}" # 2025-06-07.tar.gz(和上一个一样——因为最短/最长一致)
url="https://example.com/path/file.txt"
echo "${url##*/}" # file.txt(删除最后一个 / 前的所有)
echo "${url#*/}" # /example.com/path/file.txt(删除第一个 / 前的所有)
# 从末尾删除最短匹配(%)
echo "${file%.tar.gz}" # backup_2025-06-07
echo "${file%.*}" # backup_2025-06-07.tar(最短匹配——删掉 .gz)
# 从末尾删除最长匹配(%%)
echo "${file%%.*}" # backup_2025-06-07(最长匹配——删掉 .tar.gz)
🔑 #/% 模式删除速查表:
| 语法 | 含义 | 示例:"abc.def.ghi" |
|---|---|---|
${var#pattern} | 行首最短删除 | ${var#*.} → def.ghi |
${var##pattern} | 行首最长删除 | ${var##*.} → ghi |
${var%pattern} | 行尾最短删除 | ${var%.*} → abc.def |
${var%%pattern} | 行尾最长删除 | ${var%%.*} → abc |
# ===== 实战:批量修改文件扩展名 =====
for file in *.jpeg; do
mv "$file" "${file%.jpeg}.jpg" # .jpeg → .jpg
done
# 实战:提取文件名和路径
fullpath="/home/user/data/file.txt"
filename="${fullpath##*/}" # file.txt
dir="${fullpath%/*}" # /home/user/data
basename="${filename%.*}" # file
ext="${filename##*.}" # txt
echo "目录:$dir"
echo "文件名:$filename"
echo "不含扩展名:$basename"
echo "扩展名:$ext"
# 3.2.2 大小写转换与模式匹配
#!/bin/bash
# ===== 大小写转换(bash 4.0+)=====
msg="Hello World"
echo "${msg,,}" # hello world(全小写)
echo "${msg^^}" # HELLO WORLD(全大写)
echo "${msg,}" # hello World(首字母小写)
echo "${msg^}" # Hello World(首字母大写)
# ===== 模式匹配——[[ ]] 的 == 和 =~ =====
phone="13812345678"
email="user@example.com"
ip="192.168.1.1"
# 通配符匹配
if [[ "$phone" == 138* ]]; then
echo "是联通号码"
fi
# 正则匹配
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "有效邮箱"
fi
if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
echo "有效的 IP 地址"
fi
# ===== 检查包含关系 =====
if [[ "$msg" == *"World"* ]]; then
echo "包含 World"
fi
# ===== 多条件模式匹配 =====
user_input="yes"
case "${user_input,,}" in # 转小写后再匹配
y | yes | yep | yeah)
echo "确认"
;;
n | no | nope)
echo "取消"
;;
*)
echo "输入有误"
;;
esac
# 3.2.3 Here Document 与 Here String
Here Document(<<)——在脚本里嵌入多行文本:
#!/bin/bash
# ===== 基础用法 =====
cat <<EOF
这是第一行
这是第二行
当前用户是 $USER
EOF
# ===== 阻止变量展开——定界符加引号 =====
cat <<'EOF' # EOF 加引号——不展开变量!
变量不会被展开:$USER # 输出:$USER(原样)
$(date) # 输出:$(date)(原样)
EOF
# ===== <<- 自动忽略行首 Tab =====
if true; then
cat <<-EOF
这一行的 Tab 缩进会被忽略
包括这一行也是
EOF
fi
# 输出(无缩进):
# 这一行的 Tab 缩进会被忽略
# 包括这一行也是
# ===== 实战:生成配置文件 =====
port=8080
db_name="myapp"
cat > config.yaml <<EOF
server:
port: $port
database:
name: $db_name
host: localhost
EOF
# ===== 实战:生成 HTML 报告 =====
title="系统巡检报告"
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
cat > report.html <<HTML_END
<!DOCTYPE html>
<html>
<head><title>$title</title></head>
<body>
<h1>$title</h1>
<p>生成时间:$timestamp</p>
<p>主机:$(hostname)</p>
</body>
</html>
HTML_END
# ===== 输出到变量(而不是文件)=====
html_content=$(cat <<EOF
<ul>
<li>$USER</li>
<li>$(hostname)</li>
</ul>
EOF
)
echo "$html_content"
Here String(<<<)——把字符串当作 stdin 输入:
#!/bin/bash
# ===== 基础 =====
# 不用 echo + 管道
echo "hello world" | wc -c # 12(包括换行符)
# 用 Here String——更简洁
wc -c <<< "hello world" # 12(同样包括换行符)
# ===== 配合 read 使用 =====
read -r first rest <<< "apple banana orange"
echo "first=$first, rest=$rest" # first=apple, rest=banana orange
# ===== 配合 grep/sed/awk =====
grep -o '[0-9]\+' <<< "abc123def456" # 123\n456
sed 's/foo/bar/g' <<< "foo foo foo" # bar bar bar
awk '{print $2, $1}' <<< "name=张三 age=25"
# ===== 字符串分割到数组 =====
csv="apple,banana,orange"
IFS=',' read -ra items <<< "$csv" # -r 保留反斜杠,-a 读入数组
echo "items[0]=${items[0]}" # apple
echo "items[1]=${items[1]}" # banana
echo "items[2]=${items[2]}" # orange
# 3.2.4 printf 格式化输出
echo 够用但不够灵活——printf 是 Shell 的格式化神器(和 C 语言的 printf 语法一致):
#!/bin/bash
# ===== 基础——%s 字符串 / %d 整数 / %f 浮点 =====
printf "姓名:%s,年龄:%d\n" "张三" 25
# 姓名:张三,年龄:25
# ===== 宽度与对齐 =====
printf "|%-10s|%10s|\n" "左对齐" "右对齐"
# |左对齐 | 右对齐|
# ===== 浮点数精度 =====
printf "圆周率:%.2f\n" 3.14159 # 圆周率:3.14
printf "百分比:%.1f%%\n" 85.678 # 百分比:85.7%
# ===== 多个参数重复使用格式 =====
printf "%s\n" 苹果 香蕉 橘子 # 每个一行
# 苹果
# 香蕉
# 橘子
# ===== 实战:表格输出 =====
header="%-10s %-8s %-10s\n"
row="%-10s %-8s %-10s\n"
printf "$header" "姓名" "年龄" "城市"
printf "$header" "------" "----" "--------"
printf "$row" "张三" "25" "深圳"
printf "$row" "李四" "30" "北京"
printf "$row" "王五" "28" "上海"
# 姓名 年龄 城市
# ------ ---- --------
# 张三 25 深圳
# 李四 30 北京
# 王五 28 上海
# ===== 实战:对齐数字 =====
for i in 1 12 123 1234; do
printf "序号:%4d\n" "$i" # 右对齐,占 4 位
done
# 序号: 1
# 序号: 12
# 序号: 123
# 序号:1234
🔑 echo vs printf 对比:
| 特性 | echo | printf |
|---|---|---|
| 换行 | 默认加(-n 取消) | 需要 \n |
| 格式化 | ❌ 不支持 | ✅ %s %d %f 等 |
| 对齐 | ❌ | ✅ 宽度 + 左右对齐 |
| 转义 | -e 才能启用 | 原生支持 \n \t |
| 跨平台 | macOS/Linux 行为不同 | ✅ 行为一致 |
📌 打印变量用
echo,格式化输出/表格用printf。
# 3.3 重定向与管道
# 3.3.1 stdin/stdout/stderr——三剑客
每个 Linux 进程启动时都有三个标准文件描述符——它们是 Shell 重定向的基石:
┌──────────────┐
stdin (0) ←──────│ │
(键盘输入) │ 进程 │──────→ stdout (1) ——正常输出(屏幕)
│ │──────→ stderr (2) ——错误输出(屏幕)
└──────────────┘
# 查看标准输入/输出/错误对应的设备
ls -l /dev/stdin # lrwxrwxrwx ... /dev/stdin -> /proc/self/fd/0
ls -l /dev/stdout # lrwxrwxrwx ... /dev/stdout -> /proc/self/fd/1
ls -l /dev/stderr # lrwxrwxrwx ... /dev/stderr -> /proc/self/fd/2
🔑 三者用途:
| 描述符 | 编号 | 默认去向 | 用途 |
|---|---|---|---|
| stdin | 0 | 键盘 | 读取输入 |
| stdout | 1 | 屏幕 | 正常输出 |
| stderr | 2 | 屏幕 | 错误/诊断信息(与正常输出分离) |
为什么 stderr 和 stdout 要分开?
#!/bin/bash
# 假设这个脚本正常输出和处理结果,错误输出诊断信息
echo "文件处理完成" # stdout → 结果报告
echo "处理了 100 条记录" >> /var/log/app.log # 追加到日志
ls /nonexistent >/dev/null # stderr → 错误信息(默认显示到屏幕)
# 分开的意义:
# ① 用户可以在屏幕上看到错误,同时把结果重定向到文件
# ② 管道只传递 stdout——stderr 不会被意外塞进下游
# ③ CI/CD 中可以根据 stderr 是否为空判断构建状态
# 3.3.2 > / >> / < / << 四大家族
#!/bin/bash
# ===== > —— 覆盖写入(截断后写入)=====
echo "第一行" > /tmp/test.txt # 写入/覆盖
# 文件内容:第一行
# ===== >> —— 追加写入 =====
echo "第二行" >> /tmp/test.txt # 追加
# 文件内容:
# 第一行
# 第二行
# ===== < —— 从文件读取 =====
cat < /tmp/test.txt # 把文件内容作为 cat 的输入
# ===== 2> —— 重定向 stderr =====
ls /nonexistent 2> /dev/null # 错误信息进黑洞——屏幕干净了
# ===== 2>&1 —— 合并 stderr 到 stdout =====
cmd="ls /existing /nonexistent"
$cmd > /tmp/output.txt 2>&1 # stdout 和 stderr 都写入同一个文件
# ===== 现代写法(更清晰)=====
$cmd > /tmp/output.txt 2>&1 # 传统写法
$cmd &> /tmp/output.txt # bash 4.0+ 简写——等价
# ===== 追加 stderr 到 stdout =====
$cmd >> /tmp/output.txt 2>&1 # 追加模式
$cmd &>> /tmp/output.txt # bash 4.0+ 简写
# ===== 丢弃所有输出 =====
$cmd > /dev/null 2>&1
$cmd &> /dev/null # 等价简写
# ===== 只保留 stderr,丢弃 stdout =====
$cmd > /dev/null # stdout → 黑洞,stderr → 屏幕
# ===== 管道只传 stdout,忽略 stderr =====
$cmd 2>&1 | grep "error" # 如果 stderr 里也有 error,先合并再 grep
🔑 重定向语法速查表:
| 语法 | 含义 | 示例 |
|---|---|---|
> | stdout 覆盖写入 | echo hi > file |
>> | stdout 追加写入 | echo hi >> file |
< | 从文件读取 stdin | cat < file |
2> | stderr 覆盖写入 | cmd 2> err.log |
2>> | stderr 追加写入 | cmd 2>> err.log |
2>&1 | stderr 合并到 stdout | cmd > all.log 2>&1 |
&> | stdout+stderr 覆盖 | cmd &> all.log |
&>> | stdout+stderr 追加 | cmd &>> all.log |
# ===== 实战:正确分离正常输出和错误 =====
script_runner() {
echo "开始处理..." # stdout
some_command_that_might_fail
if [[ $? -ne 0 ]]; then
echo "处理失败!" >&2 # 显式写到 stderr
return 1
fi
echo "处理完成" # stdout
}
# 调用——用户可以这样:
script_runner > result.log 2> error.log
# result.log:开始处理...\n处理完成
# error.log:处理失败!(如果有的话)
# 3.3.3 管道 | ——命令协作的艺术
管道(|)把前一个命令的 stdout 连接到后一个命令的 stdin——
cmd1 │ cmd2 │ cmd3
stdin → │ stdin → │ stdin → 最终输出
#!/bin/bash
# ===== 入门三件套 =====
grep "ERROR" /var/log/app.log | wc -l # 统计错误行数
cat /etc/passwd | cut -d: -f1 | sort # 排序所有用户名
ps aux | grep nginx | awk '{print $2}' # 获取 nginx 进程 PID
# ===== 长管道 =====
# 找出访问量 Top 10 的 IP
cat access.log \
| awk '{print $1}' \
| sort \
| uniq -c \
| sort -rn \
| head -10
# ===== 管道 + xargs ——把前一个的输出作为后一个的参数 =====
# find + xargs(比 find -exec 更高效——批量传参)
find /var/log -name "*.log" -mtime +7 | xargs rm -f # 删除7天前的日志
# xargs 的 -n 控制每次传几个参数
echo "a b c d e f" | xargs -n 3 echo # 每次 echo 3 个
# a b c
# d e f
# xargs 的 -P 并行执行
find . -name "*.jpg" -print0 | xargs -0 -P 4 -I {} convert {} {}.png
# -print0 / -0:空字符分割(应对文件名含空格/换行)
# -P 4:4 个进程并行
# -I {}:用 {} 占位符
# ===== 管道 + tee:同时输出到屏幕和文件 =====
echo "启动服务..." | tee -a /var/log/setup.log
# ===== 管道 + 子 Shell:在子 Shell 中处理 =====
# 统计当前目录的文件大小分布
find . -type f -size +10M | while read -r f; do
echo "大文件:$f"
done
⚠️ 管道陷阱——set -o pipefail:
set -e # 遇到错误就退出
# ❌ 管道中只有最后的命令决定退出码
cat nonexistent.txt | wc -l # cat 失败了,但 wc -l 成功(输出 0)
echo "还会继续执行——危险!" # ← set -e 没拦截
# ✅ 加 pipefail——管道中任何命令失败都算失败
set -eo pipefail
cat nonexistent.txt | wc -l # 立即退出
echo "不会执行到这里"
# 3.3.4 tee 分流与 /dev/null 黑洞
tee——T 型分流器,数据一份给屏幕、一份给文件:
#!/bin/bash
# ===== 基础:同时输出到屏幕和文件 =====
echo "Hello, World!" | tee output.txt
# 屏幕显示:Hello, World!
# 文件 output.txt:Hello, World!
# ===== -a 追加模式 =====
echo "第一行" | tee output.txt # 覆盖
echo "第二行" | tee -a output.txt # 追加
# ===== 写入多个文件 =====
echo "重要日志" | tee log1.txt log2.txt log3.txt
# ===== 结合 sudo——tee 解决权限问题 =====
# echo 后面跟着 > 重定向——但当前用户没有写权限时:
echo "nameserver 8.8.8.8" | sudo tee -a /etc/resolv.conf
# ===== 保留 stderr =====
cmd 2>&1 | tee output.txt # 标准输出和错误都进文件和屏幕
/dev/null——Linux 的"黑洞":
#!/bin/bash
# ===== 丢弃不需要的输出 =====
find / -name "*.log" 2>/dev/null # 只显示找得到的,忽略权限错误
# ===== 丢弃所有输出 =====
some_noisy_command > /dev/null 2>&1 # 静默执行
# ===== 测试命令是否成功(只看退出码,不看输出)=====
if grep -q "pattern" /var/log/app.log 2>/dev/null; then
echo "找到匹配"
fi
# ===== 创建空文件(清空文件内容)=====
> /tmp/empty_file.txt # 等价于 truncate -s 0
# ===== 验证 /dev/null 的特性 =====
cat /dev/null | wc -c # 0——永远是空的
dd if=/dev/zero of=/dev/null bs=1M count=1000 # 写 1GB 到黑洞——瞬间完成
# 3.3.5 文件描述符——深入理解 0/1/2
除了标准三剑客(0/1/2),Shell 还允许你操作额外的文件描述符:
#!/bin/bash
# ===== 在脚本内重定向自己的 stdin/stdout/stderr =====
exec 3> /tmp/debug.log # 打开文件描述符 3 用于写入
echo "调试信息" >&3 # 写入 fd 3 → debug.log
exec 3>&- # 关闭 fd 3
# ===== exec 全局重定向 =====
# 脚本内所有 stdout 都写入文件
exec > /tmp/build.log 2>&1 # 自此以后所有输出都去文件
echo "这行不会出现在屏幕" # → /tmp/build.log
ls /nonexistent # 错误也去文件
echo "构建结束"
# ===== 交换文件描述符 =====
# 临时保存 stdout,重定向到 stderr
exec 3>&1 # 把当前 stdout 存到 fd 3
exec 1>&2 # stdout → stderr(都去错误输出)
echo "这行是错误输出" # 这行现在去 stderr
exec 1>&3 # 恢复 stdout
echo "这行恢复正常" # 回到正常 stdout
exec 3>&- # 关闭临时 fd 3
# ===== 实战:在脚本内同时输出到屏幕和文件 =====
exec 3>&1 # 保存 stdout 到 fd 3
exec > >(tee -a /var/log/setup.log) # stdout → tee → 同时去屏幕+文件
echo "安装依赖..."
echo "启动服务..."
exec 1>&3 # 恢复 stdout
exec 3>&- # 关闭 fd 3
echo "脚本结束"
🔑 文件描述符操作速查:
| 语法 | 含义 |
|---|---|
exec N> file | 打开 fd N 写入文件 |
exec N< file | 打开 fd N 读取文件 |
exec N>&M | fd N 复制 fd M 的写入端 |
exec N<&M | fd N 复制 fd M 的读取端 |
exec N>&- | 关闭 fd N(写入) |
exec N<&- | 关闭 fd N(读取) |
# ===== 高级实战:读取文件同时保留 stdin =====
# 场景:脚本想从一个文件读取配置,同时还要接受用户输入
exec 3< /etc/config.cfg # 用 fd 3 打开配置文件
read -r line <&3 # 从 fd 3 读配置
exec 3<&- # 关闭
read -r -p "请输入操作:" user_input # stdin(键盘)没受影响
echo "配置行:$line"
echo "用户输入:$user_input"
# 3.4 综合案例:日志分析器
把本章全部知识——数组 + 关联数组 + 字符串处理 + 重定向 + 管道——串联成一个生产级日志分析脚本:
#!/bin/bash
# log_analyzer.sh —— 日志分析器
# 用法:./log_analyzer.sh [日志文件] [选项]
# 示例:./log_analyzer.sh /var/log/nginx/access.log --top-ip --top-url
set -euo pipefail
# ===== 1. 配置 =====
LOG_FILE="${1:-/var/log/nginx/access.log}"
shift || true
# 选项标志
TOP_IP=false
TOP_URL=false
SHOW_ERRORS=false
OUTPUT_FILE=""
# ===== 2. 函数定义 =====
# 彩色输出
RED='\033[0;31m'; GREEN='\033[0;32m'
YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
usage() {
cat <<EOF
用法:$0 <日志文件> [选项]
选项:
--top-ip 显示访问量 Top 10 的 IP
--top-url 显示访问量 Top 10 的 URL
--errors 显示 4xx/5xx 错误统计
--output FILE 将报告输出到文件
-h, --help 显示帮助
EOF
exit 0
}
# ===== 3. 参数解析 =====
while [[ $# -gt 0 ]]; do
case "$1" in
--top-ip) TOP_IP=true; shift ;;
--top-url) TOP_URL=true; shift ;;
--errors) SHOW_ERRORS=true; shift ;;
--output) OUTPUT_FILE="$2"; shift 2 ;;
-h|--help) usage ;;
*) log_warn "忽略未知选项:$1"; shift ;;
esac
done
# ===== 4. 检查文件 =====
if [[ ! -f "$LOG_FILE" ]]; then
log_error "日志文件不存在:$LOG_FILE"
exit 1
fi
if [[ ! -r "$LOG_FILE" ]]; then
log_error "日志文件不可读:$LOG_FILE"
exit 1
fi
# ===== 5. 主分析逻辑 =====
# 5.1 基本信息
total_lines=$(wc -l < "$LOG_FILE")
file_size=$(du -h "$LOG_FILE" | cut -f1)
log_info "分析文件:$LOG_FILE(${file_size},${total_lines} 行)"
# 生成报告的临时目录
REPORT_DIR=$(mktemp -d /tmp/log_analyzer.XXXXXX)
trap 'rm -rf "$REPORT_DIR"' EXIT # 脚本退出时自动清理
# 5.2 Top IP 分析
if $TOP_IP; then
log_info "正在统计 Top IP..."
# 提取 IP -> 排序 -> 去重计数 -> 按数量排序 -> Top 10
awk '{print $1}' "$LOG_FILE" \
| sort \
| uniq -c \
| sort -rn \
| head -10 \
> "$REPORT_DIR/top_ip.txt"
echo ""
echo "━━━ Top 10 访问 IP ━━━━━━━━━━━━━━━━━━━"
printf "%-6s %-18s %s\n" "排名" "IP 地址" "访问次数"
printf "%-6s %-18s %s\n" "────" "────────────────" "──────"
rank=1
while read -r count ip; do
printf "%-6d %-18s %s\n" "$rank" "$ip" "$count"
((rank++))
done < "$REPORT_DIR/top_ip.txt"
fi
# 5.3 Top URL 分析
if $TOP_URL; then
log_info "正在统计 Top URL..."
# 假设 Nginx 日志格式:$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent
# $request 在第 7 列(用引号括着),我们用 awk 提取第 7 列作为 URL
awk '{print $7}' "$LOG_FILE" \
| sort \
| uniq -c \
| sort -rn \
| head -10 \
> "$REPORT_DIR/top_url.txt"
echo ""
echo "━━━ Top 10 访问 URL ━━━━━━━━━━━━━━━━━━━"
printf "%-6s %-50s %s\n" "排名" "URL" "访问次数"
printf "%-6s %-50s %s\n" "────" "─────────────────────────────────────" "──────"
rank=1
while read -r count url; do
# URL 可能很长——截断显示
display_url="${url:0:50}"
printf "%-6d %-50s %s\n" "$rank" "$display_url" "$count"
((rank++))
done < "$REPORT_DIR/top_url.txt"
fi
# 5.4 错误分析
if $SHOW_ERRORS; then
log_info "正在统计 HTTP 错误..."
# 统计 4xx 和 5xx 状态码
declare -A error_codes
while IFS= read -r line; do
# 提取状态码(Nginx 第 9 列)
status_code=$(echo "$line" | awk '{print $9}')
if [[ "$status_code" =~ ^[45][0-9]{2}$ ]]; then
((error_codes["$status_code"]++))
fi
done < "$LOG_FILE"
echo ""
echo "━━━ HTTP 错误统计 ━━━━━━━━━━━━━━━━━━━━━"
if [[ ${#error_codes[@]} -eq 0 ]]; then
echo " 没有发现 4xx/5xx 错误 ✅"
else
printf "%-8s %s\n" "状态码" "次数"
printf "%-8s %s\n" "──────" "────"
# 按错误码排序输出
for code in "${!error_codes[@]}"; do
printf "%-8s %d\n" "$code" "${error_codes[$code]}"
done | sort -k2 -rn
# 总错误数
total_errors=0
for count in "${error_codes[@]}"; do
((total_errors += count))
done
echo ""
echo "总错误数:$total_errors"
error_rate=$(echo "scale=2; $total_errors * 100 / $total_lines" | bc)
echo "错误率:${error_rate}%"
fi
fi
# ===== 6. 生成报告文件 =====
if [[ -n "$OUTPUT_FILE" ]]; then
{
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " 日志分析报告"
echo " 文件:$LOG_FILE"
echo " 时间:$(date '+%Y-%m-%d %H:%M:%S')"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "总行数:$total_lines | 文件大小:$file_size"
echo ""
if [[ -f "$REPORT_DIR/top_ip.txt" ]]; then
echo "━━ Top IP ━━━━━━━━━━━━━━━━━━━━━━━━━━"
cat "$REPORT_DIR/top_ip.txt" | while read -r count ip; do
printf " %-18s → %s\n" "$ip" "$count"
done
fi
if [[ -f "$REPORT_DIR/top_url.txt" ]]; then
echo "━━ Top URL ━━━━━━━━━━━━━━━━━━━━━━━━━"
cat "$REPORT_DIR/top_url.txt" | while read -r count url; do
printf " %-50s → %s\n" "$url" "$count"
done
fi
} > "$OUTPUT_FILE"
log_info "报告已保存至:$OUTPUT_FILE"
fi
echo ""
log_info "分析完成"
案例知识融合:这个脚本覆盖了本章全部核心技术——索引数组(error_codes 关联数组)、字符串处理(${url:0:50} 截断、${1:-default} 默认值、set -euo pipefail)、Here Document(usage 函数的帮助文本、trap 清理函数)、重定向与管道(> "$OUTPUT_FILE" 写入报告、>> 追加、>&2 错误输出、2>/dev/null 静默、trap 清理、最后生成报告的 { ... } > "$OUTPUT_FILE" 多行重定向、mktemp 创建临时目录);同时使用了 set -euo pipefail 安全执行、函数封装与 local 局部变量、数组操作(关联数组统计错误码)、管道链(awk | sort | uniq -c | sort -rn | head 数据分析流水线)。
# 3.5 新手陷阱 Top 5
| # | 陷阱 | 说明 |
|---|---|---|
| 1 | if 里把 [ ] 和 [[ ]] 混用 | [ "$a" > "$b" ] 的 > 被当作重定向!创建了一个叫 $b] 的文件 |
| 2 | 数组遍历忘加引号 | for f in ${files[@]}——如果元素包含空格会被拆散。永远用 "${files[@]}" |
| 3 | 管道让 while 变量丢失 | ls \| while read f; do count=$((count+1)); done——count 在子 Shell 里 |
| 4 | 2>&1 的位置错误 | cmd 2>&1 > file——先合并到当前 stdout(屏幕),然后重定向 stdout 到文件 |
| 5 | $* 和 $@ 混淆 | "$*" 把所有参数合成一个字符串——如果用 for arg in "$*" 只会循环一次 |
陷阱 1 详解——> 被当作重定向符:
# ❌ 灾难
if [ "$a" > "$b" ]; then # > 被当作输出重定向!
echo "大于"
fi
# 这行代码:
# 1. 创建了一个名为 "$b" 的空文件
# 2. 把空字符串写入该文件
# 3. [ 命令只收到一个参数 "$a"——永远为 true!
# ✅ 正确
if [[ "$a" > "$b" ]]; then # [[ 内 > 是字符串比较
echo "大于"
fi
if [[ "$a" -gt "$b" ]]; then # 数值比较——更安全
echo "大于"
fi
if (( a > b )); then # 或算术比较
echo "大于"
fi
陷阱 3 详解——修复管道子 Shell 问题:
# ❌ 管道——子 Shell 变量丢失
count=0
cat /etc/hosts | while read -r line; do
((count++))
done
echo "$count" # 0——count 在子 Shell 里修改,外面不知道!
# ✅ 方案一:输入重定向
count=0
while read -r line; do
((count++))
done < /etc/hosts
echo "$count" # 正确
# ✅ 方案二:进程替换
count=0
while read -r line; do
((count++))
done < <(cat /etc/hosts)
echo "$count" # 正确
陷阱 4 详解——正确的重定向顺序:
# ❌ 错误顺序——看似合并了,实际没合并
cmd 2>&1 > file
# 1. 2>&1:stderr 指向当前的 stdout(屏幕)
# 2. > file:stdout 改为指向 file
# 结果:stdout → file,stderr → 屏幕(没有合并!)
# ✅ 正确顺序
cmd > file 2>&1
# 1. > file:stdout 指向 file
# 2. 2>&1:stderr 指向当前 stdout(即 file)
# 结果:stdout + stderr → file(正确合并)
# ✅ 现代简写
cmd &> file # 更清晰——推荐
# 3.6 综合思考题
${arr[@]}vs${arr[*]}的本质差异:两个写法在双引号中行为完全不同——"${arr[@]}"保留元素边界,"${arr[*]}"把 IFS 的第一个字符作为连字符合并。这个设计起源于 1970 年代 Unix 的命令行参数传递方式——为什么@分开、*合并?如果你是 Shell 设计者,会设计第三种写法吗?# %模式删除的性能与可读性:Shell 内置的${var#pattern}比sed 's/^pattern//'快约 100 倍(不需要 fork 子进程)。但代价是什么?对于超过 100MB 的大字符串,Shell 内置操作的性能如何?什么时候应该放弃 Shell 内置操作,转而用awk/sed甚至 Python?文件描述符
>&2的设计哲学:为什么 Shell 选择>&2而不是2&>或2>这样更直观的语法?2>&1中间的&到底代表什么?(提示:>&的语法源自> filevs>& fd的区分——&表示"后面是文件描述符而不是文件名")。这个设计在哪种场景下会救命?Here Document 的 heredoc EOF vs file:
cat <<EOF > file和cat > file <<EOF在功能上等价——但它们背后的执行顺序完全不同(先重定向再输出 vs. 先构造 heredoc 再重定向)。在什么极端情况下这两种写法会产生不同的结果?(提示:考虑文件已存在的情况,以及set -o noclobber的影响)管道 vs 临时文件的性能取舍:
cmd1 | cmd2是流式处理——cmd1 一产生输出,cmd2 就能开始消费。而cmd1 > /tmp/tmp; cmd2 < /tmp/tmp需要等 cmd1 完全结束后 cmd2 才开始。在什么情况下管道的流式特性反而会成为问题?(提示:考虑一个 cmd1 产生了 100GB 输出、cmd2 需要随机访问的场景——以及如何使用sort命令的-T选项找到出路)