Shell Style Guide

时间:2022-07-24
本文章向大家介绍Shell Style Guide,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

基础

脚本结构

  • 脚本的基本结构应该遵循以下格式:
#!SHEBANG

CONFIGURATION_VARIABLES

FUNCTION_DEFINITIONS

MAIN_CODE

shebang

文件中标明解释器的命令叫shebang,该字串以#!开头,并放于文件的第一行开头,操作系统的加载程序在执行时可以使用这一行来加载此文件的解释器,使其成为一个自可执行的脚本。

  • 使用 Bash 作为唯一的shell脚本shebang

正例:

#!/bin/bash
echo

反例:

#!/bin/sh
echo

空白字符

  • 除了在行结束使用换行符,空格是源文件中唯一允许出现的空白字符。
  • 字符串中的非空格空白字符,使用转义字符
  • 不应在行尾出现没有意义的空白字符
  • function函数前后用空行隔开
  • 不允许行前使用tab缩进,如果使用tab缩进,必须设置1个tab为4个空格,vim中相关设置:
set autoindent
set smartindent     "indent when
set tabstop=4       "tab width
set softtabstop=4   "backspace
set shiftwidth=4    "indent width
set expandtab       "expand tab to space
set shiftround

单行长度

  • 每行最多不超过120个字符。每行代码最大长度限制的根本原因是过长的行会导致阅读障碍,使得缩进失效。
  • 除了以下两种情况例外:
    • 导入模块语句
    • 注释中包含的URL
    • 如出现长度必须超过120个字符的字符串,应尽量使用here document或者嵌入的换行符等合适的方法使其变短。

示例:

# DO use 'here document's
cat <<END;
I am an exceptionally long
string.
END

# Embedded newlines are ok too
long_string="I am an exceptionally
  long string."

命名

文件名

  • 文件名要求全部小写, 可以包含下划线 _ 或连字符 -, 建议统一使用下划线。

函数名

  • 使用小写字母,并用下划线分隔单词。
  • 函数命名要注意可读性,做到见名知功能。
  • 不要使用命令名做函数名,如test
  • 函数名之后必须有圆括号(函数名和圆括号之间没有空格)。
  • 左大括号在函数名后另起一行,不与函数名放于同一行(适配于匿名函数)。
  • 当函数名后存在 () 时,关键词 function 是多余的,但是建议保留 function 的写法,使函数整洁明了。 正例:
# Single function
function my_func()
{
    ...
}

变量名

  • 规则同函数名一致。
  • 如果使用大写字母,注意避免保留字,如PATHHOMEIFS等。
  • 如果使用大写字母,可以添加唯一的前缀,如MY_
  • 变量使用前最好做初始化动作,以防环境中相同名称变量被脚本内引用,出现不可预知情况。
  • 循环中的变量名应该和正在被循环的变量名保持相似的名称。 示例:
for zone in ${zones}; do
    something_with "${zone}"
done

常量和环境变量名

  • 全部大写,用下划线分隔,声明在文件的顶部。
  • 常量和任何导出到环境中的变量都应该大写。示例:
# Constant
readonly PATH_TO_FILES='/some/path'

# Both constant and environment
declare -xr BACKUP_SID='PROD'
  • 有些情况下首次初始化及常量(例如,通过getopts),因此,在getopts中或基于条件来设定常量是可以的,但之后应该立即设置其为只读。 值得注意的是,在函数中使用 declare 对全局变量无效,所以推荐使用 readonlyexport 来代替。 示例:
VERBOSE='false'
while getopts 'v' flag; do
  case "${flag}" in
    v) VERBOSE='true' ;;
  esac
done
readonly VERBOSE

只读变量

  • 使用 readonly 或者 declare -r 来确保变量只读。 因为全局变量在shell中广泛使用,所以在使用它们的过程中捕获错误是很重要的。当你声明了一个变量,希望其只读,那么请明确指出。

示例:

zip_version="$(dpkg --status zip | grep Version: | cut -d ' ' -f 2)"
if [[ -z "${zip_version}" ]]; then
    error_message
