高效的Shell编程建议及入坑

2022-09-28 20:52:02 浏览数 (1)

[TOC]

0x00 快速入门

描述:在进行shell脚本语言编写的时候,不仅要注意写的功能,更要注意他的美观以及通用性,还需要让其他参与运维的人都能看懂;

(1)代码风格规范

1)解释器 在很多脚本的第一行出现的以”#!”开头的注释,他指明了当我们没有指定解释器的时候默认的解释器;

代码语言:javascript复制
#如果没有定义解释器。linux会自动采用$SHELL指定的解释器
cat /etc/shells #  查看本机支持的解释器

#!/bin/bash  #常用解释器
#!/bin/sh

#推荐的使用方式比较通用
#!/usr/bin/env bash

2)注释 注释的意义不仅在于解释用途,而在于告诉我们注意事项,就像是一个README。因为很多单行的shell命令不是那么浅显易懂,没有注释的话在维护起来会让人尤其的头大。

注释一般包括下面几个部分:

  • 脚本的写作时间,作者,版权等
  • 脚本的函数参数
  • 脚本的函数用途
  • 脚本函数的注意事项
  • 一些较复杂的单行命令注释

3)缩进有规矩,太长要分行 正确的缩进是很重要的,尤其是在写函数的时候,否则我们在阅读的时候很容易把函数体跟直接执行的命令搞混。 常见的缩进方法主要有”soft tab”和”hard tab”两种,根据自己的喜好选择;

  • 所谓soft tab就是使用n个空格进行缩进(n通常是2或4)
  • 所谓hard tab当然就是指真实的””字符

在调用某些程序的时候,参数可能会很长,这时候为了保证较好的阅读体验,我们可以用反斜杠来分行:

代码语言:javascript复制
#注意在反斜杠前有个空格。
./configure 
-prefix=usr 
-sbin-path=/usr/sbin/nginx 
-conf-path=/etc/nginx/nginx.conf 

4)命名有标准 所谓命名规范基本包含下面这几点:

  • 文件名规范:以.sh结尾,方便识别
  • 变量名字要有含义:取变量和函数要有意义
  • 统一命名风格:由于在bash环境变量名字都是大写,因此建议自己定义的变量用小写字母命名,所以写shell一般用小写字母加下划线以防止命名冲突

5)变量和魔数 这里的变量有系统变量也有用户自定义变量,定义方式有一个很常见的用途 最典型的应用就是当我们本地安装了很多java版本时,我们可能需要指定一个java来用。那么这时我们就会在脚本开头重新定义JAVA_HOME以及PATH变量来进行控制。

魔数是指在shell脚本中开头的预定义变量,只在shell执行中有效; 通常是用一个变量的形式定义在开头,然后调用的时候直接调用这个变量,这样方便日后的修改。

代码语言:javascript复制
#系统变量于环境变量
source /etc/profile #系统环境变量
export PATH=$PATH:/app/bin

#用readonly声明静态变量
#静态变量不会改变;它的值一旦在脚本中定义后就不能被修改,对于这类变量,在声明的时候应该用readonly去声明。
readonly passwd_file="/etc/passwd"
readonly group_file="/etc/group"

6)参数要规范 当我们的脚本需要接受参数的时候,我们一定要先判断参数是否合乎规范,并给出合适的回显,方便使用者了解参数的使用。 比如:

代码语言:javascript复制
if [[ $# < 2 ]];then
    echo "#这时只有一个参数及运行的shell脚本文件 = $0"
    exit
fi

7)编码要统一 尽量使用UTF-8编码能够支持中文等一些奇奇怪怪的字符,但是需要注意再能使用英文输出的情况下尽量才用英文,因为有的机器默认是英文语言环境再这样的环境中执行打出来的中文可能是乱码;

注意:在windows下用utf-8编码来写shell脚本的时候,一定要注意这个utf-8是否是有BOM的,在Linux下运行的时候就会识别到开头的三个字符,从而报一些无法识别命令的错。

  • 默认情况下windows判断utf-8格式是通过在文件开头加上三个EF BB BF字节来判断的,但是在Linux中默认是无BOM的所以会报错;

8)脚本权限执行,日志和回显 描述:不加执行权限会导致无法直接执行,所以再执行脚本前需要对其进行chomd x test.sh

日志的重要性不必多说能够方便我们回头纠错,在大型的项目里是非常重要的,同时能够在执行时实时回显执行过程,方便用户掌控。

有时候为了提高用户体验,我们会在回显中添加一些特效,比如颜色啊,闪烁啊之类的,具体可以参考ANSI/VT100 Control sequences文章的介绍。

9)请勿再脚本中硬编码敏感信息 描述:不要把密码硬编码在脚本里,不要把密码硬编码在脚本里,不要把密码硬编码在脚本里。


(2)编码细节规范 1)代码执行效率简短 在使用命令的时候要了解命令的具体做法,尤其当数据处理量大的时候,要时刻考虑该命令是否会影响效率。 简短不单单是指代码长度,而是只用到的命令数原则上我们应当做到,能一条命令解决的问题绝不用两条命令解决。 这不仅牵涉到代码的可读性,而且也关乎代码的执行效率

代码语言:javascript复制
#作用一样,都是获取文件的第一行,当文件很大的时候,仅仅是这样一条命令不一样就会造成巨大的效率差异。
sed -n '1p' file  #会读取整个文件
sed -n '1p;1q' file #命令只读取第一行
#真正正确的用法应该是使用head -n1 file命令

#最最经典的例子如下:
cat /etc/passwd | grep root #cat命令最为人不齿的用法就是这样明明一条命令可以解决

#其实代码简短在还能某种程度上能保证效率的提升
find . --name "*.txt" | xargs sed -i "s/223/666/g;s/235/279/g" #查找所有的.txt后缀的文件并做一系列替换
#并且巧用xargs命令我们还可以十分方便的进行并行化处理:
find . --name "*.txt" | xargs -P $(nproc) sed -i "s/223/666/g;s/235/279/g"

2)勤用双引号 推荐在使用”$”来获取变量的时候最好加上双引号,当使用一个变量的值时,用双引号有助于防止由于空格导致单词分割开和由于识别和扩展了通配符而导致的不必要匹配; 不加上双引号在很多情况下都会造成很大的麻烦,为什么呢?举一个例子:

代码语言:javascript复制
#!/usr/bin/env sh
#已知当前文件夹下有一个a.sh
var="*.sh"  #赋值不要有空格(非常注意)
echo $var  #a.sh 
echo "$var" #*.sh  #注意上面这一点,仔细体会其中的差异


#示例2.单双引号的不同
names="Tecmint FOSSMint Linusay"

echo "Names without double quotes" 
for name in $names;   #不同点 无双引号
    do  
        echo "$name" #空格会换行
    done


echo "Names with double quotes" 
for name in "$names";    #不同点 双引号
    do  
        echo "$name"
    done
exit 0

WeiyiGeek.单双引号

3)shell函数 我们知道像java/C这样的编译型语言都会有一个函数入口,这种结构使得代码可读性很强,我们知道哪些直接执行那些是函数。 同样也适用其它编程语言函数的使用使得代码更模块化,更可读和可重用,shell脚本中定义函数的语法如下所示:

代码语言:javascript复制
#!/usr/bin/env bash
func1() {
    #do some..
}

#函数入口
main(){
    func1
}

#我们可以采用这种写法,同样实现类似的main函数,使得脚本的结构化程度更好。
main "$@"  #调用主函数并传入命令行的参数

5)函数返回值 在使用函数返回值要注意shell中函数的返回值只能是整数,估计是因为一般情况下一个函数的返回值通常表示这个函数的运行状态,所以一般都是0或者是1就够了,因此就设计成了这样;

但是我们可以采用下面这种方式来进行返回值:

代码语言:javascript复制
#!/usr/bin/env bash
#建议加上function关键字
function func(){
    echo "Func"
}
res=$(func)
echo "This is from $res ." #This is from Func .

6)间隔引用值 [值得学习] 什么叫间接引用? 答:类似于C语言中的指针*pt=”123456”,pt实际是指向123456的地址,而再shell 中就是下面这样;

代码语言:javascript复制
#!/usr/bin/env bash
VAR1="123"
VAR2="VAR1"

#VAR2的值是VAR1的名字,那么我们现在想通过VAR2来获取VAR1的值,这时候应该怎么办呢?
eval echo $$VAR2  #用法的确可行但是看起来十分的不舒服,很难只管的去理解而且不推荐使用eval这个命令。

#推荐方式,通过在变量名前加一个!就可以做到简单的间接引用了。
echo ${!VAR2}  #123 (这种方式非常需要注意)

#不过需要注意的是用上面的方法,我们只能够做到取值而不能做到赋值。如果想要做到赋值,还要老老实实的用eval来处理:
VAR1=VAR2
eval $VAR1=123456789
echo $VAR2

7)巧用heredocs[常用 ] 所谓heredocs,也可以算是一种多行输入的方法,即在”<<”后定一个标识符,接着我们可以输入多行内容,直到再次遇到标识符为止。 使用heredocs,我们可以非常方便的生成一些模板文件:

WeiyiGeek.herrdocs

7)考虑作用域 描述:shell中默认的变量作用域都是全局的,比如下面的脚本他的输出结果就是2而不是1,这样显然不符合我们的编码习惯,很容易造成一些问题,所以要慎用全局方式定义。

WeiyiGeek.作用域

因此相比直接使用全局变量,我们最好使用local readonly这类的命令,其次我们可以使用declare来声明变量。

8)脚本文件路径 通常我们是直接用pwd以期获得脚本的路径,不过其实这样是不严谨的,pwd获得的是当前shell的执行路径,而不是当前脚本的执行路径

常用做法:

代码语言:javascript复制
#当先cd进当前脚本的目录然后再pwd,或者直接读取当前脚本的所在路径。
script_dir=$(cd $(dirname $0) && pwd)
script_dir=$(dirname $(readlink -f $0))

9)命令替换 两种形式都可以用作命令替换,所谓命令替换是用这个命令的输出结果取代命令本身,这里建议用 $(command) 而不是反引号 command来做命令代换。

代码语言:javascript复制
#用$(command) 代替传统的`command`
user=`echo "$UID"` #不建议做法
user=$(echo "$UID") #建议做法

10)命令并行化 当我们需要充分考虑执行效率时,我们可能需要在执行命令的时候考虑并行化。shell中最简单的并行化是通过”&”以及”wait”命令来做:

代码语言:javascript复制
function func(){
  #do sth
}

for(( i=0; i<10; i   ));do
  func &
done
wait

注意事项:

  • 当然这里并行的次数不能太多否则机器会卡死
  • 稍微正确的做法比较复杂,以后再讨论,如果图省事可以使用parallel命令来做。

11)脚本中有命令运行失败时/未声明变量时候退出脚本 如果脚本中某条命令运行失败,我们不应该让其继续运行,因为这样可能会影响脚本的其余部分,导致逻辑错误。逻辑错误一般又是很难定位的,与其这样不如让其提前结束更早的找出脚本中的错误。

代码语言:javascript复制
# 如果命令运行失败让脚本退出执行
set -o errexit  # 或
set -e

如果脚本中使用到未声明的变量同样可能导致逻辑错误,可以用下面的命令设置脚本在使用到未声明的变量时退出执行:

代码语言:javascript复制
# 若有用未设置的变量即让脚本退出执行
set -o nounset
# 或
set-u

12)新写法新特性-在变量测试的 新写法不是指有多厉害而是指我们可能更希望使用较新引入的一些语法,更多是偏向代码风格的,比如

  • 尽量使用func(){}来定义函数,而不是func{}
  • 尽量使用[[]]来代替[],[]采用 < ,> , <= ,>= 会出现以外的错误
  • 尽量使用(())来代[[]]采用 <= ,>= 会出现以外的错误,(())的通用性比较好
  • 尽量使用$()将命令的结果赋给变量而不是反引号
  • 在复杂的场景下尽量使用printf代替echo进行回显
代码语言:javascript复制
#示例1.建议采用(())来做为除了test命令的首选,不容易出错在进行变量测试的时候
(( "$1" >= "$2" )) && {
  echo "满足"
} || echo "不满足"


#示例2.注意空格可有可无,
(( "1"=="2" && "3"!="4" )) && {
  echo "条件满足"
} || echo "条件不满足"  #条件不满足

WeiyiGeek.简单示例

注意:事实上这些新写法很多功能都比旧的写法要强大,用的时候就知道了。

13)字符串变量测试比较时候 字符串比较时用 = 而不是 ==,为什么会有这个建议,原文并没有给出详细的说明只是简单的提了句:== 是 = 的同义词,因此仅用个单个 = 来做字符串比较。 实际上是因为==只适用于bash;POSIX形式是“=”,使用 = 更方便移植。

代码语言:javascript复制
#示例1.字符串比较的
value1="tecmint.com"
value2="fossmint.com"
[ "$value1" = "$value2" ]  #推荐这样的形式
[ "$value1" == "$value2" ]


#替换if功能代码段,[[]] 通用性比[]好一点点
[[ 1 > 2 ]] && {
  echo "执行1"
  echo "执行2"
} || {
  echo "条件不满足"  #条件不满足
}

14) 变量预定义 描述: 注意在shell脚本中的 : 可作为预定义变量使得不将变量中的字符串作为命令执行;

命令使用1:

代码语言:javascript复制
# 差异查看
: ${VAR1:="Linux"} # 不会将linux字符串当做命令执行只是将其赋值给遍历VAR1
${VAR2:="whomi"}   # 没有whomi会报错
${VAR3:="whoami"}  # 会先执行whoami命令,然后将字符串赋值给VAR3

echo ${VAR1} - ${VAR2} - ${VAR3}

执行结果:

代码语言:javascript复制
/tmp$ : ${VAR1:="Linux"}
/tmp$ ${VAR2:="whomi"}
  # Command 'whomi' not found, did you mean:
  #   command 'whom' from deb mailutils-mh (1:3.7-2.1)
  #   command 'whom' from deb mmh (0.4-2)
  #   command 'whom' from deb nmh (1.7.1-6)
  #   command 'whoami' from deb coreutils (8.30-3ubuntu2)
  # Try: sudo apt install <deb name>
/tmp$ ${VAR3:="whoami"}  # 值得学习
  # weiyigeek  
/tmp$ echo ${VAR1} - ${VAR2} - ${VAR3}
  # Linux - whomi - whoami

脚本实例2:

代码语言:javascript复制
#!/bin/sh
: ${BINARY_NAME:="helm"}
: ${USE_SUDO:="true"}
: ${DEBUG:="false"}
echo ${BINARY_NAME} ${USE_SUDO} ${DEBUG}
# 执行结果
# helm true false

15)其他小tip 考虑到还有很多零碎的点就不一一展开了这里简单提一提

  • 路径尽量保持绝对路径,绝多路径不容易出错,如果非要用相对路径,最好用./修饰
  • 优先使用bash的变量替换代替awk sed,这样更加简短
  • 简单的if尽量使用 && || 写成单行。比如[[ x > 2]] && echo x 短路求和
  • 当export变量时尽量加上子脚本的 namespace 保证变量不冲突
  • 会使用trap捕获信号,并在接受到终止信号时执行一些收尾工作
  • 使用mktemp生成临时文件或文件夹
  • 利用/dev/null过滤不友好的输出信息
  • 会利用命令的返回值判断命令的执行情况
  • 使用文件前要判断文件是否存在,否则做好异常处理
  • 不要处理ls后的数据(比如ls -l | awk ‘{ print $8 }’),ls的结果非常不确定,并且平台有关
  • 读取文件时不要使用for loop而要使用while read

(3)静态检测工具 描述:为了从制度上保证脚本的质量,我们最简单的想法大概就是搞一个静态检查工具,通过引入工具来弥补开发者可能存在的知识盲点。

shellcheck的工具开源在github上有8K多的star,这个工具的对不同平台的支持力度都很大,他至少支持了Debian,Arch,Gentoo,EPEL,Fedora,OS X,openSUSE等等各种的平台的主流包管理工具,安装方便。

集成既然是静态检查工具,就一定可以集成在CI框架里,shellcheck可以非常方便的集成在Travis CI中,供以shell脚本为主语言的项目进行静态检查;在文档的Gallery of bad code里,也提供了非常详细的“坏代码”的标准,具有非常不错的参考价值,可以在闲下来的时候当成”Java Puzzlers“之类的书来读读还是很惬意的。

不过,其实我觉得这个项目最最精华的部分都不是上面的功能,而是他提供了一个非常非常强大的wiki。在这个wiki里,我们可以找到这个工具所有判断的依据


0x01 实际示例

示例1.演示shell编程与命令行参数的结合

代码语言:javascript复制
#!/usr/bin/env bash
# 若命令失败让脚本退出
set -o errexit 
# 若未设置的变量被使用让脚本退出
set -o nounset

URL=$1
DIRECTORY=$2

function help() {
  echo "usage: bash yaml-fetch.sh <'snapshot_url'> <directory>"
}

function active() {
  #如果目录不存在则创建它
  echo 'create directory'
  mkdir $DIRECTORY
  # 下载并解压 yaml 文件
  echo 'fetch and untar the yaml files'
}

function  main() {
  #判断输入的个数
  if [ $# -eq 0 ];then
    help  #注意不加括号
  else
    active
  fi  
}

main "$@"  #传入命令行中的参数

#执行结果
bash dir.sh
usage: bash yaml-fetch.sh <'snapshot_url'> <directory>

0x02 入坑出坑

问题1:在windows下编写的shell脚本到Linux无法执行? 原因:test.sh是我在windows下编辑然后上传到linux系统里执行的,.sh文件的格式为dos格式,而linux只能执行格式为unix格式的脚本。 因为在dos/window下按一次回车键实际上输入的是“回车(CR)”和“换行(LF)”,而Linux/unix下按一次回车键只输入“换行(LF)”,所以修改的sh文件在每行都会多了一个CR,所以Linux下运行时就会报错找不到命令。

代码语言:javascript复制
^M 是dos的格式
rn 是微软
n 是linux
r 是苹果

举出三种解决方法:为了防止中文乱码:建议编码设置UTF-8无BOM格式。 1、在editplus中“文档->文件格式(CR/LF)->UNIX”,这样Linux下就能按unix的格式保存文件 2、在vim中,输入:set ff=unix,同样也是转换成unix的格式。 3、dos2unix 1.sh (注:dos2unix需要安装 yum install -y dos2unix)

0 人点赞