5

Linux脚本:浅浅入下Bash编程

 2 years ago
source link: https://blackdn.github.io/2022/08/06/Shell-Script-2022/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

“失眠,是明日心事的序章。”

Linux脚本:浅浅入下Bash编程

其实关于Linux脚本,不同的人叫法不同,有的叫Bash,也有的叫Shell
具体区别会简单说一下,不过大家本质都一样,都是通过Linux命令行执行一连串指令
我也懒得分那么细,所以Tag就简单给一个Linux吧,下面也简称Linux命令好了。
这里推荐一本Bash编程教程的电子书,来自Linux中国,请放心食用:电子书:An introduction to programming with Bash

Shell 和 Bash

从广义上来讲,我们可以把操作系统分为Shell(壳)Kernel(内核)
Kernel包含着计算机的许多基本功能,包括但不限于管理系统进程、内存、网络等。这部分内容往往是用户/应用程序不可达、不能直接调用的。这也是考虑到操作系统的高效性与安全性,不然随便来个程序都给自己最多的内存等资源,那不是乱套了。
Shell则是操作系统用来沟通外界和Kernel的桥梁,用于沟通用户和Kernel。最早的Shell通过命令行(CLI,Command Line Interface)实现,发展到如今的图形界面(GUI,Graphical User Interface)。因此,事实上Shell是一种抽象概念,而Windows中的命令行和桌面都是一种Shell

Bash则是Linux GNU中最常用的一种Shell,全称为Bourne-Again Shell,是当前大多数Linux发行版的默认Shell。
其他的shell还有sh、bash、ksh、rsh、csh等。sh全称是Bourne Shell,源自其作者玻恩(Bourne ),Bash则是其改进版。

在命令行界面输入echo $SHELL可以看到当前系统所使用的Shell

root$ echo $SHELL
/bin/bash

一些Linux脚本知识

因为Linux脚本的本质就是让很多Linux命令一起执行,这里就不从头介绍Linux命令了,假设大家多多少少都会一点
一些用到比较多的Linux命令和一些基础都在这:有用的linux操作
这里主要放一些在脚本编写时候用到的知识或容易见到的命令。
不过一些死板的语法就不提了,比如循环啊判断啊啥的,反正例子中会涉及,自己去看看也很快的。

Shebang:注释

在Linux脚本开头,我们需要先写上#!/bin/bash#!/usr/bin/env bash,这就是所谓的Shebang,没怎么找到它的中文名,就这么叫着先吧。
这一段注释是用来告诉Shell,我这个文件是一个Linux脚本,需要将其中的内容当作Linux命令解释执行。同时声明自己用的是上面解释器。
虽然不同Shell的Shebang不同,但都大同小异。这里我们就简单区别一下#!/bin/bash#!/usr/bin/env bash的区别。

我们知道/bin目录下都是放的一些应用程序,而解释器就在其中,包括bash。因此,当我们声明了#!/bin/bash,就是为了让系统知道,要去这个地方找bash程序作为解释器来执行当前脚本。

root:/bin$ ls | grep 'bash'
bash
bashbug
rbash

env也在/bin目录下,其除了能显示环境变量外,还可以执行指令。也就是说,可以在env后接指令,而系统会在环境变量中找到这个指令并执行。
通常,/bin目录往往会在环境变量($PATH)中,因此,#!/usr/bin/env bashbash作为参数传给env执行,而env会在PATH中查找bash执行,碰巧bash/bin目录下,而/bin也碰巧在PATH中,因此就可以成功解释执行脚本。

root:/bin$ env | grep PATH
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin......

