Bash学习笔记

学习阮一峰《Bash脚本教程》笔记

快捷键:

  • ctrl+L: 清除屏幕并移到顶部
  • ctrl+U: 删到行首(剪) ctrl+A: 移到行首
  • ctrl+K: 删到行尾(剪) ctrl+E: 移到行尾
  • ctrl+b, f, (左移, 右移)
  • alt+b, f (前, 后移一个单词)
  • ctrl+1 等同于clear
  • ctrl+d delete, ctrl+w 删到单词首
  • alt+t 词换位, ctrl+t 字符换位 (与前一个)
  • alt+d, alt+backspace 剪切至词首, 词尾
  • ctrl+y 粘贴

"Something wrong happend" >&2解释:

  • >代表重定向输出
  • &表示接下来的是一个文件描述符数字(file descriptor number)
  • 2表示stderr
  • 所以就是把上述字符串输出到stderr里去的意思, 如果是”str” >2 (没有&), 则会输出到一个叫2的文件里去

echo

  • -n 可以取消结尾的换行
  • -e 解释字符串里的转义符

扩展(通配符):

  • ~/walker 表示用户目录下的walker目录
  • ~walker 表示名为walker的目录
  • ~+ 扩展为当前目录, 等同于pwd
  • echo d{a,e,i,o,u}g 输出: dag, deg, dig, dog, dug. —>大括号内不要有空格(否则会当成参数)
  • echo {j{p,pe}g, png} —> 嵌套

波浪, 方括号的括号都是基于”路径”的, 如果当前路径没有匹配到对应的文件名, 则会(变成字符串)原样输出,

而大括号则不然, 是基于”逻辑”的, 只管扩展, 不会去探测扩展后对应的路径存不存在, 因此可能报错文件不存在

如:

  • echo [a,b].txt, 如果不存在a.txt, b.txt, 则会变成”[a,b].txt”这样一个输出,
  • {a,b}.txt则一定地会扩展成a.txt, b.txt
  • 例外: 在用..来扩展时, 如果无法理解, 则不会扩展, 如{1..5}会扩展成1,2,3,4,5, 但{ab..123}, 则会变成字符串
  • 但是前导0不参与路径匹配: {01…5} # 01,02,03,04,05 (几个零都可以)
  • 步长:{0..8..2} (未演示成功) # 要打开哪个shopt开关?

活用

  • echo .{mp{3..4},m4{a,b,p,v}} # 匹配了: .mp3 .mp4 .m4a .m4b .m4p .m4v
  • mkdir {2007..2009}-{01..12} # 建了2007-2009每年12个目录 for I in {1..4}
  • echo ${!S*} # 返回所有以S开头的”变量名”, 如SHELL, SSH..等

另两种转义(string interpolation):

  • echo date is $(date), 即包在$(…)中
  • echo date is `date` , 包在反引号中 但是要计算2+2, 只有echo $((2+2)) 这种形式(两层括号)

[[:alnum:]], [[:digit:]]等预置的字符类扩展见: https://wangdoc.com/bash/expansion.html

  • (?, *, +, @, !)则为匹配的个数, 分别是(0或1, 0或多, 一或多, 一个, 非一个), 如song@(.)mp3等同于song.mp3, (需要打开shopt -s extglob)

  • 双引号碰到$, ` 和反斜杠都会自动扩展, 所以echo “$SHELL” 等同于echo echo $SHELL

  • 双引号能保留”输出”的格式, 比如 echo `cal` , 格式就没了, 但是 echo “$(cal)”则不同, 自己试

command << token
Texts
token

等同于: echo Texts | command 即把echo的输出作为command的输入, 这个一般用于多行文本(texts里面可以进行使用变量, 但是如果把token用双引号包起来就不能解释变量了).

如果只是简单字符串, 用下面更明确: command <<< ‘text’ 如: cat <<< “hello world”, 一样等同于echo “hello world” | cat

  • <<< 还有一个作用就是把变量值用这种方式能变成标准输入, 这样被”计算”出来的值也能用于只接受标准输入的命令了, 比如(read)

变量

环境变量: printenv PATHecho $PATH等同 解释变量中的变量, 比如$PATH, 可以写成:

$ myvar=PATH
$ echo ${!myvar}, 

即多加一个惊叹号, 而不是想象中的$嵌套: ${${myvar}}

  • $? 上一个命令的退出码(0成功, 1失败)
  • $$ 当前Shell进程的ID
  • $_ 上一个命令的最后一个参数
  • $! 最后一个后台执行的异步命令的进程ID
  • $0 当前Shell的名称
  • $- 当前shell的启动参数, $@, $#表示脚本的参数数量

取变量

  • ${varname-:value} 取值, 如果不存在则返value, 但不赋值
  • ${varname=:value} 取值, 如果不存在则返value, 顺便赋值
  • ${varname+:value} 如果有值则返value(而不是值本身), 没有值则为空, 所以这个时候的value一般用一个标识符号就好了
  • ${varname?:value} 取值, 如果不存在就报错并把value作为错误错误打印出来 比如 filename=${$1:?”filename missing”} 从脚本中取第一个参数作为文件名, 发现没有文件名就报错退出

变量值都是字符串, 可以用declare来进行一些限定

$ declare -I v1=13 v2=14 v3=v1+v2
$ echo $v3

# 这样更快: 

$ let v=13+14  如果用空格, 则加引号: $ let “v = 13 + 14

字符串

  • ${#”string”} 长度,
  • ${varname:offset:length} 切片(变量名不需要美元符号) 删除: (# 和 ## 的区别就是贪婪与否的区别)
$ phone="555-456-1414"
$ echo ${phone#*-}  # 从最后一个-开始删
456-1414
$ echo ${phone##*-} # 从第一个-开始删
1414

替换: ${variable/#pattern/string} 注意, # 左边多了一个 / , 右边多了替换字串

  • 以上, 都是从头匹配, 从尾部匹配把 # 换成 % 任意位置匹配则换成/, 所以就成了你们最熟悉的语法:varname/search/replace 这个时候再回头看/# , /%, 不过是/语法的修饰符罢了(限定起始方向)

  • ${varname^^}, ${varname,,} # 转大写, 转小写

数值运算

逗号是求值, 如 echo $((foo = 1+2, 3*4)) 输出为12, 但foo的值是3, 依次计算, 输出是逗号后面的

expr命令等同于双括号: expr 3+5$((3+5))同义

行操作

Bash内置Readline库, 默认采用Emacs快捷键, 切换: set -o vi 或 $ set -o emacs

切换目录堆栈

  • 不管你CD到哪个了哪个目录, 想回到CD前的目录, 用cd -就行了
  • pushd , popd则可以把目录推到堆栈里, 演示:
  1. pushd a, pushd b, pushd c 此时目录就到了~/a/b/c
    • 查看堆栈: dirs
  2. 然后cd /abc
    • 这个时候再查看堆栈, 成了 /abc, ~/b, ~/c
  3. 然后你再cd /usr
    • 堆栈变成了 /usr, ~/b, ~/c

现在你知道 了, CD永远只是把更改顶栈, 大多数情况下, 你可以用pushd来替换cd, 这样你就有了后退权了,此时你再popd, 目录会顺利切到~/b, 不管你进行过多少次cd, 在堆栈里~/b位置没变那就永远会被pop出来

如果你查看堆栈, 要从第4个开始后退(0为起始), 那么可以把从3开始(不是4)的记录提到顶层来(然后再popd):

  • pushd +3 (加号不可省), 注意, 此时0, 1, 2都还在, 只是挪到了尾巴
  • 而popd +3则不是”移动”, 而是删除了, 意思是正向删除第3个, 如果不带+, 则理解为删除3以后的所有堆栈(即从5开始)

注: 为什么要从第4个开始退要把从3开始的移到顶层呢? 因为如下dirs: /1, /2, /3 , 你做popd, 是会回到/2的, 所以你想要变更后退的顺序, 只能多包一层, 把顶层也一起移动(因为pushd, popd+数字改动的只是堆栈, 不是目录, 然后通过修改过的堆栈去跳目录, 换句话说, 你没有办法”跳到”堆栈中的顶层去,(严格来说, 顶层即当前目录)

脚本

  • #!/usr/bin/env bash 的写法是为了避免#!/bin/bash 这种写法时bash不在bin目录
  • source命令可以 用一个点来表示: . ~/.bash_profile
  • 读用户的输入:
$ read firstname lastname
$ “you input: $firstname, $lastname# 如果read后没有给变量名, 则由默认的$REPLY来取出
  • 读文件:
while read myline  # 每次读一行
do
  echo "$myline"
done < $filename  # 注意这里特殊的传参方式, 同时, 如果不传入文件路径, 就是一个无限循环了(read)
  • 存数据
$ read -a varname. #  这就把用户的多个输入全存到varname这个数组里了
$ read -e -p “please input the path to the file” # -e参数使得用户在输入的时候能用tab补全(包含所有readline库快捷键), 如果没有这个参数输入文本的时候是不能使用快捷键的
$ read -s  -p “input password”  # 可以隐藏用户的输入, 通常用于密码
-p 显然就是能直接显示输入前的提示了

条件判断

if test $USER = "foo"; then
  echo "Hello foo."
[elif commands; then
  commands...]
else
  echo "You are not foo."
fi

if里面的test命令

  • test expression, [ expression ], [[ expression ]] 是等价的(第三种支持正则) # 空格不能省
  • [ -? file ] 查看文件状态有非常多的表达式(参数), 具体参阅https://wangdoc.com/bash/condition
    • 比如 -e /tmp/foo.txt是检查文件是否存在
  • 字符串判断,正则判断,数字判断等,具体用法查阅文档

循环

while, until(while的反条件), for...in, for循环

  • for [ test ] in list; do … done 其中的in list如果省略, 则代表所有脚本参数”$@“:
$ for filename; do echo$filename; done
# 等同于
$ for filename in$@; do echo$filename; done

同理, 如果是用在函数中, 则等于所有函数参数.

  • case in, 如果希望一个匹配后继续做下一个匹配, 每一个case 的结尾用;;&而不是;; (多了一个&)
  • for 循环: for (( I=0; I<5; I+=1 )); do echo $i; done # 用双括号, 变量也无需加$了

select

echo "Which Operating System do you like?"

select os in Ubuntu LinuxMint Windows8 Windows10 WindowsXP
do
  case $os in
    "Ubuntu"|"LinuxMint")
      echo "I also use $os."
    ;;
    "Windows8" | "Windows10" | "WindowsXP")
      echo "Why don't you try Linux?"
    ;;
    *)
      echo "Invalid entry."
      break
    ;;
  esac
done
  • *?命令除了取出上一个命令的返回值, 也可以取出上一个函数的返回值

数组

以下方式声明数组

  • names=(hatter [5]=duchess Alice), 指定了0, 5, 6, 其它为空字符串
  • mp3s=( *.mp3 )
  • declare -a ARRAYNAME
  • read -a ARRAYNAME
  • 读取的时候: echo ${array[1]} 大括号不可省
  • @ 仍然是返回所有元素: echo ${array[@]}
    • 但是在for…in中, 要把整个表达式放双引号中:
activities=( swimming "water skiing" canoeing "white-water rafting" surfing )
for act in${activities[@]}; do….; done

不然其中有”water”, “skiing”,”white-water”, “water rafting”等都会被拆开(bug吧? 字符串也拆)

  • @换成*, 加上双引号, 则会一个个字符返回

拷贝数组最方便的方法:

hobbies=(${activities[@]}” diving ) 
# 可见,不要忘了双引号。 顺便演示了为数组添加成员
  • 直接赋值给一个数组(即没有指定索引), 则是赋给第0个组员, 同理, 使用数组名也是使用的0号组员
  • echo ${#array[@或*]} 返回数组长度#仍然用以计数, 但是如果传的是具体索引, 则返回的是对应项的字符串长度
  • echo ${!array[@或*]} 返回有值的索引 (为空的不返回) # 活用的话遍历数组更高效
  • echo ${array[@]:2:3} 切片,2,3分别为position和length
  • arr+=(3 4 5) 追加
  • unset arr[2] 删除
    • 或: arr[2]= arr[2]=‘’ 三者等效,
  • 根据上面知识arr= 表示删除第一个成员, 但是unset arr 则是清空整个数组了
  • 也可以用字符串做索引, 就成了字典了:
    • declare -A colors a变成变成大写A即可
    • colors[“red”]=“#ff0000”
    • echo ${colors["red]}

set 和 shopt

set

  • 单独一个set会显示所有环境变量和Shell函数
  • 以下都可以以set -xxx 的方式写在脚本头或任何位置, 就当一个即时开关使用吧
  • 也可以在调用bash脚本前传入比如:bash -eux script.sh
    • -u 遇到不存在的变量就报错, 而不是忽略 与 -o nounset 等价
    • -x 每一个命令执行前会先打印出来 等同于 -o xtrace, 关闭用set +x (组合起来用就是一个小* 环境)
    • -e 有错误就中止 等同于 -o errexit
    • -o pipefail 即使在管道中, 有错也中止(-e 在管道中会失效)
    • -n: -o noexec 不执行命令只检查语法
    • -f: -o noglob 不对通配符进行文件名(路径)扩展 可用+f 关闭
    • -v: -o verbose 打印shell接收到的每一行输入 可用+v 关闭
  • set -euxo pipefail 一般这么四个连用

一旦设置了-e参数,会导致函数内的错误不会被trap命令捕获(参考《trap 命令》一章)。-E参数可以纠正这个行为,使得函数也能继承trap命令。所以现在一般这么用:

# 写法一
set -Eeuxo pipefail

# 写法二
set -Eeux
set -o pipefail

shopt

  • 即: shell option
  • set, 直接shopt也可以列出所有参数, -s, -u分别是是打开, 关闭某个参数 shopt 参数名, 可直接查询该参数是否打开关闭, 但是如果是编程, 因为返回是字符串, 所以提供了-q参数(返回0/1, 分别表示打开/关闭)
  • if shopt -q globstar; then …; fi

脚本除错

  • [[ -d $dir_name ]] && cd $dir_name && echo rm *
  • ↑↑↑先看目录存不存在, 然后再进入, 然后再打印出来将要删除的文件, 这是最安全的删除方法 否则一旦目录不存在, 不同的写法会有不同的问题
  • 如果在执行bash脚本前加入-x参数, 则每一条命令执行前都会打印出来
    • 等同于set -x
    • 或者写在脚本的shebang行里也行
    • 打印的每一条命令前会加上一个标识符作前缀, 默认是+, 可以用export PS4=‘$LINENO +’这种方式自定义PS4的值(比如现在就加上了行号)

几个环境变量

  • LINENO 这个变量在哪, 打印的就是这一行的行号
  • FUNCNAME 返回一个数组, 函数调用的名称堆栈, 最里层(即本函数)的是0
  • BASH_SOURCE 返回一个数组, 函数调用的脚本堆栈, 即每层调用的脚本是哪一个, 最里层(即本文件)的是0
  • BASH_LINENO 返回一个数组, 函数每一次被调用时在该脚本的行号, 同样也是从最里层开始
    • ${BASH_LINENO[$i]}${FUNCNAME[$i]}是一一对应关系,表示${FUNCNAME[$i]}在调用它的脚本文件${BASH_SOURCE[$i+1]}里面的行号。

例:

  • ${BASH_SOURCE[1]} = main.sh [0] 是文件本身, 所以要[1]
  • ${BASH_LINENO[0]} = 17 调用来源的行号 —> 所以调用来源的行号的索引永远比调用来源(文件)的索引要小1
  • ${FUNCNAME[0]} = hello # 本方法(或者说”被调用的方法”)。代表在 main.sh的17行调用了hello()方法
#!/bin/bash
source lv2.sh   # 引入外部脚本
function lv1method()
{
    echo ---------lv1------------
    i=0
    for v in "${BASH_LINENO[@]}"; do
        echo "bash_line_no[$((i++))]: $v"
    done
    i=0
    for v in "${FUNCNAME[@]}"; do
        echo "func_name[$((i++))]: $v"
    done
    i=0
    for v in "${BASH_SOURCE[@]}"; do
        echo "bash_source[$((i++))]: $v"
    done
    lv2method # 调用外部脚本的方法
}

以上脚本, 多做几次嵌套, 打印出来看看索引之间的关系

临时文件

直接创建临时文件,尤其在/tmp目录里面,往往会导致安全问题。

  • 首先,/tmp目录是所有人可读写的,任何用户都可以往该目录里面写文件。创建的临时文件也是所有人可读的。
  • 其次,如果攻击者知道临时文件的文件名,他可以创建符号链接,链接到临时文件,可能导致系统运行异常。攻击者也可能向脚本提供一些恶意数据。因此,临时文件最好使用不可预测、每次都不一样的文件名,防止被利用。
  • 最后,临时文件使用完毕,应该删除。但是,脚本意外退出时,往往会忽略清理临时文件。

生成临时文件应该遵循下面的规则:

  • 创建前检查文件是否已经存在。
  • 确保临时文件已成功创建。
  • 临时文件必须有权限的限制。
  • 临时文件要使用不可预测的文件名。
  • 脚本退出时,要删除临时文件(使用trap命令)。

---> mktemp,生成一个文件名随机,且权限只有本人可以读写的文件

安全的用法:

  • trap 'rm -f "$TMPFILE"’ EXIT 退出时删除临时文件)
  • TMPFILE=$(mktemp) || exit 1 用mktemp命令建立临时文件可以只有本人能读, 如果失败就退出
  • echo "Our temp file is $TMPFILE”
  • 参数: -d 创建的是目录, -p 指定目录 -t 指定模板,
  • mktemp -t aaa.XXXXXXX 能生成/tmp/aaa.yZ1HgZV(与X个数相同)
  • trap是用来响应系统信号的, 如ctrl+c产生中断信号SIGINT
  • trap -l 列出所有信号,

trap 命令接的信号有如下

  • HUP:编号1,脚本与所在的终端脱离联系。
  • INT:编号2,用户按下 Ctrl + C,意图让脚本中止运行。
  • QUIT:编号3,用户按下 Ctrl + 斜杠,意图退出脚本。
  • KILL:编号9,该信号用于杀死进程。
  • TERM:编号15,这是kill命令发出的默认信号。
  • EXIT:编号0,这不是系统信号,而是 Bash 脚本特有的信号,不管什么情况,只要退出脚本就会产生。

如果trap要执行多条命令, 可以封装到函数里, 命令的位置写函数: trap func_name EXIT

启动环境

登录session依次启动如下脚本:

  • /etc/profile
  • /etc/profile.d 目录下的所有.sh文件
  • ~/.bash_profile 如果有, 则中止
  • ~/.bash_login 如果有, 则中止 此为C shell 初始化脚本
  • ~/.profile Bourne shell 和 Korn shell 初始化脚本
  • 通过bash --login 参数, 可以强制执行以上脚本

非登录session

  • /etc/bash.bashrc 所有用户都执行
  • ~/.bashrc 当前用户的

启动参数:

  • -n: 不执行脚本, 只检查语法
  • -v: 执行语句前先输出
  • -x: 执行语句后输出该语句

  • ~/.bash_logout 退出时要执行的命令
  • include /etc/inputrc 在~/.inputrc里加这一行, 可以在里面自定义快捷键

命令提示符

  • 上面提到过$PS4能修改set -x时打印的每句语句前面的+号
  • 命令提示符过默认的符号(根用户是#号)则可以用`PS1`来修改, 怎么改参考https://wangdoc.com/bash/prompt.html
  • PS2表示的是输入时折行的提示符, 默认为>
  • PS3表示使用select命令时系统输入菜单的提示符

实例

最后,来两个简单demo,第一个,根据规则重命名文件(夹),因为是硬性映射,之前每次都是手动重命名,想到不如把映射规则写到一个脚本里,这样循环执行mv命令就好了:

#!/bin/bash

# 接受目标文件夹位置
path=$1
echo executing path: $path

# list dirs
for entry in "$path"/*; do
    case $entry in
        "$path"/Chinese)
            mv $entry $path/zh-CN
            ;;
        "$path"/English)
            mv $entry $path/en-US
            ;;
        "$path"/German)
            mv $entry $path/de-DE
            ;;
        "$path"/French)
            mv $entry $path/fr-FR
            ;;
        "$path"/Italian)
            mv $entry $path/it-IT
            ;;
        "$path"/Norwegian)
            mv $entry $path/nb-NO
            ;;
        "$path"/Swedish)
            mv $entry $path/sv-SE
            ;;
        "$path"/Dutch)
            mv $entry $path/nl-NL
            ;;
        "$path"/Spanish)
            mv $entry $path/es-ES
            ;;
        "$path"/Danish)
            mv $entry $path/da-DK
            ;;
        *)
            echo $entry
            ;;
        esac
done

因为for...in语法能直接遍历文件夹,省去了一个ls命令。

第二个,写了个node脚本,因为传参比较多,打算用一个bash脚本包一下,这样既可以保存脚本,也方便编辑参数

#!/bin/bash
author='walker'
projectname='MyProjName'
passkeys='code,flag,message'
fileuri='/path/to/you/file'
offset=1
debug=true
verbose=false
modulename='taskCommon'
httpclient='MyRESTBaseClient'
models=(
#group1
modelAreaListItem,modelAreaJuniorDOListItem
#group2
modelHospitalSearchResult,modelHospitalSearchResultItem
#group3
modelHospitalDetail
#group4
modelHospitalDeptSearchResult,modelDeptSearchResultItem
)

for model in ${models[@]}; do
    types=${types}${model},
done
types=${types}

if [ "$debug" = true ] ; then
    enableDebug='--debug'
fi

if [ "$verbose" = true ] ; then
    enableVerbose='--verbose'
fi

./app.js ${enableVerbose} ${enableDebug} \
    -C ${types} \
    -P ${passkeys} \
    -f ${fileuri} \
    -a ${author} \
    -m ${modulename} \
    -j ${projectname} \
    -B ${httpclient} \
    -o ${offset}
  1. 我保留了models是为了明示数组是用空格分隔,而不是逗号
  2. types=${types}这一句我忘记了当初为什么要加
  3. 最后可以看到,就是一个简单的调用,其它的都是把脚本里的变量拼上去

再一个示例,自定义hosts地址,如果有匹配则更改,否则新增

#!/bin/bash

# insert/update hosts entry
ip_address="192.168.x.x"
host_name="my.hostname.example.com"
# find existing instances in the host file and save the line numbers
matches_in_hosts="$(grep -n $host_name /etc/hosts | cut -f1 -d:)"
host_entry="${ip_address} ${host_name}"

echo "Please enter your password if requested."

if [ ! -z "$matches_in_hosts" ]
then
    echo "Updating existing hosts entry."
    # iterate over the line numbers on which matches were found
    while read -r line_number; do
        # replace the text of each line with the desired host entry
        sudo sed -i '' "${line_number}s/.*/${host_entry} /" /etc/hosts
    done <<< "$matches_in_hosts"
else
    echo "Adding new hosts entry."
    echo "$host_entry" | sudo tee -a /etc/hosts > /dev/null
fi