Shell 编程规范

Shell 是用户与 Linux 或 Unix 内核通信的工具,shell 编程指的并不是编写这个工具,而是指利用现有的 shell 工具进行编程,写出来的程序是轻量级的脚本,我们叫做 shell 脚本。

Shell 的语法是从C语言继承过来的,因此我们在写 shell 脚本的时候往往能看到C语言的影子。

Shell 脚本实在是太灵活了,相比标准的 Java、C、C++ 等,它不过是一些现有命令的堆叠,这是他的优势也是他的劣势,太灵活导致不容易书写规范。以下整理本人在写 shell 脚本的过程中形成了自己一些规范,这些规范还在实践中,在此分享出来,以期更多的人来帮助我完善。

命名

  1. 命名只能使用字母,数字和下划线,首个字符不能以数字开头
  2. 中间不能有空格,不能使用标点符号,不能使用汉字,可以使用下划线 _,所以我们往往使用 _ 作为分词的标识,例如 user_namecity_id 等等
  3. 不能使用 bash 里的关键字(可用 help 命令查看保留关键字)
  4. 脚本中的所有变量风格统一使用下划线命名风格(不强制,视情况而定)

统一的风格是好的编程习惯的开始,这样程序给人一种清爽的感觉,至于使用驼峰格式还是使用下划线格式,仁者见仁智者见智。

首行

使用 #!/usr/bin/env bash

我们看到大多数 shell 脚本的第一行是 #!/bin/bash,当然也有 #!/bin/sh#!/usr/bin/bash,这几种写法也都算是正确,当然还有一些野路子的写法,为了避免误导这里就不示例了。本 shell 规约并不推荐使用上面的任何一种,而是使用 #!/usr/bin/env bash 这种。

1
2
3
4
5
#!/usr/bin/env bash
# 主函数 []<-()
function main(){
echo "Hello World!!!"
}

shell 脚本的第一行用来指定执行脚本的时候使用的默认解析器是什么,#!/bin/bash 这样写就是指定使用 /bin 目录下的 bash 来解析。大多数 linux 发行版中默认的 shell 就是 bash,不同的 shell 下可用的命令不同,比如 sh 就比 bash 可用的基础命令少很多,这也就是为什么虽然 sh 是始祖却用的人很少,而它的增强版 bash 能够后来居上的原因。

shell 脚本是逐行解释执行的,在遇到第一行是 #!/bin/bash 的时候就会加载 bash 相关的环境,在遇到 #!/bin/sh 就会加载 sh 相关的环境,避免在执行脚本的时候遇到意想不到的错误。但一开始我并不知道我电脑上安装了哪些 shell,默认使用的又是哪一个 shell,我脚本移植到别人的计算机上执行,我更不可能知道别人的计算机是 Ubuntu 还是 Arch 或是 Centos。为了提高程序的移植性,本 shell 规约规定使用 #!/usr/bin/env bash#!/usr/bin/env bash 会自己判断使用的 shell 是什么,并加载相应的环境变量。

注释

  1. 除脚本首行外,所有以 # 开头的语句都将成为注释
  2. 变量的注释紧跟在变量的后面
  3. 函数内注释 # 与缩进格式对整齐
  4. 函数必须有注释标识该函数的用途、入参变量、函数的返回值类型,且必须简单在一行内写完
  5. 函数的注释 # 顶格写,井号后面紧跟一个空格,对于该格式的要求是为了最后生成函数的帮助文档(markdown语法),然后是注释的内容,注释尽量简短且在一行,最后跟的是函数的类型
1
2
3
4
5
6
7
8
9
# 主函数 []<-()                  <-------函数注释这样写
function main(){
local var="Hello World!!!"
echo ${var}
}
# info级别的日志 []<-(msg:String) <-------带入参的函数注释
log_info(){
echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')][$$]: [info] $*" >&2
}

[]<-(): <- 左侧表的是函数的返回值类型,用 [] 包裹,右侧是函数的参数类型,用 () 包裹,多个参数用 , 分隔

函数注释的几种常见写法:

1
2
3
4
[]<-()
[String]<-(var1:String,var2:String)
[Boolean]<-(var1:String,var2:Int)
[]<-(var1:String)

缩进

  1. 使用2/4空格进行缩进(二选一,一致即可),不使用 tab 缩进
  2. 不在一行的时候使用 \ 进行换行,使用 \ 换行的原则是整齐美观
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/env bash
# 脚本使用帮助文档 []<-()
manual(){
cat "$0"|grep -v "less \"\$0\"" \
|grep -B1 "function " \
|grep -v "\\--" \
|sed "s/function //g" \
|sed "s/(){//g" \
|sed "s/#//g" \
|sed 'N;s/\n/ /' \
|column -t \
|awk '{print $1,$3,$2}' \
|column -t
}

function search_user_info(){
local result=$(httpclient_get --cookie "${cookie}" \
"${url}/userName=${user_name}")
}

变量

  1. 变量赋值使用 = 等号,左右不能留有空格

  2. 使用变量的值用 $ 取值符号

  3. 使用变量的时候,变量名一定要用 {} 包裹

  4. 使用变量的时候一定要用双引号 "${}" 包裹

    1
    2
    3
    4
    5
    6
    var1="Hello World"   # 正确,推荐使用双引号
    var2='Hello World' # 正确,不推荐使用单引号
    var3="${var1}" # 应用前面定义的变量的时候也要使用双引号包裹
    var4=6
    var5=6.70 # 小数
    var3=${var1} # 正确,不推荐
  5. 常量一定要定义成 readonly,这种变量不能使用 source 跨 shell 使用
    比如一个 a.sh 中定义了一个全局的变量 readonly TURE=0b.sh 中在一开始使用 source a.sh 引入的 a.sh 的内容,则在 b.sh 中无需重复定义 readonly local TRUE=0,否则会报错

  6. 函数中的变量要用 local 修饰,定义成局部变量,这样在外部遇到重名的变量也不会影响

    1
    2
    3
    4
    5
    6
    7
    8
    9
    web="www.chen-shang.github.io"
    function main(){
    # 这里使用 local 定义一个局部变量
    local name="chenshang"
    # 这里 ${} 内的 web 是全局变量,之后在函数中在使用 web 变量都是使用的局部变量
    local web="${web}"
    # 对于全局变量,虽然在使用的时候直接使用即可,但还是推荐使用一个局部变量进行接收,然后使用局部变量,以防止在多线程操作的时候出现异常
    local web2="${web}"
    }
  7. 变量一经定义,不允许删除(也就是禁用 unset命令,因为到目前我还没遇到过什么情况必须 unset 的)

变量类型

先在这儿声明一下,shell 脚本并没有什么复杂的数据类型和数据结构,shell 的数据类型仅仅包括 字符串、数值。shell 并没有像 布尔类型、长整型、短整形 等 Java 中的基本数据类型,数值类型也没有明确的 float 和 double 之分。shell 比较复杂的数据类型就是数组Map 了,但Map 这种数据类型或者说数据结构只有在高版本的 shell 解析器才拥有,像我们现在使用的 Linux 系统往往是不支持 Map 的。
shell 中变量的基本类型就是 String数值(可以自己看做 Int、Double之类的) 、BooleanBoolean 其实是 Int 类型的变种,在 shell 中 0代表真、非0代表假, 所以往往我会在 shell 脚本中用 readonly TURN=0 && readonly FALSE=1

  1. 定义在函数中的我们称之为函数局部变量;定义在函数外部,shell 脚本中变量我们称之为脚本全局变量
  2. 环境变量:所有的程序,包括 shell 启动的程序,都能访问环境变量,有些程序需要环境变量来保证其正常运行。必要的时候 shell 脚本也可以定义环境变量。

函数

函数定义的形式是:

1
2
3
4
function main(){
# 函数执行的操作
# 函数的返回结果
}

或:

1
2
3
4
main(){
# 函数执行的操作
# 函数的返回结果
}
  1. 使用关键字 function 显示定义的函数为 public 的函数,可以供外部脚本以 sh 脚本 函数 函数入参 的形式调用

  2. 未使用关键字 function 显示定义的函数为 private 的函数,仅供本脚本内部调用,注意这种private 是人为规定的,并不是 shell 的语法,不推荐以 sh 脚本 函数 函数入参 的形式调用,注意是不推荐而不是不能

    本 shell 规约这样做的目的就在于使脚本具有一定的封装性,看到 function 修饰的就知道这个函数能被外部调用,没有被修饰的函数就仅供内部调用。你就知道如果你修改了改函数的影响范围,如果是 被 function 修饰的函数,修改后可能影响到外部调用他的脚本,而修改未被 function 修饰的函数的时候,仅仅影响本文件中其他函数。

    栗子,core.sh 脚本内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # 重新设置DNS地址 []<-()
    function set_name_server(){
    > /etc/resolv.conf
    echo nameserver 114.114.114.114 >> /etc/resolv.conf
    echo nameserver 8.8.8.8 >> /etc/resolv.conf
    cat /etc/resolv.conf
    }
    # info级别的日志 []<-(msg:String)
    log_info(){
    echo -e "[$(date +'%Y-%m-%dT%H:%M:%S%z')][$$]: \033[32m [info] \033[0m $*" >&2
    }
    # error级别的日志 []<-(msg:String)
    log_error(){
    # todo [error]用红色显示
    echo -e "[$(date +'%Y-%m-%dT%H:%M:%S%z')][$$]: \033[31m [error] \033[0m $*" >&2
    }

    则我可以使用 sh core.sh set_name_server 的形式调用 set_name_server 函数,但就不推荐使用 sh core.sh log_info "Hello World" 的形式使用 log_infolog_error 函数,注意是不推荐不是不能。

  3. 在函数内部首先使用有意义的变量名接受参数,然后在使用这些变量进行操作,禁止直接操作 $1$2 等,除非这些变量只用一次

  4. 函数的注释,函数类型的概念是从函数编程语言中的概念偷过来的,shell函数的函数类型指的是函数的输入到函数的输入的映射关系

返回值

shell 函数的返回值比较复杂,获取函数的返回值又有多种方式。我们先来说,我们执行一条命令的时候,比如 pwd 正常情况下它输出的结果是当前所处的目录

