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