養成撰寫 Bash 腳本的好習慣 - Bash Best Practice Guide
前言
因為最近為了撰寫 CI/CD Pipeline,因此有很大的時間都在寫 bash 腳本,寫著寫著就覺得應該來好好了解一些最佳實踐!因此整理出這篇教學。
在目前的世界裡面,Bash 繼承了 shell 的寶座,Bash 是可執行檔唯一允許的 shell 腳本語言。幾乎可以在所有 Linux 上找到,包括 Docker 鏡像。這是大多數後端運行的環境。因此,如果您需要編寫伺服器應用程式啟動、CI/CD 步驟或集成測試運行的腳本,Bash 隨時為您服務。
這篇主要參考了 google 所撰寫的 Shell Style Guide。然後還有網路常見的建議所組成,詳細可以參考 Reference。希望自己在寫腳本的時候可以養成良好的習慣…
如果您正在編寫超過 100 行的腳本,或者使用不直覺的控制流邏輯,那麼現在應該用更結構化的語言重寫它。請記住,腳本會不斷增長。儘早重寫腳本,以避免以後更耗時的重寫。
Environment
所有錯誤消息都應轉到 STDERR
- 這使得更容易區分正常狀態和實際問題。
- 建議使用列印錯誤消息和其他狀態資訊的功能。
ps.我自己覺得還可以添加顏色來加強錯誤訊息可讀性
1 | err() { |
Comments
每個檔都以其內容的說明開始
- 每個文件都必須有一個置頂的註釋,包括其內容的簡要概述。
- 版權聲明和作者資訊是可選的。
註釋代碼中棘手、不明顯、有趣或重要的部分
- 這遵循了一般的Google編碼註釋實踐。不要評論所有內容。如果演算法複雜,或者你正在做一些不尋常的事情,請放一個簡短的評論。
1 |
|
Function Comments
任何既不明顯又簡短的函數都必須具有『函數頭註釋』
- 庫中的任何函數都必須具有函數頭註釋,無論長度或複雜程度如何。
- 其他人應該可以通過閱讀註釋(和自助,如果提供)來學習如何使用您的程式或使用庫中的函數,而無需閱讀代碼。
- 所有函數標頭註釋都應使用以下命令描述預期的 API 行為:
Description (DESC)
: 函數的描述。Globals (GLOBS)
:使用和修改的全域變數清單。Arguments (ARGS)
:所採用的參數。Outputs (OUTS)
:輸出到 STDOUT 或 STDERR。Return (RETS)
:返回的值不是上次命令運行的預設退出狀態。
1 | # google 的寫法 + 我覺得 github 上別人不錯的寫法 |
Todo Comments
對臨時代碼、短期解決方案代碼或足夠好但不完美的代碼使用 TODO 註釋
TODO
應包含全部大寫的字串 TO`DOTODO
應該引用相關人員、email或是其他識別符號,以便於瞭解如何獲取更多詳細信息- 主要目的是擁有一個一致的
TODO
,可以搜索以瞭解如何根據請求獲取更多詳細資訊 - 此外,
TODO
不是被引用人將問題解決的承諾 - 建立
TODO
時幾乎總是要把自己的名字寫上
Examples:
1 | # TODO(mrmonkey): Handle the unlikely edge cases (bug ####) |
Formatting
Indentatio 縮排
- 縮排 2 個 spaces
- 無論你做什麼都不要使用 tabs
- 使用換行 (blank lines) 在 block 之間來提高可讀性
Line Length and Long Strings
- 最大行長度為80個字元
- 如果你必須編寫超過 80 個字元的文字字串,這應該使用 here-document 或嵌入的換行符 embedded newline(內嵌了一個換行)來完成
1 | # 如果需要編寫超過 80 個字元的文字字串請使用 here-document |
Exception: 只有在使用 <<-
這種 tab-indented 的方式才能允許使用 tabs 在 here-document裡面。
Pipeline
所謂的管道(Pipeline)是指將一個指令的輸出作為另一個指令的輸入。在 Bash 中,管道由 |
符號來連接兩個或多個指令。
- 如果管道不能全部放在一行上,則應每行拆分一個管道。
- 如果管道全部可以放在同一行,那就都放在同一行。
- 否則,應將其拆分為每行一個管段。
- 管道位於換行符上,管道的下一段縮進 2 個空格。
\
應始終用於表示行繼續- 這適用於使用
|
以及使用||
和&&
1 | # All fits on one line |
總結來說:
- 這有助於在區分 pipeline 和常規的 long command continuation 時提高可讀性。
- 評論需要在整個管道之前。如果 comment 和 pipeline 很大很複雜,那麼值得考慮使用 helper 函數將它們的低級細節移到一邊。
Control Flow
將 ;then
或是 ;do
與 if
或 for
放在同一行
- shell 中的控制流語句略有不同,但我們在聲明函數時遵循與大括號相同的原則。
- 那是
;then
和;do
應該與if/for/while/until/select
在同一行 else
應該在它自己的行上- 結束語句 (
fi
和done
) 應該在它們自己的行上,並且與開始語句垂直對齊。
- 那是
Example
1 | # 如果是在 function 裡面記得要把 loop 變數宣告為 local,避免它洩漏到全域環境中 |
儘管在 for 迴圈中可以省略 “$@” ,但為了清楚起見,我們建議始終包含它。
1 | # 保留的寫法 |
Case statement
case
語句的格式規範,
-
縮進要統一
case
裡的選項 (a)
、absolute)
) 要縮進『兩個空格』,讓結構清楚。- 如果
case
內有多行指令,它們要再縮進『一層』,保持層級分明。
-
單行選項的格式
- 單行的
case
選項(例如a) some_command ;;
)要在)
和;;
之間加空格,讓可讀性更好。
- 單行的
-
多行選項的格式
- 如果
case
內的某個選項有『多行指令』,應該:模式 (pattern)
獨立一行,這裡的模式就是a)
或absolute)
指${expression}
的值是哪一個 Pattern執行的命令
也就是some_command
各自一行;;
(結束標記)也獨立一行
- 如果
1 | case "${expression}" in |
簡單命令
- 可以與
模式 Pattern
和;;
只要表達式保持可讀即可。 - 這通常適用於 single-letter option 處理。
- 當作不適合一行時
- 將
pattern
單獨放在一行上 - 然後是
;;
也在自己的一行上
- 將
- 當與作位於同一行時
- 請在模式的右括弧
)
後使用一個空格 - 在
;;
之前使用另一個空格
- 請在模式的右括弧
1 | # getopts 是一種可以處裡命令列選項的工具 e.g. test.sh -a -b -c 這種輸入 |
Variable expansion
使用 ${var}
而不是 $var
這些是強烈建議的指導方針,但不是強制性法規。儘管如此,這是一項建議而不是強制性的事實,並不意味著它應該不做,簡單來說,這段的主要內容是:
- 保持一致性:
- 如果你修改的是別人寫的代碼,最好跟現有代碼的格式保持一致。這樣可以確保代碼的可讀性和一致性,減少錯誤。
- 引用變數:
- 當你使用變數時,最好總是加上大括號(例如:
${var}
)來引用它,而不是簡單的"$var"
。 - 這是因為在某些情況下,簡單的
"$var"
會引發解析錯誤,尤其是在變數後面緊跟著其他字符時。使用大括號可以確保變數被正確解析。
- 當你使用變數時,最好總是加上大括號(例如:
- 避免混淆:
- 對於特殊字符或位置參數(比如
$1
,$@
),如果不是必要,應避免使用大括號({}
)包圍單個字符,這樣可以避免不必要的混淆。 - 對於其他變數,首選使用大括號來包裹變數(例如
${some_var}
),這樣能避免解析上的錯誤,特別是當變數後接其他字符時。
- 對於特殊字符或位置參數(比如
1 | # Section of *recommended* cases. |
Quoting
大概有一些總整理:
- 使用變數、命令替換、空格或特殊字元時,引用(加上引號)例如
"$var"
- 使用陣列來安全引用,例如
FLAGS=(--foo --bar='baz')
,然後使用"${FLAGS[@]}"
來引用 - 在進行整數運算時,可以不用引用內部整數變數(如
$#
或$PPID
) - 引用 命令替換 時,使用雙引號將命令替換的結果括起來 (如
"$(commend)"
),並將結果賦值給變數Var="$(commend)"
,可以確保命令替換的結果被正確解析 - 如果不希望把
$
解析成變數,可以使用單引號'$$$'
或是\$
來引用
1 |
|
Test, [...]
, and [[...]]
永遠使用 [[ ... ]]
優先於 [ ... ]
、test
和 /usr/bin/[
,主要有幾個原因:
[[ ... ]]
支援模式和正則表達式匹配,像==
或=~
可以用來進行模式匹配或正則表達式匹配。- 在
[[ ... ]]
內部,像*
、?
等特殊字符不會被自動展開成目錄或文件名
1 | file=$(echo *) # 會印出 目前目錄下的所有檔案 file1.txt file2.txt file3.txt |
Naming Conventions
命名公約
Function Names
- 小寫,下劃線(
_
)分隔單詞 - 使用
::
分隔 library - 如果要編寫 package,請用
::
分隔 package 名稱。但是,用於互動式使用的函數可以選擇避免使用冒號,因為它可能會混淆 bash 自動完成。 - 函數名稱后需要括號,大括弧必須與函數名稱位於同一行,並且函數名稱和括弧之間沒有空格。
- 關鍵funciton數是可選的,但必須在整個專案中一致地使用
- 如果您有函數,請將它們全部放在文件頂部附近。只有 includes、set 語句和設置常量可以在聲明函數之前完成。
1 | # Single function |
Variable Names
- 與 Function Names 相同
- 循環的變數名稱應該與您正在迴圈的任何變數的命名類似
1 | for zone in "${zones[@]}"; do |
Constants, Environment Variables, and readonly Variables
- 常量和導出到環境的任何內容都應該大寫,用下劃線分隔,並在文件頂部聲明。
- 為清楚起見,建議使用
readonly
或export
而不是等效的declare
命令。
1 | # Constant |
Source Filenames
- 小寫,如果需要,可以使用下劃線分隔單詞。
Use Local Variables
- 使用
local
關鍵字來聲明函數內部的變數,以避免變數泄漏到全局環境中。 - 當你用命令替換(command substitution)(
$(command)
)給變數賦值時,declaration 和 assignment(聲明與賦值)必須分開來寫
在函數中,如果你使用 local 來聲明一個變數,並將其賦值為一個命令替換的結果(例如:$(my_func)
),這時需要注意:如果把聲明和賦值寫在同一行,$?
會返回的是 local
命令的退出碼,而不是 my_func
的退出碼。
1 | my_func2() { |
其他建議
Choose Bash
腳本(Script)通常會以 “shebang” 開頭,這是指在腳本的第一行加上一個特殊的標記 #!
,後面跟著解釋器的路徑(例如 /bin/bash
或 /usr/bin/env
),用來指定該腳本應該由哪個解釋器來執行。
-
/usr/bin/env
:這是一個通用的工具,它會尋找並啟動指定的解釋器(例如 bash)。這樣做的目的是提高腳本的可移植性,因為它可以根據系統環境自動尋找安裝的解釋器位置。例如,某些系統可能把 bash 安裝在不同的位置(比如/bin/bash
或/usr/local/bin/bash
),而使用env
可以確保無論解釋器在哪裡,都能找到並執行它。 -
/bin/bash
:這是直接指定解釋器的位置。如果腳本的第一行寫成#!/bin/bash
,那麼腳本會強制使用這個具體位置的 bash 來執行。這樣做在某些情況下可能會有兼容性問題,特別是在不同的操作系統或不同的環境中,bash 可能不在/bin/bash
。
如果程式是可以用 POSIX sh 語法的話,應該優先考慮 /bin/sh
,如果用到非 POSIX 標準的語法的話,用 env
帶出來會少一些問題:
1 |
Q: POSIX標準語法?
POSIX(Portable Operating System Interface,便攜式作業系統介面)標準是一組為 UNIX 系統制定的規範,旨在確保不同 UNIX 類作業系統之間的相容性。POSIX 標準語法指的是符合 POSIX 規範的命令行語法、工具、庫、函數及腳本語言的語法。簡單來說,POSIX 標準語法使得編寫的腳本、程式或命令能夠在不同的作業系統間保持一致性,無論是 Linux、macOS、AIX、Solaris 等作業系統。
Source
如果你今天想要載入某個 source 的腳本可以這樣寫:
1 | source "$(dirname "${BASH_SOURCE[0]}")/source.sh" |
${BASH_SOURCE[0]}
- 代表目前執行的腳本檔案的完整路徑(不一定是當前工作目錄)。
- 與
$0
不同,${BASH_SOURCE[0]}
更可靠,即使腳本是被 source 執行也能取得正確路徑。
dirname "${BASH_SOURCE[0]}"
- 取得
${BASH_SOURCE[0]}
所在的目錄(去掉檔名)。 - 這樣可以確保載入的
source.sh
在與當前腳本相同的資料夾中。
source "<path>/source.sh"
- source(或 .)用來執行並載入另一個腳本。
source.sh
可能包含函數、變數、設定,載入後可供當前腳本使用。
🔹 為什麼這樣寫?
假設我們這樣寫:
1 | source ./source.sh |
如果執行:
1 | cd /home/user/myscript |
但如果執行:
1 | cd / |
補充:Bash Script Cheatsheet
1. Built-in Variables
1 | $0, $1, $2 # 第 n 個參數,即 argv[n] |
2. Bash Builins
數學運算,這兩個寫法等價。
1 | echo $((1+1)) |
變數,{} 可省略
1 | name="Shannon" |
指令替換,兩個寫法等價
1 | echo $(whoami) |
展開
1 | echo {1..5} // 1 2 3 4 5 |
3. Logic Statement
binary comparison operator 常見可以參考 Advanced Bash-Scripting Guide
1 | # 前面成功執行的話,後面才會執行 |
字串用 ><= 一類的符號,數字則是用英文縮寫.詳細可以參考 3.1, 3.2 章節
1 | if [ $age -ge 65 ] && [ $name == "John" ]; then |
3.1 Boolean Operation
1 | ! #not |
1 | # 變數 num 比 10 小 『或是』 比 100 大 |
3.2 Integer Opteraion
1 | # -eq: is equal to |
3.3 String Comparision
在 Bash 中,=
和 ==
都用來進行字串比較,但它們之間有一些微妙的差異,特別是在單中括號([ ]
)和雙中括號([[ ]]
)中的行為有所不同。
=
比較符號
單中括號 [ "$a" = "$b" ]
- 語法:
[ "$a" = "$b" ]
- 功能:這是最基本的字串比較,用於檢查兩個字串是否相等。
- 注意事項:一定要注意空格。在
[ "$a" = "$b" ]
中,=
兩側必須有空格。如果缺少空格,如[ "$a"="$b" ]
,這樣的比較會導致錯誤,因為=
會被誤認為是命令的一部分。
範例:
1 | a="apple" |
==
比較符號
單中括號 [ "$a" == "$b" ]
和雙中括號 [[ "$a" == "$b" ]]
- 語法:
[ "$a" == "$b" ]
或[[ "$a" == "$b" ]]
- 功能:
==
在某些情況下可以用來比較字串,但在單中括號([ ]
)中,==
和=
基本上是等效的。唯一的區別在於,==
可以在雙中括號([[ ]]
)中使用更多的功能,並且會支援模式匹配(pattern matching)。 - 注意事項:
- 在單中括號中,
==
和=
是相同的,兩者都用來比較字串。 - 在雙中括號中,
==
支援字串的模式匹配(例如通配符匹配),這是=
所不支援的。 - 單中括號中的
==
行為類似於=
,但在雙中括號中,==
可以進行模式匹配。
- 在單中括號中,
範例(單中括號):
1 | a="apple" |
範例(雙中括號,模式匹配):
1 | a="apple" |
==
與 =
的差異
- 單中括號
[ ]
:- 在
[ "$a" == "$b" ]
和[ "$a" = "$b" ]
中,==
和=
基本上是等效的。 - 它們都比較字串是否相等。
- 在
- 雙中括號
[[ ]]
:- 在
[[ "$a" == "$b" ]]
中,==
會進行模式匹配,因此支持通配符(如*
或?
)。 - 而
=
只是做簡單的字串比較,不支持模式匹配。
- 在
4. Loop
for each,這兩種 array 存取值的寫法是等價的
1 | arr=(Alice Bob Oscar) |
也可以使用 C-style for loop
1 | for (( i=1; i<=5; i++ )); do |
while Loop
1 | i=0 |
5. Redirection
1 | echo john | grep 'john' # pipe,第一個指令的 stdout 作為第二個指令的 stdin |
6. Subshell (bash parallel script)
這段內容主要解釋了 子殼層 (subshell) 和 父殼層 (parent shell) 之間的執行行為,並說明了如何使用 &
來達到並行執行的效果。
基本的子殼層 (()
的用法)
1 | (sleep 5) |
(sleep 5)
這段程式碼會在一個 子殼層 中執行。子殼層是一個新的 shell 進程,它會從父殼層繼承環境變數等設定,但執行的指令會在這個子殼層中處理。sleep 5
會讓這個子殼層暫停 5 秒。- 然後,
echo done
在父殼層中執行。
注意:
- 如果你只是寫
(sleep 5)
,父殼層會等待這個子殼層完成後再繼續執行下面的指令(即echo done
)。這是因為沒有使用&
,父殼層會「等待」子殼層執行完成。 - 所以,這段程式碼的結果是:子殼層執行
sleep 5
,父殼層會等待 5 秒,然後才顯示done
。
使用 &
讓父殼層不等待子殼層
1 | (sleep 2; echo child) & |
(sleep 2; echo child)
這段程式碼放在子殼層中執行,並且加上了&
,表示 將子殼層的執行放到背景進行。&
讓父殼層不會等子殼層執行完成,而是會繼續執行後續的指令。echo parent
在父殼層中立即執行。
結果:
- 子殼層會執行
sleep 2
然後顯示child
,這需要 2 秒鐘。 - 父殼層會立即執行
echo parent
,所以會先顯示parent
。 - 兩個
echo
命令是並行執行的,這就達到了平行處理的效果。
7. Here Documents
什麼是 “Here Document”?
這個名稱的概念來自:「這裡 (here) 就有一份文件 (document)」 → 你不需要從外部讀取,而是直接在腳本內提供多行內容。對應傳統 Shell Script 的標準輸入 (stdin) → 通常程式需要用 < file.txt 讀取外部檔案,但 Here Document 讓你直接內嵌資料。
📌 具體比較
方法 | 輸入來源 | 描述 |
---|---|---|
< file.txt |
外部檔案 | 讀取 file.txt 內容當作標準輸入 |
<<EOF ... EOF |
內嵌內容 | 直接在腳本內提供多行內容 |
傳統方式(讀取外部檔案)
1 | cat < myfile.txt |
Here Document(直接提供內容)
1 | cat <<EOF |
總結來說:
Here Document 讓 Shell Script 不需要額外的檔案,而可以直接在 “這裡 (here)” 定義輸入,這就是它名稱的由來!
什麼是EOF?
EOF 的全名是 “End of File”,意思是「檔案結束」。在 Here Document 的語境中,EOF 只是個常見的 標記 (delimiter),它本身沒有特別的語法意義。你可以用任何字串作為結束標記,但 EOF 是最常見的約定俗成的標記,因為它表示「這段輸入的結束」。
例如:
1 | cat <<MYMARKER |
Refernece
- Minimal safe Bash script template
- Advanced Bash-Scripting Guide: 如果真的要精通 bash ,可以讀一下,小心不要走火入魔
- Google | Style Guide - Shell Guide 本篇文章的主要來源