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

AWK & SED 生产力教程 / 第 13 章:脚本编写

第 13 章:脚本编写

好的脚本不只是能运行,更要可靠、可读、可维护。

13.1 SED/AWK 脚本文件

SED 脚本文件

# 创建 sed 脚本文件
cat > transform.sed << 'EOF'
#!/usr/bin/sed -f
# transform.sed — 配置文件转换脚本
# 用法: ./transform.sed config.txt

# 删除空行和注释
/^$/d
/^[[:space:]]*#/d

# 去掉行首尾空白
s/^[[:space:]]*//
s/[[:space:]]*$//

# 标准化等号两边的空格
s/[[:space:]]*=[[:space:]]*/=/

# 将 key=value 转为大写的 KEY
s/^\([^=]*\)=\(.*\)/\U\1\E=\2/
EOF

chmod +x transform.sed
$ ./transform.sed config.txt

AWK 脚本文件

# 创建 awk 脚本文件
cat > report.awk << 'EOF'
#!/usr/bin/awk -f
# report.awk — 生成销售报告
# 用法: ./report.awk sales.csv

BEGIN {
    FS = ","
    OFS = "\t"
    
    printf "%-15s %-10s %12s %12s %12s\n", "产品", "类别", "销量", "单价", "总收入"
    printf "%-15s %-10s %12s %12s %12s\n", "----", "----", "----", "----", "------"
}

NR > 1 {
    revenue = $3 * $4
    category_revenue[$2] += revenue
    category_count[$2]++
    total_revenue += revenue
    
    printf "%-15s %-10s %12d %12.2f %12.2f\n", $1, $2, $3, $4, revenue
}

END {
    printf "%-15s %-10s %12s %12s %12s\n", "----", "----", "----", "----", "------"
    
    print "\n分类汇总:"
    for (c in category_revenue) {
        printf "  %-15s 总收入: %12.2f  平均: %12.2f\n", 
            c, category_revenue[c], category_revenue[c]/category_count[c]
    }
    
    printf "\n总收入: %.2f\n", total_revenue
}
EOF

chmod +x report.awk
$ ./report.awk sales.csv

13.2 Shell 脚本中的 AWK/SED

基本集成模式

#!/bin/bash
# analyze.sh — 分析日志文件

set -euo pipefail

LOG_FILE="${1:?用法: $0 <日志文件>}"

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

# 将 awk 结果存储到变量
total_requests=$(awk 'END{print NR}' "$LOG_FILE")
error_count=$(awk '$9 >= 400 {count++} END{print count+0}' "$LOG_FILE")
unique_ips=$(awk '{print $1}' "$LOG_FILE" | sort -u | wc -l)

echo "=== 日志分析结果 ==="
echo "总请求数: $total_requests"
echo "错误请求数: $error_count"
echo "独立 IP 数: $unique_ips"

# 计算错误率
if (( total_requests > 0 )); then
    error_rate=$(awk "BEGIN{printf \"%.2f\", $error_count/$total_requests*100}")
    echo "错误率: ${error_rate}%"
fi

参数传递

#!/bin/bash
# 将 Shell 变量传递给 AWK

THRESHOLD=100
DATE=$(date +%Y-%m-%d)

awk -v threshold="$THRESHOLD" -v date="$DATE" '
{
    if ($3 > threshold) {
        printf "[%s] 高值警告: %s = %d (阈值: %d)\n", date, $1, $3, threshold
    }
}' data.txt

管道中的错误处理

#!/bin/bash
# safe_pipeline.sh — 带错误处理的管道

set -o pipefail

INPUT_FILE="${1:?用法: $0 <输入文件>}"

# 使用临时文件
TEMP_FILE=$(mktemp)
trap 'rm -f "$TEMP_FILE"' EXIT

# 步骤 1:预处理
if ! sed 's/^[[:space:]]*//; s/[[:space:]]*$//' "$INPUT_FILE" > "$TEMP_FILE"; then
    echo "错误: 预处理失败" >&2
    exit 1
fi

# 步骤 2:过滤和统计
result=$(awk '!/^#/ && NF > 0 {count++} END{print count+0}' "$TEMP_FILE")

echo "有效行数: $result"

13.3 错误处理

AWK 中的错误处理

# 检查文件是否存在
awk '
BEGIN {
    if (ARGV[1] == "") {
        print "用法: awk -f script.awk <文件>" > "/dev/stderr"
        exit 1
    }
}
{
    # 正常处理
    print
}' "$input_file"

# 检查字段数量
awk '{
    if (NF < 3) {
        printf "警告: 第 %d 行字段不足: %s\n", NR, $0 > "/dev/stderr"
        next
    }
    # 处理数据
    print $1, $2, $3
}' data.txt

# 安全的除法(避免除以零)
awk '{
    if ($2 > 0) {
        result = $1 / $2
    } else {
        printf "警告: 第 %d 行除数为零\n", NR > "/dev/stderr"
        result = 0
    }
    print $1, $2, result
}' data.txt

