Google Shell 代码规范

编写 Shell 脚本也有一段时间了,不过大体上,没有遵循什么 Shell 编码规范。为此,完整地阅读了一下 《Google Style Guide》,并据此做了相应的记录。

Shell 编码规范

版本:2.02

背景

使用哪种 Shell

约定,使用 Bash 作为唯一的 Shell 脚本语言。

Shell 脚本文件必须以 #!/bin/bash 开头。同时,仅通过 set 设置 Shell 选项,以便使用 bash script_name 命令调用脚本时,不会产生其他影响。

限制只能使用 Bash 编写 Shell 脚本,这样就可以在所有主机上统一安装 Bash。

不过,无论在编写什么样的脚本,有一种情况始终是无法约束的。比如:Solaris SVR4 包要求必须使用纯 Bourne Shell 编写脚本。

何时使用 Shell

Shell 应该只用于开发:小型实用程序、封装简单的脚本。

虽然 Shell 脚本语言不是一种开发语言,但其可以用于编写各种实用程序脚本。本规范,更多的是对 Shell 脚本使用的认可,但不建议将其广泛应用于开发。

使用 Shell 的一些准则:

  • 如果大部分情况下都是在调用其他实用程序并且数据操作相对较少,那么使用 Shell 是可以接受的。
  • 如果比较注重性能问题,那就不要使用 Shell。
  • 如果编写的脚本长度超过 100 行或使用非直接控制流逻辑,则应使用结构化语言进行重构(记住,脚本会不断增长,越早重构越好)。
  • 评估代码复杂度时,请考虑代码是否易于他人维护。

Shell 文件与解释器调用

文件扩展名

可执行文件应该没有扩展名(强烈推荐)或者只能有 .sh 扩展名。库文件则必须具有 .sh 扩展名,并且无可执行权限。

在执行程序时,不需要知道程序是用哪种语言编写的,并且 Shell 也不需要依赖扩展,因此,我们不希望对可执行程序使用一种语言。

然而,对于库来讲,了解其编写语言是很重要的,有时,需要使用不同的语言来开发一些相似的库。允许存在功能一样但语言不同的库文件(其后缀是不同语言后缀)。

SUID/SGID

在 Shell 脚本中,禁止使用 SUID、SGID。

因为 Shell 有太多的安全问题,导致基本无法完全安全地使用 SUID/SGID。因此,明确弃用 SUID/SGID。

如需提升权限,请使用 sudo 运行脚本。

环境

STDOUT vs STDERR

所有错误信息应该要重定向到 STDERR。这样可以更轻松地将正常状态与实际问题区分开。

建议使用以下函数来打印错误消息以及其他状态信息。

err() {
  echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2
}

if ! do_something; then
  err "Unable to do_something"
  exit 1
fi

注释

文件头部

在每个文件头部,为其添加内容描述。

每个文件都必须具有一个最高级别的注释,该注释包含:内容概要、版权申明(可选)、作者信息(可选)。

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

函数注释

既不明显也不简短的函数必须添加注释。库文件中所有函数都必须添加注释。

确保他人尽可能只阅读注释就会使用程序或者库函数,而不需要完整阅读代码。

所有函数注释都应包含以下内容:

  • 函数描述。
  • Globals:全局变量列表。
  • Arguments:参数列表。
  • Output:输出到 STDOUT 的内容、输出到 STDERR 的内容。
  • Returns:返回值(不是指最后一个命令的退出状态)。

例如:

#######################################
# 清理备份目录
# Globals:
#   BACKUP_DIR
#   ORACLE_SID
# Arguments:
#   None
#######################################
function cleanup() {
  …
}

#######################################
# 获取配置目录
# Globals:
#   SOMEDIR
# Arguments:
#   None
# Outputs:
#   Writes location to stdout
#######################################
function get_dir() {
  echo "${SOMEDIR}"
}

