Shell 脚本和编程 | 青训营笔记

34 阅读9分钟

学习 shell 的价值

  1. Linux 服务器的基本操作和管理

  2. 前端 Node.js 服务的进程管理、问题排查、资源监控等运维操作

  3. 使用 shell 编写 TCE、SCM、Docker 脚本,完成服务编译和部署

课程准备

  • 一台安装了 linux 系统的物理机或者云主机,可运行 shell 脚本
  • 本地的 vscode 安装 Bash Debug 插件,并升级到 bash 到 4.x 以上
  • Npm 全局安装 zx 依赖

Shell 基础概念

概念

发展

  • 除了替代 v6 shell,sh 还有几个优点,把控制流程、循环、变量引入了脚本,提供了一种更具功能性的语言
  • 主流 Linux 系统使用的 shell,许多都以它为锚点
  • bash 是 sh 的超集,可以直接执行大部分 sh 脚本
  • bash 在兼容 Bourne shell 脚本编程的同时,集成了 Kom shell 和 C shell 的功能,包括命令历史、命令行编辑、目录堆栈(pushd 和 popd),一些实用的环境变量,命令自动补全等

构成

shell 不仅提供了与内核和设备交互的方法,还集成了一些今天软件开发中通用的设计模式(比如管道和过滤器),具备控制流程、循环、变量、命令查找的机制。

shell 既是命令解释器,也是一门编程语言,作为命令解释器,它提供给用户接口,使用丰富的 GNU 工具集,第三方库或内置的,比如:cd、pwd、exec、test、netstat 等等。

语法和命令

变量

类型作用域声明方式规范
自定义变量当前shell=字符串、整型、浮点型、日期型
环境变量当前 shell 及其子 shellexport、declare -x
系统环境变量所有 shell启动加载

父子 shell

当父进程在执行第三方或核心工具时,会启动一个子进程去执行,执行完后交还给父进程。父进程再执行下一条命令。

在这个过程中,自定义变量只有在父进程中使用到,而环境变量和系统环境变量在父进程和子进程中都可以用到。

不同的脚本执行方式产生的父子shell

  • source filename 或者 . filename (点 空格 filename)

不创建 子shell,在当前 bash 环境下读取 filename 中的命令,并执行。

关于 source 方法:如果 filename 不在当前路径下,或者 filename 不在执行程序默认的搜索路径下(也就是 PATH 环境变量),bash 可能会提示找不到 filename

  • bash filenamesh filename./filename 或者 /path1/path2/filename(绝对路径)

创建 子shell,在当前 bash 环境下,创建一个新的子 shell 来读取 filename 中的命令,并执行。子 shell 环境随机关闭,回到父 shell 中。

关于 bash、sh、./ 方法:如果 filename 不在当前路径下,或者 filename 不在执行程序默认的搜索路径下(也就是 PATH 环境变量),bash 可能会提示找不到 filename。

关于 bash 和 sh 方法:filename 可以不必实现设定可执行权限。因为是将 filename 作为参数传递给 bash 或者 sh,是被调用执行。

关于 ./ 方法:filename 必须具备可执行权限,它才可以自己执行。

额外:子 shell 继承父 shell 的变量,子 shell 通过 export 来使用父 shell 的变量。

子 shell 从父 shell 继承如下内容:

  • 当前工作目录
  • 环境变量
  • 标准输入、标准输出、标准错误输出
  • 所有已打开的文件标识符
  • 忽略的信号

子 shell 不能从父 shell 继承的内容:

  • 除环境变量和 .bashrc 文件定义的变量之外的 shell 变量
  • 未被忽略的信号

自定义变量

declare [+/-] 选项 变量
选项含义
-给变量设定类型属性
+取消变量的类型属性
-a将变量声明为数组类型
-i将变量声明为整数
-x将变量声明为环境变量
-r将变量声明为只读变量
-p显示指定变量的被声明类型

系统环境变量

变量名含义常见操作
$0当前 shell 名称/脚本名称$1、$2 等可以获取到传入参数
$#传入脚本的参数数量if[$# -gt 1]
$*传入脚本的所有参数
$?上条命令执行的状态码if[$? -eq 0];
$PS1命令提示符export PS1="\u@\h \w>"
$HOME当前用户主文件夹cd ~
$PATH全局命令的搜索路径PATH=$PATH:[新增路径]

配置文件加载

如果取得 bash 需要完整的登录流程,我们称之为 login shell,比如 ssh 远程登录一台主机。

不需要登录的 bash 我们称为 non-login shell,比如在原来的 bash 中执行 bash 开启子进程,执行一些外部命令。

需要注意的是,如果修改了配置文件,不会立即生效,需要我们重启终端或者执行 source 命令:

source ~/.bashrc

运算符和引用

类型 符号 作用 用法
算术运算符 + - * / % | & 常规运算
逻辑运算符 || && !
比较运算符 == != < >
引号 双引号 " " 部分引用,仅仅 $ ` \ 保留作用 foo= "${a} 123"
单引号 ' ' 完全引用,原样输出 foo= 'foo$a'
反引号 ` ` 执行命令 foo= `ls -a`
圆括号 (()) 算术命令 foo= $((1+2))
() 执行命令 $(ls -a)
命令连接 || cmd1 执行完且返回码非0,则继续执行 cmd2 cmd1 || cmd2
&& cmd1 执行完且返回码为0,则继续执行 cmd2 cmd1 && cmd2
; cmd1、cmd2 串行执行 cmd1 ; cmd2
后台运行 & 让命令在后台运行,可与 nohup 一起使用 cmd &
cmd &实现让命令在后台运行,当我们关闭终端,命令就会停止运行。若加上 nohup 则关闭终端后不停止命令:
nohup cmd &

管道

管道与管道符 |,作用是将前一个命令的结果传递给后面的命令。管道的本质就是将多个程序进行连接,和信号一样,也是进程通信的方式之一。

语法cmd1 | cmd2

要求:管道右侧的命令必须能接受标准输入,比如 grep 命令。lsmv 等不能直接使用,要使用 xarg 预处理。

注意:管道命令不仅仅处理 stdout(标准输出),对于 stderr(标准错误输出)会予以忽略,可以使用 set -o pipefail 设置 shell 遇到管道错误退出。

#!/bin/bash

cat plantform.access.log | grep ERROR

netstat -an | grep ESTABLISHED | wc -l

find . -maxdepth l -name "*.sh" | xargs ls -l

例:

重定向

每个 shell 命令在执行时都会打开三个文件描述符,文件描述符 0、1、2,分别对应 stdin、stdout、stderr,这三个文件描述符默认指向终端输入、终端输出,那么当命令需要获取输入的时候,它会去取 fd0,当要输出的时候它会向 fd1、fd2 写入,改变这些描述符指向的行为叫做 重定向

输出重定向符号

command > file:覆盖写入文件

command >> file:追加写入文件

command 2> file:错误输出写入文件

n >& m:将输出文件 m 和 n 合并

command &> file:正确和错误输出统一写入到文件中

输入重定向符号

command < file:将输入重定向到 file

command << delimiter:将输入重定向到一个交互式 shell 脚本或程序

注意2>&1 必须写在 > 之后

<< 比较特殊,表示继续沿用当前的标准输入,只是当识别到指定的标识符后停止,将接收到的内容作为 stdin

实例:用户在命令行输入内容,当输入 EOF 时停止,所输入的内容写入 foo.txt

判断命令

shell 中提供了 test、[、[[ 三种判断符号,可以用于:

  • 整数测试
  • 字符串测试
  • 文件测试

语法

  • test condition
  • [ condition ]
  • [[ condition ]]

注意

  • 中括号前后要有空格符;
  • [test 是命令,只能使用自己支持的标志位,<、>、= 只能用来比较字符串
  • 中括号内的变量,最好都是用引号括起来
  • [[ 更丰富,在整型比较中支持 <、>、=,在字符串比较中支持 =~ 正则

分支语句

语法 1

if condition; then
  程序段
elif condition; then
  程序段
else
  程序段
fi

语法 2

case $变量 in:
  "第一个变量内容")
    程序段
    ;;
  "第一个变量内容")
    程序段
    ;;
  *)
  程序段
  ;;
esac

循环

  • while 循环

while condition; do 程序段; done

#!bin/bash

let num = 0
while [ $num -lt 10 ];
do
	echo "current idx: $num"
	((num++))
done
  • until 循环

until condition; do 程序段; done

#!bin/bash

let num = 0
until [ $num -gt 10 ];
do
	echo "current idx: $num"
	((num++))
done
  • for 循环

for var in [words...]; do 程序段; done

#!bin/bash

# 对列表进行循环
for foo in a b c
do
	echo $foo
done

# 数值方式循环
for((i=0;i<10;i++))
do
	echo $i
done

函数

语法一

funcName(){echo "abc";}

语法二

function funcName(){echo "abc";}

#!bin/bash

printName() {
		if [ $# -lt 2 ]; then
			echo "illegal parameter."
			exit 1
		fi
		
		echo "firstname is :$1"
		echo "lastname is:$2"
}

printName jacky chen

注意

  • shell 自上而下执行,函数必须在使用前定义
  • 函数获取变量和 shell script 类似,$0 代表函数名,后续参数通过 $1、$2 ... 获取
  • 函数内 return 仅仅表示函数执行状态,不代表函数执行结果
  • 返回结果一般使用 echo、printf,在外面使用 $()、`` 获取结果
  • 如果没有 return,函数状态时上一条命令的执行状态,存储在 $?
#!bin/bash

function test() {
		local word="hello world"
		echo $word
		return 10
		
		unset word
}

content=`test`

echo "状态码: $?"
echo "执行结果: $content"

函数也是命令

exit:手动退出 shell、命令,exit 10 返回 10 给 shell,返回值非 0 为不正常退出

$?用于判断当前 shell 前一个命令是否正常退出(非 0 为不正常退出)

为了函数内定义的变量不污染全局,最好使用 local 定义,或者在函数退出之前使用 unset 去处理

模块化

模块化的原理是在当前 shell 内执行函数文件,方式:

source [函数库的路径]

常用命令

命令使用
grep查找错误日志:grep -n "ERROR" -A3 -B3 cloudfun.log
统计次数: grep -n "ERROR" -c cloudfun.log
sort指定分隔符后以第三列进行排序: sort -t " " -k 3
wc统计出现的行数、单词数、字符数: wc -lwn
head查看前十行: head -n 10 cloudfun.log
tail等待追加内容: tail -f -n 10 cloudfun.log
cut对数据行的内容进行处理: cut -d " " -f 3
find文件和目录查找
xargs参数处理
which查找命令路径

执行过程和原理

执行

  1. shell 脚本一般以 .sh 结尾,也可以没有,这是一个约定:第一行需要指定用什么命令解释器来执行
    • #! /bin/bash
    • #! /usr/bin/env bash
  2. 启动方式
# 文件名运行 -- 会在子进程中执行
./filename.sh

# 解释器运行 -- 会在子进程中执行
bash ./filename.sh

# source 运行 -- 会在当前进程中执行
source ./filename.sh

#! 是内核识别并选择合适的解释器后,将文本文件再交给解释器执行

执行过程

这个架构类似一个流水线,在里面加入输入分析和解析,bash 会以一些 特殊字符作为分隔符,将文本进行分段解析。

最主要的是 回车 还有 分号。在 bash 脚本中是以回车或者分号作为一行命令结束的标志。这就是第一层的解析,将大段的命令进行分段。

符号拓展(使用各种方法,比如 {}、~、变量和参数的 展开/替换、文件名展开),并最终执行命令(通过 shell 内置命令或外部命令)。

  1. 字符解析
  • 识别换行符、分号,做行的分割
  • 识别命令连接符(||、&&、管道) 做命令的分割
  • 识别空格、tab符,做命令和参数的分割
  1. shell 展开,例如 {1..3} 解析为 1 2 3
  2. 重定向,将 stdin、stdout、stderr 的文件描述符进行指向变更
  3. 执行命令
  • builtin 直接执行
  • builtin 使用 $PATH 查找,然后启动子进程执行
  1. 收集状态并返回

shell 展开

  1. 大括号展开 (Brace Expansion) {...}
  2. 波浪号展开 (Tilde Expansion) ~
  3. 参数展开 (Shell Parameter Expansion)
  4. 命令替换 (Command Substitution)
  5. 数学计算 (Arithmetic Expansion) $((..))
  6. 文件名展开 (Filename Expansion) * ? [..] 外壳文件名模式匹配

大括号展开 (Brace Expansion) {...}

一般由三部分构成,前缀、一对大括号、后缀,大括号内可以是逗号分割的字符串序列,也可以是序列表达式:

{x..y[..incr]}

# 字符串序列
a{b,c,d}e => abe ace ade

# 表达式序列,(数字可以使用 incr 调整增量,字母不行)
{1..5} => 1 2 3 4 5
{1..5..2} => 1 3 5
{a..e} => a b c d e

波浪号展开 (Tilde Expansion) ~

# 当前用户主目录
~ => $HOME
!/foo => $HOME/foo

# 指定用户的主目录
~fred/foo => 用户fred的 $HOME/foo

# 当前工作目录
~+/foo => $PWD/foo

# 上一个工作目录
~-/foo => ${$OLDPWD- '~-'}/foo

参数展开 (Shell Parameter Expansion)

  1. 间接参数展开

    ${!parameter},其中引用的参数 并不是 parameter 而是 parameter 实际的值

  2. 参数长度

    ${#parameter}

  3. 空参数处理

    ${patameter:-word} # 为空替换

    ${patameter:=word} # 为空替换,并将值赋给 $parameter 变量

    ${patameter:?word} # 为空报错

    ${patameter:+word} # 不为空替换

  4. 参数切片

    ${patameter:offset}

    ${patameter:offset:length}

  5. 参数部分删除

    ${patameter%word} # 最小限度从后面截取 word

    ${patameter%%word} # 最大限度从后面截取 word

    ${patameter#word} # 最小限度从前面截取 word

    ${patameter##word} # 最大限度从前面截取 word

命令替换 (Command Substitution)

在子进程中执行命令,并用得到的结果替换包裹的内容,形式上有两种:$(...)`...`

#! /bin/bash

echo $(whoimi)
foo() {
  echo "asdasd"
}
a=`foo`

数学计算 (Arithmetic Expansion) $((..))

使用 $(()) 包裹数学运算表达式,得到结果并替换

#! /bin/bash
echo $((1+2))  # 3

文件名展开 (Filename Expansion) * ? [..] 外壳文件名模式匹配

当有单词没有被引号包裹,且其中出现了 *、?、[ 字符,则 shell 会去按照正则匹配的方式查找文件名进行替换,如果没找到则保持不变。

#! /bin/bash

$ echo D*
# 输出当前目录下所有以 D 字母开头的目录、文件

调试和前端集成

调试

  1. 普通 log,使用 echo、printf

  2. 使用 set 命令

set 配置作用补充
-u遇到不存在的变量就会报错,并停止执行。-o nounset
-x运行结果之前,先输出执行的那一行命令。-o xtrace
-e只要发生错误,就终止执行-o errexit
-o pipefail管道符连接的,只要一个子命令失败,
整个管道命令就失败,脚本就会终止执行
  1. vscode debug 插件

VSCode 配置

  1. shellmen:代码提示和自动补全
  1. shellcheck:代码语法校验
  1. shell-format:代码格式化
  2. Bash Debug:支持单步调试
  • 安装 vscode 插件
  • 编写 launch.json 文件
  • 升级 bash 到 4.x 以上版本

前端集成

  1. node 中通过 exec、spawn 调用 shell 命令

    exec 启动一个子 shell 进程执行传入的命令,并且将执行结果保存在缓冲区中(有大小限制),执行完毕后通过回调函数返回。

    spawn 默认不使用 shell,而是直接启动子进程执行命令,且会直接返回一个流对象,支持写入或者读取流数据,在大数据量交互的场景较适合。

  2. shell 脚本中调用 node 命令

  3. 借助 zx 等库进行 javascript、shell script 的融合

  • 借助 shell 完成系统操作,文件io、内存、磁盘系统状态查询等
  • 借助 nodejs 完成应用层能力,网络io、计算等

总结