Shell Scripting¶
Note
从定义角度来看,shell其实应该算是一种解释型编程语言,之所以将其放在tools而不是programming language则是因 shell 在实际应用上作为辅助工具的使用几乎占据了其所有的应用场景;介于其糟糕的接口以及极差的代码复用性,网络上的部分观点甚至都不认为 shell 是一个真正意义上的编程语言。对于 shell 与普通编程语言的对比,可参考Shell scripting vs programming language | stackoverflow
shebang¶
shebang(或 hashbang),指脚本文件开头的特殊注释行,用于指定执行该脚本的解释器:
#!/path/to/interpreter
#(hash),第二个字符为!(bang),故称为 "shebang" 或 "hashbang"。 Note
注意#!后是解释器的绝对路径;shebang是 Unix 和类 Unix 的系统特性,在其他系统中通常不受支持或需要工具辅助支持。关于shebang的详细信息还可参考Shebang (Unix) | Wikipedia
echo && read¶
在 shell 中,echo用于将文本信息输出到终端上:
echo [options] [params]
Tip
注意下面二者的区别:
# echo without double/single quote
echo Hello World
# echo with quote
echo "Hello World"
echo将Hello World视为一个文本参数,前者的echo则将其视为两个文本参数;在前者中,无论Hello与World间存在多少个空格或是tab,最终在解释器眼里都是一样的操作,执行结果也就一致。 read则用于交互地从用户读取数据:
#!/bin/sh
echo "What's your name?"
read USER
echo "Hello, $USER"
',需要使用双引号。 Note
在执行脚本时,输入变量的过程中则不需要再为右值添加双引号或其他转义字符,read会自动完成这一操作
变量¶
类型安全¶
Source of the Image: Stop writing shell scripts
图片的源文章很有意思,强烈建议参考阅读。
赋值¶
注意赋值时=前后不能有空格:
VAR1="Hello World"
# This may get some wrong
VAR2 = "Hello World"
Warning
上面的例子中,shell 解释器会将变量VAR2当作是一个名为VAR2的命令执行;自然地,后面的=与文本信息也就成了这个“命令”的参数。当我们尝试使用echo输出VAR2时,可能会出现如下报错:
bash: VAR: command not found
同时,这时的Hello World就必须使用双引号引用,否则World就会被解释器当作变量赋值操作后需要尝试执行的命令。
Tip
并不是所有的字符串变量都需要使用引号,当然,在使用字符串时通过引号引用是一个良好的编程习惯。
最后还需注意在引用时,"与'的区别,以以下脚本为例:
var1="Hello, $USER"
var2='Hello, $USER'
echo $var1
echo $var2
-
前者允许变量扩展或命令替换,因此第一个
echo输出的结果会将$USER替换为变量USER的值 -
后者则保留完整的原始字符串
变量作用域¶
未定义变量¶
在 shell 脚本中,若尝试访问一个未定义的变量:
USER=virtualguard
echo "Hello, $USOR" # mis-spelled
Tip
可通过在脚本开头添加set -u解决这个的问题,这会使shell解释器在遇到未定义变量时报错;
然而 shell 还有一个致命问题就是在遇到错误时默认不会中断,而是继续执行脚本的内容...🙃,这可通过在set后添加-e参数解决。
shell 解释器并不会报错,而是返回一个空字符串:
./var.sh
Hello,
export¶
在使用交互式 shell(shell会话)时,可直接进行变量赋值:
NAME=virtualguard
#!/bin/sh
set -u
echo "hello, $NAME"
NAME=vg
echo "hello, $NAME"
./test.sh
./test.sh: 行 4: NAME: 未绑定的变量
Note
注意在设置变量(如使用export)时不需要在变量前添加$,但在获取变量值时则需要添加。
若需要将变量名用于拼接,可使用{}将变量名包裹起来,注意引号:
read APP
echo "Create ${APP}.conf for config"
touch "${APP}.conf"
熟悉 Linux 的话应该知道可使用export命令在当前 shell 会话设置环境变量来解决这个问题,使得变量能够传递给子进程:
export NAME
NAME=virtualguard
# Or for one step
export VAR=virtualguard
./test.sh
hello, virtualguard
hello, vg
Info
还有两种方法:
# Set the variable at the same line with executed command
NAME=virtualguard ./test.sh # valid only on this command
# Execute in current shell
source ./test.sh # or . ./test.sh
特殊变量(预设变量)¶
$0...$9 && $# && $@/$*(参数管理)¶
简单来说,$n用于返回当前命令的第\(n\)个参数;$#用于返回当前命令后传入的参数数量;$@/$*用于返回当前命令后传入的所有参数。下面看一个简单的例子,有以下脚本:
#!/bin/sh
echo "This command which named $0 has called $# params just now"
echo "The first param is $1"
echo "The second one is $2"
echo "All params are $@"
./var.sh
This command which named ./var.sh has called 0 params just now
The first param is
The second one is
All params are
./var.sh "hello world"
This command which named ./var.sh has called 1 params just now
The first param is hello world
The second one is
All params are hello world
./var.sh hello world
This command which named ./var.sh has called 2 params just now
The first param is hello
The second one is world
All params are hello world
$@ vs $*
二者在参数不带引号时的效果是相同的,区别主要体现在参数被双引号包裹时:前者会将每个参数保持为单独的实体,保留原始参数的完整性;后者会将所有参数合并为一个字符串,参数之间用第一个IFS(内部字段分隔符)字符分隔,默认是空格。可参考What is the difference between $@ and $* in shell scripts? | stackoverflow
另外,假设我们想要使一个命令(脚本)能够传入\(9\)个以上的参数,可以使用shift命令将位置参数左移\(n\)位(未添加参数默认逐个移动:
while [[ "$#" -gt "0" ]]
do
echo "\$1 is $1"
shift #[n]
done
shift后,$#(参数数量)会相应减少。 $? && $! && $$(流程控制/错误检查)¶
$?用于获取最近一次命令、函数或脚本的退出状态码(exit status),通常应用于错误检查与流程控制上。
/path/to/some/command
if [[ "$?" -ne "0" ]]; then
echo "Failure with code $?!"
else
echo "Success! Exit with $?"
fi
./process.sh
# some operations
Success! Exit with 1
$$与$!用于指代进程编号
$$用于指代当前命令或脚本(shell 进程)的PID,常用于创建临时文件:
TEMP_FILE="/tmp/script.$$"
touch $TEMP_FILE
$!则用于指代最近一个放入后台执行的命令的PID。
$IFS¶
$IFS(Internal Field Separator,内部字段分隔符)是 Shell 中用于确定单词边界的重要环境变量。实际应用中用于定义 shell 如何识别字段分隔符,在处理文本数据、解析输入和输出时扮演着关键角色。
$IFS默认为一下三个值:
-
SPACE(空格) -
TAB/'\t'(制表符) -
NEWLINE/'\n'(换行符)
通常情况下,我们只在脚本中临时修改$IFS的值:
#!/bin/bash
# 原始 IFS 的备份
OLD_IFS=$IFS
# 将 IFS 设置为冒号,用于处理 /etc/passwd
IFS=":"
while read username password uid gid fullname homedir shell; do
echo "用户: $username, UID: $uid, 主目录: $homedir"
done < /etc/passwd
# 恢复原来的 IFS
IFS=$OLD_IFS
转义字符¶
在 shell 中,部分字符具有特殊意义,如 " 被 shell 解释器用于定义字符串边界,会影响 shell 对文本参数的解释;$会使得后面包含于特殊参数列表的参数被解释等。
但在某些场景我们可能希望不希望这些字符被 shell 解释,而是直接在终端上输出它们;若尝试直接在命令或脚本中拼接它们,例如:
echo "hello, " world! ""
Note
world两端与"间是否存在空格是有区别的,前者 shell 认为这段信息有三个参数,而后者则只有一个参数,可通过ls命令验证这一点:
ls "hello, " world! ""
ls: 无法访问 'hello, ': 没有那个文件或目录
ls: 无法访问 'world!': 没有那个文件或目录
ls: 无法访问 '': 没有那个文件或目录
ls "hello "world""
ls: 无法访问 'hello world': 没有那个文件或目录
解释器则会将上面的文本信息解释为以下三个参数:
-
"hello, " -
world! -
""
然后输出:
hello, world!
使用\对这类符号进行转义操作:
echo "hello, \"world\""
hello, "world
Note
大部分特殊字符(e.g. *, ', etc)在"的包裹下不会被 shell 解释,而是直接输出:
echo *
build.py docs LICENSE mkdocs.yml overrides README.md requirements.txt scripts
echo "*"
*
"包裹仍会被 shell 解释的特殊字符有 "、$、\。想要输出它们就需要使用转义字符 循环控制语句¶
for循环¶
语法如下:
for __var__ in __var_list__
do
# .....
done
Note
这里的*就是通配符,表示当前路径下的所有文件和文件夹;如果对其使用转义字符(\*),则会直接输出*本身
./test.sh
Looping ... i is set to hello
Looping ... i is set to 1
Looping ... i is set to *
Looping ... i is set to 2
Looping ... i is set to goodbye
#!/bin/sh
set -ue
for i in 1 * goodbye
do
echo "Looping ... i is $i"
done
./test.sh
Looping ... i is set to 1
Looping ... i is set to test.sh
Looping ... i is set to goodbye
while循环¶
语法如下:
while [ __condition__ ]
do
# .....
done
e.g.
Tip
:表示死循环,在 shell 中还有以下方法表示死循环(表达式为“真”):
-
使用
truewhile true do # ..... done -
使用数值 1(注意[ ])
while [ 1 ] do # ..... done -
使用字符串(注意[ ])
while [ "true" ] do # ..... done
#!/bin/sh
set -ue
while :
do
echo "You're welcome to leave some message here, type \"^C\" to quit"
read INPUT
echo "You typed $INPUT"
done
./test.sh
You're welcome to leave some message here, type "^C" to quit
hello
You typed hello
You're welcome to leave some message here, type "^C" to quit
5
You typed 5
You're welcome to leave some message here, type "^C" to quit
bye
You typed bye
You're welcome to leave some message here, type "^C" to quit
^C
#!/bin/sh
set -ue
while read input_text
do
case $input_text in
hello) echo English ;;
howdy) echo American ;;
你好) echo Chinese ;;
*) echo "Unknown Language: $input_text" ;;
esac
done < test.txt
Tip
这个例子用于从文件中读取信息并进行匹配。其中使用了case语句,它的用法类似于 C 中的switch...case...语句,后文我们会再介绍。
假设有test.txt:
hello
你好
howdy
hola
./test.sh
English
Chinese
American
Unknown Language: hola
测试(test) && 分支结构语句¶
test/[]与if¶
在 shell 中,测试命令test/[用于评估表达式的值。在 if与while 的条件语句中最为常用。下面是 shell 中if的语法:
Note
then也可以与if语句放在不同的行中,放在同一行就必须像这样使用;分隔二者,以示二者不处于同一行;与其具有相反作用的操作符是\:
[ "$FILE" -nt "/etc/passwd" ] && \
echo "$FILE is newer than /etc/passwd"
if [ __condition__ ]; then
# .....
elif [ __another_condition__ ]
# .....
else
# .....
fi
特别注意,[与test等效,通常都是 shell 的内置命令,因此其中需要评估的表达式__condition__是作为这个命令的参数传入,因此需要在[(])与__condition__之间添加空格;
同时,表达式__condition__中也需要适时添加空格,以示同一表达式的不同参数。下面简单举一个例子:
Note
在使用测试命令进行行字符串比较时,使用=进行比较(部分 shell 也接受==);整数比较( shell 默认不支持浮点数计算)则需要使用-eq,-ne等参数;
数值比较的参数就是符号描述的英文简写:如等于(equal)就是-eq、大于(greater than)就是-gt、小于等于(less or equal than)就是-le等;或也可使用现代 shell 的算术扩展((()))进行表达式的计算评估,可直接使用C的算术操作符构造表达式,详情参考Bash Reference Manual或Unix & Linux StackExchange
有如下if语句:
if [$foo = "bar" ]
$foo与[间没有括号。在 shell 眼里,这段命令就会被解释为if test$foo = "bar" ],即一个没有闭合的测试语句。 正确的写法应当是if [ $foo = "bar" ]。注意到$foo与=、=与"bar"等不同参数间也需要留有空格,这是由于需要测试命令将它们视作不同的参数以进行表达式计算,否则test命令会将它们视作一个整体(一个参数),无法进行表达式评估。
简单理解就是命令与参数之间、参数与参数之间需要具有空格
Tip
现代 shell (bash, zsh, ksh, etc.)中,支持使用扩展test命令([[]])进行表达式评估计算;但需要注意[[]]的本质不是命令,而是一个特殊语法结构!
关于[[]]的更多信息受篇幅限制这里不多作记录,可参考The difference between [] and [[]] | reddit和What is the difference between the Bash operators [[ vs [ vs ( vs ((? | Unix & Linux StackExchange。简单来说,[[]]更容易被正确使用。
case¶
除if...elif...else外另一个分支结构语句,类似与C中的switch...case...。语法如下:
case __statement__ in
__pattern1__)
# operations_1...
;;
__pattern2__)
# operations_2...
;;
*)
# defualt operations...
;;
esac
flowchart TD
A[start] --> B{case __statement__}
B -->|__pattern1__| C[operations_1]
B -->|__pattern2__| D[operations_2]
B -->|__*__| E[defualt operations]
C --> G[;;]
D --> H[;;]
E --> I[;;]
G --> J[esac]
H --> J
I --> J
J --> K[end] 反引号(`)¶
使用反引号在 shell 脚本中抓取外部命令的返回信息:
HTML_FILES=`find / -name "*.html" -print`
echo "$HTML_FILES" | grep "/index.html$"
echo "$HTML_FILES" | grep "/contents.html$"
主要观察第一行命令:
-
使用
find命令从根目录/开始搜索 -
-name "*.html"匹配所有以.html结尾的文件 -
-print输出找到的文件路径 -
反引号(
执行命令并将结果存储到HTML_FILES`` 变量中