#######################################
# 使用复杂的方式删除文件
# Arguments:
#   File to delete, a path.
# Returns:
#   0 if thing was deleted, non-zero on error.
#######################################
function del_thing() {
  rm "$1"
}

实现注释

对复杂的、不清晰的、有趣的、重要的代码,进行注释。

TODO 注释

对临时的、短期的解决方案、足够好但不完美的代码,进行 TODO 注释。

TODO 注释包含一个全大写字符串 TODO,接着是相关人员名称、邮件等。一般都是写发起人的名称。

# TODO(mrmonkey): Handle the unlikely edge cases (bug ####)

格式化

缩进

缩进:使用两个空格,不使用制表符(TAB 按键)。

在代码块之间,使用空行以提高可读性。

行长度与长字符串

最大行长度为 80 个字符。

如果必须编写长度超过 80 个字符的字符串,则应使用 heredoc 或嵌入换行符来实现。

字面量字符串长度必须超过 80 个字符并且无法清晰地拆分成多行,那么放在一行也是可以的,但是强烈建议使用任意方法来缩短长度。

# heredoc
cat <<END
I am an exceptionally long
string.
END

# 内嵌换行符
long_string="I am an exceptionally
long string."

管道

如果不能将所有管道放在同一行中,则每个管道都需要换行。换行符添加在管道符之前,并缩进两个空格。

# 将所有管道放在一行
command1 | command2

# 每个管道都换行
command1 \
  | command2 \
  | command3 \
  | command4

循环语句

; do; then 放在 whileforif 同一行中。

# 如果是在函数体内,考虑使用 local 定义 dir 变量,避免修改了全局变量:
# local dir
for dir in "${dirs_to_cleanup[@]}"; do
  if [[ -d "${dir}/${ORACLE_SID}" ]]; then
    log_date "Cleaning up old files in ${dir}/${ORACLE_SID}"
    rm "${dir}/${ORACLE_SID}/"*
    if (( $? != 0 )); then
      error_message
    fi
  else
    mkdir -p "${dir}/${ORACLE_SID}"
    if (( $? != 0 )); then
      error_message
    fi
  fi
done

Case 语句

  • 对分支缩进 2 个空格。
  • 单行分支,) 之后,;; 之前,都需要添加一个空格。
  • 长命令、多行命令需要将匹配表达式、动作、;; 分开为多行。

case esac 中匹配表达式(即 ))直接缩进一级。
多行动作再缩进一级。
通常,不需要再对 case 匹配表达式使用双引号。
避免使用 ;&;;& 符号。

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

变量扩展

使用双引号界定变量,并推荐 "${var}" 而不是 "$var"。

以下规范为推荐,而不是强制:

  • 与现有代码风格保持一致。
  • 使用双引号包裹变量。
  • 不要使用 {} 界定特殊参数、位置参数,除非强制要求,否则应避免此类情况。

最好使用 {} 界定其他变量。

# 示例

# 特殊变量的首选样式:
echo "Positional: $1" "$5" "$3"
echo "Specials: !=$!, -=$-, _=$_. ?=$?, #=$# *=$* @=$@ \$=$$ …"

# 必要的大括号:
echo "many parameters: ${10}"

# 使用大括号避免代码混淆:
# 输出 "a0b0c0"
set -- a b c
echo "${1}0${2}0${3}0"

# 其他变量的首选样式:
echo "PATH=${PATH}, PWD=${PWD}, mine=${some_var}"
while read -r f; do
  echo "file=${f}"
