Bash脚本:浅浅入下Bash编程

Bash脚本介绍 + 实例

Posted by BlackDn on August 6, 2022

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

Bash 脚本:浅浅入下 Bash 编程

前言

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

更新:因为这篇文章对 Bash 语法涉及甚少,涉及的知识都是为了实现实例的脚本而介绍的,因此又重新写了一篇介绍 Bash 的语法:Bash 脚本 DLC:Bash 语法和 URL 检测脚本实例,并且用的脚本例子更简单,算是本篇的 DLC 吧。
推荐大家把两篇 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 命令了,假设大家多多少少都会一点
这里主要放一些在脚本编写时候用到的知识或容易见到的命令。
不过一些死板的语法就不提了,比如循环啊判断啊啥的,反正例子中会涉及,自己去看看也很快的。

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中,因此就可以成功解释执行脚本。
base作为参数传给env -> env在PATH中找bash -> env进入了PATH中的/bin目录 -> /bin目录中有bash -> env成功找到bash,并用其解释执行脚本

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方便

实例:命令统计脚本

需求描述

假设我们有一个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

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

参考

  1. Bash 编程入门-1:Shell 与 Bash
  2. #!/bin/bash 和 #!/usr/bin/env bash 的区别
  3. Shell |各种括号的作用
  4. Bash 的基本语法
  5. 电子书:An introduction to programming with Bash
  6. linux 中的 set 命令: “set -e” 与 “set -o pipefail”