Shell风格

我在最近的几次shell开发中,遇到过 shell变量名不可用 - 问题,这触发我在使用变量时更为谨慎。我在编程时,就在考虑如何将SHELL写得更规范清晰。

正好看到 Google 开源项目风格指南 其中有一个篇章是关于 Shell 风格指南 ,篇幅不长,阅读以后发现:

  • 其实日常编写SHELL的时候,已经不知不觉契合了Google的Shell风格指南(这可能是因为很多开源项目广泛采用,在模仿学习中也就习惯了)

  • 但是我自己在编写SHELL的时候,没有强烈的意识去遵循Shell风格指南,所以开发的SHELL风格也在不断变化,有些脚本回头来看确实很丑陋

  • Google的Shell风格指南符合很多普遍遵循的风格,所以采用时非常舒畅,可以用接近自己的编程习惯来采纳

备注

我最初只是阅读 “命名约定” ,觉得深得我心。不过,完整阅读摘抄发现原文确实已经非常精炼,几乎是完整复制了。汗…

多实践,实践可以将风格融入编程习惯中…

Google的使用Shell建议(采纳)

  • 如果工作是 调用其他工具 并且做 少量数据处理 ,建议使用shell快速完成

  • 对性能有要求,不要使用 shell

  • 需要处理复杂的数据,包括计算和格式化,应该使用 Python Atlas ( 血泪:曾经编写shell处理大量的数据,随着功能需求增加,越来越难以维护,后期开发成本极高 )

  • 编写超过100行的脚本,应该尽早转为 Python Atlas 重写,越往后重写成本越高

文件扩展名和 SUID/SGID

  • 可执行文件(包括shell)建议没有扩展名(或使用 .sh 扩展名) : 我今后将不使用扩展名

  • 库文件 必须 使用 .sh 作为扩展名,并且设置为 不可执行 : 库文件不应直接运行,而只能被调用

  • 禁止在shell脚本中使用 SUID/SGID : 存在严重风险

STDOUT vs STDERR

备注

所有错误信息都应该导向 STDERR

推荐采用以下函数(赞),将错误信息和其他状态信息一起打印出来:

使用err()函数处理错误信息和状态打印到STDERR
err() {
    echo "[$(date +'%Y-%m%dT%H:%M:%S%z')]: $@" >&2
}

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

注释

顶层注释

  • 每个文件的开头是其文件内容的描述

  • 每个文件 必须 包含一个顶层注释,对内容进行概述。版权声明和作者信息可选

举例:

每个shell文件开头必须包含顶层注释,对内容进行概述
#!/bin/bash
#
# Perform hot backups of Oracle databases.

函数注释

要求:

  • 除非函数非常短且功能逻辑明显,否则必须注释

  • 任何 库函数 (反复调用的) 不论长短和复杂性都 必须注释

  • 注释必须达到: 无需阅读代码 就能够学会如何使用你的程序或库函数

所有函数注释必须包含:

  • 函数的描述

  • 全局变量的使用和修改

  • 使用的参数说明

  • 返回值,而不是上一条命令运行后默认的退出状态

举例:

函数注释案例
#!/bin/bash
#
# Perform hot backups of Oracle databases.

export PATH='/usr/xpg4/bin:/usr/bin:/opt/csw/bin:/opt/goog/bin'

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

实现部分的注释

实现部分注释是指代码中间的代码注释,建议:

  • 不要注释所有代码

  • 对复杂的算法注释

  • 对包含技巧、不明显、有趣或者重要的部分进行注释(例如,对特殊数据的特别处理)

  • 注释应该是简洁的,代码清晰且常规的部分不需要注释

TODO注释

  • 使用TODO注释临时的、短期解决方案的、或者足够好但不够完美的代码。

  • TODOs应该包含全部大写的字符串TODO,接着是括号中你的用户名。

  • 最好在TODO条目之后加上 bug或者ticket 的序号。

举例:

TODO注释案例
# TODO(mrmonkey): Handle the unlikely edge cases (bug ####)

格式

缩进

  • 缩进两个空格,没有制表符

  • 代码块之间使用空行以提升可读性

  • 对于已有文件,保持已有的缩进格式

行的长度和长字符串

  • 行的最大长度为 80 个字符

  • 对于行长度超过80字符的,尽量使用 here document 或者切入 换行符

备注

限制行的长度不仅是显示美化也是帮助开发者阅读(过长的代码行使人疲惫)

举例:

代码行长度保持不超过80字符的案例
# 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."