done < <(find /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"
注意:对变量使用大括号,如:${var},产生的结果是无引号格式。如果需要引号,则双引号必须明确添加。

引号

  • 始终对(包含:变量、命令替换、空格、Shell 元字符的)字符串使用双引号。
  • 使用数组来安全地引用列表元素,尤其是命令行选项。
  • 推荐,对 Shell 内部只读特殊变量:$?$#$$$! 使用双引号。对 Shell 内置变量使用双引号,例如:PPID 等,以确保不会错解析成别的变量。
  • 引号包裹的字符串最好是单词
  • 不要对数字字面量使用引号。
  • 注意对 [[ … ]] 中匹配规则使用引号。
  • 如无特殊情况,使用 $@ 代替 $*,例如:将参数附加到消息或日志中。
# '单'引号代表不需要替换变量。
# "双"引号代表需要/允许替换变量。

# 简单示例

# "双引号命令替换"
# 注意:嵌套在 $() 内的引号不需要转义
flag="$(some_command and its args "$@" 'quoted separately')"

# "双引号变量"
echo "${flag}"

# 将双引号数组扩展用于列表。
declare -a FLAGS
FLAGS=( --foo --bar='baz' )
readonly FLAGS
mybinary "${FLAGS[@]}"

# Shell 内置变量不使用引号也是可以的。
if (( $# > 3 )); then
  echo "ppid=${PPID}"
fi

# "整数字面量不使用引号"
value=32
# "双引号命令替换",即使结果值为整数
number="$(generate_number)"

# "推荐对单词使用引号"
readonly USE_INTEGER='true'

# "对 Shell 元字符使用引号"
echo 'Hello stranger, and well met. Earn lots of $$$'
echo "Process $$: Done making \$\$\$."

# "双引号命令选项或者路径名称"
# (这里假设 $1 包含一个值)
grep -li Hugo /dev/null "$1"

# 少量简单例子
# "双引号变量,除非变量为 false":css 可能为空字符串
git send-email --to "${reviewers}" ${ccs:+"--cc" "${ccs}"}

# 位置参数注意事项:$1 可能未设置
# 单引号让正则表达式不进行任何转义
grep -cP '([Ss]pecial|\|?characters*)$' ${1:+"$1"}

# 对于参数传递,
# 使用 "$@" 是对的
# 使用 $* 可能是错的:
#
# * $*、$@ 将使用空格分隔传递的参数,破坏原先就带空格的参数,同时丢弃空字符串参数。
#
# * "$@" 会按原样保留参数,没有提供参数则不传递任何参数;
#    通常,参数传递使用 "$@" 总是对的。
#
# * "$*" 会扩展为一个参数(并使用空格分隔所有参数),
#   如果没有提供任何参数,则返回一个空字符串。

(set -- 1 "2 two" "3 three tres"; echo $#; set -- "$*"; echo "$#, $@")
(set -- 1 "2 two" "3 three tres"; echo $#; set -- "$@"; echo "$#, $@")

功能与 BUG

ShellCheck

使用 ShellCheck 检查 Shell 脚本中常见的错误和警告。

命令替换

使用 $(command) 代替 `command`。

# 正例
var="$(command "$(command1)")"

# 反例
var="`command \`command1\``"

Test、[ ... ] 与 [[ ... ]]

建议使用 [[ ... ]],而不是 [ ... ]test/usr/bin/[

因为 [[ ... ]] 能够减少不必要的错误,比如:[[ ... ]] 没有路径名扩展、单词分割;[[ ... ]] 允许正则表达式匹配,而 [ ... ] 不允许。

# 确保左侧的字符串由:字母+name 组成。
# 注意,这里的 RHS 表达式不能使用引号。
if [[ "filename" =~ ^[[:alnum:]]+name ]]; then
  echo "Match"
fi

# 明确匹配条件为:等于 "f*" 字符串(此处,结果为不匹配)
if [[ "filename" == "f*" ]]; then
  echo "Match"
fi
# f* 会扩展为当前目录文件列表,因此会提示错误:"too many arguments"。
if [ "filename" == f* ]; then
  echo "Match"
fi

测试字符串

对于空字符串测试,Bash 足够灵活。因此,为了让代码更易于维护,不应该通过填充字符来判断字符串是否为空。

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

# -z(字符串长度为 0)
# -n (字符串长度不为 0)
# 推荐用此来测试字符串是否为空
if [[ -z "${my_var}" ]]; then
  do_something
fi

# 这样也可以判断空字符串,但不是首选的:
if [[ "${my_var}" == "" ]]; then
  do_something
fi
# 反例
if [[ "${my_var}X" == "some_stringX" ]]; then
  do_something
fi

避免混淆,明确使用 -z-n

# 正例
if [[ -n "${my_var}" ]]; then
  do_something
fi
# 反例
if [[ "${my_var}" ]]; then
  do_something
fi

明确使用 == 而不是 = 来判断相等(虽然它们都可以用来判断相等)。
鼓励使用 [[,在比较数值的时候,使用 -lt-gt 操作符配合 [[
如果使用 <> 操作符来比较数值,则应该使用 (( ... )) 语句。

# 正例
if [[ "${my_var}" == "val" ]]; then
  do_something
fi

if (( my_var > 3 )); then
  do_something
fi

if [[ "${my_var}" -gt 3 ]]; then
  do_something
fi
# 反例
if [[ "${my_var}" = "val" ]]; then
  do_something
fi

# 这样可能会变成比较 ASCII 值
if [[ "${my_var}" > 3 ]]; then
  # my_var=4 时,为 true
  # my_var=22 时,为 false
  do_something
fi

文件名的通配符扩展

在执行文件名的通配符扩展时,需要指定明确的路径。

由于文件名可以是 - 开头,因此使用 ./** 通配符更为安全。

# 示例目录内容:
# -f -r somedir somefile

# 这里,看起来是删除目录下的所有内容,但其实不是。
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 无法检测变量是否定义成功,导致无法确定成功定义了哪些变量。

# 是否设置成功?部分成功还是全部成功?
eval $(set_my_variables)

# 如果其中一个返回值包含空格,那么会发生什么事情?
variable="$(eval some_function)"

数组

Bash 数组应该只用于储存列表元素,以免使调用变得复杂。数组适用于参数列表。

数组存储有序的字符串集合,并且可以安全地扩展为用于命令或循环的元素。

# 使用括号定义数组,并且可以使用 +=( ... ) 附加元素
declare -a flags
flags=(--foo --bar='baz')
flags+=(--greeting="Hello ${name}")
mybinary "${flags[@]}"
# 请勿在序列中使用字符串。
flags='--foo --bar=baz'
flags+=' --greeting="Hello world"'  # This won’t work as intended.
mybinary ${flags}
# 命令扩展返回单个字符串,而不是数组。
# 在赋值数组时,避免对数组无引用扩展,因为,当命令扩展返回包含特殊字符时,无法正常处理赋值
declare -a files=($(ls /directory))
mybinary $(get_arguments)

数组优点

  • 使用数组可以避免转义引号,清晰地列出事物。
  • 数组可以安全地存储任何字符串序列/列表(包含:空格字符串)。

数组缺点

增加脚本复杂度。

数组使用场景

  • 数据用于安全地创建、传递列表。
  • 特别是,在构建一组命令参数时,使用数组可以避免引号混淆的问题。
  • 使用引用扩展 "${array[@]}" 访问数组。

Pipes to While

优先使用进程替换、内置 readarry(bash4+),而不是通过管道传递给 while。
管道会创建一个子 shell,因此管道内修改的变量都不会传递给父 shell。

管道传给 while 后,隐式子 shell 可能会引入难以跟踪的小错误。

last_line='NULL'
your_command | while read -r line; do
  if [[ -n "${line}" ]]; then
    last_line="${line}"
  fi
done

# 因为是子 shell 处理赋值,导致这里总是输出 'NULL'!
echo "${last_line}"

使用进程替换还会创建一个子 shell。但是,它允许从子 shell 重定向到 while,而无需将 while(或任何其他命令)放在子 shell 中。

last_line='NULL'
while read line; do
  if [[ -n "${line}" ]]; then
    last_line="${line}"
  fi
done < <(your_command)

# 将输出来自 your_command 中,最后非空行
echo "${last_line}"

另外,也可以使用内置的 readarray 将文件读入数组,然后遍历数组。

last_line='NULL'
readarray -t lines < <(your_command)
for line in "${lines[@]}"; do
  if [[ -n "${line}" ]]; then
    last_line="${line}"
  fi
done
echo "${last_line}"

算术

始终使用 (( ... ))$(( ... )) 而不是 let$[ ... ]expr

切勿使用 $[ ... ] 语法、expr 命令、let 内置函数。

不要在 [[ ... ]] 内,使用 <> 执行数值比较,这回导致 ASICII 比较。

避免将 (( ... )) 作为独立语句,以免其表达式的值为 0。

  • 尤其是 set -e 时,set -e; i=0; (( i++ )) 会导致 shell 退出。
# 简单的计算用作文本 - 注意!在字符串中使用 $(( ... ))。
echo "$(( 2 + 2 )) is 4!?"

# 判断算术比较
if (( a < b )); then
  …
fi

# 将计算结果赋值给一个变量
(( i = 10 * j + 400 ))
# 以下格式是不可移植、弃用的。
i=$[2 * 10]

# 'let' 不是变量声明关键字之一,因此无引号赋值会被全局分词。
# 为了简单起见,避免使用 'let' 而是使用 (( ... )))
let i="2 + 2"

# expr 是外部程序,不是 shell 内建函数。
i=$( expr 4 + 4 )

# 使用 expr 时,引用也很容易出错。
i=$( expr 4 '*' 4 )

除了代码规范的考虑之外,shell 内置算术函数会比 expr 快很多。

$(( ... )) 中使用变量时,${var}$var 格式不是必须的。Shell 会自行为你查找 var 变量,同时,省略 ${...} 会使代码变得更简洁。

# 可能的话,声明变量为局部变量、整型
local -i hundred=$(( 10 * 10 ))
declare -i five=$(( 10 / 2 ))

# 将变量 "i" 加 3。
# 注意
#  - 不写为 ${i} 或 $i。
#  - 在 (( 之后 )) 之前放一个空格符。
(( i += 3 ))

# 将变量 "i" 减 5。
(( i -= 5 ))

# 做一些复杂计算。
# 注意,以下表达式遵循常规算术运算符优先级。
hr=2
min=5
sec=30
echo $(( hr * 3600 + min * 60 + sec )) # prints 7530 as expected

命名约定

函数名称

小写、下划线分隔单词,用 :: 分隔库。函数名称后必须跟随括号。关键字 function 是可选的,但是在整个项目中必须保持一致。

如果要编写单一函数,请再下划线处使用小写字母和单独的单词。如果要编写包函数,请使用 :: 分隔包名称。花括号必须与函数名称在同一行,并且函数名称和括号之间不能有空格。

# 单一函数
my_func() {
  …
}

# 包函数
mypackage::my_func() {
  …
}

() 出现在函数名称时,function 关键字是多余的,但可以增强对函数的快速识别。

变量名称

与函数名称规范一样。

循环体中的变量名称应与被遍历的变量命名相似。

for zone in "${zones[@]}"; do
  something_with "${zone}"
done

常量与环境变量名称

在文件顶部声明、全大写字母、使用下划线分隔单词。

常量与环境变量名称都应该要大写。

# 常量
readonly PATH_TO_FILES='/some/path'

# 常量与环境变量
declare -xr ORACLE_SID='PROD'

有些东西需要在首次设置之后,保持不变(例如:通过 getopts 获取到的参数)。因此,可以在 getopts 中设置常量或根据调解设置常量,但此后应立即将其设置为只读。为了清楚,建议使用 readonlyexport 而不是 declare 命令。

VERBOSE='false'
while getopts 'v' flag; do
  case "${flag}" in
    v) VERBOSE='true' ;;
  esac
done
readonly VERBOSE

源文件名称

小写、按需使用下划线分隔单词。

这是为了与 Google 中的其他代码风格保持一致:maketemplatemake_template 而不是 make-template

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

使用局部变量

用 local 声明函数中的变量。声明与赋值应该分别写在不同的行上。

通过在声明函数时使用 local 来确保仅在函数及其子函数中看到局部变量。这样可以避免污染全局命名空间以及避免无意中设置了具有重要意义的全局变量。

当通过命令替换赋值时,声明与赋值必须是分开的两个语句;因为内置命令 local 不会回传命令替换中的退出代码。

my_func2() {
  local name="$1"

  # 声明与赋值分开两行:
  local my_var
  my_var="$(my_func)"
  (( $? == 0 )) || return

  …
}
my_func2() {
  # 不要这么做:
  # $? 总是 0,因此它包含的是 'local' 的退出码,而不是 my_func 的退出码
  local my_var="$(my_func)"
  (( $? == 0 )) || return

  …
}

函数位置

将所有函数放在文件中,紧挨着常量。并且,不要在函数直接隐藏可执行代码。因为这样会让代码变得难以跟踪,在调试代码时,也容易产生意外的情况。

如果有声明函数,请将它们全部放到文件顶部附近。并且,在函数声明之前,只能包含:set 语句、变量声明语句、常量声明语句。

main

如果脚本比较长,并且包含至少一个其他函数时,必须定义 main 函数。

为了能够尽可能方便地找到程序的入口,请将程序主体放在 main 函数中,main 函数放在所有函数最后的位置。这样,可以确保所有代码库风格一致,并且,可以将更多变量定义为局部变量(如果程序主体没有放在 main 函数中就不行)。脚本文件中的最后一行代码,应该是对 main 函数的调用:

main "$@"

对于简短的脚本,main 函数会显得有些冗余,因此不是必要的。

调用命令

检查返回值

始终检查返回值并提供有用的返回值。

对于非管道命令,请使用 $? 或直接通过 if 语句进行检查以便保持简洁的代码。

if ! mv "${file_list[@]}" "${dest_dir}/"; then
  echo "Unable to move ${file_list[*]} to ${dest_dir}" >&2
  exit 1
fi

# 或者
mv "${file_list[@]}" "${dest_dir}/"
if (( $? != 0 )); then
  echo "Unable to move ${file_list[*]} to ${dest_dir}" >&2
  exit 1
fi

在 Bash 中,可以通过 PIPESTATUS 变量检查所有管道的返回码。如果仅需要检查整个管道是成功还是失败,则参考以下示例:

tar -cf - ./* | ( cd "${dir}" && tar -xf - )
if (( PIPESTATUS[0] != 0 || PIPESTATUS[1] != 0 )); then
  echo "Unable to tar files to ${dir}" >&2
fi

然而,因为在执行其他命令后,PIPESTATUS 会立即被覆盖,因此,如果对指定管道错误进行处理的话,则需要在运行命令后,立即保存 PIPESTATUS 值。

tar -cf - ./* | ( cd "${DIR}" && tar -xf - )
return_codes=( "${PIPESTATUS[@]}" )
if (( return_codes[0] != 0 )); then
  do_something
fi
if (( return_codes[1] != 0 )); then
  do_something_else
fi

内置命令与外部命令

在内置命令与外部命令都可以使用的时候,首选内置命令。

# 首选:
addition=$(( ${X} + ${Y} ))
substitution="${string/#foo/bar}"
# 代替这个:
addition="$(expr ${X} + ${Y})"
substitution="$(echo "${string}" | sed -e 's/^foo/bar/')"

总结

使用常用的并且保持代码风格是一致的。

参考资料

添加评论

验证码: