iT邦幫忙

2025 iThome 鐵人賽

DAY 22
2

嗨!歡迎回到上班族的命令列生存手冊。終於要來正式的寫一個 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

先來看看結果圖:

day22-orginize-files.gif

比如說平常下載了太多檔案,通常「下載」資料夾應該是超亂,而且什麼檔案都有。這個 Script 就能幫忙分門別類,圖片、文件、壓縮檔、影片…等等。

這一章大方向看起來很像是在學怎麼寫程式,讓我們一步步的來看要怎麼寫,讀者也可以依照需求,開啟完整的程式碼

先來 touch 一個檔案:

$ touch organize.sh

Shebang

然後用文字編輯器進去。首先每一個 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 的部份,先把幾個大方向確定之後,再一一將細項的子任務實作出來即可,就像拼圖一樣一一拼上。程式碼分成三個部份:

  • Arrange(or Given):準備主要動作用的前置作業流程,也就是備料區。
  • Act(or When):主要工作都在這邊做,邏輯也會比較複雜,是為烹飪區。
  • PostAct(or Then):主要工作做完的收尾部份,即擺盤區。

來看看各自需要完成什麼子任務。

程式碼區塊 子任務
備料區 建立好目標資料夾,像是圖片、文件、壓縮檔…之類的。
烹飪區 將目前資料夾的各種檔案分門別類放入目標資料夾。
擺盤區 印出任務已經完成,提示使用者。

備料 (Arrange)

區塊主任務: 建立好目標資料夾,像是圖片、文件、壓縮檔…之類的。

任務再切細成子任務:

  • subtask01: mkdir 建立資料夾,已經存在就不用加了。
    • mkdir -p ,就能防止重複執行
  • subtask02: 在執行時,需要知道「現在的資料夾」。
    • pwd 取得目前的路徑
  • subtask03: 和使用者說明要來準備整理資料夾了

目前的程式碼:

# 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 當成變數,就不用重複打字

烹飪 (Act)

區塊主任務: 將目前資料夾的各種檔案分門別類放入目標資料夾。

任務再切細成子任務:

  • subtask01: 把目前目錄每個檔案都拿出來看看並分類
    • 這個任務還太大可以再切割
      • miniTask01: 取得目錄每個檔案
      • miniTask02: 確認是檔案嗎?不是就直接忽略
      • miniTask03: 是檔案!分門別類放到目標資料夾
  • subtask02: 不要把「Script 自己」也移動了,使用者用完不見就尷尬了
    • 要知道目前的檔案「是否是自己」?
    • 🚸 需要在備料時存 Shell Script 的名子

Act - subtask01 把目前目錄每個檔案都拿出來看看

有幾個語法需要說明,我們一個一個看:

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/圖片/"

其他依此類推實作。

Act - subtask02 不要把「Script 自己」也移動了

得預先準備「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
  • 用 if 判斷現在讀取到的檔案,取 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

擺盤 (PostAct)

區塊主任務:印出任務已經完成,提示使用者。

這個最簡單,直接 echo 就可以了。

echo "--- ✅ 檔案整理完成! ---"

再看看完整的程式碼,上面就是所有的 Script 內容。請讀者自行建立 Shell Script,並賦予執行權限來嘗試看看吧!

時間過得很快,下一章節要來介紹那個最有名的編輯器,傳說中只有它和其他編輯器,我們下次見囉。

補充資料:深水區

關於上面的 Shell Script,讀者應該注意到了兩種不同的 if 括弧寫法:

  • if [ -f "$file" ]
  • if [[ "$(basename "$file")" != "$SCRIPT_NAME" ]]

為何有 [ <內容> ][[ <內容> ]] 兩種呢?(在講 [ ] 中括號的數量啦!)

結論

一般寫 Shell Script,請優先用比較多括弧的那一個,也就是 [[ <內容> ]] 。除非,今天需求是追求最大相容性,像支援舊的 Shell 或是 Debian/Ubuntu 的 dash,再考慮用 [ <內容> ]

差異

那麼兩者有什麼差異?為何要用括弧比較多的那一個?

test 指令:[ <內容> ]

這是一個叫做 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(保留字):[[ <內容> ]]

這是現代 Shell 的 keyword,就像是程式語言裡頭的保留字:if、switch、when… 等,可以想像成上面 test 語法糖的加強版。除了不用面對指令本質可能會遇到的問題以外,還多了其他的功能,例如正則表達式、萬用字元(也就是 *)。

只是,更老舊的 Shell 或是 dash 並不支援這個,遷就於相容性選擇上面的寫法;否則優先選擇雙中括號,就是為了避免 test 指令遇到的問題,並且多了很多實用的功能。

連結


上一篇
Day21 進階觀念篇:權限怎麼修改?來談談 chmod
下一篇
Day23 進階觀念篇:所到之處,都聽到你的名子 vim
系列文
上班族的命令列 (CLI) 生存手冊25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
2
AndyAWD
iT邦新手 2 級 ‧ 2025-10-06 20:24:47

這篇也太有料了吧

我要留言

立即登入留言