Shell编程规范

作者: MrRobot | 来源:发表于2018-02-27 23:00 被阅读217次

    原文地址
    Shell是用户与Linux或Unix内核通信的工具,shell编程指的并不是编写这个工具,而是指利用现有的shell工具进行编程,写出来的程序是轻量级的脚本,我们叫做shell脚本。
    Shell的语法是从C语言继承过来的,因此我们在写shell脚本的时候往往能看到c语言的影子。
    Shell脚本实在是太灵活了,相比标准的Java、C、C++ 等,它不过是一些现有命令的堆叠,这是他的优势也是他的劣势,太灵活导致不容易书写规范。本人在写shell脚本的过程中形成了自己一些规范,这些规范还在实践中,在此分享出来,以期更多的人来帮助我完善。

    一些收集整理的shell脚本,在此项目的基础上大家可以轻松的构建自己的shell脚本,希望多多提意见
    https://github.com/chen-shang/baseshell

    首先来了解一下shell

    什么是shell

    关于命名

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

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

    local userName="chenshang"
    local user_name="陈尚"
    function log_info(){
      # todo
    }
    function logInfo(){
      # todo
    }
    function get_user_info(){
      # todo
    }
    function getUserInfo(){
      # todo
    }
    

    对比一下就感觉 userNameuser_name 简洁; 函数名 log_infologInfo 更加贴切,
    我们Java中打印日志的时候一般这样写 log.info("") 或者 log.error(""),所以在shell中使用 log_info "" 这种写法更像Java的习惯; getUserInfoget_user_info 更加紧凑,使用起来更像Java的函数命名风格。

    虽然这样,但是本规约还是规定使用下划线风格(特殊情况特殊处理,不强制),因为你可以看看Linux自带的一些脚本,定义变量的时候都使用下划线分割。

    关于首行

    1. 使用#!/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是什么,并加载相应的环境变量。

    我们看一下下面一段脚本,在改变第一行头部的时候,shellcheck给出的建议是什么
    $ cat test.sh

    function main(){
      local string="Hello World!!!"
      echo $string
    }
    

    使用 #!/bin/bash 或 #!/usr/bin/env bash

    $ shellchek test.sh
    In base_file.sh line 4:
      echo $string
           ^-- SC2086: Double quote to prevent globbing and word splitting.
    

    使用 #!/bin/zsh

    $ shellchek test.sh
    In base_file.sh line 1:
    #!/bin/zsh
    ^-- SC1071: ShellCheck only supports sh/bash/dash/ksh scripts. Sorry!
    

    使用 #!/bin/sh

    $ shellchek test.sh
    In base_file.sh line 2:
    function main(){
    ^-- SC2112: 'function' keyword is non-standard. Delete it.
    In base_file.sh line 3:
      local string="Hello World!!!"
      ^-- SC2039: In POSIX sh, 'local' is undefined.
    In base_file.sh line 4:
      echo $string
           ^-- SC2086: Double quote to prevent globbing and word splitting.
    

    这一行不写大多数时候我们运行脚本的时候也没有问题,但在使用shellcheck进行检查的时候,会提示

    ^-- SC2148: Tips depend on target shell and yours is unknown. Add a shebang.
    

    如果使用Intellij IDEA 也会提示


    shebang.jpg

    当你点击 Add shebangline 的时候它会自动添加 #!/usr/bin/env bash ,这也是为什么本shell规约推荐使用 #!/usr/bin/env bash 的原因之一

    shebang 维基百科

    在计算机科学中,Shebang(也称为 Hashbang )是一个由井号和叹号构成的字符序列 #! ,其出现在文本文件的第一行的前两个字符。 在文件中存在 Shebang 的情况下,类 Unix 操作系统的程序载入器会分析 Shebang 后的内容,将这些内容作为解释器指令,并调用该指令,并将载有 Shebang 的文件路径作为该解释器的参数[1]。

    例如,以指令#!/bin/sh开头的文件在执行时会实际调用 /bin/sh 程序(通常是 Bourne shell 或兼容的 shell,例如 bash、dash 等)来执行。这行内容也是 shell 脚本的标准起始行。

    关于注释

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

    关于缩进

    1. 使用两个空格进行缩进,不适用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}         #正确,不推荐
    

    注意: 单引号和双引号的区别
    单引号里的任何字符都会原样输出,单引号字符串中的变量是无效的,单引号字串中不能出现单引号(对单引号使用转义符后也不行)。
    双引号中的普通字符都会原样输出,单引号中的使用$取值的变量会替换成响应变量的真实值得,然后在进行输出,双引号中可以出现单引号

    1. 常量一定要定义成readonly,这种变量不能使用source跨shell使用
      比如一个a.sh 中定义了一个全局的变量 readonly TURE=0,b.sh 中在一开始使用 source a.sh 引入的a.sh 的内容,则在 b.sh 中无需重复定义 readonly local TRUE=0,否则会报错

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

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

    关于变量类型

    先在这儿声明一下,shell脚本并没有什么复杂的数据类型和数据结构,shell 的数据类型仅仅包括 字符串、数值。shell并没有像 布尔类型、长整型、短整形等Java中的基本数据类型,数值类型也没有明确的 float和 double 之分。shell比较复杂的数据类型就是数组和Map了,但Map这种数据类型或者说数据结构只有在高版本的shell解析器才拥有,像我们现在使用的Linux系统往往是不支持Map的。
    shell 中变量的基本类型就是 String 、数值(可以自己看做 Int、Double之类的) 、Boolean。Boolean 其实是 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 显示定义的函数为 privat 的函数, 仅供本脚本内部调用,注意这种privat是人为规定的,并不是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 函数,注意是不推荐不是不能。

    如何使用脚本单独调用函数中的某个函数

    #!/usr/bin/env bash
    # shellcheck disable=SC1091,SC2155
    readonly local TRUE=0 && readonly local FALSE=1
    # 脚本使用帮助文档
    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
    }
    ######################################################################
    # 主函数
    main(){
      (manual)
    }
    ######################################################################
    # 执行函数 [Any]<-(function_name:String,function_parameters:List<Any>)
    execute(){
      function_name=$1
      shift # 参数列表以空格为分割左移一位,相当于丢弃掉第一个参数
      function_parameters=$*
      (${function_name} "${function_parameters}")
    }
    case $1 in
      "-h" | "--help" | "?") (manual);;
      "") (main) ;;
      *) (execute "$@") ;;
    esac
    

    使用如上的框架,只需要在 两个 ###################################################################### 之间写函数,就可以使用 sh 脚本名称 脚本中的某个函数 脚本中的某个函数的入参 的形式调用函数了。
    使用 sh 脚本名称 ? 或者 sh 脚本名称 -h/--help 就可以查看这个脚本中的函数说明了。

    image
    1. 在函数内部首先使用有意义的变量名接受参数,然后在使用这些变量进行操作,禁止直接操作$1,$2 等,除非这些变量只用一次
    2. 函数的注释
      函数类型的概念是从函数编程语言中的概念偷过来的,shell函数的函数类型指的是函数的输入到函数的输入的映射关系
    # 主函数 []<-()                  <-------函数注释这样写
    function main(){
      local var="Hello World!!!"
      echo ${var}
    }
    # info级别的日志 []<-(msg:String)  <-------带入参的函数注释
    log_info(){
      echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')][$$]: [info] $*" >&2
    }
    

    main函数的函数类型是 []<-() , <- 左侧表的是函数的返回值类型 用[]包裹, 右侧是函数的参数类型 用()包裹,多个参数用 ',' 分隔,
    参数的描述是从 Scala 语言中偷过来, 先是参数名称 然后是参数类型 中间用:分隔

    对于main函数的注释来说, # 顶格写,后面紧跟一个空格,其实这样写是遵循的markdown的语法, 后面再跟一个空格,然后是 []<-(),代表这个函数没有入参也没有返回值,这个函数的目的就是执行这个这个函数中的命令,但我不关心这个函数的返回值。也就是利用函数的副作用来完成我们想要的操作。

    对于log_info 也是一样 不过 最后的函数类型是 []<-(msg:String) 代表入参是一个string类型的信息,然后也没有返回值。
    关于函数的返回值,我理解的函数的返回值有两种形式,一种是显示的return一种是隐式的echo

    以下是几种常见的写法

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

    关于返回值

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

    Last login: Sat Jan 20 17:39:16 on ttys000
    chenshang@chenshangMacBook-Pro:~$ pwd
    /Users/chenshang
    

    注意我说的是正常情况下,那异常情况下呢?输出的结果又是什么?输出的结果有可能五花八门!
    所以,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]之间的数值类型的时候使用显示 reuturn 返回结果
    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}
    }
    

    隐式echo

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

    # 将json字符串格式化树形结构 [String]<-(json_string:String)
    function json_format(){
      local json_string=$1
      echo "${json_string}"|jq . #jq是shell中处理json的一个工具
    }
    

    函数中所有的echo照理都应该输出到控制台上 例如

    json_format "{\"1\":\"one\"}"
    

    你会在控制台上看到如下输出

    {
      "1": "one"
    }
    

    但是一旦你用变量接收函数的返回值,这些本该输出到控制台的结果就都会存储到你定义的变量中 例如

    json=$(json_format "{\"1\":\"one\"}")
    echo "${json}" #如果没有这句,上面的语句执行完成后,不会在控制台有任何的输出
    

    我们把 json_format 改造一下

    # 将json字符串格式化树形结构 [String]<-(json_string:String)
    function json_format(){
      local json_string=$1
      echo "为格式化之前:${json_string}" #其实新添加的只一句只是用来记录一行日志的
      echo "${json_string}"|jq . #jq是shell中处理json的一个工具
    }
    

    echo "为格式化之前:${json_string}" 其实新添加的只一句只是用来记录一行日志的,但是json=$(json_format "{"1":"one"}")
    json 也会将这句话作为返回结果进行接收,但这是我不想要看到的。
    解决这个问题需要了解 父子shell的 概念
    shell中 子shell 可以理解成闭包,可以捕获父shell的变量,但不能改变父shell的变量,使用()将代码块包裹,包裹的代码块将在子shell中运行,子shell相当于独立的一个环境,其中的代码爱怎么玩儿怎么玩,不会影响到父shell的结果
    所以如果我不想让 echo "为格式化之前:${json_string}" 这句话也作为结果的话,我就只需要用()将代码块儿包裹即可

    # 将json字符串格式化树形结构 [String]<-(json_string:String)
    function json_format(){
      local json_string=$1
      (echo "为格式化之前:${json_string}") #其实新添加的只一句只是用来记录一行日志的
      echo "${json_string}"|jq . #jq是shell中处理json的一个工具
    }
    

    关于计算

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

    关于分支

    HEAD_KEYWORD parameters; BODY_BEGIN
      BODY_COMMANDS
    BODY_END
    
    • 将HEAD_KEYWORD和初始化命令或者参数放在第一行;
    • 将BODY_BEGIN同样放在第一行;
    • 复合命令中的BODY部分以2个空格缩进;
    • BODY_END部分独立一行放在最后;
    1. if
      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 写在一行
    1. while
    while [[ condition ]]; do
      # statements
    done
    
    while read -r item ;do
      # statements
    done < 'file_name'
    
    1. until
    until [[ condition ]]; do
      # statements
    done
    
    1. for
    for (( i = 0; i < 10; i++ )); do
      # statements
    done
    
    for item in ${array}; do
      # statements
    done
    
    1. case
    case word in
      pattern )
        #statements
        ;;
        *)
        #statements
        ;;
    esac
    

    未完待续

    相关文章

      网友评论

      • 王诗翔:你的终端是神马字体呀?
        MrRobot:就默认的字体,没有修改,只是字号调大了

      本文标题:Shell编程规范

      本文链接:https://www.haomeiwen.com/subject/aajvxftx.html