else
    readonly zip_version
fi

局部变量

  • 每次只声明一个变量,不要使用组合声明,比如a=1 b=2;
  • 使用 local 声明特定功能的变量。声明和赋值应该在不同行。
  • 必须使用 local 来声明局部变量,以确保其只在函数内部和子函数中可见。这样可以避免污染全局名称空间以及避免无意中设置可能在函数外部具有重要意义的变量。
  • 当使用命令替换进行赋值时,变量声明和赋值必须分开。因为内建的 local 不会从命令替换中传递退出码。

正例:

my_func2()
{
    local name="$1"
    # 命令替换赋值,变量声明和赋值需放到不同行:
    local my_var
    my_var="$(my_func)" || return
    ...
}

反例:

my_func2() {
    # 禁止以下写法: $? 将获取到'local'指令的返回值, 而非 my_func
    local my_var="$(my_func)"
    [[ $? -eq 0 ]] || return

    ...
}

缩进

块缩进

  • 每当开始一个新的块,缩进增加4个空格(不能使用t字符来缩进)。当块结束时,缩进返回先前的缩进级别。缩进级别适用于代码和注释。
main()
{
    # 缩进4个空格
    say="hello"
    flag=0
    if [[ $flag = 0 ]]; then
        # 缩进4个空格
        echo "$say"
    fi
}

管道

  • 如果一行容不下整个管道操作,那么请将整个管道操作分割成每行一个管段。
  • 如果一行容得下整个管道操作,那么请将整个管道操作写在同一行,管道左右应有空格。
  • 否则,应该将整个管道操作分割成每行一段,管道操作的下一部分应该将管道符放在新行并且缩进4个空格。这适用于管道符 | 以及逻辑运算 || 和 && 。

正例:

# 单行管道连接,管道左右空格
command1 | command2

# 长命令管道换行连接,管道放置于下一个命令开头,缩进4个空格
command1 
    | command2 
    | command3 
    | command4

反例:

# 管道左右无空格
command1|command2

# 换行连接管道放置于行末
command1 | 
    command2 | 
    command3 | 
    command4
  • 当在grep中使用|时需要注意此处|左右不可随意添加空格。如,搜索aaa2bccc2b

正例:

echo "aaa2bccc" | grep -Eo "2|b"

反例:

echo "aaa2bccc" | grep -Eo "2 | b"

分组命令

  • Bash提供了两种方法来将要执行的命令列表分组为一个单元。当命令分组时,可以对整个命令列表应用重定向。例如,列表中所有命令的输出可以重定向到单个流。
    • ( list ) 在圆括号之间放置一列命令将创建一个子shell环境,列表中的每个命令将在该子shell中执行。由于该列表是在子shell中执行的,所以在子shell完成后,变量分配将不再有效。
    • { list; } 在花括号之间放置一个命令列表将导致该列表在当前shell上下文中执行。不创建子shell。必须在列表后面使用分号(或换行符)。当花括号与list在同一行时,必须使用分号和空格隔开。

循环

  • ; do , ; thenwhile , for , if ,elif 放在同一行。另 else 应该单独一行。
  • 结束语句应该单独一行且跟开始语句缩进对齐。

正例:

function clean_up()
{
    for dir in ${dirs_to_cleanup}; do
        if [ -d "${dir}/${BACKUP_SID}" ]; then
            log_date "Cleaning up old files in ${dir}/${BACKUP_SID}"
            rm "${dir}/${BACKUP_SID}/"*
            if [ "$?" -ne 0 ]; then
                error_message
            fi
        else
            mkdir -p "${dir}/${BACKUP_SID}"
            if [ "$?" -ne 0 ]; then
                error_message
            fi
        fi
    done
}

反例:

function getBatchName()
{
batch_name="batch"
if [[ "$input5"x == *$batch_name* ]]
then
batch_name=$input5
else if [[ "$input6"x == *$batch_name* ]]
then
batch_name=$input6
else if [[ "$input7"x == *$batch_name* ]]
then
batch_name=$input7
fi
fi
fi
}

case语句

  • 通过4个空格缩进可选项。
  • 可选项中的多个命令应该被拆分成多行,模式表达式、操作和结束符 ;; 在不同的行。
  • 匹配表达式比 case 和 esac 缩进一级。多行操作要再缩进一级。
  • 模式表达式前面不应该出现左括号。避免使用 ;&;;& 符号。

示例:

case "${expression}" in
    a)
        variable="..."
        some_command "${variable}" "${other_expr}" ...
        ;;
    absolute)
        actions="relative"
        another_command "${actions}" "${other_expr}" ...
        ;;
    *)
        error "Unexpected expression '${expression}'"
        ;;
esac
  • 只要整个表达式可读,简单的单行命令可以跟模式和 ;; 写在同一行。当单行容不下操作时,请使用多行的写法。

单行示例:

verbose='false'
aflag=''
bflag=''
files=''
while getopts 'abf:v' flag; do
    case "${flag}" in
        a) aflag='true' ;;
        b) bflag='true' ;;
        f) files="${OPTARG}" ;;
        v) verbose='true' ;;
        *) error "Unexpected option ${flag}" ;;
    esac
done

函数位置

  • 将文件中所有的函数统一放在常量下面。不要在函数之间隐藏可执行代码。
  • 如果函数,请将他们统一放在文件头部。
  • 只有sourceset 声明和常量设置在函数声明之前完成。
  • 不要在函数之间隐藏可执行代码。如果那样做,会使得代码在调试时难以跟踪并出现意想不到的结果。

主函数main

  • 对于包含至少了一个其他函数的足够长的脚本,建议定义一个名为 main 的函数。对于功能简单的短脚本, main函数是没有必要的。
  • 为了方便查找程序的入口位置,将主程序放入一个名为 main 的函数中,作为最底部的函数。这使其和代码库的其余部分保持一致性,同时允许你定义更多变量为局部变量(如果主代码不是一个函数就不支持这种做法)。 文件中最后的非注释行应该是对 main 函数的调用:
main "$@"

注释

  • 代码注释的基本原则:
    • 注释应能使代码更加明确
    • 避免注释部分的过度修饰
    • 保持注释部分简单、明确
    • 在编码以前就应开始写注释
    • 注释应说明设计思路而不是描述代码的行为
    • 注释与其周围的代码在同一缩进级别,#号与注释文本间需保持一个空格以和注释代码进行区分。

文件头

  • 每个文件的开头是其文件内容的描述。除版权声明外,每个文件必须包含一个顶层注释,对其功能进行简要概述。

例如:

#!/bin/bash
#
# Perform hot backups of databases.

功能注释

  • 主体脚本中除简洁明了的函数外都必须带有注释。库文件中所有函数无论其长短和复杂性都必须带有注释。 这使得其他人通过阅读注释即可学会如何使用你的程序或库函数,而不需要阅读代码。
  • 所有的函数注释应该包含:
    • 函数的描述
    • 全局变量的使用和修改
    • 使用的参数说明
    • 返回值,而不是上一条命令运行后默认的退出状态

例如:

#!/bin/bash
#
# Perform hot backups of databases.

export PATH='/usr/sbin/bin:/usr/bin:/usr/local/bin'

#######################################
# Cleanup files from the backup dir
# Globals:
#   BACKUP_DIR
#   BACKUP_SID
# Arguments:
#   None
# Returns:
#   None
#######################################
cleanup() {
    ...
}

实现部分的注释

  • 注释你代码中含有技巧、不明显、有趣的或者重要的部分。 这部分遵循代码注释的基本原则即可。不要注释所有代码。如果有一个复杂的不易理解的逻辑,请进行简单的注释。

TODO注释

  • 对那些临时的, 短期的解决方案, 或已经够好但仍不完美的代码使用 TODO 注释。 TODO 注释要使用全大写的字符串 TODO, 在随后的圆括号里写上你的名字,邮件地址, bug ID, 或其它身份标识和与这一 TODO 相关的 issue。 主要目的是让添加注释的人 (也是可以请求提供更多细节的人) 可根据规范的TODO 格式进行查找。 添加 TODO 注释并不意味着你要自己来修正,因此当你加上带有姓名的 TODO 时, 一般都是写上自己的名字。 这与C++ Style Guide中的约定相一致。

例如:

# TODO(mrmonkey): Handle the unlikely edge cases (bug ####)
# TODO(--bug=123456): remove the "Last visitors" feature

异常与日志

异常

  • 使用shell返回值来返回异常,并根据不同的异常情况返回不同的值。
  • 日志
  • 所有的错误信息都应被导向到STDERR,这样将有利于出现问题时快速区分正常输出和异常输出。

建议使用与以下函数类似的方式来打印正常和异常输出:

function err() {
    echo "[$(date +'%FT%T%z')]: $@" >&2
}

if ! do_something; then
    err "Unable to do_something"
    exit "${E_DID_NOTHING}"
fi

编程实践

变量扩展

  • 通常情况下推荐为变量加上大括号如 "
{var}" 而不是 "

var" ,但具体也要视情况而定。

  • 以下按照优先顺序列出建议:
    • 与现有代码保持一致
    • 单字符变量在特定情况下才需要被括起来
    • 使用引号引用变量,参考下一节:变量引用

正例:

# 位置变量和特殊变量,可以不用大括号:
echo "Positional: $1" "$5" "$3"
# $! 最后运行的后台Process的PID
# $- 使用Set命令设定的Flag一览
# $_ 在前台执行的前一个命令的最后一个参数
echo "Specials: !=$!, -=$-, _=$_. ?=$?, #=$# *=$* @=$@ $=$$ ..."

# 当位置变量大于等于10,则必须有大括号:
echo "many parameters: ${10}"

# 当出现歧义时,必须有大括号:
# Output is "a0b0c0"
set -- a b c
echo "${1}0${2}0${3}0"

# 使用变量扩展赋值时,必须有大括号:
DEFAULT_MEM=${DEFUALT_MEM:-"-Xms2g -Xmx2g -XX:MaxDirectMemorySize=4g"}

# 其他常规变量的推荐处理方式:
echo "PATH=${PATH}, PWD=${PWD}, mine=${some_var}"
while read f; do
    echo "file=${f}"
done < <(ls -l /tmp)
反例:

# 无引号, 无大括号, 特殊变量,单字符变量
echo a=$avar "b=$bvar" "PID=${$}" "${1}"

# 无大括号产生歧义场景:以下会被解析为 "${1}0${2}0${3}0",
# 而非 "${10}${20}${30}
set -- a b c
echo "$10$20$30"