管道

  • 如果一行容得下整个管道操作,建议将整个管道操作写在同一行

  • 否则,应该将整个管道操作分割成每行一个管道;管道操作的下一部分应该将管道符号放在新的一行并且缩进2个空格

  • 对于 ||&& 逻辑运算符也应该使用这种方式

举例:

多个管道操作超出80个字符行限制,分割成多行,每行一个管道操作
# All fits on one line
command1 | command2

# Long commands
command1 \
  | command2 \
  | command3 \
  | command4

循环

  • ; do , ; then 应该和 if/for/while 放在同一行

  • else 单独一行

  • 结束语句应该单独一行,并且和开始语句垂直对齐

举例:

循环代码部分的风格举例
# If inside a function, consider declaring the loop variable as
# a local to avoid it leaking into the global environment:
# 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代码部分的风格举例
case "${expression}" in
  a)
    variable="..."
    some_command "${variable}" "${other_expr}" ...
    ;;
  absolute)
    actions="relative"
    another_command "${actions}" "${other_expr}" ...
    ;;
  *)
    error "Unexpected expression '${expression}'"
    ;;
esac
  • 如果整个表达式刻度,简单的命令可以跟模式和 ;; 写在同一行: 通常是单字母选项的处理

  • 如果单行容不下操作时,模式单独放一行,然后是操作,最后结束符 ;; 也单独一行

  • 当操作在同一行是,模式的右括号之后和结束符 ;; 之前应该使用一个空格分隔

举例:

简单的单行case代码的风格举例
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

变量扩展

备注

这里我简化了指南的摘要,我准备尽可能采用统一风格且选择清晰风格:

以前我写变量,如果变量简单,则直接使用 $a ,如果变量复杂,则加上 {} ,例如 ${var}_file.txt

为了能够更清晰,今后我写脚本,除 单字符变量 其他变量都使用 {}

  • 推荐使用 ${var} 而不是 $var

  • 单个字符的shell特殊变量或者定位变量不要使用 {} 括号,其他所有变量建议使用大括号 {}

举例:

变量风格举例
# Section of recommended cases.

# Preferred style for 'special' variables:
echo "Positional: $1" "$5" "$3"
echo "Specials: !=$!, -=$-, _=$_. ?=$?, #=$# *=$* @=$@ \$=$$ ..."

# Braces necessary:
echo "many parameters: ${10}"

# Braces avoiding confusion:
# Output is "a0b0c0"
set -- a b c
echo "${1}0${2}0${3}0"

# Preferred style for other variables:
echo "PATH=${PATH}, PWD=${PWD}, mine=${some_var}"
while read f; do
  echo "file=${f}"
done < <(ls -l /tmp)

# Section of discouraged cases

# Unquoted vars, unbraced vars, brace-quoted single letter
# shell specials.
echo a=$avar "b=$bvar" "PID=${$}" "${1}"

# Confusing use: this is expanded as "${1}0${2}0${3}0",
# not "${10}${20}${30}
set -- a b c
echo "$10$20$30"

引用

  • " " (双引号)引用是扩展变量的, ' ' (单引号)引用不带扩展

  • 建议引用单词的字符串,而不是命令选项或者路径名

  • 千万 不要引用整数 (整数引用后就是字符串了)

  • 注意 [[ 中模式匹配的引用规则

  • 应该使用 $@ 引用参数,除非有特殊原因需要使用 $*

举例:

引用案例
# '单'引号表示不需要替换。
# “双”引号表示需要/容忍替换。

# 简单的例子
# "引用命令替换"
flag="$(some_command and its args "$@" 'quoted separately')"

# "引用变量"
echo "${flag}"

# "永远不要引用文字整数"
value=32
# "引用命令替换", 即使你期望整数
number="$(generate_number)"

# "prefer quoting words", not compulsory
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"

# Less simple examples
# "quote variables, unless proven false": ccs might be empty
git send-email --to "${reviewers}" ${ccs:+"--cc" "${ccs}"}

# Positional parameter precautions: $1 might be unset
# Single quotes leave regex as-is.
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 "$#, $@")

特性及错误

命令替换

  • 应该使用 $(command) ,不建议使用反引号

  • 嵌套的反引号要求用反斜杠转义内部的反引号。而 $(command) 形式嵌套时不需要改变,而且更易于阅读

举例:

命令替换案例,应该使用 $(command)
# This is preferred:
var="$(command "$(command1)")"

# This is not:
var="`command \`command1\``"

test,[和[[

  • 推荐使用 [[ ... ]] ,而不是 [ , test , 和 /usr/bin/[

  • [[]] 之间不会有路径名称扩展或单词分割发生,所以使用 [[ ... ]] 能够减少错误

  • [[ ... ]] 允许正则表达式匹配,而 [ ... ] 不允许

举例:

test测试语句
# This ensures the string on the left is made up of characters in the
# alnum character class followed by the string name.
# Note that the RHS should not be quoted here.
# For the gory details, see
# E14 at http://tiswww.case.edu/php/chet/bash/FAQ
if [[ "filename" =~ ^[[:alnum:]]+name ]]; then
  echo "Match"
fi

# This matches the exact pattern "f*" (Does not match in this case)
if [[ "filename" == "f*" ]]; then
  echo "Match"
fi

# This gives a "too many arguments" error as f* is expanded to the
# contents of the current directory
if [ "filename" == f* ]; then
  echo "Match"
fi

测试字符串

  • 尽可能使用引用,而不是过滤字符串

  • Bash能够在测试中处理空字符串,所以不要使用填充字符,以便代码易于阅读

举例:

test测试字符串
# 好的案例
if [[ "${my_var}" = "some_string" ]]; then
  do_something
fi

# -z (字符串长度为0) and -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

管道导向while循环

备注

这段理解需要实践

  • 使用过程替换或者for循环,而不是管道导向while循环: 在while循环中被修改的变量是不能传递给父shell的,因为循环命令是在一个子shell中运行的。

  • 管道导向while循环中的隐式子shell使得追踪bug变得很困难:

不建议使用管道导向while循环
last_line='NULL'
your_command | while read line; do
  last_line="${line}"
done

# This will output 'NULL'
echo "${last_line}"
  • 如果你确定输入中不包含空格或者特殊符号(通常意味着不是用户输入的),那么可以使用一个for循环:

使用for循环命令输出中是否包含值
total=0
# Only do this if there are no spaces in return values.
for value in $(command); do
  total+="${value}"
done
  • 使用过程替换允许重定向输出,但是请将命令放入一个显式的子shell中,而不是bash为while循环创建的隐式子shell:

使用while循环命令
total=0
last_file=
while read count filename; do
  total+="${count}"
  last_file="${filename}"
done < <(your_command | uniq -c)

# This will output the second field of the last line of output from
# the command.
echo "Total = ${total}"
echo "Last one = ${last_file}"
  • 当不需要传递复杂的结果给父shell时可以使用while循环,但是复杂解析建议使用 awk :

使用while循环命令处理简单结果
# Trivial implementation of awk expression:
#   awk '$3 == "nfs" { print $2 " maps to " $1 }' /proc/mounts
cat /proc/mounts | while read src dest type opts rest; do
  if [[ ${type} == "nfs" ]]; then
    echo "NFS ${dest} maps to ${src}"
  fi
done

算术

  • 总是使用 ((...)) 或者 $((...)) ,避免使用 let 或者 $[...] 或者 expr

  • 永远不要使用 $[...] 语法,以及 expr 命令或者内建的 let

  • <> 不在 [[   ]] 表达式中执行数值比较(它们执行字典顺序比较;请参阅测试字符串)。根据偏好,根本不要使用 [[   ]] 进行数字比较,而是使用 ((   ))

  • 建议避免将 ((   )) 用作独立语句,否则要警惕其表达式的计算结果为零,例如:

    • 在启用 set -e 情况下,如 set -e; i=0; (( i++ )) 会导致shell退出

算术脚本案例
# Simple calculation used as text - note the use of $(( … )) within
# a string.
echo "$(( 2 + 2 )) is 4"

# When performing arithmetic comparisons for testing
if (( a < b )); then
  fi

# Some calculation assigned to a variable.
(( i = 10 * j + 400 ))

# N.B.: Remember to declare your variables as integers when
# possible, and to prefer local variables over globals.
local -i hundred=$(( 10 * 10 ))
declare -i five=$(( 10 / 2 ))

# Increment the variable "i" by three.
# Note that:
#  - We do not write ${i} or $i.
#  - We put a space after the (( and before the )).
(( i += 3 ))

# To decrement the variable "i" by five:
(( i -= 5 ))

# Do some complicated computations.
# Note that normal arithmetic operator precedence is observed.
hr=2
min=5
sec=30
echo $(( hr * 3600 + min * 60 + sec )) # prints 7530 as expected

命名约定

函数名

  • 函数名使用小写字母,并且使用下划线分隔单词( 不要使用 - ,类似变量不能包含 - ,风格统一 )

  • 使用双冒号 :: 分隔库

  • 函数名之后 必须 有圆括号; (我准备)不使用(可选的) function 关键字

    • 当函数名后面存在 () 时,关键字 function 是多余的,但是促进了函数的快速识别

  • 第一个大括号必须和函数名位于同一行,并且函数名和圆括号之间没有空格

举例:

函数举例
# Single function
my_func() {
  ...
}

# Part of a package
mypackage::my_func() {
  ...
}

变量名

  • 变量名的命名方法和函数名一样:小写字母,并使用下划线分隔单词

  • 循环的变量名应该和循环的任何变量同样命名

举例:

变量举例
for zone in ${zones}; do
  something_with "${zone}"
done

常量和环境变量名

  • 常量和环境变量名应全部大写,用下划线分隔,声明在文件的顶部

举例:

常量和环境变量
# Constant
readonly PATH_TO_FILES='/some/path'

# Both constant and environment
declare -xr ORACLE_SID='PROD'
  • 只读变量应该使用 readonly 修饰

  • 第一次设置就变成常量( 例如通过 getopts ),则应该立即将其设为只读:

只读变量
VERBOSE='false'
while getopts 'v' flag; do
  case "${flag}" in
    v) VERBOSE='true' ;;
  esac
done
readonly VERBOSE
  • 在函数中 declare 不会对全局变量进行操作。所以推荐使用 readonlyexport 来代替

源文件名

  • 文件名使用小写,需要的话使用下划线分隔单词: 例如,使用 maketemplate 或者 make_template ,而不是 make-template

只读变量

  • 使用 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

使用本地变量

  • 使用 local 声明特定功能的变量。声明和赋值应该在不同行

  • 使用 local 来声明局部变量以确保其只在函数内部和子函数中可见。这避免了污染全局命名空间和不经意间设置可能具有函数之外重要性的变量。

  • 当赋值的值由命令替换提供时,声明和赋值必须分开。因为内建的 local 不会从命令替换中传递退出码。

举例:

本地变量举例
my_func2() {
  local name="$1"

  # Separate lines for declaration and assignment:
  local my_var
  my_var="$(my_func)" || return

  # DO NOT do this: $? contains the exit code of 'local', not my_func
  local my_var="$(my_func)"
  [[ $? -eq 0 ]] || return

  ...
}

函数位置

  • 将文件中所有的函数一起放在常量下面

  • 不要在函数之间隐藏可执行代码

  • 只有 includesset 声明和常量设置可能在函数声明之前完成

主函数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

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

备注

请注意Google的shell编程案例,对每个命令执行都要判断返回值并做相应处理,否则脚本可能会有异常。这个编程风格非常重要

  • Bash 有 PIPESTATUS 变量,允许检查从管道所有部分返回的代码,如果仅检查整个管道是成功还是失败,可以使用以下方法:

检查管道是否成功的案例(注意只能知道整个管道的结果)
tar -cf - ./* | ( cd "${dir}" && tar -xf - )
if [[ "${PIPESTATUS[0]}" -ne 0 || "${PIPESTATUS[1]}" -ne 0 ]]; then
  echo "Unable to tar files to ${dir}" >&2
fi

警告

只要运行其他任何命令 PIPESTATUS 就会被覆盖,所以如果要知道管道中发生的错误执行对应操作,一定要在运行管道命令之后立即将 PIPESTATUS 赋值另一个变量( 注意: [ 是一个会将 PIPESTATUS 擦除的命令,也就是隐含的 test ):

检查管道的PIPESTATUS一定要在管道后立即保存变量,否则检测失效
tar -cf - ./* | ( cd "${DIR}" && tar -xf - )
return_codes=(${PIPESTATUS[*]})
if [[ "${return_codes[0]}" -ne 0 ]]; then
  do_something
fi
if [[ "${return_codes[1]}" -ne 0 ]]; then
  do_something_else
fi

SHELL内建命令和外部命令

  • 如果能使用内部命令,则建议使用内部命令,这样脚本更强健和更具移植性

举例:

内建命令和外部命令案例
# Prefer this:
addition=$(( X + Y ))
substitution="${string/#foo/bar}"

# Instead of this:
addition="$(expr "${X}" + "${Y}")"
substitution="$(echo "${string}" | sed -e 's/^foo/bar/')"

参考