课程结构
01.课程概览与 shell
02.Shell 工具和脚本
03.编辑器 (Vim)
04.数据整理
05.命令行环境
06.版本控制(Git)
07.调试及性能分析
08.元编程
09.安全和密码学
10.大杂烩
11.提问&回答
本文档修改自这里,补充了一些视频中展示但配套文档中未提供的代码,以及一些注释。
数据整理
在之前的课程中,使用管道运算符的时候,其实就是在进行某种形式的数据整理。例如这样一条命令 journalctl | grep -i intel
,它会找到所有包含intel(不区分大小写)的系统日志。
大多数情况下,数据整理需要您能够明确哪些工具可以被用来达成特定数据整理的目的,并且明白如何组合使用这些工具。有两样东西自然是必不可少的:用来整理的数据以及相关的应用场景。
日志处理通常是一个比较典型的使用场景,让我们研究一下,看看哪些用户曾经尝试过登录我们的服务器:
~$ ssh myserver journalctl
内容太多了,把涉及 sshd 的信息过滤出来:
ssh myserver journalctl | grep sshd
- 使用管道将一个远程服务器上的文件传递给本机的
grep
程序
打印出的内容,仍然比我们需要的要多得多,读起来也非常费劲。来改进一下:
$ ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' > ssh.log
$ less ssh.log
- 先在远端机器上过滤文本内容,然后保存到本地文件
ssh.log
中 less
创建了一个文件分页器,可通过翻页方式浏览较长的文本
sed编辑器
sed
是一个基于文本编辑器ed
构建的”流编辑器” 。在 sed
中,您基本上是利用一些简短的命令来修改文件,而不是直接操作文件的内容(尽管您也可以选择这样做)。
相关的命令行非常多,但是最常用的是 s
,即 替换 命令:
ssh myserver journalctl | grep sshd | grep "Disconnected from" | sed 's/.*Disconnected from //'
上面这段命令中,我们使用了一段简单的 正则表达式 。正则表达式是一种非常强大的工具,可以让我们基于某种模式来对字符串进行匹配。
s
命令的语法如下:s/REGEX/SUBSTITUTION/
, 其中 REGEX
部分是我们需要使用的正则表达式,用来匹配字符串,而 SUBSTITUTION
是用于替换匹配结果的文本。
正则表达式
正则表达式常见的模式如下,详细内容及示例练习见这里:
匹配单字符的语法 | 含义 | 匹配多个字符的语法 | 含义 |
---|---|---|---|
abc… | Letters | {m} | m Repetitions |
123… | Digits | {m,n} | m to n Repetitions |
\d | Any Digit | * | Zero or more repetitions |
\D | Any Non-digit character | + | One or more repetitions |
. | Any Character | ? | Optional character(zero or one) |
\. | Period | ^…$ | Line Starts and ends |
[abc] | Only a, b, or c | (…) | Capture Group |
[^abc] | Not a, b, nor c | (a(bc)) | Capture Sub-group |
[a-z] | Characters a to z | (.*) | Capture all |
[0-9] | Numbers 0 to 9 | (abc|def) | Matches abc or def |
\w | Any Alphanumeric character | ||
\W | Any Non-alphanumeric character | ||
\s | Any Whitespace | ||
\S | Any Non-whitespace character |
- 对于特殊字符,如
. * + ? [ ] \
,若想匹配这些字符本身,需要在前面加上转义符\
,如\. \* \+ \? \[ \] \\
- 注意:
^
有两种含义,在[]
中,表示非
,如上面的[^abc]
匹配除了abc
之外的任意一个字符;单独使用,表示行首
,如^a
匹配以字符a
作为行开头的字符串 - 空白特殊字符(
whitespace
)包括:空格space
(" "
)、换行符(\n
)、制表符(\t
)、回车符(\r
) - 使用
()
捕获的内容,可以用特殊符号表示sed -i -E 's/(foo) and (bar)/\1-\2/g'
:查找所有的foo and bar
,替换为foo-bar
sed
的-E
选项支持正则表达式匹配sed
的-i
选项,将替换内容写入文档中
- 其他编辑器使用正则表达式时,引用捕获内容的符号会有不同(如vscode的
$1 $2
等)
回过头再看s/.*Disconnected from //
,这个正则表达式可以匹配任何以若干任意字符开头,并接着包含”Disconnected from “的字符串,并将其删除。
可如果有人将 “Disconnected from” 作为自己的用户名会怎样呢?
Jan 17 03:13:00 thesquareplanet.com sshd[2631]: Disconnected from invalid user Disconnected from 46.97.239.16 port 55920 [preauth]
正则表达式会如何匹配?*
和 +
在默认情况下是 贪婪模式(会尽可能多的匹配文本)。因此对上述字符串的匹配结果如下:
46.97.239.16 port 55920 [preauth]
这可不是我们想要的结果。
对于某些正则表达式的实现来说,您可以给 *
或 +
增加一个?
后缀使其变成 非贪婪模式,但是很可惜 sed
并不支持该后缀。不过,我们可以切换到 perl 的命令行模式,该模式支持编写这样的正则表达式:
perl -pe 's/.*?Disconnected from //'
好的,我们还需要去掉用户名后面的后缀,应该如何操作呢?
想要匹配用户名后面的文本,尤其是当这里的用户名可以包含空格时,这个问题变得非常棘手!这里我们需要做的是匹配 一整行:
| sed -E 's/.*Disconnected from (invalid |authenticating )?user .* [^ ]+ port [0-9]+( \[preauth\])?$//'
可以借助正则表达式在线调试工具regex debugger 来理解这段表达式。
".*Disconnected from "
: 与前面分析的一样"(invalid |authenticating )?user "
: 匹配"invalid user "
或"authenticating user "
或"user "
".* "
: 尽可能多的匹配一些字符,即"user "
与下一项匹配的ip地址之间的内容,即用户名(而且匹配到的用户名允许包含多个单词)"[^ ]+ "
: 匹配一个非空的单词,本例中匹配的就是"port"
之前的那一串连接到服务器的机器的ip地址"port [0-9]+( \[preauth\])?$"
: 匹配以"port {一串数字} [preauth]"
或者"port {一串数字}"
结尾的行(注意要匹配[]
需要使用转义符\
)
使用上面的sed命令,整个日志的内容都被删除了。我们实际上希望能够将用户名保留下来,可以使用如下命令:
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
回到数据整理
数据排序与截取
我们还可以过滤出那些最常出现的用户:
ssh myserver journalctl\
| grep sshd\
| grep "Disconnected from"\
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'\
| sort | uniq -c
sort
会对其输入数据进行排序。uniq -c
会把连续出现的行折叠为一行并使用出现次数作为前缀。我们希望按照出现次数排序,过滤出最常出现的用户名:
ssh myserver journalctl\
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
| sort -nk1,1 | tail -n10
sort -n
会按照数字顺序对输入进行排序(默认情况下是按照字典序排序)-k1,1
则表示“仅基于以空格分割的第一列进行排序”- 如行
I have a dream.
,将以第一列的I
作为排序基准
- 如行
,n
部分表示“仅排序到第n个部分”,默认情况是到行尾
如果我们希望得到登录次数最少的用户,我们可以使用 head
来代替tail
。或者使用sort -r
来进行倒序排序。
但我们只想获取用户名,而且不要一行一个地显示。
ssh myserver journalctl\
| grep sshd\
| grep "Disconnected from"\
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'\
| sort | uniq -c\
| sort -nk1,1 | tail -n10\
| awk '{print $2}' | paste -sd,
如果您使用的是 MacOS:注意这个命令并不能配合 MacOS 系统默认的 BSD paste
使用。参考课程概览与 shell的习题内容获取更多相关信息。
我们可以利用 paste
命令来合并行(-s
),并指定一个分隔符进行分割 (-d
)。
awk编辑器
awk
其实是一种编程语言,只不过它碰巧非常善于处理文本。
awk
程序接受一个模式串(可选),以及一个代码块,指定当模式匹配时应该做何种操作。默认匹配所有行(上面命令中的用法)。
在代码块中,$0
表示整行的内容,$1
到 $n
为一行中的 n 个区域,区域的分割基于 awk
的域分隔符(默认是空格,可以通过-F
来修改)。在这个例子中,我们的代码意思是:对于每一行文本,打印其第二个部分,也就是用户名。
我们也可以统计一下所有以c
开头,以 e
结尾,并且仅尝试过一次登录的用户。
| awk '$1 == 1 && $2 ~ /^c[^ ]*e$/ { print $2 }' | wc -l
让我们好好分析一下。首先,注意这次我们为 awk
指定了一个匹配模式串(也就是{...}
前面的那部分内容)。该匹配要求文本的第一部分需要等于1(这部分刚好是uniq -c
得到的计数值),然后其第二部分必须满足给定的一个正则表达式。代码块中的内容则表示打印用户名。然后我们使用 wc -l
统计输出结果的行数。
不过,既然 awk
是一种编程语言,那么则可以这样:
BEGIN { rows = 0 }
$1 == 1 && $2 ~ /^c[^ ]*e$/ { rows += $1 }
END { print rows }
- 使用变量
rows
来加总第一列的数字之和,刚好每一行的第一部分等于1,正好表示了输出结果的行数 BEGIN
也是一种模式,它会匹配输入的开头(END
则匹配结尾)
事实上,我们完全可以抛弃 grep
和 sed
,因为 awk
就可以解决所有问题。
数据整理进阶
分析数据
想做数学计算也是可以的!例如这样,您可以将每行的数字加起来:
| paste -sd+ | bc -l
bc
是一种精度计算语言sudo tldr bc
查看用法
下面这种更加复杂的表达式也可以:
echo "2*($(data | paste -sd+))" | bc -l
您可以通过多种方式获取统计数据。如果已经安装了R语言,st
是个不错的选择:
ssh myserver journalctl\
| grep sshd\
| grep "Disconnected from"\
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'\
| sort | uniq -c\
| awk '{print $1}' | R --slave -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'
R 也是一种编程语言,它非常适合被用来进行数据分析和绘制图表。summary
可以打印某个向量的统计结果。我们将输入的一系列数据存放在一个向量后,利用R语言就可以得到我们想要的统计数据。
如果您希望绘制一些简单的图表, gnuplot
可以帮助到您:
ssh myserver journalctl\
| grep sshd\
| grep "Disconnected from"\
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'\
| sort | uniq -c\
| sort -nk1,1 | tail -n10\
| gnuplot -p -e 'set boxwidth 0.5; plot "-" using 1:xtic(2) with boxes'
利用数据整理来确定参数
有时候您要利用数据整理技术从一长串列表里找出你所需要安装或移除的东西。我们之前讨论的相关技术配合 xargs
即可实现:
rustup toolchain list | grep nightly | grep -vE "nightly-x86" | sed 's/-x86.*//' | xargs rustup toolchain uninstall
整理二进制数据
虽然到目前为止我们的讨论都是基于文本数据,但对于二进制文件其实同样有用。例如我们可以用 ffmpeg 从相机中捕获一张图片,将其转换成灰度图后通过SSH将压缩后的文件发送到远端服务器,并在那里解压、存档并显示。
ffmpeg -loglevel panic -i /dev/video0 -frames 1 -f image2 -\
| convert - -colorspace gray -\
| gzip\
| ssh mymachine 'gzip -d | tee copy.jpg | env DISPLAY=:0 feh -'
课后练习
-
学习一下这篇简短的 交互式正则表达式教程.
-
统计words文件 (
/usr/share/dict/words
) 中包含至少三个a
且不以's
结尾的单词个数。这些单词中,出现频率前三的末尾两个字母是什么?sed
的y
命令,或者tr
程序也许可以帮你解决大小写的问题。共存在多少种词尾两字母组合?还有一个很 有挑战性的问题:哪个组合从未出现过? -
进行原地替换听上去很有诱惑力,例如:
sed s/REGEX/SUBSTITUTION/ input.txt > input.txt
。但是这并不是一个明智的做法,为什么呢?还是说只有sed
是这样的? 查看man sed
来完成这个问题 -
找出您最近十次开机的开机时间平均数、中位数和最长时间。在Linux上需要用到
journalctl
,而在 macOS 上使用log show
。找到每次起到开始和结束时的时间戳。在Linux上时间戳格式类似:
Logs begin at ...
和systemd[577]: Startup finished in ...
在 macOS 上, 查找:
=== system boot:
和Previous shutdown cause: 5
-
查看之前三次重启启动信息中不同的部分(参见
journalctl
的-b
选项)。将这一任务分为几个步骤,首先获取之前三次启动的启动日志,也许获取启动日志的命令就有合适的选项可以帮助您提取前三次启动的日志,亦或者您可以使用sed '0,/STRING/d'
来删除STRING
匹配到的字符串前面的全部内容。然后,过滤掉每次都不相同的部分,例如时间戳。下一步,重复记录输入行并对其计数(可以使用uniq
)。最后,删除所有出现过3次的内容(因为这些内容是三次启动日志中的重复部分)。 -
在网上找一个类似 这个 或者这个的数据集。或者从这里找一些。使用
curl
获取数据集并提取其中两列数据,如果您想要获取的是HTML数据,那么pup
可能会更有帮助。对于JSON类型的数据,可以试试jq
。请使用一条指令来找出其中一列的最大值和最小值,用另外一条指令计算两列之间差的总和。
习题解答
- 学习一下这篇简短的 交互式正则表达式教程.
- 1) 统计words文件 (
/usr/share/dict/words
) 中包含至少三个a
且不以's
结尾的单词个数。# 这里我是在树莓派上面操作的 cat /usr/share/dict/words | tr "[:upper:]" "[:lower:]" | grep -E "^([^a]*a){3}.*$" | grep -v "'s$" | wc -l # 850
-
大小写转换:
tr "[:upper:]" "[:lower:]"
-
^([^a]*a){3}.*$
:查找包含至少三个a
的单词 -
grep -v "'s$"
:匹配结尾为's
的结果,然后取反。 借助grep -v
主要是这里不支持 lookback,不然可通过^([^a]*a){3}.*(?<!'s)$
匹配2) 这些单词中,出现频率前三的末尾两个字母是什么?
sed
的y
命令,或者tr
程序也许可以帮你解决大小写的问题。cat /usr/share/dict/words | tr "[:upper:]" "[:lower:]" | grep -E "^([^a]*a){3}.*$" | grep -v "'s$" | sed -E "s/.*([a-z]{2})$/\1/" | sort | uniq -c | sort --numeric-sort| tail -n3 # 53 as # 64 ns # 102 an
3) 共存在多少种词尾两字母组合?
cat /usr/share/dict/words | tr "[:upper:]" "[:lower:]" | grep -E "^([^a]*a){3}.*$" | grep -v "'s$" | sed -E "s/.*([a-z]{2})$/\1/" | sort | uniq | wc -l
4) 还有一个很有挑战性的问题:哪个组合从未出现过? 为了得到没出现的组合,首先我们要生成一个包含全部组合的列表,然后再使用上面得到的出现的组合,比较二者不同即可。
#!/bin/bash for i in {a..z};do for j in {a..z};do echo "$i$j" done done ./all.sh > all.txt cat /usr/share/dict/words | tr "[:upper:]" "[:lower:]" | grep -E "^([^a]*a){3}.*$" | grep -v "'s$" | sed -E "s/.*([a-z]{2})$/\1/" | sort | uniq > occurance.txt diff --unchanged-group-format='' <(cat occurance.txt) <(cat all.txt) | wc -l
-
--unchanged-group-format=''
用于将两个文件中相同的内容设置为空字符串,剩下的内容就是差异的部分。
- 进行原地替换听上去很有诱惑力,例如:
sed s/REGEX/SUBSTITUTION/ input.txt > input.txt
。但是这并不是一个明智的做法,为什么呢?还是说只有sed
是这样的? 查看man sed
来完成这个问题。
- 使用文件重定向输出时,输出文件
input.txt
会首先被清空并打开,见下面的示例 - 后续打开
input.txt
执行sed
命令时,文件已经为空了# 文件重定向示例 ~$ touch test.txt ~$ echo hello >test.txt ~$ cat test.txt hello ~$ cat test.txt >test.txt # 文件重定向时,先将输出文件清空 ~$ cat test.txt
sed -i.bak 's/REGEX/SUBSTITUTION/' input.txt
-i
参数,允许将替换后的内容写入原文件中.bak
参数,可以自动创建一个后缀为.bak
的备份文件。
-
找出您最近十次开机的开机时间平均数、中位数和最长时间。在Linux上需要用到
journalctl
,而在 macOS 上使用log show
。找到每次起到开始和结束时的时间戳。在Linux上的时间戳格式类似:
Logs begin at ...
和systemd[577]: Startup finished in ...
在 macOS 上, 查找:
=== system boot:
和Previous shutdown cause: 5
为了进行这个练习,我们需要首先允许
journalctl
记录多次开机的日志,具体背景信息可以参考这里和这里,否则我们看到的始终都只有本次启动的日志。vim /etc/systemd/journald.conf
设置
Storage=persistent
执行上述命令后,重启
pi@raspberrypi:~$ journalctl --list-boots # 该命令会从最近开机启动时间开始,从0,到1,到...给每次开机的启动信息进行编号
可以看到已经可以列出多次启动信息了,然后我们进行十次重启。
可以使用
systemd-analyze
工具看一下启动时间都花在哪里:sudo systemd-analyze plot > systemd.svg
- 直接打开图片systemd.svg可查看开机启动信息
接下来,编写脚本
getlog.sh
来获取最近十次的启动时间数据:#!/bin/bash for i in {0..9}; do journalctl -b-$i | grep "Startup finished in" # 使用启动信息编号来读取最近的10次记录 done ./getlog.sh > starttime.txt #获取最长时间 cat starttime.txt | grep "systemd\[1\]" | sed -E "s/.*= (.*)s\.$/\1/"| sort | tail -n1 #获取最短时间 cat starttime.txt | grep "systemd\[1\]" | sed -E "s/.*= (.*)s\.$/\1/"| sort -r | tail -n1 #平均数(注意 awk 要使用单引号) cat starttime.txt | grep "systemd\[1\]" | sed -E "s/.*= (.*)s\.$/\1/"| paste -sd+ | bc -l | awk '{print $1/10}' # 中位数 cat starttime.txt | grep "systemd\[1\]" | sed -E "s/.*= (.*)s\.$/\1/"| sort |paste -s -d ' ' | awk '{print ($5+$6)/2}'
如果配合使用 R 语言脚本则更加简单:
sudo apt-get install r-base cat starttime.txt | grep "systemd\[1\]" | sed -E "s/.*=\ (.*)s\.$/\1/"| sort | R -e 'd<-scan("stdin",quiet=TRUE);min(d);max(d);mean(d);median(d);' > d<-scan("stdin",quiet=TRUE);min(d);max(d);mean(d);median(d); [1] 14.023 [1] 15.989 [1] 14.4304 [1] 14.2915
-
查看之前三次重启启动信息中不同的部分(参见
journalctl
的-b
选项)。将这一任务分为几个步骤,首先获取之前三次启动的启动日志,也许获取启动日志的命令就有合适的选项可以帮助您提取前三次启动的日志,亦或者您可以使用sed '0,/STRING/d'
来删除STRING
匹配到的字符串前面的全部内容。然后,过滤掉每次都不相同的部分,例如时间戳。下一步,重复记录输入行并对其计数(可以使用uniq
)。最后,删除所有出现过3次的内容(因为这些内容上三次启动日志中的重复部分)。查看journalctl记录的启动日志,大致格式如下
4月 29 09:06:19 laihj ...
- 每一行的前几列为启动时记录的时间戳,接下来一列为本机的用户名,之后的是启动的程序信息
- 可使用用户名作为匹配的字符串,删除前面的时间戳
~$ cat getlog.sh #!/bin/bash for i in {0..2}; do journalctl -b-$i | sed -E 's/.*laihj//' # 使用启动信息编号来读取最近的3次记录 # 将每一行的用户名laihj之前的时间戳删除 done ~$ ./getlog.sh > last3start.txt # 注意 uniq 只能过滤相邻的行,所以必须先排序 ~$ cat last3start.txt | sort | uniq -c | sort | awk '$1!=3 { print }' > diff.log
-
在网上找一个类似 这个 或者这个的数据集。或者从这里找一些。使用
curl
获取数据集并提取其中两列数据,如果您想要获取的是HTML数据,那么pup
可能会更有帮助。对于JSON类型的数据,可以试试jq
。请使用一条指令来找出其中一列的最大值和最小值,用另外一条指令计算两列之间差的总和。~$ curl 'https://stats.wikimedia.org/EN/TablesWikipediaZZ.htm#wikipedians' \ |sed -n "/table1/,/<\/table>/p" \ |grep "<tr" | sed "1,12d"|head -n -3 \ |sed -E 's/(<[^>]*>)+/ /g' \ |sed 's/ / -/g' \ |sed 's/ //g' > data ~$ cat data # 处理后的数据为Jan2001截至Oct2018的 Oct2018 2642056 12641 70805 10498 48.9M - 6101 - - - - 10.3M - - - - - - 42.6M Sep2018 2629415 11171 66574 10004 48.7M - 6116 - - - - 10.1M - - - - - - 42.4M Aug2018 2618244 12058 68688 10640 48.5M - 6839 - - - - 10.2M - - - - - - 42.1M Jul2018 2606186 12026 68037 10305 48.3M - 6987 - - - - 9.5M - - - - - - 41.9M ... Jan2001 7 7 9 - 31 12 1 8.6 1352 29% 10% 267 301kB 3.0k 15 - - 2 163
命令说明(建议先查看网站的源码格式,更加容易理解下面的操作)
-
|sed -n "/table1/,/<\/table>/p"
:观察网站的源代码,可以发现第一个表格的id为table1
,该命令将截取匹配到的table1
以及下一个</table>
标签行之间的内容 -
|grep "<tr"
:html表格中,含有数据的行是以<tr
开头,匹配这样的行 -
|sed "1,12d"
:去掉前12行(包含表格的表头) -
|head -n -3
:去掉最后3行(包含非数据的内容)(注意:部分操作系统可能不支持该用法,最笨拙的替换实现方式是:|sed "$d"|sed "$d"|sed "$d"
,即执行3次删除最后一行的操作) -
|sed -E 's/(<[^>]*>)+/ /g'
:使用正则匹配,将所有相邻的多个html标签(格式行如< tag >
)替换为空格 -
|sed 's/ / -/g
:原表格中部分没有数据的单元格是以
填充的,将其替换为-
,避免在对数据操作时发生窜列的情况 -
|sed 's/ //g
:原表格中部分单元格内的空格也是用
表示的,将其全部删除(不影响数据处理)~$ awk '{print $1,$4,$5}' data | sort --key=2n | head -n 1 Jan2001 9 - # 从data中读取第一列(时间,用来定位后续结果)及第三、四列,并以第二行的数据以数字大小进行排序,然后显示最大值的结果;下一个命令显示最小值的结果 ~$ awk '{print $1,$4,$5}' data | sort --key=2n | tail -n 1 Mar2007 91388 11506 ~$ awk '{print $1,$4,$5}' data | awk '{print $2-$3}' | awk '{s+=$1} END {print s}' 10153001 # 使用第二列的数据减去第三列的数据后,将结果加总