强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Bash 脚本编写教程 / 07 - 输入输出与重定向

07 - 输入输出与重定向

7.1 文件描述符

每个进程都有文件描述符(File Descriptor, FD),用于标识打开的文件或数据流:

FD名称说明默认设备
0stdin标准输入键盘
1stdout标准输出终端
2stderr标准错误终端
# 查看当前进程的文件描述符
ls -la /proc/self/fd

# 自定义文件描述符
exec 3>/tmp/mylog.txt   # 打开 FD 3 用于写入
echo "hello" >&3        # 写入 FD 3
exec 3>&-               # 关闭 FD 3

exec 4</etc/hosts       # 打开 FD 4 用于读取
while IFS= read -r line <&4; do
    echo "$line"
done
exec 4<&-               # 关闭 FD 4

7.2 输出重定向

# 覆盖写入
echo "hello" > file.txt

# 追加写入
echo "world" >> file.txt

# 标准输出重定向
ls /etc > ls_output.txt 2>/dev/null  # stdout 到文件,stderr 丢弃

# 标准错误重定向
ls /nonexistent 2> error.txt

# stdout 和 stderr 合并重定向到同一文件
command > output.txt 2>&1     # 传统写法
command &> output.txt         # Bash 简写(推荐)

# stdout 和 stderr 分别重定向
command > stdout.txt 2> stderr.txt

# 丢弃所有输出
command &>/dev/null
command > /dev/null 2>&1      # 等价

# stderr 重定向到 stdout
command 2>&1 | grep "error"

# stdout 重定向到 stderr
echo "错误信息" 1>&2
echo "错误信息" >&2           # 等价简写

重定向速查表

操作语法说明
输出覆盖> filestdout → file
输出追加>> filestdout → file(追加)
错误覆盖2> filestderr → file
错误追加2>> filestderr → file(追加)
合并输出&> filestdout + stderr → file
合并追加&>> filestdout + stderr → file(追加)
stderr→stdout2>&1stderr 合并到 stdout
stdout→stderr1>&2stdout 合并到 stderr
输入重定向< filefile → stdin
丢弃输出>/dev/null丢弃 stdout
丢弃错误2>/dev/null丢弃 stderr
丢弃全部&>/dev/null丢弃全部输出

7.3 输入重定向

# 从文件读取输入
wc -l < /etc/hosts

# 读取文件到变量
config=$(< /etc/hosts)

# 逐行读取文件
while IFS= read -r line; do
    echo "$line"
done < /etc/hosts

# 从字符串读取
read -r first rest <<< "hello world foo"
echo "第一个词: $first"
echo "剩余: $rest"

7.4 管道(Pipe)

# 基本管道:前一个命令的 stdout 作为后一个命令的 stdin
ls -la | grep "\.txt$"

# 多级管道
cat /var/log/syslog | grep "error" | awk '{print $1, $2, $3, $NF}' | sort | uniq -c | sort -rn | head -10

# 统计当前目录下代码行数
find . -name "*.sh" -type f | xargs wc -l | sort -rn | head -20

# 管道与进程退出码
# $? 是管道中最后一个命令的退出码
false | true
echo $?  # 输出: 0(true 的退出码)

# 使用 PIPESTATUS 获取管道中每个命令的退出码
false | true | false
echo "${PIPESTATUS[@]}"  # 输出: 1 0 1
echo "${PIPESTATUS[0]}"  # 第一个命令: 1

# set -o pipefail:管道中任一命令失败则整个管道失败
set -o pipefail
false | true
echo $?  # 输出: 1

⚠️ 注意:管道中每个命令都在独立的子 Shell 中执行。在管道中修改变量不会影响父 Shell。

7.5 tee:同时输出到屏幕和文件

# 基本用法:stdout 同时输出到终端和文件
echo "hello" | tee output.txt

# 追加模式
echo "world" | tee -a output.txt

# 管道中使用
ls -la | tee file_list.txt | grep ".txt"

# 同时记录 stdout 和 stderr
./script.sh 2>&1 | tee output.log

# 写入多个文件
echo "hello" | tee file1.txt file2.txt file3.txt

# 仅写入文件,不输出到终端
echo "hello" | tee output.txt > /dev/null

7.6 Here Document

Here Document 允许在脚本中嵌入多行文本。

# 基本用法
cat << EOF
Hello, World!
这是一个 Here Document。
当前时间: $(date)
当前用户: $(whoami)
EOF

# 不展开变量(引号包裹标记)
cat << 'EOF'
$name 不会被展开
$(date) 也不会执行
EOF

# 去除前导 Tab(<<-)
if true; then
	cat <<- EOF
	这行前面的 Tab 会被删除
	方便在缩进代码中使用
	EOF
fi

# 用 Here Document 创建文件
cat > /tmp/config.ini << 'EOF'
[server]
host = 0.0.0.0
port = 8080

[database]
host = localhost
port = 5432
EOF

# 用 Here Document 传递多行输入
mysql -u root -p << 'SQL'
CREATE DATABASE IF NOT EXISTS myapp;
USE myapp;
CREATE TABLE IF NOT EXISTS users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) UNIQUE
);
SQL

# 用 Here Document 生成脚本
generate_deploy_script() {
    local env="$1"
    local version="$2"
    
    cat << EOF
#!/bin/bash
# 自动生成的部署脚本
# 环境: $env
# 版本: $version
# 时间: $(date '+%Y-%m-%d %H:%M:%S')

set -euo pipefail

echo "正在部署 $env 环境..."
echo "版本: $version"

docker pull myapp:$version
docker compose -f docker-compose.$env.yml up -d

echo "部署完成"
EOF
}

generate_deploy_script "prod" "2.1.0" > deploy_prod.sh
chmod +x deploy_prod.sh

7.7 Here String

# Here String:<<< 将字符串作为命令的 stdin
read -r first last <<< "John Doe"
echo "名: $first, 姓: $last"

# 用于 while read
while IFS=: read -r user _ uid; do
    [[ $uid -ge 1000 ]] && echo "$user ($uid)"
done <<< "$(getent passwd)"

# 用于 grep
grep "error" <<< "this line has an error in it"

# 用于 awk
awk '{print $2}' <<< "hello world foo bar"

7.8 read 命令详解

# 基本读取
read -rp "请输入姓名: " name
echo "你好, $name"

# 多变量读取
read -rp "请输入姓名和年龄: " name age
echo "$name 今年 $age 岁"

# 读取密码(不回显)
read -rsp "请输入密码: " password
echo
echo "密码长度: ${#password}"

# 带超时
if read -t 5 -rp "5秒内输入: " answer; then
    echo "你输入了: $answer"
else
    echo "超时了"
fi

# 读取单个字符
read -rn 1 -p "按任意键继续..."

# 指定分隔符
IFS=: read -r user _ uid gid _ home < <(getent passwd root)
echo "用户=$user UID=$uid HOME=$home"

# 从数组读取
mapfile -t lines < /etc/hosts
echo "文件有 ${#lines[@]} 行"

# 读取到数组(带行号)
mapfile -t lines < <(head -5 /etc/passwd)
for ((i = 0; i < ${#lines[@]}; i++)); do
    printf "第%d行: %s\n" $((i + 1)) "${lines[$i]}"
done

7.9 业务场景:日志分析脚本

#!/bin/bash
# analyze_log.sh —— Nginx 日志分析
set -euo pipefail

readonly LOG_FILE="${1:-/var/log/nginx/access.log}"
readonly REPORT_FILE="/tmp/log_report_$(date +%Y%m%d_%H%M%S).txt"

if [[ ! -f "$LOG_FILE" ]]; then
    echo "❌ 日志文件不存在: $LOG_FILE" >&2
    exit 1
fi

echo "正在分析日志: $LOG_FILE" | tee "$REPORT_FILE"
echo "生成时间: $(date '+%Y-%m-%d %H:%M:%S')" | tee -a "$REPORT_FILE"
echo "========================================" | tee -a "$REPORT_FILE"

# 总请求数
total=$(wc -l < "$LOG_FILE")
echo "" | tee -a "$REPORT_FILE"
echo "📊 总请求数: $total" | tee -a "$REPORT_FILE"

# HTTP 状态码分布
echo "" | tee -a "$REPORT_FILE"
echo "📊 HTTP 状态码分布:" | tee -a "$REPORT_FILE"
awk '{print $9}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10 | \
    while read -r count code; do
        pct=$(awk "BEGIN {printf \"%.1f\", $count * 100 / $total}")
        printf "  %6d (%5s%%)  HTTP %s\n" "$count" "$pct" "$code"
    done | tee -a "$REPORT_FILE"

# 访问最多的 IP
echo "" | tee -a "$REPORT_FILE"
echo "📊 访问 Top 10 IP:" | tee -a "$REPORT_FILE"
awk '{print $1}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10 | \
    while read -r count ip; do
        printf "  %8d  %s\n" "$count" "$ip"
    done | tee -a "$REPORT_FILE"

# 最热门的 URL
echo "" | tee -a "$REPORT_FILE"
echo "📊 访问 Top 10 URL:" | tee -a "$REPORT_FILE"
awk '{print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10 | \
    while read -r count url; do
        printf "  %8d  %s\n" "$count" "$url"
    done | tee -a "$REPORT_FILE"

echo "" | tee -a "$REPORT_FILE"
echo "✅ 报告已保存到: $REPORT_FILE"

7.10 注意事项

陷阱说明解决方案
管道中的子 Shell变量修改丢失< <()lastpipe
> 覆盖危险> 会清空文件set -o noclobber
未加引号的重定向文件名有空格时出错>"$filename"
EOF 缩进<< 不去除 tab使用 <<-
管道退出码只取最后一个命令的码set -o pipefail + PIPESTATUS

7.11 扩展阅读