1
2
3
xiejiancong@xiejc-pro ~ $ pwd
/Users/xiejiancong
xiejiancong@xiejc-pro ~ $

注意我说的是正常情况下,那异常情况下呢?输出的结果又是什么?输出的结果有可能五花八门!

所以,shell 中必然有一种状态来标识一条命令是否执行成功,也就是命令执行结果的状态。

那这种状态是怎么标识的的,这就引出了 shell 中一个反人类的规定,也不算反人类,只能是在变种语言中算是另类,就是 0 代表真、成功的含义。非零代表假、失败的含义。

所以 pwd 这条命令如果执行成功的话,命令的执行结果状态一定是 0,然后返回值才是当前目录。如果这条命令执行失败的话,命令的执行结果状态一定不是0,有可能是 1 代表命令不存在,然后输出 not found,也有可能执行结果状态是 2 代表超时,然后什么也不输出。(不要以为 pwd 这种 linux 内带的命令就一定执行成功,有可能你拿到的就是一台阉割版的 linux 呢),那怎么获取这个命令的执行结果和执行结果的状态呢?

1
2
3
function main(){
pwd
}

执行 main 函数就会在控制台输出当前目录。如果想要将 pwd 的内容获取到变量中以供后续使用呢:

1
2
3
4
function main(){
local dir=$(pwd)
echo "dir is ${dir}"
}

如果想要获取 pwd 的执行结果的状态呢:

1
2
3
4
5
6
function main(){
local dir=$(pwd)
local status=$?
echo "pwd run status is ${status}" #这个stauts一定有值,且是int类型,取值范围在0-255之间
echo "dir is ${dir}"
}

显式 return

return 用来显式的返回函数的返回结果,例如:

1
2
3
4
5
6
7
8
# 检查当前系统版本 [Integer]<-()
function check_version(){
(log_info "check_version ...") # log_info是我写的工具类中的一个函数
local version # 这里是先定义变量,在对变量进行赋值,我们往往是直接初始化,而不是像这样先定义在赋值,这里只是告诉大家可以这么用
version=$(sed -r 's/.* ([0-9]+)\..*/\1/' /etc/redhat-release)
(log_info "centos version is ${version}")
return "${version}"
}

这样这个函数的返回值是一个数值类型,我在脚本的任何地方调用 check_version 这个函数后,使用 $? 获取返回值

1
2
3
check_version
local version=$?
echo "${version}"

注意这里不用 local version=$(check_version) 这种形式获取结果,这样也是获取不到结果的,因为显示的 return 结果,返回值只能是 [0-255] 的数值,这对于我们一般的函数来说就足够了,因为我们使用显示 return 的时候往往是知道返回结果一定是数字且在 [0-255] 之间的,常常用在状态判断的时候。所以,本 shell 规约规定:

  1. 明确返回结果是在 [0-255] 之间的数值类型的时候使用显示 return 返回结果
  2. 返回结果类型是 Boolean 类型,也就是说函数的功能是起判断作用,返回结果是真或者假的时候使用显示 return 返回结果

栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 检查网络 [Boolean]<-()
function check_network(){
(log_info "check_network ...")
for((i=1;i<=3;i++));do
http_code=$(curl -I -m 10 -o /dev/null -s -w %\{http_code\} www.baidu.com)
if [[ ${http_code} -eq 200 ]];then
(log_info "network is ok")
return ${TRUE}
fi
done
(log_error "network is not ok")
return ${FALSE}
}
# 获取数组中指定元素的下标 [int]<-(TABLE:Array,target:String)
function get_index_of(){
readonly local array=($1)
local target=$2
local index=-1 # -1其实是255
local size=${#array[@]}
for ((i=0;i<${size};i++));do
if [[ ${array[i]} == ${target} ]];then
return ${i}
fi
done
return ${index}
}

计算

  1. 整数计算使用 $(())
  2. 小数计算使用 bc 计算器

分支

1
2
3
HEAD_KEYWORD parameters; BODY_BEGIN
BODY_COMMANDS
BODY_END
  • HEAD_KEYWORD 和初始化命令或者参数放在第一行
  • BODY_BEGIN 同样放在第一行
  • 复合命令中的 BODY 部分以 2/4 个空格缩进
  • BODY_END 部分独立一行放在最后

if

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if [[ condition ]]; then
# statements
fi

if [[ condition ]]; then
# statements
else
# statements
fi

if [[ condition ]]; then
# statements
elif [[ condition ]]; then
# statements
else
# statements
fi
  • if 后面的判断使用双中括号 [[]]
  • if [[ condition ]]; then 写在一行

while

1
2
3
4
5
6
7
while [[ condition ]]; do
# statements
done

while read -r item ;do
# statements
done < 'file_name'

until

1
2
3
until [[ condition ]]; do
# statements
done

for

1
2
3
4
5
6
7
for (( i = 0; i < 10; i++ )); do
# statements
done

for item in ${array}; do
# statements
done

case

1
2
3
4
5
6
7
8
case word in
pattern )
#statements
;;
*)
#statements
;;
esac