Google Shell編程風格指南

最近更新時間 2020-01-03 21:59:08

背景

使用什麼shell?

Bash是唯一被允許執行的shell腳本語言。

可執行文件必須以 #!/bin/bash 和最小數量的標誌開始。請使用 set 來設置shell的選項,使得用 bash 調用你的腳本時不會破壞其功能。

限制所有的可執行shell腳本為bash使得我們安裝在所有計算機中的shell語言保持一致性。

無論你是為什麼而編碼,對此唯一例外的是當你被迫時可以不這麼做的。其中一個例子是Solaris SVR4包,編寫任何腳本都需要用純Bourne shell。

什麼時候使用Shell

Shell應該僅僅被用於小功能或者簡單的包裝腳本。

儘管Shell腳本不是一種開發語言,但在整個谷歌它被用於編寫多種實用工具的腳本。這個風格指南更多的是認同它的使用,而不是一個建議,即它可被用於廣泛部署。

以下是一些準則:

  • 如果你主要是在調用其他的工具並且做一些相對很小數據量的操作,那麼使用shell來完成任務是一種可接受的選擇。
  • 如果你在乎性能,那麼請選擇其他工具,而不是使用shell。
  • 如果你發現你需要使用數據而不是變量賦值(如 ${PHPESTATUS}),那麼你應該使用Python腳本。
  • 如果你將要編寫的腳本會超過100行,那麼你可能應該使用Python來編寫,而不是Shell。請記住,當腳本行數增加,儘早使用另外一種語言重寫你的腳本,以避免之後花更多的時間來重寫。

Shell文件和解釋器調用

文件擴展名

可執行文件應該沒有擴展名(強烈建議)或者使用.sh擴展名。庫文件必須使用.sh作為擴展名,而且應該是不可執行的。

當執行一個程序時,並不需要知道它是用什麼語言編寫的。而且shell腳本也不要求有擴展名。所以我們更喜歡可執行文件沒有擴展名。

然而,對於庫文件,知道其用什麼語言編寫的是很重要的,有時候會需要使用不同語言編寫的相似的庫文件。使用.sh這樣特定語言後綴作為擴展名,就使得用不同語言編寫的具有相同功能的庫文件可以採用一樣的名稱。

SUID / SGID

SUID(Set User ID)和SGID(Set Group ID)在shell腳本中是被禁止的。

shell存在太多的安全問題,以致於如果允許SUID/SGID會使得shell幾乎不可能足夠安全。雖然bash使得運行SUID非常困難,但在某些平臺上仍然有可能運行,這就是為什麼我們明確提出要禁止它。

如果你需要較高權限的訪問請使用 sudo

環境

STDOUT vs STDERR

所有的錯誤信息都應該被導向STDERR。

這使得從實際問題中分離出正常狀態變得更容易。

推薦使用類似如下函數,將錯誤信息和其他狀態信息一起打印出來。

#!/bin/bash

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

註釋

文件頭

每個文件的開頭是其文件內容的描述。

每個文件必須包含一個頂層註釋,對其內容進行簡要概述。版權聲明和作者信息是可選的。

例如:

#!/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註釋臨時的、短期解決方案的、或者足夠好但不夠完美的代碼。

這與C++指南中的約定相一致。

TODOs應該包含全部大寫的字符串TODO,接著是括號中你的用戶名。冒號是可選的。最好在TODO條目之後加上 bug或者ticket 的序號。

例如:

#!/bin/bash
# TODO(mrmonkey): Handle the unlikely edge cases (bug ####)

格式

縮進

縮進兩個空格,沒有製表符。

在代碼塊之間請使用空行以提升可讀性。縮進為兩個空格。無論你做什麼,請不要使用製表符。對於已有文件,保持已有的縮進格式。

行的長度和長字符串

行的最大長度為80個字符。

如果你必須寫長度超過80個字符的字符串,如果可能的話,儘量使用here document或者嵌入的換行符。長度超過80個字符的文字串且不能被合理地分割,這是正常的。但強烈建議找到一個方法使其變短。

#!/bin/bash
# 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個空格。這適用於使用管道符’|’的合併命令鏈以及使用’||’和’&&’的邏輯運算鏈。

#!/bin/bash
# All fits on one line
command1 | command2
 
# Long commands
command1 \
  | command2 \
  | command3 \
  | command4

循環

請將 ; do , ; thenwhile , for , if 放在同一行。

shell中的循環略有不同,但是我們遵循跟聲明函數時的大括號相同的原則。也就是說, ; do , ; then 應該和 if/for/while 放在同一行。 else 應該單獨一行,結束語句應該單獨一行並且跟開始語句垂直對齊。

例如:

#!/bin/bash

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 縮進一級。多行操作要再縮進一級。一般情況下,不需要引用匹配表達式。模式表達式前面不應該出現左括號。避免使用 ;&;;& 符號。

#!/bin/bash

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

只要整個表達式可讀,簡單的命令可以跟模式和 ;; 寫在同一行。這通常適用於單字母選項的處理。當單行容不下操作時,請將模式單獨放一行,然後是操作,最後結束符 ;; 也單獨一行。當操作在同一行時,模式的右括號之後和結束符 ;; 之前請使用一個空格分隔。

#!/bin/bash

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 ,詳細解釋如下。

這些僅僅是指南,因為作為強制規定似乎飽受爭議。

以下按照優先順序列出:

  • 與現存代碼中你所發現的保持一致。
  • 引用變量參閱下面一節,引用。
  • 除非絕對必要或者為了避免深深的困惑,否則不要用大括號將單個字符的shell特殊變量或定位變量括起來。推薦將其他所有變量用大括號括起來。
#!/bin/bash


# 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元字符的字符串。
    推薦引用是單詞的字符串(而不是命令選項或者路徑名)。
  • 千萬不要引用整數。
  • 注意 [[ 中模式匹配的引用規則。
  • 請使用 $@ 除非你有特殊原因需要使用 $*
#!/bin/bash


# '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循環

請使用過程替換或者for循環,而不是管道導向while循環。在while循環中被修改的變量是不能傳遞給父shell的,因為循環命令是在一個子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中,而不是bash為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循環。這通常需要一些更復雜的“解析”。請注意簡單的例子使用如awk這類工具可能更容易完成。當你特別不希望改變父shell的範圍變量時這可能也是有用的。

# 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)。因此,可以在getopts中或基於條件來設定常量,但之後應該立即設置其為只讀。值得注意的是,在函數中 declare 不會對全局變量進行操作。所以推薦使用 readonlyexport 來代替。

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

源文件名

小寫,如果需要的話使用下劃線分隔單詞。

這是為了和在Google中的其他代碼風格保持一致: 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 "${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

內建命令和外部命令

可以在調用shell內建命令和調用另外的程序之間選擇,請選擇內建命令。

我們更喜歡使用內建命令,如在 bash(1) 中參數擴展函數。因為它更強健和便攜(尤其是跟像 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/')"

 

rss_feed