FPs

Google Shell 编程风格指南

背景

使用哪种Shell

Bash 是唯一被允许用于编写可执行文件的Shell 脚本语言(译注:存在多种Shell语言,可参考Wikipedia:Unix_Shell
可执行文件必须以#!/bin/bash 开始(译注:Wikipedia:Shebang),并且使用最小数量的执行选项(译注:Find out what your UNIX shell’s flags are & then change them, The Set Builtin)。
使用set设置shell 执行选项,以便用bash <脚本名> 的方式调用脚本时候不会破坏执行选项的功能。
限制所有的可执行shell 脚本统一使用bash 使得我们在机器上能统一安装一种shell 。 唯一的例外,你正在编写的项目强制你使用其他shell 语言。例如Solaris SVR4 软件包要求包内的任何脚本用纯Bourne shell 编写(译注:即sh,参考Wikipedia:Bourne_shell)。

什么时候使用Shell

Shell 应该只用于编写小工具或者简单的包装脚本(译注:wrapper scripts,Shell Wrappers)。
尽管shell 脚本不是一种开发语言,但在Google 内部它被用于编写各种各样的工具性脚本。在广泛的开发部署中,遵循这份编程风格指南是一种共识,而不是一个建议。

一些准则:

  • 如果你主要是调用其他工具和做相对少量的数据处理,使用shell 来完成任务是合适的选择。
  • 如果你在意性能,请使用其他工具来代替shell。
  • 任何情况下,如果你发现需要使用数组(译注:Bash:Array variables),并且不是使用${PIPESTATUS}(译注:PIPESTATUS 保存着管道中各命令的返回值),你应该使用Python。
  • 如果你要编写一份超过一百行的Shell 脚本,你应该尽量使用Python 来编写。记住,随着Shell脚本行数的增长,尽早使用其他语言来重写你的脚本,以免将来重写的时候浪费更多的时间。

Shell文件和解释器调用

文件扩展名

可执行文件应该不带扩展名(强烈建议)或者使用.sh 的扩展名。 库文件应该带一个.sh的扩展名,并且不应该是可执行的。
当我们执行一个程序的时候不需要知道它是用什么语言写的,并且shell 也不要求脚本必须带扩展名。所以我们不希望一个可执行文件带着扩展名。
然而,对于库文件来说知道是什么语言写的却非常重要,有时需要使用不同的语言编写类似的库文件。使用代表语言的文件名后缀(即扩展名),就可以让使用不同语言编写的具有同样功能的库件有着相同的名字。

SUID/SGID

禁止在Shell 脚本中使用SUID 或SGID (译注:What is SUID, SGID and Sticky bit ?
shell 存在太多的安全问题,以至于允许SUID/SGID 后几乎不可能保证shell 的安全。虽然bash 让运行 SUID 变得困难,但是在某些平台上还是有可能,所以我们明确禁止使用它。
当你需要提权的时候,使用sudo(译注:Wikipedia:sudo)。

环境

STDOUT vs STDERR

所有的错误信息应该传入STDERR(译注:标准错误输出,延伸阅读:I/O Redirection 这使得从实际问题中区分正常状态变得容易。
推荐使用一个函数来专门打印错误信息和其他状态信息。

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

注释

文件头

每个文件的开头必须有一段关于它内容的概述
每个文件必须在开头部分包含一段关于其内容的概述的注释。也可以选择添加版权声明和作者信息。
例:

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

函数注释

除了简短、明确的函数之外,任何一个函数都必须写注释。库文件的中的任何一个函数必须写注释,无论其长短和复杂性。
他人应该能够在不阅读源码的情况下通过阅读注释(和帮助信息,如果有提供的话),从而学会使用你的程序或者库文件中的函数。
所有函数的注释都应该包含:

  • 对函数的描述;
  • 会使用或修改的全局变量;
  • 函数传参;
  • 返回值,不是运行的最后一条命令默认的退出状态码。

例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#!/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() {
...
}

实现的注释

代码中使用了技巧,或晦涩难懂,或有趣,或十分重要的部分你都应该添加注释。
这里要遵循Google 代码注释的通用惯例。不要任何东西都添加注释。如果是一个复杂的算法,或者你在做一些与众不同的事情,加一段简短的注释。

TODO 注释

对临时性的代码,或短期的解决方案,或足够好但是不够完美的代码等添加TODO 注释。
这和C++ Guide 中的做法约定一致。
TODO 注释都应该在开头包含大写的TODO,跟着是一对小括号,中间注明你的用户名。冒号是可选的。最好也在TODO 条目末尾添加bug/ticket 号码。
例:

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

格式

修改代码的时候应该遵循现存代码风格,任何新代码都应该遵循下列规范。

缩进

使用两个空格做缩进,不要使用tabs。
在代码块之间使用空行提提高可读性。缩进是两个空格。无论如何都不要使用tabs。对于已经存在的文件,如实的保留已经存在的缩进。

行宽和长字符串

行宽最大为80 个字符。
如果不得不写超过80 个字符的字符串,你应该尽可能的使用here 文档(译注:Wikipedia:Here文档)或者嵌入新行。如果有超过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个空格作为缩进。这个规范适用与使用“|” 链接起来的组合命令以及使用“||” 和“&&”的组合逻辑语句。

# All fits on one line
command1 | command2

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

循环

; do, ; thenwhile,forif 应置于同一行。
Shell 中的循环有一点特别,但是我们遵循和声明函数时大括号的相同的准则。即; then; do 应该和 if/for/while 语句写在同一行。else 应该独占一行,结束声明也应该独占一行,并且和开始声明垂直对齐。

例:

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 [[ "$?" -ne 0 ]]; then
      error_message
    fi
  else
    mkdir -p "${dir}/${ORACLE_SID}"
    if [[ "$?" -ne 0 ]]; then
      error_message
    fi
  fi
done

Case 声明

  • 可以选择2个空格作为缩进。
  • 匹配行右括号后面和;;前面都需要加一个空格。
  • 匹配模式,操作和;; 应该分成不同的行。长的语句或者多命令组合语句应该切割成多行。

匹配表达式应该比caseesac 缩进一级。多行操作应该再缩进一级。一般情况下,不需要给匹配表达式加引号。匹配模式前面不应该有左括号。避免使用;&;;&这些标记。

case "${expression}" in
  a)
    variable="..."
    some_command "${variable}" "${other_expr}" ...
  ;;
  absolute)
    actions="relative"
    another_command "${actions}" "${other_expr}" ...
  ;;
  *)
    error "Unexpected expression '${expression}'"
  ;;
esac

变量

按优先级排序:和已存的风格一致;给你的变量加引号;推荐使用"${var}"而不是"$var",但是视具体而定。
这些仅仅是指南,因为这个主题内容作为强制规定似乎是有争议的。
以下按照优先级排列:

  1. 和现存代码的风格保持一致。
  2. 给变量加引号,参考「加引号」一节。
  3. 如果不是绝对必要或为了避免歧义,不要用大括号把单个字符的shell 变量或 特殊参数(译注:指$?,$$,$@,$*等这类参数,Special Parameters)或位置参数(译注: Positional Parameters)。推荐将其他所有变量都用大括号括起来。
# 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"

加引号

  • 包含变量的字符串,命令替换,空格和shell 元字符都必须加引号,除了一定要仔细得处理表达式,不加引号。
  • 推荐给包含单词的字符串加引号(不包括命令选项或路径名)
  • 不要给字面上的整数加引号。
  • 仔细处理[[中匹配模式的引号。
  • 坚持使用"$@",除非你有原因要使用 $* 。
# 'Single' quotes indicate that no substitution is desired.
# "Double" quotes indicate that substitution is required/tolerated.

# Simple examples
# "quote command substitutions"
flag="$(some_command and its args "$@" 'quoted separately')"

# "quote variables"
echo "${flag}"

# "never quote literal integers"
value=32
# "quote command substitutions", even when you expect integers
number="$(generate_number)"

# "prefer quoting words", not compulsory
readonly USE_INTEGER='true'

# "quote shell meta characters"
echo 'Hello stranger, and well met. Earn lots of $$$'
echo "Process $$: Done making \$\$\$."

# "command options or path names"
# ($1 is assumed to contain a value here)
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"}

# For passing on arguments,
# "$@" is right almost everytime, and
# $* is wrong almost everytime:
#
# * $* and $@ will split on spaces, clobbering up arguments
#   that contain spaces and dropping empty strings;
# * "$@" will retain arguments as-is, so no args
#   provided will result in no args being passed on;
#   This is in most cases what you want to use for passing
#   on arguments.
# * "$*" expands to one argument, with all args joined
#   by (usually) spaces,
#   so no args provided will result in one empty string
#   being passed on.
# (Consult 'man bash' for the nit-grits ;-)

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

特性和坑

命令替换

使用$(command) 代替反引号。
嵌套的反引号需要在内部使用\ 转义。嵌套的$(command) 不需要改变格式,可读性也更好。
例:

# This is preferred:
var="$(command "$(command1)")"

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

Test, [ 和 [[

推荐使用[[ ... ]]代替 [,test/usr/bin/[
[[ ... ]] 可以降低错误,因为在 [[]] 直接不会发生路径扩展或单词分割,并且[[ ... ]] 允许正则表达式而[ ... ]不允许。

# 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能够智能的处理空字符串。所以,为了让代码可读性更好,应用空或非空字符串测试,而不是过滤字符串。

# Do this:
if [[ "${my_var}" = "some_string" ]]; then
  do_something
fi

# -z (string length is zero) and -n (string length is not zero) are
# preferred over testing for an empty string
if [[ -z "${my_var}" ]]; then
  do_something
fi

# This is OK (ensure quotes on the empty side), but not preferred:
if [[ "${my_var}" = "" ]]; then
  do_something
fi

# Not this:
if [[ "${my_var}X" = "some_stringX" ]]; then
  do_something
fi  

为避免对你检测的目的感到困惑,请直接使用-z-n

# Use this
if [[ -n "${my_var}" ]]; then
  do_something
fi

# Instead of this as errors can occur if ${my_var} expands to a test
# flag
if [[ "${my_var}" ]]; then
  do_something
fi

文件名的通配符扩展

当对文件名使用通配符的时候,请使用准确的路径。
因为文件名可以以-为开头,所以使用./* 代替*会更安全。

# Here's the contents of the directory:
# -f  -r  somedir  somefile

# This deletes almost everything in the directory by force
psa@bilby$ rm -v *
removed directory: `somedir'
removed `somefile'

# As opposed to:
psa@bilby$ rm -v ./*
removed `./-f'
removed `./-r'
rm: cannot remove `./somedir': Is a directory
removed `./somefile'

Eval

应该避免使用eval
当用于给变量赋值时,eval 可以解析输入,设置变量,但是不能检查这些变量是什么。

# What does this set?
# Did it succeed? In part or whole?
eval $(set_my_variables)

# What happens if one of the returned values has a space in it?
variable="$(eval some_function)"

管道导入While

相比管道导入while,更推荐使用程序替换(译注:Process Substitution)或 for 循环。在 一个while 循环中修改的变量是不能传递给父进程的,因为循环命令是允许在一个子shell 中。
管道导入while 循环中隐藏的子shell 让追踪bug 变得困难。

last_line='NULL'
your_command | while read line; do
  last_line="${line}"
done

# This will output 'NULL'
echo "${last_line}"

如果你确定输入不包含空格或者特殊字符串(通常,这意味着不是用户输入的内容),请使用 for 循环。

total=0
# Only do this if there are no spaces in return values.
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)

# 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 循环,通常情况下更多的结果需要复杂的“解析”。另外注意一些简单的例子通过类似aws 这样的工具解决起来更容易。这个特性在你特别不希望改变父进程域的变量的时候也是有用的。

# 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

命名习惯

函数名

使用小写字母,用下划线分隔单词。使用::分隔库文件。函数名后面必须有小括号。关键词function 是可选的,但在项目中应该保持一致。
如果你在写一个简单的函数,请用小写字母和下划线分隔单词。如果你在写一个包,包名请用:: 分隔。左大括号必须和函数名在同一行(和Google 内的其他语言规范一样),并且在函数名和小括号直接不能有空格。

# Single function
my_func() {
  ...
}

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

当函数名后面带"()" 的时候,关键词function 是多余的,但是它提高了函数的辨识度。

变量名

和函数名规范一致。
循环内的变量名应该和其他变量名一样命名。

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'

有些元素在初始设置时就成了常量(例如通过getopts,(译注:Small getopts tutorial))。所以可以在getops 中或在某种情况中设置变量,但是应该在设置之后马上将其设置成只读。注意在函数内部declare 不会对全局变量进行操作,所以推荐使用readonlyexport来代替。

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

源文件名

全小写,如果有必要的话应该用下划线分隔单词。
这和Google 内部的其他代码风格一致:maketemplatemake_template是可以的,但不可以是make-template

只读变量

使用readonlydeclare -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 声明局部变量来确保它们只作用于函数和子函数内部。这样做避免污染全局命名空间,和避免不经意之间设置了一个对于函数外部十分重要的变量。

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

  ...
  }

函数位置

将所有函数一起放在常量下方。不要在函数之间挟藏可执行代码。

如果存在函数,请将它们一起放在文件的开头。只有includes,set 声明和常量设置有可能出现在函数上面。
不要在函数之间挟藏可执行代码。如果这样做会导致在debug 的时候,代码难以跟踪和出现意想不到的执行结果。

main

至少包含一个函数的脚本,如果足够长的话,都应该有一个叫main 的函数。
为了方便找到程序开始执行的地方,应该在所有函数的底部放一个叫main的主函数,包含主要的程序调用。这使得其他的代码保持一致性,也允许你使用local定义更多的变量(如果主代码不是一个函数是做不到的)。文件最后一行非注释的内容应该是调用main

main "$@"

当然,对于顺序执行的简短代码,加'main' 函数是适得其反的,并不需要。

调用命令

检查返回值

总是检查返回值,并给出具体解释信息。
对于非管道的命令,可以简单的使用$? 或使用if 语句直接检查返回值。
例:

if ! mv "${file_list}" "${dest_dir}/" ; then
  echo "Unable to move ${file_list} to ${dest_dir}" >&2
  exit "${E_BAD_MOVE}"
fi

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

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的内容)。

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

内建命令 vs 外部命令

在选择调用内建命令还是外部程序时,选择内建命令。
我们推荐使用bash(1)中「Parameter Expansion」部分提到的内建命令,因为内建命令更加可靠和可移植(特别是和sed 之类的命令相比)。
例:

# Prefer this:
addition=$((${X} + ${Y}))
substitution="${string/#foo/bar}"

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

总结

始终遵循常识。

请花几分钟阅读C++ Guide 底部的Parting Words 部分。

临别赠言

始终遵循常识。

当你编码时,花几分钟阅读一下其他代码,并熟悉它的风格。如果他们在if 条件从句中使用空格,那么你也应该这样做。如果他们的注释由星号组成的盒子围着,那么你也应该这样做。

编程风格指南是为了提供一个通用的编程规范,以便人们可以集中精力在编码实现上,而不是考虑代码形式上。我们展示了整体上的风格规范,另外局部的风格也同样重要。如果你在一个文件 中添加的代码的风格和原来的风格差异巨大,当阅读这份代码时,整体的韵味就被破坏了。请尽量避免这样做。

好了,关于编程风格指南写的够多了,代码本身更加有趣。尽情享受吧!

2016-04-13 Shell