Shell 编程规范

2018/03/01 shellstandard

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 这种。

#!/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语法),然后是注释的内容,注释尽量简短且在一行,最后跟的是函数的类型
# 主函数 []<-()                  <-------函数注释这样写
function main(){
    local var="Hello World!!!"
    echo ${var}
}
# info级别的日志 []<-(msg:String)  <-------带入参的函数注释
log_info(){
    echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')][$$]: [info] $*" >&2
}

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

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

[]<-()
[String]<-(var1:String,var2:String)
[Boolean]<-(var1:String,var2:Int)
[]<-(var1:String)

# 缩进

  1. 使用2/4空格进行缩进(二选一,一致即可),不使用 tab 缩进
  2. 不在一行的时候使用 \ 进行换行,使用 \ 换行的原则是整齐美观
#!/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. 使用变量的时候一定要用双引号 "${}" 包裹

    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 修饰,定义成局部变量,这样在外部遇到重名的变量也不会影响

    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 脚本也可以定义环境变量。

# 函数

函数定义的形式是:

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

或:

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

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

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

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

    # 重新设置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 正常情况下它输出的结果是当前所处的目录

xiejiancong@xiejc-pro ~ $ pwd
/Users/xiejiancong
xiejiancong@xiejc-pro ~ $

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

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

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

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

function main(){
    pwd
}

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

function main(){
    local dir=$(pwd)
    echo "dir is ${dir}"
}

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

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

显式 return

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

# 检查当前系统版本 [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 这个函数后,使用 $? 获取返回值

check_version
local version=$?
echo "${version}"

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

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

栗子:

# 检查网络 [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 计算器

# 分支

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

if

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

while [[ condition ]]; do
    # statements
done

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

until

until [[ condition ]]; do
    # statements
done

for

for (( i = 0; i < 10; i++ )); do
    # statements
done

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

case

case word in
    pattern )
        #statements
    ;;
    *)
        #statements
    ;;
esac
上次更新: 2021/8/7 上午3:33:06