虽然在大部分情况下,#!/bin/bash#!/usr/bin/env bash的写法没有差别,但是还是推荐使用 #!/usr/bin/env bash
因为#!/bin/bash相当于以静态路径的形式规定了解释器的位置,这会导致在不同的Linux系统下,同一脚本可能无法正常运行(用的解释器不一样),使得脚本可移植性较差;而#!/usr/bin/env bash 不必在系统的特定位置查找命令解释器,便于在多系统间移植。因此,在不了解主机的环境时,#!/usr/bin/env bash 写法可以使开发工作快速地展开。
不过,由于#!/usr/bin/env bash 会选择使用从 $PATH 中匹配到的第一个解释器,因此,如果有人恶意伪造解释器(自己写一个假的bash)并将其写入环境变量中位于靠前位置,系统就会选择这个假的bash来执行脚本,存在安全隐患。

Shell中的变量及声明

一些常见的环境变量和特殊变量可见这里:Linux变量
这里还有一些变量是可以在脚本内部使用的:

变量 作用
$0 保存当前脚本的名字
$1~$9 对应脚本的第一个参数到第九个参数。
$# 保存参数的总数
$@ 保存全部的参数,参数之间使用空格分隔
$* 保存全部的参数,参数之间使用变量$IFS值的第一个字符分隔,默认为空格

如果脚本的参数多于9个,那么第10个参数可以用${10}的形式引用,以此类推
如果命令是command -o foo bar,那么-o$1foo$2bar$3。但是如果用引号包括,则视为一个参数,比如command -o "foo bar"$1-o$2则是foo bar

可以用循环来读取每一个参数:

for i in "$@"; 
do
  echo "$i"
done

注意,在Shell脚本中最好在引用变量的时候给它加上引号,养成良好习惯QwQ

declare 命令定义变量

如果我们想要自己定义变量,就需要用到declare命令:declare OPTION VARIABLE=value
其中,常用的参数OPTION如下:

参数 作用
-a 声明数组变量
-i 声明整数变量
-l 声明变量为小写字母(lower
-u 声明变量为大写字母(upper
-r 声明只读变量(常量)
-x 设为环境变量(declare -x等同于export
-f 输出所有函数定义
-F 输出所有函数名
-p 查看变量信息

set命令

set命令在一般的Linux命令行操作中比较少见,但是在脚本中却经常看见。其主要是对之后执行的脚本进行一些配置。主要见到的有两种:set -eset -o pipefail

  • set -e:在set -e之后出现的代码,一旦出现了返回值非零,整个脚本就会立即退出。在脚本中一些意料之外的情况,如输入参数为空或不正确之类的情况,我们就可以用exit 1等代码退出脚本。
  • set -o pipefail:设置了这个选项以后,包含管道命令(用|连接多个命令)的语句的返回值,会变成最后一个返回非零的管道命令的返回值。

文件表达式

在对文件进行读写的时候,可以用文件表达式快速判断文件是否存在,是否可读可写可执行等

表达式 作用
-f filename 如果 filename存在且为常规文件,则为true
-e filename 如果 filename存在(exist),则为true
-d filename 如果 filename为目录(directory),则为true
-L filename 如果 filename为符号链接,则为true
-r filename 如果 filename可读,则为true
-w filename 如果 filename可写,则为true
-x filename 如果 filename可执行,则为true
-s filename 如果filename内容长度不为0,则为true
-h filename 如果filename是软链接,则为true

刚开始接触Shell的时候被括号搞得头疼,各种括号的用法和用处都不同,有的括号内可以进行算术运算,有的括号两边需要加空格,这里还是总结一下的好

简单来说就是:

  • shell命令及输出用小括号( ),左右不留空格
  • 算数运算用双小括号(( ))
  • 算数比较用单中括号[ ],左右留空格
  • 字符串比较用双中括号[[ ]]
  • 快速替换用花括号{ },左右留空格
  • 反单引号可以将其中的内容作为命令执行 ` `` `

然后细节讲讲

括号 作用
单括号() 1. 另开命令:小括号中的内容会开启一个子shell独立运行
2. 执行命令并输出:a=$(command), 等同于a=$`command`,执行command后将输出赋给变量a
3. 初始化数组:array=(a b c d)
双括号(()) 1. 省去$符号的算术运算:内部的字符会自动视为变量而无需$((foo = a + 5))
2. C语言规则运算:$((exp))exp为符合C语言规则的运算符 / 表达式
3. 跨进制运算:二、八、十六进制运算时,输出结果转为十进制echo $((16#5f))输出95
单中括号[ ] 1. 字符串比较:==!=
2. 整数比较:不等于 -gt大于 -lt小于 -eq等于 -ne
3. 数组索引:array[0]
双中括号[[]] 1. 允许使用模式或正则表达式:[[ "hello" == hell? ]],结果为true
2. 逻辑运算符:可直接使用&&<>等操作符,单中括号则需要用字符-lt等表示
大括号{} 1. 创建匿名函数
2. 特殊替换:${var:-string} - 若变量var为空,则使用变量string, ${var:=string} - 若变量var为空,则将string赋给var, ${var:+string} - 若变量var不为空,则使用变量string, ${var:?string} - 若变量var为空,则输出string并退出脚本

当我们写完一个脚本后,需要加上脚本的路径来运行脚本,否则会报错说找不到命令:/path/script para1 para2...
比如是当前路径就很方便,直接./script para1 para2...

如果想像Linux命令那样不加路径,则需要把文件放到/bin目录下,很多程序都在这个目录里,该目录包含在环境变量中,所以系统会到这里寻找我们执行的程序。
因此,比较无脑的方法就是把自己写的脚本复制到/bin目录下,当然缺点显而易见,每次改完都要复制一遍。
好一点的方法则可以在/bin目录里创建一个链接指向我们的脚本文件,就好像一个指针。
比如我想在/bin目录里创建一个链接指向我当前目录的一个脚本:

root$ ln -s $PWD/script /usr/local/bin
root$ ll /usr/local/bin
total 8
drwxr-xr-x  2 root root 4096 Aug  1 16:20 ./
drwxr-xr-x 10 root root 4096 Aug 20  2021 ../
lrwxrwxrwx  1 root root   50 Mar  1 14:58 script -> /mnt/f/test/script*

这之后就可以直接用script命令来执行我们的脚本了
不过我都懒得创建链接,还是直接./script方便

实例1:命令统计脚本

假设我们有一个history.log文件,里面保存了很多条历史命令(这里假设只有这么10条)

root$ cat history.log
ll
git
ll
sudo snap install shell2http
./hotreload
telnet -nltp
telnet localhost 8080
ps aux | grep shell
ll
cat nohup.out

我们想要写一个脚本count,来统计命令条数、所占百分比:

root$ ./count history.log
   3  27.27%  ll
   2  18.18%  telnet
   1   9.09%  cat
   1   9.09%  git 
   1   9.09%  grep 
   1   9.09%  hotreload
   1   9.09%  ps
   1   9.09%  snap

大家要明白一个道理,基本上所有脚本的实现都是一步步来的,在基础上添加修改。
除非你天赋异禀=。=

由于我们这个命令肯定要接收一个文件名作为参数,所以我们可以先为这个参数设置一个变量

#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
cat "${FILE}"

变量名为FILEdeclare -r表示其为只读变量,"${1:?file no found!}"表示将第一个参数赋值给FILE。如果没有第一个参数(那就是没有参数),则输出file no found!并退出程序

root$ ./count
./count: line 2: 1: file no found!

初步统计命令数量

首先,我们考虑到所有命令都是采用命令 参数的格式,我们想统计的只有命令,所以后面的参数可以扔掉。
于是我们可以用cut命令帮我们获取空格前面的命令

root$ cat history.log | cut -d' ' -f1
ll
git
ll
sudo
./hotreload
telnet
telnet
ps
ll
cat

虽然好像获取到了命令,但是没能进行一个统计计数,于是我们可以先用sort把相同的命令放到一起,再用uniq计数。
这时候结果并没有根据前面的数字大小排序,所以我们要再进行以此sort

root$ cat history.log | cut -d' ' -f1 | sort | uniq -c | sort -n -r
      3 ll
      2 telnet
      1 sudo 
      1 ps
      1 git
      1 cat 
      1 ./hotreload

sort默认是对文本进行排序,即ASCII码排序,想让其根据数字排序需要加上-n。其默认升序,我们用-r让其变成降序。

最后将命令放入脚本:

#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
cat "${FILE}" | cut -d' ' -f1 | sort | uniq -c | sort -n -r

处理被管道分割的命令

虽然好像还不错的样子,但是回头一看,发现其实一行可能有多个命令,他们通过管道|来连接,比如ps aux | grep shell。但是我们上述命令只获取了管道前面的命令。
为了能够获取管道后面的命令,我们用sed将管道的|替换成换行符,让其成为新的一行:sed -E -e 's/\|/\n/'-E启用扩展正则表达式后|需要被转义)
但是可能输入命令的小朋友比较呆,管道前后可能有一个或多个空格,甚至没有空格,因此正则表达式还要改一下:sed -E -e 's/ *\| */\n/'
最后,可能一行有很多个管道,连接了很多个命令,所以最后加个g进行全局匹配:sed -E -e 's/ +\| +/\n/g'

而这个处理过程需要在其他命令执行之前,所以我们修改后的脚本:

#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' "${FILE}" | cut -d' ' -f1 | sort | uniq -c | sort -n -r

看一下效果:

root$ ./count history.log
      3 ll
      2 telnet
      1 sudo  
      1 ps
      1 grep  
      1 git  
      1 cat  
      1 ./hotreload

在我们这个例子中,多了个grep,那就是成功了。

处理sudo等非程序命令

类似sudonohup等命令并非执行某程序,比如sudo是“以管理员身份执行某程序”,因此其后面跟着的才是我们真正要统计的命令。
所以我们还需要sed来把这些命令给去掉:sed -e 's/^(sudo|nohub)//'
考虑到其后面可能手抖多按了空格,所以修改表达式:sed -e 's/^(sudo|nohub) +//'
然后放到脚本里。为了防止脚本过长,我们让一条语句占一行,在每行后面加个\连接符:

#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' \
        -e 's/^(sudo|nohup) +//' \
        "${FILE}" \
        | cut -d' ' -f1 | sort | uniq -c | sort -n -r

然后运行一下:

root$ ./count history.log
      3 ll
      2 telnet
      1 snap
      1 ps
      1 grep
      1 git
      1 cat
      1 ./hotreload

可以看到sudo没有了,变成了snap,那就没问题。

处理含有路径的命令

有些命令含有其路径,比如./hotreload,我们要把路径去掉,于是又需要我们的sed
在写命令之前,我们先写正则表达式。路径可能有很多层,但是不变的格式就是path/command,我们需要的就是斜杠后面的命令command,因此在斜杠前面的任何字符我都不要,所以正则表达式就是^.*/,表示“斜杠以及斜杠前的全部字符”。
我们要把它去掉,就是替换为空,那么sed命令可以这么写:sed -E 's#^.*/##'(因为表达式中带有斜杠/,因此分隔符选用#,否则需要加上转义符变成sed -E 's/^.*\///',可读性也差)

#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' \
        -e 's/^(sudo|nohup) +//' \
        "${FILE}" \
        | cut -d' ' -f1 \
        | sed -E 's#^.*/##' \
        | sort | uniq -c | sort -n -r

看下效果:

root$ ./count history.log
      3 ll
      2 telnet
      1 snap
      1 ps
      1 hotreload
      1 grep
      1 git
      1 cat

可以看到./hotreload变成hotreload,也算成功了吧

计算百分比

涉及到了计算,我们可以用awk命令,它允许我们执行C语言的语句,因此我们可以计算每个命令的百分比。
awk格式为awk BEGIN{} {} END{}三个语句块分别表示执行前操作对所有行操作执行后操作。这里我们不需要BEGIN
{}中,我们设置两个变量,一个是total,表示命令总数,每次+1;另一个是类似Map的数据结构cmds[],比如cmds[ll]表示命令ll的执行次数。
于是我们给出{}中的代码:{total++; cmds[$1]++;}
END{}中,我们进行循环计算百分比并且格式化输出:

END{
	for (cmd in cmds) {
		printf "%d %f %s\n", cmds[cmd], cmds[cmd]/total*100, cmd;
	}
}

由于我们输出的时候,每个cmd只输出一次,所以我们的uniq就不需要了,脚本就可以修改如下:

#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' \
        -e 's/^(sudo|nohup) +//' \
        "${FILE}" \
        | cut -d' ' -f1 \
        | sed -E 's#^.*/##' \
        | awk '{total++; cmds[$1]++;} END{for (cmd in cmds) {printf "%d %f %s\n", cmds[cmd], cmds[cmd]/total*100, cmd;}}' \
        | sort -n -r

看看效果:

root$ ./count history.log sort
3 27.272727 ll
2 18.181818 telnet
1 9.090909 snap
1 9.090909 ps
1 9.090909 hotreload
1 9.090909 grep
1 9.090909 git
1 9.090909 cat

感觉不错,那么我们进行最后一步的格式化输出

格式化输出

最后我们要让输出变得好看一点,比如规定其宽度,百分数的小数点等
说到格式化我们还是习惯用printf,所以最后还是用awk

我们只是想处理每一行的输出,所以BEGIN{}END{}都不需要
我们规定命令出现的次数为3位的整数:%3d ;百分数保留2位小数,共6位,并输出%%6.2f%%;最后输出命令名的字符串:%s别忘了最后还有一个换行符
awk '{printf "%3d %6.2f%% %s\n", $1, $2, $3}'

最后结果就如下啦:

#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' \
        -e 's/^(sudo|nohup) +//' \
        "${FILE}" \
        | cut -d' ' -f1 \
        | sed -E 's#^.*/##' \
        | awk '{total++; cmds[$1]++;} END{for (cmd in cmds) {printf "%d %f %s\n", cmds[cmd], cmds[cmd]/total*100, cmd;}}' \
        | sort -n -r \
        | awk '{printf "%4d %6.2f%%  %s\n", $1, $2, $3}'

输出结果:

root$ ./count history.log
   3  27.27%  ll
   2  18.18%  telnet
   1   9.09%  snap
   1   9.09%  ps
   1   9.09%  hotreload
   1   9.09%  grep
   1   9.09%  git
   1   9.09%  cat

按字母排序

这是最后一个小问题,虽然我们的输出根据命令的数量排序了,但是当数量相同时,后面并没有根据命令名进行次级排序

于是我们修改倒数第二行的sort命令为:
sort -t' ' -k1,1nr -k3,3
我们先用-t' '将每行按空格分割,-k1~-k3分别表示三个区域,即对第一行来说,-k1 = 3-k2 = 27.27%-k1 = ll
-k1,1表示对第一列排序(-k1,2表示对第一列和第二列排序,以此类推),n表示该列为数字而非字符,r表示降序排序
最后-k3,3指定第三列为次级排序,默认升序所以不用再多加什么参数。

最最最最后的结果如下

#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' \
        -e 's/^(sudo|nohup) +//' \
        "${FILE}" \
        | cut -d' ' -f1 \
        | sed -E 's#^.*/##' \
        | awk '{total++; cmds[$1]++;} END{for (cmd in cmds) {printf "%d %f %s\n", cmds[cmd], cmds[cmd]/total*100, cmd;}}' \
        | sort -t' ' -k1,1nr -k3,3 \
        | awk '{printf "%4d %6.2f%%  %s\n", $1, $2, $3}'
root$ ./count history.log
   3  27.27%  ll
   2  18.18%  telnet
   1   9.09%  cat
   1   9.09%  git
   1   9.09%  grep
   1   9.09%  hotreload
   1   9.09%  ps
   1   9.09%  snap

这样我们的这个脚本就完美实现啦!



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK