第5章 管道的神奇魔力

5.1 从结构化文本文件提取数据

背景:/etc/passwd 用户密码文件

每行代表一个用户,冒号:作为字段分隔符,一共7个字段

1
2
3
4
5
6
7
8
jones:*:32713:899:Adrian W. Jones/0SD211/555-0123:/home/jones:/bin/ksh
dorothy:*:123:30:Dorothy Gale/KNS321/555-0044:/home/dorothy:/bin/bash
toto:*:1027:18:Toto Gale/KNS322/555-0045:/home/toto:/bin/tcsh
ben:*:301:10:Ben Franklin/OSD212/555-0022:/home/ben:/bin/bash
jhancock:*:1457:57:John Hancock/SIG435/555-0099:/home/jhancock:/bin/bash
betsy:*:110:20:Betsy Ross/BMD17/555-0033:/home/betsy:/bin/ksh
tj:*:60:33:Thomas Jefferson/BMD19/555-0095:/home/tj:/bin/bash
george:*:692:42:George Washington/BST999/555-0001:/home/george:/bin/tcsh
  1. 用户名
  2. 加密密码占位
  3. UID 用户ID
  4. GID 用户组ID
  5. GECOS字段:用户姓名、办公室、电话(本例内部用/再次分隔子字段)
  6. 用户家目录
  7. 登录Shell

查看帮助:man 5 passwd5代表配置文件格式手册。

需求示例:从passwd生成办公室通讯录。

  • 原始GECOS:Adrian W. Jones/OSD211/555‑0123
  • 需要拆分为:姓名、办公室编号、电话号码。

核心工具回顾

  1. cut -d: -f5:按冒号分隔,提取第5字段
  2. awk -F: '{print $5}':冒号做分隔符,打印第5字段
  3. join类似数据库join,把多个文件按照共同key(键)合并

前提:待合并文件必须以key排序;用‑t:指定分隔符为冒号;-代表读取标准输入。

处理思路:

  1. 从passwd提取数据,生成3个临时文件,格式为 key:value,key为用户名
    • pd.key.person.$$:用户名:格式化后的姓名
    • pd.key.office.$$:用户名:办公室编号
    • pd.key.telephone.$$:用户名:电话号码

    $$:shell PID,用来生成唯一临时文件名。

  2. 使用join多次合并这三份文件
  3. cut删掉不再需要的key字段
  4. sort排序
  5. awk printf格式化输出,制表符\t分隔列

完整管道逻辑

1
2
3
4
5
join -t: $PERSON $OFFICE |
join -t: - $TELEPHONE |   # "-"代表读取上一段管道输出
cut -d: -f2- |            # 丢弃第1列key,保留从第2列往后全部字段
sort -t: -k1,1 -k2,2 -k3,3 |
awk -F: '{ printf("%-39s\t%s\t%s\n", $1, $2, $3) }'

例5‑1 办公室名录脚本关键点

1
2
3
4
5
6
7
8
umask 077   # 设置临时文件权限,其他用户不可读
# 临时文件,$$是进程ID
PERSON=/tmp/pd.key.person.$$
OFFICE=/tmp/pd.key.office.$$
TELEPHONE=/tmp/pd.key.telephone.$$

# trap:脚本退出时自动清理临时文件,防止残留垃圾文件
trap "rm -f $PERSON $OFFICE $TELEPHONE" EXIT

trap信号处理:收到退出信号,执行rm删除临时文件。

设计思想:

  • 脚本从标准输入读数据,输出到标准输出。支持管道重定向:passwd‑to‑directory < /etc/passwd > out.txt
  • 后续想要增加字段(例如职位):只需要新增一个key文件,多加一条join,修改sort、awk格式化即可。

类比关系数据库:

  • key=主键;join等价SQL JOIN;文本文件相当于数据表。

5.2 结构化数据输出HTML网页

目标:把制表符分隔的通讯录文本,转换成HTML表格。

知识点:here‑document <<EOF

1
2
3
4
cat <<EOFILE
<!DOCTYPE HTML ...>
<TABLE>
EOFILE
  • <<标记>> :here文档,shell读取直到标记行之前所有内容,交给cat作为标准输入。
  • 内部会做变量、命令替换

HTML转义特殊字符

& < > 在HTML有特殊含义,必须替换成实体:

  • &&amp;
  • <&lt;
  • >&gt;

sed批量替换示例:

1
sed -e 's/&/&amp;/g' -e 's/</&lt;/g' -e 's/>/&gt;/g'

例5‑2 tsv‑to‑html脚本功能

输入:Tab分隔的文本;输出:HTML表格 流程:

  1. here‑document输出HTML头部样板
  2. sed完成特殊字符转义、把Tab替换成</TD><TD>,每行包上<TR><TD>...</TD></TR>
  3. here‑document输出HTML尾部样板

好处:不写死列数,任意Tab分隔文件都可以转HTML表格。

字符编码转换思路(例5‑3)

ISO‑8859‑1特殊字符(é å等)替换成HTML实体,思路就是一堆sed替换规则。实际项目一般交给专门工具处理。

5.3 文字解谜:利用系统单词词典

系统词典文件,每行一个英文单词:

  • /usr/share/dict/words/usr/dict/words

例5‑4 puzzle‑help脚本:用正则在词典文件搜索单词

1
egrep -h -i "$pattern" $FILES 2> /dev/null | sort -u -f
  • -h:不输出文件名
  • -i:忽略大小写
  • 2> /dev/null:丢弃错误(文件不存在、无权限的报错)
  • sort -u -f:去重,忽略大小写排序

示例:查找10位,第7位是x/z的单词

1
puzzle-help '^b....[xz]...$'

正则小提示:[^aeiouy] 方括号内部开头的^代表取反;和行首^不是同一个含义。

5.4 单词频率统计 wf脚本(经典McIlroy六步管道)

需求:统计文本里每个单词出现次数,输出频率最高的N个单词。

例5‑5 wf脚本完整拆解

1
2
3
4
5
6
tr -cs A-Za-z\' '\n'  |    # 把非字母/撇号字符替换成换行;每个单词单独一行
tr A-Z a-z            |    # 大写全部转为小写
sort                  |    # 把相同单词排到相邻行
uniq -c               |    # 相邻重复行计数,输出【计数 单词】
sort -k1,1nr -k2      |    # 第一列数字逆序(频率从高到低);第二列单词正序
sed ${1:-25}q              # 默认输出前25行;传参数n就输出前n行
  1. tr -cs SET1 SET2
    • -c:取反,不在SET1里面的字符
    • -s:压缩连续重复字符
  2. uniq -c只对相邻重复行计数,所以前面必须sort
  3. sort -k1,1nrn数字排序,r逆序从大到小
  4. ${1:-25} 参数展开:如果$1没有传入,默认值25

性能:大部分耗时消耗在sort排序;其余步骤是线性处理。

使用示例:

1
wf 12 < hamlet   #统计哈姆雷特,输出前12高频词

5.5 taglist:提取XML/HTML标签内容

需求:解析xml,提取 <tag>内容</tag>,输出:计数、文本、标签名、文件名。

例5‑6 taglist脚本管道步骤

1
2
3
4
5
6
7
8
9
cat "$1" |
sed -e 's#systemitem *role="url"#URL#g' -e 's#/systemitem#/URL#' |
tr '(){}[]' '\n\n\n\n\n\n' |      # 括号全部换成换行
egrep '>[^<>]+</' |              # 筛选匹配 <xxx>word</xxx> 的行
awk -F'[<>]' -v FILE="$1" '{ printf("%-31s\t%-15s\t%s\n", $3, $2, FILE) }' |
sort |
uniq -c |
sort -k2,2 -k3,3 |
awk '{ print ($2 == Last) ? ($0 " <---") : $0 } { Last = $2 }'
  1. sed:简化特定标签
  2. tr:把各类括号转为换行,打散文本
  3. egrep:只保留带闭合标签的行
  4. awk以<>做分隔符,提取标签内文字、标签名称,附带文件名
  5. sort + uniq‑c:统计出现次数
  6. sort:按单词、标签排序
  7. awk记忆上一行单词,相同单词标记<---提示,方便人工校对文档标签是否混用。

5.6 本章小结

  1. 结构化文本处理思路:使用简单分隔符(冒号、Tab),方便管道工具处理,输出继续作为下一个工具输入。
  2. 工具组合:cut/awk提取字段;join做多文件按键合并;tr字符转换;sort排序;uniq统计去重;sed文本替换。
  3. 临时文件管理:使用$$生成唯一名字,trap EXIT脚本退出自动清理,避免残留垃圾。
  4. here‑document <<EOF:批量输出大段文本(HTML模板等)。
  5. 文本可以转换成HTML表格,Tab分隔格式是电子表格/脚本之间很好的交换格式。
  6. 复杂任务:不要写大程序;拆分成简单过滤器,管道串联,UNIX工具优先。

本章重点命令速查表

命令 作用 使用示例
cut -d分隔符 -f字段 按分隔符提取指定字段 cut -d: -f1 /etc/passwd 按冒号分隔,提取第1列(所有用户名)
join -t: file1 file2 按键合并两个已经排序的文件,类似数据库join join -t: user.txt phone.txt 以冒号为分隔符,根据第一列key合并两个文件
tr -cs A‑Za‑z '\n' 字符集替换、‑c取反、‑s压缩连续重复字符 tr -cs A-Za-z\' '\n' < test.txt 把所有非字母非撇号的字符替换成换行,每个单词一行
uniq -c 统计相邻重复行出现次数;⚠前面必须先sort,否则统计不全 sort words.txt uniq -c 排序之后统计每行重复出现多少次
sort -k1,1nr 按指定字段数字逆序(从大到小)排序 sort -k1,1nr -k2 file.txt 第1列按数字降序,第2列按文本升序
trap "rm xxx" EXIT 脚本退出(正常/异常退出)时,自动执行清理命令 trap "rm -f /tmp/tmp.$$" EXIT 脚本结束自动删除临时文件,避免残留垃圾
<<EOF here‑document,在脚本里输出一大段多行文本给命令(常见给cat) cat <<EOF
hello
world
EOF
输出两行文字 hello、world
  1. join输入文件必须按key提前排序,否则结果错乱
  2. uniq -c 只识别挨在一起的重复行,分散的重复不会统计,所以管道里一定先sortuniq -c
  3. trap EXIT:不管脚本是正常结束,还是被Ctrl‑C中断,都会执行trap里面的清理命令。
  4. <<EOF:结束标记EOF顶格写,前面不能有空格;如果写成<<‑EOF,可以允许行首tab缩进。