前言

因為最近為了撰寫 CI/CD Pipeline,因此有很大的時間都在寫 bash 腳本,寫著寫著就覺得應該來好好了解一些最佳實踐!因此整理出這篇教學。

在目前的世界裡面,Bash 繼承了 shell 的寶座,Bash 是可執行檔唯一允許的 shell 腳本語言。幾乎可以在所有 Linux 上找到,包括 Docker 鏡像。這是大多數後端運行的環境。因此,如果您需要編寫伺服器應用程式啟動、CI/CD 步驟或集成測試運行的腳本,Bash 隨時為您服務。

這篇主要參考了 google 所撰寫的 Shell Style Guide。然後還有網路常見的建議所組成,詳細可以參考 Reference。希望自己在寫腳本的時候可以養成良好的習慣…

google

如果您正在編寫超過 100 行的腳本,或者使用不直覺的控制流邏輯,那麼現在應該用更結構化的語言重寫它。請記住,腳本會不斷增長。儘早重寫腳本,以避免以後更耗時的重寫

Environment

所有錯誤消息都應轉到 STDERR

  • 這使得更容易區分正常狀態和實際問題。
  • 建議使用列印錯誤消息和其他狀態資訊的功能。

ps.我自己覺得還可以添加顏色來加強錯誤訊息可讀性

1
2
3
4
5
6
7
8
err() {
echo -e "\033[31m[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*\033[0m" >&2
}

if ! do_something; then
err "Unable to do_something"
exit 1
fi

Comments

每個檔都以其內容的說明開始

  • 每個文件都必須有一個置頂的註釋,包括其內容的簡要概述。
  • 版權聲明和作者資訊是可選的。

註釋代碼中棘手、不明顯、有趣或重要的部分

  • 這遵循了一般的Google編碼註釋實踐。不要評論所有內容。如果演算法複雜,或者你正在做一些不尋常的事情,請放一個簡短的評論
1
2
3
#!/bin/bash
#
# Perform hot backups of Oracle databases.

Function Comments

任何既不明顯又簡短的函數都必須具有『函數頭註釋』

  • 庫中的任何函數都必須具有函數頭註釋,無論長度或複雜程度如何。
  • 其他人應該可以通過閱讀註釋(和自助,如果提供)來學習如何使用您的程式或使用庫中的函數,而無需閱讀代碼。
  • 所有函數標頭註釋都應使用以下命令描述預期的 API 行為:
    • Description (DESC): 函數的描述。
    • Globals (GLOBS):使用和修改的全域變數清單。
    • Arguments (ARGS):所採用的參數。
    • Outputs (OUTS):輸出到 STDOUT 或 STDERR。
    • Return (RETS):返回的值不是上次命令運行的預設退出狀態。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# google 的寫法 + 我覺得 github 上別人不錯的寫法
#######################################
# DESC: Cleanup files from the backup directory.
# GLOBS:
# BACKUP_DIR: description
# ORACLE_SID: description
# ARGS:
# None
#######################################
function cleanup() {

}

#######################################
# DESC: Get configuration directory.
# GLOBS:
# SOMEDIR: description
# ARGS:
# None
# OUTS:
# Writes location to stdout
#######################################
function get_dir() {
echo "${SOMEDIR}"
}

#######################################
# DESC: Delete a file in a sophisticated manner.
# ARGS:
# $1 (optional): File to delete, a path.
# RETS:
# 0 if thing was deleted, non-zero on error.
#######################################
function del_thing() {
rm "$1"
}

Todo Comments

對臨時代碼、短期解決方案代碼或足夠好但不完美的代碼使用 TODO 註釋

  • TODO 應包含全部大寫的字串 TO`DO
  • TODO 應該引用相關人員、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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 如果需要編寫超過 80 個字元的文字字串請使用 here-document 
cat <<END
I am an exceptionally long
string.
END

# 允許使用 Embedded newlines (換行)在超過 80 個字元的文字字串中
long_string="I am an exceptionally
long string."

# 檔案路徑可以接受不換行的寫法
long_file="/i/am/an/exceptionally/loooooooooooooooooooooooooooooooooooooooooooooooooooong_file"

# 但是字串+路徑就應該換行
long_string_with_long_file="i am including an exceptionally \
/very/long/file\
in this long string."

# 長文件轉換為更短的變數名,並且換行更清晰。
long_string_alt="i am an including an exceptionally ${long_file} in this long\
string"

# 即使字串或路徑很長,也應該遵守換行與格式規範
# 適當換行可以提高可讀性
# 可以用 \ 或 Here Document 來處理長字串

bad_long_string_with_long_file="i am including an exceptionally /very/long/file in this long string."
# 可以改成下面的,這樣 斷行 但仍然是同一個字串。
bad_long_string_with_long_file="i am including an exceptionally \
/very/long/file in this long string."
# 或是使用 Here Document
bad_long_string_with_long_file=$(cat <<EOF
i am including an exceptionally /very/long/file
in this long string.
EOF
)

Exception: 只有在使用 <<- 這種 tab-indented 的方式才能允許使用 tabs 在 here-document裡面。

Pipeline

所謂的管道(Pipeline)是指將一個指令的輸出作為另一個指令的輸入。在 Bash 中,管道由 | 符號來連接兩個或多個指令。

  • 如果管道不能全部放在一行上,則應每行拆分一個管道。
  • 如果管道全部可以放在同一行,那就都放在同一行。
  • 否則,應將其拆分為每行一個管段。
    • 管道位於換行符上,管道的下一段縮進 2 個空格
    • \ 應始終用於表示行繼續
    • 這適用於使用 |以及使用 ||&&
1
2
3
4
5
6
7
8
9
10
# All fits on one line
# 管道全部可以放在同一行
command1 | command2

# Long commands
# 管道的下一段縮排 2 spaces 並且使用 `\` 來表示行繼續
command1 \
| command2 \
| command3 \
| command4

總結來說:

  • 這有助於在區分 pipeline 和常規的 long command continuation 時提高可讀性。
  • 評論需要在整個管道之前。如果 comment 和 pipeline 很大很複雜,那麼值得考慮使用 helper 函數將它們的低級細節移到一邊。

Control Flow

;then 或是 ;doiffor 放在同一行

  • shell 中的控制流語句略有不同,但我們在聲明函數時遵循與大括號相同的原則。
    • 那是;then;do 應該與 if/for/while/until/select 在同一行
    • else 應該在它自己的行上
    • 結束語句 (fidone) 應該在它們自己的行上,並且與開始語句垂直對齊。

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 如果是在 function 裡面記得要把 loop 變數宣告為 local,避免它洩漏到全域環境中
## 宣導 loop 變數為 local 變數
local dir
## ;do 與 for 放在同一行
for dir in "${dirs_to_cleanup[@]}"; do
## ;then 與 if 放在同一行
if [[ -d "${dir}/${SESSION_ID}" ]]; then
log_date "Cleaning up old files in ${dir}/${SESSION_ID}"
rm "${dir}/${SESSION_ID}/"* || error_message
## else 獨自一行
else
mkdir -p "${dir}/${SESSION_ID}" || error_message
## fi 獨自一行
fi
done ## done 獨自一行

儘管在 for 迴圈中可以省略 “$@” ,但為了清楚起見,我們建議始終包含它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 保留的寫法
for arg in "$@"; do
echo "argument: ${arg}"
done

# 簡化的寫法
for arg; do
echo "argument: ${arg}"
done

## 假設 for 寫在 test.sh 腳本裡面
# ./test.sh a b c
# 會印出
# argument: a
# argument: b
# argument: c

Case statement

case 語句的格式規範

  1. 縮進要統一

    • case 裡的選項 (a)absolute)) 要縮進『兩個空格』,讓結構清楚。
    • 如果 case 內有多行指令,它們要再縮進『一層』,保持層級分明。
  2. 單行選項的格式

    • 單行的 case 選項(例如 a) some_command ;;)要在 );; 之間加空格,讓可讀性更好。
  3. 多行選項的格式

    • 如果 case 內的某個選項有『多行指令』,應該:
      • 模式 (pattern) 獨立一行,這裡的模式就是 a)absolute)${expression} 的值是哪一個 Pattern
      • 執行的命令 也就是 some_command 各自一行
      • ;;(結束標記)也獨立一行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
case "${expression}" in
# 縮進 2 spaces
a)
# case 裡面有多行指令,再縮進一層
# 多行的話每個指令都要獨立一行
variable="…"
some_command "${variable}" "${other_expr}"
# 結束標記也是獨立一行
;;
absolute)
actions="relative"
another_command "${actions}" "${other_expr}"
;;
*)
error "Unexpected expression '${expression}'"
;;
esac

簡單命令

  • 可以與模式 Pattern;;只要表達式保持可讀即可。
  • 這通常適用於 single-letter option 處理。
  • 當作不適合一行時
    • pattern 單獨放在一行上
    • 然後是 ;; 也在自己的一行上
  • 當與作位於同一行時
    • 請在模式的右括弧)後使用一個空格
    • ;; 之前使用另一個空格
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# getopts 是一種可以處裡命令列選項的工具 e.g. test.sh -a -b -c 這種輸入
# 的 "abf:v" 是用來處理 短選項(single-character flags),而且每個選項的格式必須是單個字符
# 選項之後如果需要參數,則會加上 :(例如 f: 表示 -f 需要參數)
while getopts "abf:v" flag; do
case "$flag" in
# 右刮號後面加一個空格,並且 ;; 前面使用另一個空格 e.g. a) ... ;;
a) echo "選項 -a 被啟用" ;;
b) echo "選項 -b 被啟用" ;;
f) echo "選項 -f 的值是:$OPTARG" ;;
v) echo "選項 -v 被啟用" ;;
*) echo "未知選項:$flag" ;;
esac
done

# 執行
./script.sh -a -b -f myfile.txt -v

# 會印出
# 選項 -a 被啟用
# 選項 -b 被啟用
# 選項 -f 的值是:myfile.txt
# 選項 -v 被啟用

Variable expansion

使用 ${var} 而不是 $var

這些是強烈建議的指導方針,但不是強制性法規。儘管如此,這是一項建議而不是強制性的事實,並不意味著它應該不做,簡單來說,這段的主要內容是:

  • 保持一致性:
    • 如果你修改的是別人寫的代碼,最好跟現有代碼的格式保持一致。這樣可以確保代碼的可讀性和一致性,減少錯誤。
  • 引用變數:
    • 當你使用變數時,最好總是加上大括號(例如:${var})來引用它,而不是簡單的 "$var"
    • 這是因為在某些情況下,簡單的 "$var" 會引發解析錯誤,尤其是在變數後面緊跟著其他字符時。使用大括號可以確保變數被正確解析。
  • 避免混淆:
    • 對於特殊字符或位置參數(比如 $1, $@),如果不是必要,應避免使用大括號({})包圍單個字符,這樣可以避免不必要的混淆。
    • 對於其他變數,首選使用大括號來包裹變數(例如 ${some_var}),這樣能避免解析上的錯誤,特別是當變數後接其他字符時。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Section of *recommended* cases.

# 如果是特殊字符或位置參數,不需要使用大括號
echo "Positional: $1" "$5" "$3"
echo "Specials: !=$!, -=$-, _=$_. ?=$?, #=$# *=$* @=$@ \$=$$ …"

# Braces necessary: 在 Bash 中,位置參數是以 $1, $2, $3 等方式表示的,最多可以有 $9。
# 但是,當參數的數字超過 9(例如 $10、$11 等)時,直接使用 $10 會被解析為 $1 和 0(即,$1 和字面上的數字 0)。這樣會導致錯誤的解析。
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 -r f; do
echo "file=${f}"
done < <(find /tmp)

Quoting

大概有一些總整理:

  • 使用變數、命令替換、空格或特殊字元時,引用(加上引號)例如 "$var"
  • 使用陣列來安全引用,例如 FLAGS=(--foo --bar='baz'),然後使用 "${FLAGS[@]}" 來引用
  • 在進行整數運算時,可以不用引用內部整數變數(如 $#$PPID
  • 引用 命令替換 時,使用雙引號將命令替換的結果括起來 (如"$(commend)"),並將結果賦值給變數Var="$(commend)",可以確保命令替換的結果被正確解析
  • 如果不希望把$解析成變數,可以使用單引號 '$$$' 或是 \$ 來引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

path="my file.txt"
echo $path # 會報錯或處理成 "my" 和 "file.txt" 兩個參數
echo "$path" # 正確輸出 "my file.txt"

# 這行會在 /dev/null 上運行 grep,並查找是否有與 "Hugo" 匹配的內容,並顯示結果的文件名稱(如果有)
# 由於這裡使用了 "$1",它會保證即使 $1 參數中包含空格或其他特殊字符,也不會出錯
grep -li Hugo /dev/null "$1"


# ${css:+} 它使用了 條件擴展(conditional expansion):
## 如果變數 ccs 是空的,則這一部分會被忽略。
## 如果變數 ccs 有值,則會擴展成 "--cc" "${ccs}",即將 ccs 的內容加到命令中,作為抄送(CC)的收件人。
git send-email --to "${reviewers}" ${ccs:+"--cc" "${ccs}"}

# ${1:+"$1"} 與上面的類似這是條件擴展。
## 這表示如果 $1 有值(即用戶提供了第一個參數),則將 $1 傳遞給 grep 命令作為搜索的文件或參數。
## 如果 $1 沒有值,則什麼都不會傳遞。
grep -cP '([Ss]pecial|\|?characters*)$' ${1:+"$1"}

# 執行 some_command,並將其帶有的引數與 $@(所有命令行參數)和 'quoted separately' 字符串作為輸入。
# 用雙引號將命令替換的結果括起來,並將結果賦值給變數 flag,可以確保命令替換的結果被正確解析。
flag="$(some_command and its args "$@" 'quoted separately')"

# 使用陣列來安全處理參數
declare -a FLAGS # 宣告一個名為 FLAGS 的陣列
FLAGS=(--foo --bar='baz') # 給陣列賦值 例如,命令行的選項
mybinary "${FLAGS[@]}" # 使用陣列FLAGS[@]來引用會更安全,避免把空格或特殊字符當作分隔符

# 取得陣列
echo ${FLAGS[0]} # 輸出 "--foo"
echo ${FLAGS[1]} # 輸出 "--bar='baz'"

# for loop
for flag in "${FLAGS[@]}"; do
echo "Option: $flag"
done
# 會列印出
# Option: --foo
# Option: --bar='baz'

# 不需要引用的內部變數
echo "Exit status: $?"
echo "Number of arguments: $#"

# $#:表示傳遞給腳本或函數的『參數數量』。這是一個整數變數。
# 例如,如果執行 ./script.sh arg1 arg2 arg3,那麼 $# 的值就是 3。
# (()) 這是一個算術擴展的語法,用來進行數值運算或比較, (( $# > 3 )) 表示比較 $#(參數的數量)是否大於 3。
if (( $# > 3 )); then
# $PPID:表示當前腳本的父進程 ID(PID)。這也是一個整數變數,代表腳本的父進程。
echo "ppid=${PPID}"
fi

# "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"
# 如果我們不希望 shell 解析特殊字符,可以使用 '' 來引用 或是使用 \ 來 escape
echo 'Hello stranger, and well met. Earn lots of $$$'
echo "Process $$: Done making \$\$\$." # 會印出:Process 1234: Done making $$$.

# set -- 1 "2 two" "3 three tres":設置參數為 1, "2 two", 和 "3 three tres"。
# echo $#:輸出參數數量 3。

# set -- "$*"; echo "$#, $@":
## "$*" 會將所有參數『合併成一個參數』,並傳遞給 set,因此它會變成一個包含所有參數的字符串(1 2 two 3 three tres)。
# echo "$#, $@":
## $# 是參數的數量,它會輸出 1,因為 "$*" 被視為一個整體。
## $@ 輸出的所有參數會作為單獨的參數傳遞給 echo,所以它會輸出 1 2 two 3 three tres(這和 "$*" 的合併方式有關)。
(set -- 1 "2 two" "3 three tres"; echo $#; set -- "$*"; echo "$#, $@")
# 會印出
## 3
## 1, 1 2 two 3 three tres

# set -- "$@"
## 將每個參數作為『獨立的參數』傳遞給 set,所以它保持原來的三個參數 1, "2 two", 和 "3 three tres"。
# echo "$#, $@":
## $# 會輸出參數數量 3,因為 "$@" 每個參數都保持獨立。
## $@ 會列出所有參數,依次輸出 1 2 two 3 three tres。
(set -- 1 "2 two" "3 three tres"; echo $#; set -- "$@"; echo "$#, $@")
# 會印出
## 3
## 3, 1 2 two 3 three tres

Test, [...], and [[...]]

永遠使用 [[ ... ]] 優先於 [ ... ]test/usr/bin/[,主要有幾個原因:

  • [[ ... ]] 支援模式和正則表達式匹配,像 ===~ 可以用來進行模式匹配或正則表達式匹配。
  • [[ ... ]] 內部,像 *? 等特殊字符不會被自動展開成目錄或文件名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
file=$(echo *) # 會印出 目前目錄下的所有檔案 file1.txt file2.txt file3.txt

# 這裡的 "file*" 會被展開成當前目錄下所有以 file 開頭的文件名(例如 file1.txt、file2.txt 等)
# 這裡的 "file*" 會被展開成當前目錄下所有以 file 開頭的文件名(例如 file1.txt、file2.txt 等)
# 如果目錄中有多個符合條件的文件,這樣會導致比較失敗或者產生錯誤。
if [ "file*" == "file1.txt" ]; then
echo "Match"
fi

# 上面同等於下面這個寫法
if [ "file1.txt file2.txt file3.txt" == "file1.txt" ]; then
echo "Match"
fi

# 單純比較字串,不會展開成文件名
# 這裡的 "file*" 不會被自動展開。[[ ... ]] 會原樣保持字串 "file*" ,並將其視為字面值進行比較,不會擴展為文件名。
# 這樣的比較將會檢查字面上的 "file*" 是否等於 "file1.txt",結果不會有路徑名擴展的干擾。
if [[ "file*" == "file1.txt" ]]; then
echo "Match"
fi

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

# 使用 -z (字串長度為零) 和 -n (字串長度不為零) 來測試空字符串
if [[ -z "${my_var}" ]]; then
do_something
fi

# 這個不建議來檢查是否為空字串
if [[ "${my_var}" == "" ]]; then
do_something
fi

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

# (X) Instead of this
if [[ "${my_var}" ]]; then
do_something
fi

# (O) Use this
if [[ "${my_var}" == "val" ]]; then
do_something
fi

# (O) 數值使用 (())
if (( my_var > 3 )); then
do_something
fi

# (O) 數值比較使用 -lt -gt
if [[ "${my_var}" -gt 3 ]]; then
do_something
fi

# (X) Instead of this 不要使用單一個 = 避免混淆
if [[ "${my_var}" = "val" ]]; then
do_something
fi

# (X) Instead of this 可能會導致意外的字典排序
if [[ "${my_var}" > 3 ]]; then
# True for 4, false for 22.
do_something
fi

Naming Conventions

命名公約

Function Names

  • 小寫,下劃線(_)分隔單詞
  • 使用 :: 分隔 library
  • 如果要編寫 package,請用 :: 分隔 package 名稱。但是,用於互動式使用的函數可以選擇避免使用冒號,因為它可能會混淆 bash 自動完成。
  • 函數名稱后需要括號,大括弧必須與函數名稱位於同一行,並且函數名稱和括弧之間沒有空格。
  • 關鍵funciton數是可選的,但必須在整個專案中一致地使用
  • 如果您有函數,請將它們全部放在文件頂部附近。只有 includes、set 語句和設置常量可以在聲明函數之前完成。
1
2
3
4
5
6
7
8
9
# Single function
my_func() {

}

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

}

Variable Names

  • 與 Function Names 相同
  • 循環的變數名稱應該與您正在迴圈的任何變數的命名類似
1
2
3
for zone in "${zones[@]}"; do
something_with "${zone}"
done

Constants, Environment Variables, and readonly Variables

  • 常量和導出到環境的任何內容都應該大寫,用下劃線分隔,並在文件頂部聲明
  • 為清楚起見,建議使用 readonlyexport 而不是等效的 declare 命令。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Constant
readonly PATH_TO_FILES='/some/path'

# Both constant and exported to the environment
# 它可以用來設置變數為只讀(-r)、設置變數為環境變數(-x,相當於 export),還可以設置變數為數字(-i)等。
declare -xr ORACLE_SID='PROD'

# Constant
# 為清楚起見,建議使用 `readonly` 或 `export` 而不是等效的 `declare -xr` 命令
readonly PATH_TO_FILES='/some/path'
export PATH_TO_FILES

# 可以在運行時或在條件中設置常量,但之後應立即將其設為 readonly。
ZIP_VERSION="$(dpkg --status zip | sed -n 's/^Version: //p')"
if [[ -z "${ZIP_VERSION}" ]]; then
ZIP_VERSION="$(pacman -Q --info zip | sed -n 's/^Version *: //p')"
fi
if [[ -z "${ZIP_VERSION}" ]]; then
handle_error_and_quit
fi
readonly ZIP_VERSION

Source Filenames

  • 小寫,如果需要,可以使用下劃線分隔單詞。

Use Local Variables

  • 使用local關鍵字來聲明函數內部的變數,以避免變數泄漏到全局環境中。
  • 當你用命令替換(command substitution)($(command))給變數賦值時,declaration 和 assignment(聲明與賦值)必須分開來寫

在函數中,如果你使用 local 來聲明一個變數,並將其賦值為一個命令替換的結果(例如:$(my_func)),這時需要注意:如果把聲明和賦值寫在同一行,$? 會返回的是 local 命令的退出碼,而不是 my_func 的退出碼。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
my_func2() {
# 正確做法:分開聲明和賦值
local my_var
my_var="$(my_func)" # 這裡用命令替換給變數賦值
(( $? == 0 )) || return # 檢查命令 my_func 的退出碼

# 其他操作...
}

my_func2() {
# 錯誤做法:將聲明和賦值放在同一行
local my_var="$(my_func)" # $? 會返回 local 命令的退出碼,而不是 my_func 的退出碼
(( $? == 0 )) || return # 這裡 $?返回的是 local 命令的退出碼

# 其他操作...
}

其他建議

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
#!/usr/bin/env bash

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
2
cd /home/user/myscript
./main.sh # ✅ OK

但如果執行:

1
2
cd /
/home/user/myscript/main.sh # ❌ 失敗:找不到 source.sh

補充:Bash Script Cheatsheet

1. Built-in Variables

1
2
3
4
5
6
$0, $1, $2 # 第 n 個參數,即 argv[n]
$# # 參數數量,即 argc
$@ # 所有的參數
$? # 上一個指令的回傳值
$$ # 目前程式的 pid
$! # 上一個在背景執行的程式的 pid

2. Bash Builins

數學運算,這兩個寫法等價。

1
2
echo $((1+1))
echo $[1+1]

變數,{} 可省略

1
2
3
4
5
6
name="Shannon"
echo $name
echo ${name}

# 字串長度
echo ${#name} # 7

指令替換,兩個寫法等價

1
2
echo $(whoami)
echo `whoami`

展開

1
2
echo {1..5} // 1 2 3 4 5
echo {1..10..2} // 1 3 5 7 9

3. Logic Statement

binary comparison operator 常見可以參考 Advanced Bash-Scripting Guide

1
2
3
4
# 前面成功執行的話,後面才會執行
echo hi && echo john
# 前面指令失敗的話,後面才會執行
grep 'notfound' /dev/null || echo 'Not found'

字串用 ><= 一類的符號,數字則是用英文縮寫.詳細可以參考 3.1, 3.2 章節

1
2
3
if [ $age -ge 65 ] && [ $name == "John" ]; then
echo '...'
fi

3.1 Boolean Operation

1
2
3
!     #not
-a #and
-o #or
1
2
3
4
5
6
7
8
# 變數 num 比 10 小 『或是』 比 100 大
if [ $num -lt 10 -o $num -gt 100 ]
then
echo "Number $num is out of range"
elif [ ! -w $filename ]
then
echo "Cannot write to $filename"
fi

3.2 Integer Opteraion

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# -eq: is equal to
if [ "$a" -eq "$b" ]

# -ne: is not equal to
if [ "$a" -ne "$b" ]

# -gt: is greater than
if [ "$a" -gt "$b" ]

# -ge: is greater than or equal to
if [ "$a" -ge "$b" ]

# -lt: is less than
if [ "$a" -lt "$b" ]

# -le: is less than or equal to
if [ "$a" -le "$b" ]

# < is less than (within double parentheses) 要使用雙括弧
(("$a" < "$b"))

# <= is less than or equal to (within double parentheses)
(("$a" <= "$b"))

# > is greater than (within double parentheses)
(("$a" > "$b"))

# >= is greater than or equal to (within double parentheses)
(("$a" >= "$b"))

3.3 String Comparision

在 Bash 中,=== 都用來進行字串比較,但它們之間有一些微妙的差異,特別是在單中括號([ ])和雙中括號([[ ]])中的行為有所不同。

= 比較符號

單中括號 [ "$a" = "$b" ]

  • 語法:[ "$a" = "$b" ]
  • 功能:這是最基本的字串比較,用於檢查兩個字串是否相等。
  • 注意事項:一定要注意空格。在 [ "$a" = "$b" ] 中,= 兩側必須有空格。如果缺少空格,如 [ "$a"="$b" ],這樣的比較會導致錯誤,因為 = 會被誤認為是命令的一部分。

範例:

1
2
3
4
5
a="apple"
b="apple"
if [ "$a" = "$b" ]; then
echo "a 和 b 相等"
fi

== 比較符號

單中括號 [ "$a" == "$b" ] 和雙中括號 [[ "$a" == "$b" ]]

  • 語法:[ "$a" == "$b" ][[ "$a" == "$b" ]]
  • 功能:== 在某些情況下可以用來比較字串,但在單中括號([ ])中,=== 基本上是等效的。唯一的區別在於,== 可以在雙中括號([[ ]])中使用更多的功能,並且會支援模式匹配(pattern matching)。
  • 注意事項:
    • 在單中括號中,=== 是相同的,兩者都用來比較字串。
    • 在雙中括號中,== 支援字串的模式匹配(例如通配符匹配),這是 = 所不支援的。
    • 單中括號中的 == 行為類似於 =,但在雙中括號中,== 可以進行模式匹配。

範例(單中括號):

1
2
3
4
5
a="apple"
b="apple"
if [ "$a" == "$b" ]; then
echo "a 和 b 相等"
fi

範例(雙中括號,模式匹配):

1
2
3
4
a="apple"
if [[ "$a" == a* ]]; then
echo "a 以 'a' 開頭"
fi

=== 的差異

  • 單中括號 [ ]
    • [ "$a" == "$b" ][ "$a" = "$b" ] 中,=== 基本上是等效的。
    • 它們都比較字串是否相等。
  • 雙中括號 [[ ]]
    • [[ "$a" == "$b" ]] 中,== 會進行模式匹配,因此支持通配符(如 *?)。
    • = 只是做簡單的字串比較,不支持模式匹配。

4. Loop

for each,這兩種 array 存取值的寫法是等價的

1
2
3
4
5
6
7
8
9
10
11
arr=(Alice Bob Oscar)

# method 1
for i in {0..2}; do
echo ${arr[i]}
done

# method 2
for i in "${arr[@]}"; do
echo $i
done

也可以使用 C-style for loop

1
2
3
for (( i=1; i<=5; i++ )); do
echo $i
done

while Loop

1
2
3
4
5
i=0
while [ $i -lt 5 ]; do
echo $i
((i++)) # 雙括號裡面可以寫 C-style 的運算式
done

5. Redirection

1
2
3
4
5
6
7
8
echo john | grep 'john' # pipe,第一個指令的 stdout 作為第二個指令的 stdin
echo hi > /tmp/text # stdout 輸出導向檔案並『覆寫』
echo hi >> /tmp/text # stdout 輸出導向檔案,『附加』在該檔案內容後
echo hi 2>&1 # stderr 導向 stdout
echo hi 1>&2 # stdout 導向 stderr
echo hi &> /tmp/text # stdout 與 stderr 都導向檔案
cat < /tmp/text # stdin 改從檔案讀入:這個命令使用了輸入重定向(input redirection),將 /tmp/text 檔案的內容作為標準輸入(stdin)傳遞給 cat 命令。輸出結果與cat /tmp/text 無異
cat <(echo hi) # 把括號中的輸出丟到 cat 指令當 stdin

6. Subshell (bash parallel script)

這段內容主要解釋了 子殼層 (subshell)父殼層 (parent shell) 之間的執行行為,並說明了如何使用 & 來達到並行執行的效果。

基本的子殼層 (() 的用法)

1
2
(sleep 5)
echo done
  • (sleep 5) 這段程式碼會在一個 子殼層 中執行。子殼層是一個新的 shell 進程,它會從父殼層繼承環境變數等設定,但執行的指令會在這個子殼層中處理。
  • sleep 5 會讓這個子殼層暫停 5 秒。
  • 然後,echo done 在父殼層中執行。

注意:

  • 如果你只是寫 (sleep 5),父殼層會等待這個子殼層完成後再繼續執行下面的指令(即 echo done)。這是因為沒有使用 &,父殼層會「等待」子殼層執行完成。
  • 所以,這段程式碼的結果是:子殼層執行 sleep 5,父殼層會等待 5 秒,然後才顯示 done

使用 & 讓父殼層不等待子殼層

1
2
(sleep 2; echo child) &
echo parent
  • (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
2
3
cat <<EOF
這是一段內嵌的內容
EOF

總結來說:
Here Document 讓 Shell Script 不需要額外的檔案,而可以直接在 “這裡 (here)” 定義輸入,這就是它名稱的由來!

什麼是EOF?
EOF 的全名是 “End of File”,意思是「檔案結束」。在 Here Document 的語境中,EOF 只是個常見的 標記 (delimiter),它本身沒有特別的語法意義。你可以用任何字串作為結束標記,但 EOF 是最常見的約定俗成的標記,因為它表示「這段輸入的結束」。

例如:

1
2
3
4
5
6
7
8
9
10
cat <<MYMARKER
這是 Here Document 的內容
可以寫多行
MYMARKER

# 或是使用 <<- 可以使用縮排來提高可讀性
cat <<-MYMARKER
這是 Here Document 的內容
可以寫多行
MYMARKER

Refernece