变量引用

  • 变量引用通常情况下应遵循以下原则:
    • 默认情况下推荐使用引号引用包含变量、命令替换符、空格或shell元字符的字符串
    • 在有明确要求必须使用无引号扩展的情况下,可不用引号
    • 字符串为单词类型时才推荐用引号,而非命令选项或者路径名
    • 不要对整数使用引号
    • 特别注意 [[ 中模式匹配的引号规则
    • 在无特殊情况下,推荐使用
    @ 而非

    *

# '单引号' 表示禁用变量替换
# "双引号" 表示需要变量替换

# 示例1: 命令替换需使用双引号
flag="$(some_command and its args "$@" 'quoted separately')"

# 示例2:常规变量需使用双引号
echo "${flag}"

# 示例3:整数不使用引号
value=32
# 示例4:即便命令替换输出为整数,也需要使用引号
number="$(generate_number)"

# 示例5:单词可以使用引号,但不作强制要求
readonly USE_INTEGER='true'

# 示例6:输出特殊符号使用单引号或转义
echo 'Hello stranger, and well met. Earn lots of $$$'
echo "Process $$: Done making $$$."

# 示例7:命令参数及路径不需要引号
grep -li Hugo /dev/null "$1"

# 示例8:常规变量用双引号,ccs可能为空的特殊情况可不用引号
git send-email --to "${reviewers}" ${ccs:+"--cc" "${ccs}"}

# 示例9:正则用单引号,$1可能为空的特殊情况可不用引号
grep -cP '([Ss]pecial||?characters*)$' ${1:+"$1"}

# 示例10:位置参数传递推荐带引号的"$@",所有参数作为单字符串传递用带引号的"$*"
# content of t.sh
func_t() {
    echo num: $#
    echo args: 1:$1 2:$2 3:$3
}

func_t "$@"
func_t "$*"
# 当执行 ./t.sh a b c 时输出如下:
num: 3
args: 1:a 2:b 3:c
num: 1
args: 1:a b c 2: 3:

# 示例11:如果解析变量作为一个列表,则不能使用引号
list="one two three"

# you MUST NOT quote $list here
for word in $list; do
  ...
done

命令替换

  • 使用 (command) 而不是反引号。因反引号如果要嵌套则要求用反斜杠转义内部的反引号。而 (command) 形式的嵌套无需转义,且可读性更高。

正例:

var="$(command "$(command1)")"

反例:

var="`command `command1``"  

条件测试

  • 使用 [[ ... ]] ,而不是 [ , test , 和 /usr/bin/[ 。 因为在 [[]] 之间不会出现路径扩展或单词切分,所以使用 [[ ... ]] 能够减少犯错。且 [[ ... ]] 支持正则表达式匹配,而 [ ... ] 不支持。
# 示例1:正则匹配,注意右侧没有引号
# 详尽细节参考:http://tiswww.case.edu/php/chet/bash/FAQ 中E14部分
if [[ "filename" =~ ^[[:alnum:]]+name ]]; then
    echo "Match"
fi

# 示例2:严格匹配字符串"f*"(本例为不匹配)
if [[ "filename" == "f*" ]]; then
    echo "Match"
fi

# 示例3:[]中右侧不加引号将出现路径扩展,如果当前目录下有f开头的多个文件将报错[: too many arguments
if [ "filename" == f* ]; then
    echo "Match"
fi

字符串测试

  • 尽可能使用变量引用,而非字符串过滤。 Bash可以很好的处理空字符串测试,请使用空/非空字符串测试方法,而不是过滤字符,让代码具有更高的可读性。

正例:

if [[ "${my_var}" = "some_string" ]]; then
    do_something
fi

反例:

if [[ "${my_var}X" = "some_stringX" ]]; then
    do_something
fi

正例:

# 使用-z测试字符串为空
if [[ -z "${my_var}" ]]; then
    do_something
fi

反例:

# 使用空引号测试空字符串,能用但不推荐
if [[ "${my_var}" = "" ]]; then
    do_something
fi

正例:

# 使用-n测试非空字符串
if [[ -n "${my_var}" ]]; then
    do_something
fi

反例:

# 测试字符串非空,能用但不推荐
if [[ "${my_var}" ]]; then
    do_something
fi

文件名扩展

  • 当进行文件名的通配符扩展时,请指定明确的路径。 当目录中有特殊文件名如以 - 开头的文件时,使用带路径的扩展通配符 ./* 比不带路径的 * 要安全很多。
# 例如目录下有以下4个文件和子目录:
# -f  -r  somedir  somefile

# 未指定路径的通配符扩展会把-r和-f当作rm的参数,强制删除文件:
psa@bilby$ rm -v *
removed directory: `somedir'
removed `somefile'

# 而指定了路径的则不会:
psa@bilby$ rm -v ./*
removed `./-f'
removed `./-r'
rm: cannot remove `./somedir': Is a directory
removed `./somefile'

慎用eval

  • 应该避免使用eval。 Eval在用于分配变量时会修改输入内容,但设置变量的同时并不能检查这些变量是什么。 反例:
# 以下设置的内容及成功与否并不明确
eval $(set_my_variables)

慎用管道连接while循环

  • 使用进程替换或者for循环,而不是通过管道连接while循环。 这是因为在管道之后的while循环中,命令是在一个子shell中运行的,因此对变量的修改是不能传递给父shell的。 这种管道连接while循环中的隐式子shell使得bug定位非常困难。 反例:
last_line='NULL'
your_command | while read line; do
    last_line="${line}"
done
# 以下会输出'NULL':
echo "${last_line}"
  • 如果你确定输入中不包含空格或者其他特殊符号(通常不是来自用户输入),则可以用for循环代替。

例如:

total=0
# 仅当返回结果中无空格等特殊符号时以下可正常执行:
for value in $(command); do
    total+="${value}"
done
  • 使用进程替换可实现重定向输出,但是请将命令放入显式子shell,而非while循环创建的隐式子shell。

例如:

total=0
last_file=
# 注意两个<之间有空格,第一个为重定向,第二个<()为进程替换
while read count filename; do
    total+="${count}"
    last_file="${filename}"
done < <(your_command | uniq -c)

echo "Total = ${total}"
echo "Last one = ${last_file}"

检查返回值

  • 总是检查返回值,且提供有用的返回值。 对于非管道命令,使用 $? 或直接通过 if 语句来检查以保持其简洁。

例如:

# 使用if语句判断执行结果
if ! mv "${file_list}" "${dest_dir}/" ; then
    echo "Unable to move ${file_list} to ${dest_dir}" >&2
    exit "${E_BAD_MOVE}"
fi

# 或者使用$?
mv "${file_list}" "${dest_dir}/"
if [[ $? -ne 0 ]]; then
    echo "Unable to move ${file_list} to ${dest_dir}" >&2
    exit "${E_BAD_MOVE}"
fi

内建命令和外部命令

  • 当内建命令可以完成相同的任务时,在shell内建命令和调用外部命令之间,应尽量选择内建命令。 因内建命令相比外部命令而言会产生更少的依赖,且多数情况调用内建命令比调用外部命令可以获得更好的性能(通常外部命令会产生额外的进程开销)。

正例:

# 使用内建的算术扩展
addition=$((${X} + ${Y}))
# 使用内建的字符串替换
substitution="${string/#foo/bar}"

反例:

# 调用外部命令进行简单的计算
addition="$(expr ${X} + ${Y})"
# 调用外部命令进行简单的字符串替换
substitution="$(echo "${string}" | sed -e 's/^foo/bar/')"

文件加载

  • 加载外部库文件不建议用使用.,建议使用source,以提升可阅读性。

正例:

source my_libs.sh

反例:

. my_libs.sh

内容过滤与统计

  • 除非必要情况,尽量使用单个命令及其参数组合来完成一项任务,而非多个命令加上管道的不必要组合。 常见的不建议的用法例如:cat和grep连用过滤字符串; cat和wc连用统计行数; grep和wc连用统计行数等。

正例:

grep net.ipv4 /etc/sysctl.conf
grep -c net.ipv4 /etc/sysctl.conf
wc -l /etc/sysctl.conf

反例:

cat /etc/sysctl.conf | grep net.ipv4
grep net.ipv4 /etc/sysctl.conf | wc -l
cat /etc/sysctl.conf | wc -l

正确使用返回与退出

  • 除特殊情况外,几乎所有函数都不应该使用exit直接退出脚本,而应该使用return进行返回,以便后续逻辑中可以对错误进行处理。

正例:

# 当函数返回后可以继续执行cleanup
my_func() {
    [[ -e /dummy ]] || return 1
}

cleanup() {
    ...
}

my_func
cleanup

反例:

# 当函数退出时,cleanup将不会被执行
my_func() {
    [[ -e /dummy ]] || exit 1
}

cleanup() {
    ...
}

my_func
cleanup

参考链接

  • https://google.github.io/styleguide/shellguide.html
  • https://wiki.bash-hackers.org/scripting/style
  • https://www.gnu.org/software/bash/manual/bash.html#Special-Parameters
  • http://itxx00.github.io/blog/2020/01/03/shell-standards/