SED 中的错误预防

# 备份原文件
sed -i.bak 's/old/new/g' file

# 先测试再修改
if sed 's/old/new/g' file | diff -q file - > /dev/null 2>&1; then
    echo "文件未改变"
else
    sed -i 's/old/new/g' file
    echo "文件已更新"
fi

# 限制修改范围
sed -i.bak '1,10s/old/new/g' file  # 只修改前 10 行

13.4 调试技巧

AWK 调试

# 打印变量值
awk '{
    print "DEBUG: NR=" NR, "NF=" NF, "$1=" $1 > "/dev/stderr"
    # 正常处理
}' file

# 使用 END 块检查最终状态
awk '{
    data[NR] = $0
    count[$1]++
}
END {
    # 调试信息
    print "DEBUG: 总行数=" NR > "/dev/stderr"
    print "DEBUG: 唯一键数=" length(count) > "/dev/stderr"
    
    # 正常输出
    for (i=1; i<=NR; i++) print data[i]
}' file

# GNU AWK 的调试模式
gawk --debug 'BEGIN{print "start"} {print} END{print "end"}' file

SED 调试

# 使用 l 命令显示不可见字符
sed -n '/pattern/{l;p}' file

# 分步测试
sed 's/step1/replacement1/' file > /tmp/step1.txt
sed 's/step2/replacement2/' /tmp/step1.txt > /tmp/step2.txt
diff file /tmp/step2.txt

# GNU sed 调试模式(4.2.2+)
sed --debug 's/old/new/g' file

通用调试技巧

#!/bin/bash
# debug_mode.sh — 支持调试模式的脚本

DEBUG=${DEBUG:-false}

debug() {
    if [[ "$DEBUG" == "true" ]]; then
        echo "[DEBUG] $*" >&2
    fi
}

# 使用示例
debug "开始处理文件: $1"
debug "阈值设置为: $THRESHOLD"

awk -v debug="$DEBUG" '
function debug_print(msg) {
    if (debug == "true") print "[AWK-DEBUG] " msg > "/dev/stderr"
}
{
    debug_print("处理第 " NR " 行: " $0)
    # 处理逻辑
}' "$1"
# 运行时启用调试
$ DEBUG=true ./script.sh data.txt

13.5 代码组织

模块化 AWK 函数库

# 创建 AWK 函数库
cat > lib/utils.awk << 'EOF'
# utils.awk — 通用工具函数库

# 去除首尾空白
function trim(s) {
    gsub(/^[[:space:]]+|[[:space:]]+$/, "", s)
    return s
}

# 格式化字节大小
function human_size(bytes,    units, i) {
    split("B KB MB GB TB", units, " ")
    i = 1
    while (bytes >= 1024 && i < 5) {
        bytes /= 1024
        i++
    }
    return sprintf("%.2f %s", bytes, units[i])
}

# 百分比格式化
function pct(value, total) {
    if (total == 0) return "0.00%"
    return sprintf("%.2f%%", value / total * 100)
}

# 重复字符
function repeat_char(ch, n,    s, i) {
    s = ""
    for (i = 0; i < n; i++) s = s ch
    return s
}

# 进度条
function progress_bar(value, max, width,    filled, empty) {
    filled = int(value / max * width)
    empty = width - filled
    return "[" repeat_char("█", filled) repeat_char("░", empty) "]"
}
EOF

# 在主脚本中引用
cat > main.awk << 'EOF'
#!/usr/bin/awk -f
@include "lib/utils.awk"

{
    printf "%-20s %s %6.1f%%\n", $1, progress_bar($2, 100, 20), $2
}
EOF

Shell 函数封装

#!/bin/bash
# lib.sh — 通用函数库

# 日志函数
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
}

error() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] 错误: $*" >&2
}

die() {
    error "$@"
    exit 1
}

# 安全的 AWK 执行
safe_awk() {
    local script="$1"
    local input="$2"
    
    if [[ ! -f "$input" ]]; then
        die "文件不存在: $input"
    fi
    
    awk "$script" "$input"
}

# 统计文件行数(排除空行和注释)
count_active_lines() {
    local file="$1"
    awk '!/^#/ && !/^$/ && NF > 0 {count++} END{print count+0}' "$file"
}

# 提取配置值
get_config_value() {
    local file="$1"
    local key="$2"
    awk -F= -v key="$key" '$1 == key {print $2; exit}' "$file"
}

13.6 代码风格

命名规范

# AWK 变量命名
awk '{
    # 好的命名
    field_count = NF
    line_number = NR
    total_amount = 0
    
    # 不好的命名
    n = NF      # 不清楚含义
    x = NR      # 不清楚含义
    t = 0       # 不清楚含义
}' file

# 函数命名
awk '
function calculate_average(sum, count) {    # 动词+名词
    return (count > 0) ? sum / count : 0
}
function format_currency(amount) {          # 动词+名词
    return sprintf("$%.2f", amount)
}
' file

代码组织最佳实践

