嗨!歡迎回到上班族的命令列生存手冊。終於要來正式的寫一個 Shell Script,沒想到可以拖個兩回,就像看漫畫還是動畫突然進入角色回憶篇一樣。不過還有一些事情沒有做完,還記得前兩篇提到的一個簡單的 Shell Script 嗎?
請讀者試著執行它(可以把它存成 sayHi.sh
,並將權限修改成 744 😉 ):
#!/bin/bash
echo "Hi there! $1"
這個 Shell Script 印出 Hi there! 在畫面上。注意在 echo
之中,有一個 $1
代表可以帶一個參數進來。可以這樣讓他喊出傳入的名子:
$ ./sayHi.sh Lois
就會印出 Hi there! Lois 啦。
讀者可以試著修改 $1
成 $0
,會印出這個 Script 自身的路徑。
先來看看結果圖:
比如說平常下載了太多檔案,通常「下載」資料夾應該是超亂,而且什麼檔案都有。這個 Script 就能幫忙分門別類,圖片、文件、壓縮檔、影片…等等。
這一章大方向看起來很像是在學怎麼寫程式,讓我們一步步的來看要怎麼寫,讀者也可以依照需求,開啟完整的程式碼。
先來 touch
一個檔案:
$ touch organize.sh
然後用文字編輯器進去。首先每一個 Shell Script 最上面都需要寫這一行。
#!/bin/bash
#!
這東西叫做 Shebang(或是 Hashbang),這裡會告訴執行的人該用哪種 shell 來執行它(畢竟是 Shell 的 Script)。通常是寫 bash,也有一些會寫
#!/bin/sh
則該 POSIX 系統預設的 Shell 就會把它叫起來執行。為何會是這樣?有個小軼事是 sh 本來是叫 Bourne Shell 的一種 Shell,由史蒂夫·伯恩(Stephen Bourne)開發。1970 年代末很受歡迎,只是到了現在已經算是高齡駕駛,很多功能都不支援。它被當成許多系統的標準,相容很多的 Shell Script 基本的指令。
通常,系統都會把他的子弟兵叫出來執行,其中最有名的叫做 bash,全名就是 Bourne-Again Shell。
如果有些 Linux 發行版不包含 bash,寫成 #!/bin/sh
會有比較好的相容性。
文章此處以下開始,#
開頭的則是代表程式碼的註解。
接下來就是 Script 的部份,先把幾個大方向確定之後,再一一將細項的子任務實作出來即可,就像拼圖一樣一一拼上。程式碼分成三個部份:
來看看各自需要完成什麼子任務。
程式碼區塊 | 子任務 |
---|---|
備料區 | 建立好目標資料夾,像是圖片、文件、壓縮檔…之類的。 |
烹飪區 | 將目前資料夾的各種檔案分門別類放入目標資料夾。 |
擺盤區 | 印出任務已經完成,提示使用者。 |
區塊主任務: 建立好目標資料夾,像是圖片、文件、壓縮檔…之類的。
任務再切細成子任務:
mkdir
建立資料夾,已經存在就不用加了。
mkdir -p
,就能防止重複執行pwd
取得目前的路徑目前的程式碼:
# subtask02: 目標資料夾就是目前的資料夾
TARGET_DIR="$(pwd)"
# subtask03
echo "--- 開始整理資料夾: $TARGET_DIR ---"
# subtask01: 預先建立好分類用的資料夾
mkdir -p "$TARGET_DIR/圖片"
mkdir -p "$TARGET_DIR/文件"
mkdir -p "$TARGET_DIR/壓縮檔"
mkdir -p "$TARGET_DIR/影片"
mkdir -p "$TARGET_DIR/其他檔案"
$(pwd)
作為字串並存成 TARGET_DIR
變數裡。$TARGET_DIR
當成變數,就不用重複打字區塊主任務: 將目前資料夾的各種檔案分門別類放入目標資料夾。
任務再切細成子任務:
有幾個語法需要說明,我們一個一個看:
miniTask01: 取得目錄每個檔案
Shell Script 的 for 迴圈可以直接用萬用字元展開(globbing)取得所有檔案與資料夾:
for file in "$TARGET_DIR"/*
do
# [會不斷地把 $TARGER_DIR 裡頭的檔案一個一個拿出來,並將路徑放到 file 變數],[miniTask02] 在這做
done # for 結束要有 done
miniTask02: 確認是檔案嗎?不是就直接忽略
# 判斷目前處理的項目是否為一個 "檔案" (-f)
# 這樣可以避免我們移動到資料夾
if [ -f "$file" ]; then # 都要加 ; then
# [可以在這個 block 運行的,一定是檔案]
# [miniTask03] 在裡頭做
fi # if 結束要有 fi
Shell Script 的 if 判斷很玄妙,可以用文字。像是等於可以用 -eq
或是 ==
。也有這種判斷是不是檔案的 -f
,如果要判斷是不是資料夾,就是 -d
。
如果有 else if 或是 else,語法是這個樣子的:
if [ condition1 ]; then
# 符合 condition1
elif [ condition2 ]; then
# else if (condition2)
else
# else
fi
miniTask03: 是檔案!分門別類放到目標資料夾
我們需要的是 Switch Case 或是 When 等現代語言會有的語法,在 Shell Script 裡頭,有個類似的用法是用 case 判斷式:
# 使用 case 判斷式,根據檔案名稱 (副檔名) 決定要移到哪裡
case "$file" in
*.jpg|*.png|*.gif)
# 圖片
;; # 每個 case 結束都需要有 ;;
*.pdf|*.docx|*.txt)
# 文件
;;
*.zip|*.rar|*.gz)
# 壓縮檔
;;
*.mp4)
# 影片
;;
*) # `*` 代表除了上述條件以外的所有情況,即 else
# [這邊要避免移到自己,這個是 subtask02 要做的]
;;
esac # case 表達式結束都需要有 esac
所以每個 case 內部該寫什麼呢?到這步我們已經鎖定了這是什麼類型的檔案,比如說在圖片這個判斷式裡頭就是要做:
echo "移動圖片檔案: $file"
mv "$file" "$TARGET_DIR/圖片/"
其他依此類推實作。
得預先準備「Script 自己的名子」,還記得上面執行 sayHi.sh
時把裡頭的 $1
改成 $0
就可以取得目前 Script 的路徑?這正是這裡要的。
配合 basename
指令,它會取得路徑最後一個元素,打個比方,現在的路徑在:
~/Document/sayHi.sh # $0 會印出來
$ basename ~/Document/sayHi.sh
sayHi.sh # 印出最後一個部份,就是檔名
這邊備料的時候要多備一個,存變數裡頭:
SCRIPT_NAME="$(basename "$0")"
接著,case 判斷裡頭的其他檔案,把目前的 Script 檔案排除掉。
# subtask02: 只移動這個 Shell Script 以外的檔案
if [[ "$(basename "$file")" != "$SCRIPT_NAME" ]]; then
echo "移動其他檔案: $file"
mv "$file" "$TARGET_DIR/其他檔案/"
fi
basename
。如果 basename $file
的結果跟上面存的 SCRIPT_NAME
不同,再移動這些檔案到「其他檔案」資料夾。完整的組合起來,程式碼就會是這樣:
SCRIPT_NAME="$(basename "$0")"
# subtask01, minitask01
for file in "$TARGET_DIR"/*
do
# minitask02
if [ -f "$file" ]; then
# minitask03
case "$file" in
*.jpg|*.png|*.gif)
echo "移動圖片檔案: $file"
mv "$file" "$TARGET_DIR/圖片/"
;;
*.pdf|*.docx|*.txt)
echo "移動文件檔案: $file"
mv "$file" "$TARGET_DIR/文件/"
;;
*.zip|*.rar|*.gz)
echo "移動壓縮檔案: $file"
mv "$file" "$TARGET_DIR/壓縮檔/"
;;
*.mp4)
echo "移動影片檔案: $file"
mv "$file" "$TARGET_DIR/影片/"
;;
*)
# subtask02
if [[ "$(basename "$file")" != "$SCRIPT_NAME" ]]; then
echo "移動其他檔案: $file"
mv "$file" "$TARGET_DIR/其他檔案/"
fi
;;
esac
fi
done
區塊主任務:印出任務已經完成,提示使用者。
這個最簡單,直接 echo
就可以了。
echo "--- ✅ 檔案整理完成! ---"
再看看完整的程式碼,上面就是所有的 Script 內容。請讀者自行建立 Shell Script,並賦予執行權限來嘗試看看吧!
時間過得很快,下一章節要來介紹那個最有名的編輯器,傳說中只有它和其他編輯器,我們下次見囉。
關於上面的 Shell Script,讀者應該注意到了兩種不同的 if 括弧寫法:
if [ -f "$file" ]
if [[ "$(basename "$file")" != "$SCRIPT_NAME" ]]
為何有 [ <內容> ]
和 [[ <內容> ]]
兩種呢?(在講 [ ]
中括號的數量啦!)
一般寫 Shell Script,請優先用比較多括弧的那一個,也就是 [[ <內容> ]]
。除非,今天需求是追求最大相容性,像支援舊的 Shell 或是 Debian/Ubuntu 的 dash,再考慮用 [ <內容> ]
。
那麼兩者有什麼差異?為何要用括弧比較多的那一個?
[ <內容> ]
這是一個叫做 test
指令,在 Shell Script 裡頭可以寫作 [ <內容> ]
,因為這是語法糖,但本質上還是執行了 [
後面的內容,直到 ]
,這一塊會被當成參數傳入 test。
執行指令會有的行為,在用這個語法糖也會發生,像是:Word Splitting。在用 if 對比兩個內容時,也有一些需要注意的地方。
像是這個:
# 使用 [ ] 時,如果變數為空會出錯
name=""
[ $name = "banana" ] # 出錯!會變成 [ = "banana" ]
[ "$name" = "banana" ] # 正確:需要加引號
# 使用 [[ ]] 時,較為安全
name=""
[[ $name = "cherry" ]] # OK,不會出錯
[[ "$name" = "cherry" ]] # 也 OK,引號是 Optional 可加可不加
如果變數有空白,也可能遇到問題,在前面提到的 Word Splitting 就是這類問題:
var="hello world"
# [ ] 會進行 Word Splitting
[ $var = "hello world" ] # 錯誤:變成 [ hello world = "hello world" ]
[ "$var" = "hello world" ] # 正確:需要加引號
# [[ ]] 不會進行 Word Splitting
[[ $var = "hello world" ]] # OK
[[ <內容> ]]
這是現代 Shell 的 keyword,就像是程式語言裡頭的保留字:if、switch、when… 等,可以想像成上面 test 語法糖的加強版。除了不用面對指令本質可能會遇到的問題以外,還多了其他的功能,例如正則表達式、萬用字元(也就是 *
)。
只是,更老舊的 Shell 或是 dash 並不支援這個,遷就於相容性選擇上面的寫法;否則優先選擇雙中括號,就是為了避免 test 指令遇到的問題,並且多了很多實用的功能。