如何编写优雅易读的Shell脚本:规范与技巧全解析
前言

因为工作有需求,最近又开始整理 shell 脚本了。大部分命令自己平常经常会用到,然而写成脚本时总感觉写得很不美观。并且当我看其他人写的脚本时,总觉得难以读懂。要知道 shell 脚本这种东西不算正式的编程语言,它更像是一个工具,用于把不同的程序混合在一起供我们调用。
很多人在写作时想到哪写到哪,基本就像一段极长的 main 函数,实在难以直视。并且,因为历史缘故,shell 存在很多不同的版本,同时还有很多功能相同的命令供我们选择或舍弃,这导致代码的规范难以达成统一。
因为上述这些原因,我查看了一些与之相关的文档,得知这些问题实际上很多人都曾思考过,并且还形成了一些较为优秀的文章,不过依然显得有些零散。所以我就在此地对这些文章进行了适当的整理,将其作为日后我自己撰写脚本的技术规范。
代码风格规范
开头有“蛇棒”
所谓 shebang 指的是在许多脚本的第一行会出现的以#!开头的注释。它能够指明,在我们没有指定解释器时所使用的默认解释器,通常可能是如下这样的情况:
#!/bin/bash
当然,有很多种解释器。除了 bash 之外,我们能够通过以下命令来查看本机所支持的解释器。
$ cat /etc/shells
/etc/shells 表示有效的登录 shell 。
/bin/sh
/bin/dash
/bin/bash
/bin/rbash
/usr/bin/screen
当直接使用./a.sh 去执行这个脚本时,若没有 shebang,那么它会默认使用 $SHELL 所指定的解释器;若有 shebang,就会用 shebang 指定的解释器。
这种方式是我们推荐的使用方式。
代码有注释
注释是一个常识,这里需要再次强调,在 shell 脚本中这一点尤为重要。因为很多单行的 shell 命令不是很容易理解,没有注释的话在维护时会让人很头疼。
注释的意义在于告诉我们注意事项,同时也在于解释用途,它就像是一个 README。
具体的来说,对于shell脚本,注释一般包括下面几个部分:
参数要规范
这一点很重要。当脚本需要接受参数时,我们要先进行参数是否合乎规范的判断,并且要给出合适的回显,以方便使用者了解参数的使用。
最少,最少,我们至少得判断下参数的个数吧:
if [[ $# != 2 ]];then
echo "Parameter incorrect."
exit 1
fi
变量和魔数
通常我们会在开头定义一些重要的环境变量,以确保这些变量的存在。
source /etc/profile
export PATH=”/usr/local/bin:/usr/bin:/bin:/usr/local/apps/bin 路径存在
这种定义方式有一个较为常见的用途。其中最典型的应用情形是,倘若我们本地安装了众多的 java 版本,那么我们或许就需要指定某一个 java 来使用。在这种情况下,我们会在脚本的开头重新对 JAVA_HOME 以及 PATH 变量进行定义,以实现对相关情况的控制。并且,一段优秀的代码通常不会在代码里包含很多硬性编码的“魔数”。如果一定要有这种情况,通常会在开头以变量的形式进行定义,接着在调用的时候直接调用这个变量,如此一来便于日后进行修改。
缩进有规矩
对于 shell 脚本而言,缩进是一个较为突出的问题。许多需要进行缩进的地方,像 if 语句、for 语句等,其长度并不长。正因如此,很多人都懒得去进行缩进操作。并且,还有很多人不习惯使用函数,这就使得缩进功能在一定程度上被弱化了。
正确的缩进很重要。尤其是在写函数时。如果不注意缩进,我们在阅读时很容易把函数体与直接执行的命令弄混。
常见的缩进方法主要有两种,一种是“soft tab”,另一种是“hard tab”。
命名有标准
所谓命名规范,基本包含下面这几点:
编码要统一
写脚本时尽量采用 UTF-8 编码,这样可以支持中文等一些特殊字符。虽然能够书写中文,但在写注释和打 log 时还是尽量使用英文,因为很多机器并未直接支持中文,若使用中文可能会出现乱码。这里需要特别注意的一点是,若我们在 windows 环境下使用 utf-8 编码来编写 shell 脚本,那就必须留意这个 utf-8 是否带有 BOM。通常情况下,windows 会通过在文件开头添加三个 EF BB BF 字节来判断 utf-8 格式,然而在 Linux 中默认是没有 BOM 的。如果我们在 Windows 下写脚本,那么一定要注意把编码改成 Utf-8 无 BOM。一般用 notepad++之类的编辑器可以进行修改。不然的话,在 Linux 下运行时就会识别到开头的三个字符,进而会报一些无法识别命令的错误。当然,对于跨平台写脚本,还有一个比较常见的问题,那就是换行符不同。windows 默认的是回车符(\r),而 unix 下是换行符(\n)。不过存在两个小工具,能够非常便捷地解决这个问题,分别是 dos2unix 和 unix2dos。
权限记得加
这一点虽小,然而我个人时常会将其忘记。若未添加执行权限,便无法直接进行执行,着实令人讨厌。
日志和回显
日志的重要性显而易见。它可以方便我们回头进行纠错。在大型项目中,它具有非常重要的作用。
如果这个脚本是供用户直接在命令行使用的,那么我们最好能够在执行时实时回显执行过程,这样方便用户掌控。
有时为提升用户体验,我们会在回显里添加诸如颜色、闪烁等一些特效,具体可参考 ANSI/VT100 Control sequences 这篇文章的介绍。
密码要移除
不要在脚本中直接写入密码,不要将密码以明文形式存放在脚本中,不要把密码以固定方式嵌入到脚本里。
重要的事情要多说几遍,尤其是当脚本被托管在类似 Github 这样的平台中时。 重要的事情需重复多次,尤其是在脚本处于类似 Github 这类平台托管的情况下。 重要的事情得再三强调,特别是当脚本是托管在类似 Github 这种平台之中的时候。
太长要分行
调用某些程序时,参数可能会很长。在这种情况下,为了确保有较好的阅读体验,我们可以借助反斜杠进行分行。
./configure \
–prefix=/usr \
–sbin-path=/usr/sbin/nginx \
- 相关配置的路径是 /etc/nginx/nginx.conf 。
注意在反斜杠前有个空格。
编码细节规范
代码有效率
使用命令时需了解其具体做法,尤其是数据处理量较大时,要时刻思考该命令是否会对效率产生影响。
比如下面的两个sed命令:
sed -n '1p' file
sed -n '1p;1q' file
他们的作用相同,都是获取文件的首行。然而,第一条命令会读取整个文件,第二条命令则仅读取第一行。若文件规模较大,仅这一条命令的差异就会导致极大的效率差别。
这里只是用来举个例子,而这个例子正确的使用方式应当是运用 head -n1 file 这个命令。
勤用双引号
几乎所有大佬都建议,在使用“$”获取变量时,最好加上双引号。
在很多情况下不加上双引号会造成很大的麻烦。为什么呢?例如:
#!/bin/sh
#已知当前文件夹有一个a.sh的文件
var="*.sh"
echo $var
echo "$var"
他的运行结果如下:
a.sh
*.sh
为啥会这样呢?其实可以解释为他执行了下面的命令:
echo *.sh
echo "*.sh"
在很多情形下,当把变量当作参数时,必须留意上述这点,要仔细去感受其中的差别。上述仅仅是一个极为微小的例子,在实际应用中,因为这个细节而引发的问题实在是太多了……
巧用main函数
我们知道,像 java 和 C 这样的编译型语言都有一个函数入口。这种结构使得代码的可读性很强,我们能够清楚地知道哪些是直接执行的,哪些是函数。然而,脚本与之不同,脚本属于解释性语言,它是从第一行直接执行到最后一行的。如果在这个过程中,命令与函数混杂在一起,那么就会非常难以阅读。
朋友们都知道,用 Python 编写的脚本,若合乎标准,大体上至少是这样的:
#!/usr/bin/env python
def func1():
pass
def func2():
pass
if __name__=='__main__':
func1()
func2()
他使用了一种很精妙的方式达成了我们所习惯的 main 函数,这样使得代码的可读性变得更强了。
在shell中,我们也有类似的小技巧:
#!/usr/bin/env bash
func1(){
#do sth
}
func2(){
#do sth
}
main(){
func1
func2
}
main "$@"
我们能够运用这种写法,进而达成类似的 main 函数,让脚本的结构化程度得以提升。
考虑作用域
shell中默认的变量作用域都是全局的,比如下面的脚本:
#!/usr/bin/env bash
var=1
func(){
var=2
}
func
echo $var
他输出的结果是 2 而非 1,这显然与我们的编码习惯不符,很容易引发一些问题。
因此,我们最好使用 local readonly 这类命令,而不是直接使用全局变量。其次,我们可以使用 declare 来声明变量。这些方式都比使用全局方式定义更优。
函数返回值
使用函数时需注意,shell 中函数的返回值仅能为整数。大概是因为通常一个函数的返回值用于表示该函数的运行状态,所以一般只需 0 或 1 就足够了,故而设计成这样。然而,如果非要传递字符串,可通过以下变通方法:
func(){
echo "2333"
}
res=$(func)
echo "This is from $res."
这样,利用 echo 或者 print 等方式就能够达成传递一些额外参数的目的。
间接引用值
什么叫间接引用?比如下面这个场景:
VAR1="2323232"
VAR2="VAR1"
我们拥有一个变量叫做 VAR1,同时还有一个变量叫做 VAR2,VAR2 的值是 VAR1 的名字。现在我们希望借助 VAR2 去获取 VAR1 的值,那么此时该怎么做呢?
比较土鳖的方法是这样:
eval echo \$$VAR2
意思是构造了一个字符串 echo XXX,其中的 XXX 为 VAR2 的值 VAR1,接着通过 eval 进行强制解析,从而实现了变相取值。
并且,事实上我们本身就不推荐使用 eval 这个命令。
比较舒服的写法是下面这样:
echo ${!VAR1}
通过在变量名前加一个!就可以做到简单的间接引用了。
需要注意的是,通过上述方法,我们仅能实现取值这一操作,而无法实现赋值。若要实现赋值,就必须老老实实地使用 eval 来进行处理。
VAR1=VAR2
eval $VAR1=233
echo $VAR2
巧用heredocs
所谓heredocs,也可以算是一种多行输入的方法,即在”