# 1. 注释清晰
awk '
# 处理配置文件
# 输入: key=value 格式
# 输出: 标准化的配置

BEGIN {
    FS = "="
    # 初始化计数器
    count = 0
}

# 跳过注释和空行
/^#/ || /^$/ { next }

# 处理有效配置行
{
    key = trim($1)
    value = trim($2)
    
    # 存储配置
    config[key] = value
    count++
}

END {
    # 输出统计
    printf "处理了 %d 个配置项\n", count
}
' config.txt

# 2. 避免魔法数字
awk '{
    # 不好
    if ($3 > 100) print
    
    # 好
    THRESHOLD = 100
    if ($3 > THRESHOLD) print
}' file

# 3. 使用 BEGIN/END 初始化和清理
awk '
BEGIN {
    # 初始化
    total = 0
    count = 0
    FS = ","
}

{
    # 处理
    total += $3
    count++
}

END {
    # 汇总
    printf "平均值: %.2f\n", total / count
}' data.csv

13.7 测试

单元测试框架

#!/bin/bash
# test_awk.sh — AWK 脚本测试

run_test() {
    local test_name="$1"
    local input="$2"
    local expected="$3"
    local script="$4"
    
    actual=$(echo "$input" | awk "$script")
    
    if [[ "$actual" == "$expected" ]]; then
        echo "✅ 通过: $test_name"
    else
        echo "❌ 失败: $test_name"
        echo "  期望: $expected"
        echo "  实际: $actual"
    fi
}

# 测试用例
run_test "trim 函数" \
    "  hello  " \
    "hello" \
    '{gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print}'

run_test "数值计算" \
    "10 20 30" \
    "20.00" \
    '{sum=0; for(i=1;i<=NF;i++) sum+=$i; printf "%.2f", sum/NF}'

run_test "条件过滤" \
    $'a\nb\nc' \
    "b" \
    '/b/ {print}'

测试数据生成

# 生成测试数据
generate_test_data() {
    local n=${1:-100}
    awk -v n="$n" 'BEGIN {
        srand()
        for (i = 1; i <= n; i++) {
            printf "user_%03d dept_%02d %d\n", i, int(rand()*5)+1, int(rand()*10000)+3000
        }
    }'
}

# 使用测试数据
$ generate_test_data 1000 | awk '{count[$2]++} END {for (d in count) print d, count[d]}'

13.8 实战:完整脚本示例

#!/bin/bash
# log_analyzer.sh — 日志分析脚本
# 用法: log_analyzer.sh [选项] <日志文件>

set -euo pipefail

# 默认配置
THRESHOLD=100
TOP_N=10
OUTPUT_FORMAT="text"

# 函数定义
usage() {
    cat << EOF
用法: $0 [选项] <日志文件>

选项:
    -t NUM    错误阈值 (默认: $THRESHOLD)
    -n NUM    显示前 N 条 (默认: $TOP_N)
    -f FMT    输出格式: text, csv, html (默认: $OUTPUT_FORMAT)
    -h        显示帮助

示例:
    $0 -t 50 -n 20 -f html access.log
EOF
    exit 0
}

log() { echo "[$(date '+%H:%M:%S')] $*" >&2; }
error() { echo "[错误] $*" >&2; exit 1; }

# 解析参数
while getopts "t:n:f:h" opt; do
    case $opt in
        t) THRESHOLD="$OPTARG" ;;
        n) TOP_N="$OPTARG" ;;
        f) OUTPUT_FORMAT="$OPTARG" ;;
        h) usage ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

LOG_FILE="${1:?请指定日志文件}"

# 参数验证
[[ -f "$LOG_FILE" ]] || error "文件不存在: $LOG_FILE"
[[ "$THRESHOLD" =~ ^[0-9]+$ ]] || error "阈值必须是数字"
[[ "$TOP_N" =~ ^[0-9]+$ ]] || error "TOP_N 必须是数字"

log "开始分析: $LOG_FILE"

# 主要分析逻辑
awk -v threshold="$THRESHOLD" -v top_n="$TOP_N" -v format="$OUTPUT_FORMAT" '
BEGIN {
    total = 0
    errors = 0
}

{
    total++
    ip_count[$1]++
    status_count[$9]++
    path_count[$7]++
    bytes += $10
    
    if ($9 >= 400) errors++
}

END {
    if (format == "text") {
        printf "总请求数: %d\n", total
        printf "错误请求数: %d (%.2f%%)\n", errors, errors/total*100
        printf "总传输量: %.2f MB\n\n", bytes/1048576
        
        printf "Top %d IP:\n", top_n
        for (ip in ip_count)
            printf "%8d %s\n", ip_count[ip], ip
    }
}' "$LOG_FILE" | ( [[ "$OUTPUT_FORMAT" == "text" ]] && cat || sort -rn | head -"$TOP_N" )

log "分析完成"

扩展阅读


下一章:第 14 章:性能优化 — 大文件处理、并行处理、内存管理。