/

Shell Script 中那些錯過的事

前言

Shell Script 想必大家都有使用過,即使沒有真的寫 script,多少也會有在 command line 中透過 pipe 或是 and 條件句來將不同的指令結合在一起的經驗。

也因為 Shell 如此平凡,貼近一般環境,反而很少有人花時間去認真了解過 Shell。我自己也是這種人,於是近期讀了《精通 shell 程式設計 第四版》後,記錄了一些自己從來沒有了解過的細節。

檔案名稱替換

* 字元

在 Shell 中(此篇文章用 Bash 來當例子),* 會被轉譯成該目錄下所有檔案的名稱。(這件事是由 Shell 預先處理好。)

echo * 時,實際上會先把 * 替換成檔名(假如有 a b c 三個檔案),然後執行 echo a b c 印出 a b c

以 echo 指令的角度,是沒有辦法辨別 a b c 是由 * 替換而來,echo 只知道自己收到三個參數分別為 a, b, c。

? 字元

? 可以對應單一字元,例如 ?? 可以對應出全部剛好兩字元的檔名,如果想要匹配兩個字元以上(包含兩個字元)的檔名可以使用 ??*

該配對方式與 Regular Expression 類似,但並非 Regular Expression。

輸入重導向

在一行指令開始執行時,其實 Shell 已經默默幫我們做了一些事情。

1
2
wc -l users # 5 s.sh
wc -l < s.sh # 5

以上兩者的輸出並不相同,差異在於後者是透過標準輸入傳入,wc 程式並無法得知是哪一個檔案傳進來的內容,所以無從得知檔名。

變數相關

變數

  • 變數型態只有字串變數
  • 宣告變數時 = 號左右不能有空格
1
2
a = hello # 錯誤
a=hello # 正確
  • 當字串代有空格時,要用 '" 來夾住字串
  • 單引號夾住的內容不會再進行轉譯,雙引號的內容會再進行轉譯
  • $ 字號用來取值(shell 會進行替換)
1
2
3
var1=hi
echo '$var1' # $var1
echo "$var1" # hi

unset

unset 是一個好用的指令,用來把變數取消設置,也可以將 function, alias 的設定取消:

1
2
3
4
msg=hello
echo $msg # hello
unset msg
echo $msg #

還有一種取消的方式為指定空值:

1
2
3
4
msg=hello
echo $msg # hello
msg= # 不指定
echo $msg #
  • 取消 alias : unset -a
  • 取消 function : unset -f

“$var” 與 $var 的差異

(假設目錄下有 a b c 三個檔案)

1
2
3
var=*
echo $var # a b c
echo "$var" # *

因為 $var 會替換成 * ,所以 echo $var 會先被 Shell 替換成 echo * 再替換成 echo a b c

然而 echo "$var" 最後僅會被替換成 echo "*" 而印出 *

(雙引號只會轉譯變數,不會轉譯 * 。)

由上述例子可知,為何比較好的寫法是在印出變數時外面再包一層雙引號。

命令替換:`` 與 $()

這兩個包裹字串的方式會讓字串內容當作指令來執行:

1
2
3
4
var=`date`
var2=$(date)
echo "$var" # Mon Nov 25 16:56:47 CST 2019
echo "$var2" # Mon Nov 25 16:56:47 CST 2019

差異在於 $() 是新的方式,兩個 ` 則是舊的方式

常用指令以及被忽略的事

echo

echo 指令會無視空格(除非你用引號夾住字串)將空格當作分離不同參數的分隔符號:

1
echo a b  c    d   e # a b c d e

-e 參數用來開啟轉譯功能

1
2
3
4
5
6
echo -e "123\n" # 123
#
# 因為 echo 本身會換行,所以 \n 會讓 output 有兩個換行
# \c 可以讓 echo 去除換行字元
echo -e "123\c" # 123 04:17:55 yiyu@afra tmp →
# shell 的 PS1 會緊貼在 output 之後(因為沒有換行字元)

ls

使用 ls 指令,會列出該目錄下所有檔案,但不是一個檔案一行,想要一個檔案一行印出,可以使用 ls -1

wc

用來查看檔案的內容包行幾列、幾個單字、幾個字元。不幸的是 wc 的結果開頭都會有一個空白,可以使用 sed 將它剔除:

1
2
3
4
wc backup.sh
# 4 9 82 backup.sh
wc backup.sh | sed 's/^ //g'
#4 9 82 backup.sh

sort 覆蓋自己

sort 千萬不要直接重新導入自己,否則會清除該檔案內容:

1
2
3
4
5
6
7
cat a.txt
# 1
# 2
# 3
sort a.txt > a.txt
cat a.txt
#

如果有這個需求,可以加上 -o 參數:

1
2
3
4
5
6
7
8
9
cat a.txt
# 1
# 2
# 3
sort a.txt -o a.txt
cat a.txt
# 1
# 2
# 3

參數

特殊變數

  • $# 會替換成參數數量
  • $* 會替換成全部參數
  • 我們都知道 $1, $2 用來指定不同參數,但假如是參數 10,$10 會被轉譯成 $1 以及 0,這時候就需要 ${n} 格式來指定參數 10, ${10}

決策

退出狀態

  • 退出狀態為 0 代表成功,非 0 代表失敗
  • 這個退出狀態並不是回傳值(這邊跟 C 語言不太一樣)
  • 可以用 $? 查看上一個指令的退出狀態

test 指令

  • 在 if 句中可以用 [] 來取代 test 兩者並無差別:
1
2
3
4
5
6
7
#!/bin/bash
if test -e a;then
echo "yes"
fi
if [ -e a ];then
echo "yes"
fi

上述兩種寫法是完全相同的,[] 也可以直接在 Shell 中使用:

1
2
[ a = a ]
echo $? # 0
  • 有個指令 exit 也可以自行設定退出狀態,若沒有指定,則會使用上一個指令的退出狀態當作退出狀態,即 exit $?

Shell Script 也可以 debug

  • -v 參數可以先印出 script 內容,在執行 shell
  • -x 參數可以 debug,會逐行指示過程

空命令:

在 if (或是 else, elif)中若沒有內容需要執行,需要加一個空命令 ,否則會報錯誤(類似 Python 中的 pass。):

1
2
3
4
5
6
#!/bin/bash
if test -e a;then
:
else
echo "no"
fi

&& 與 ||

  • 在單行指令中使用 if 並不直覺,我們可以使用 && 與 || 來強化我們的指令
  • A && B : 當 A 指令成功則執行 B 指令(A 的退出狀態不為 0 時則不執行 B)
  • A || B : 當 A 指令執行失敗時,才執行 B

例如:

1
grep -irn "target_str" dic.txt || echo "Couldn't not find"

僅有在 grep 未搜尋到內容時,才會印出 Couldn’t not find。

迴圈

$* 與 $@

我們有時候會透過 $* 取得全部的參數並且使用迴圈迭代,但如果我們的參數中是 a b 'c d' 三個參數,會因為 $* 是把 $1, $2, … 取出來,最後變成 a b c d 丟給迴圈迭代:

1
2
3
4
for arg in $*
do
echo $arg
done

以上的程式碼會印出

1
2
3
4
a
b
c
d

此時我們可以用 "$@" 來改善,shell 會將其替換為 “$1”, “$2” …

1
2
3
4
for arg in "$@"
do
echo $arg
done

以上的程式碼會印出:

1
2
3
a
b
c d

切記!若 “$@” 沒有加上雙引號,那麼 $@ 以及 $* 的結果是一樣的。

break n

  • Shell 的 break 可以一次跳出不止一層迴圈,在 break n 指定即可
  • 若沒有指定 n 即跳出一層迴圈

迴圈次數的幾種指定方式

  • $(seq minimum maximum)
  • {minimum..maximum}
  • {minimum..maximum..step}
  • (( EXP1; EXP2; EXP3 ))

讀寫資料

$$ 變數

$$ 變數用來顯示當前 PID。

寫 script 時可能會產生一些暫存檔,如果大家都同時在用這支 script,那麼 race condition 可能會發生。要避免的方式就是讓每一次執行所產生的暫存檔名稱都不一樣,那麼使用 $$ 變數就會是一個好方法。

產生暫存檔較好的方式:

1
echo "This is tmp file" > /tmp/tmpfile_$$

環境

區域變數

在 Shell 的環境中,每執行一支 script 都會產生一個屬於該 script 自己的空間(可以想成是產生一個新的 process),該空間與外界互不干擾:

1
2
3
4
5
6
7
8
☁ cat s.sh
#!/bin/bash
echo $x
☁ x=100
☁ ./s.sh

☁ echo $x
100

s.sh 腳本看不到外界的變數,外界也看不到它的變數。

輸出變數

承上述,如果我們想要將目前的變數與 子 Shell 共享時,我們可以使用 export 指令

1
2
3
☁ export x
☁ ./s.sh
100
  • export -p :查看目前 export 的變數列表

目錄以及環境

有了 子 Shell 的概念後,不難理解在 script 中變換目錄對於外界的 Shell 來說是沒有影響力的,因為彼此之間擁有獨立的空間。

那如果想要共享環境,用當前的 Shell 去執行 script 時呢?

. 指令提供了這件事(其實就是 source 指令)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
03:11:58 yiyu@afra shell → pwd
/Users/yiyu/dev/tmp/shell

03:12:02 yiyu@afra shell → cat s.sh
#!/bin/bash
cd ..
(目錄並沒有被切換)
03:14:39 yiyu@afra shell → ./s.sh
03:14:58 yiyu@afra shell → pwd
/Users/yiyu/dev/tmp/shell
(使用 . 指令)
03:15:43 yiyu@afra shell → . s.sh
03:15:58 yiyu@afra shell → pwd
/Users/yiyu/dev/tmp/
(目錄被切換了)
03:16:54 yiyu@afra tmp → cd shell
/Users/yiyu/dev/tmp/shell
(使用 source 指令)
03:17:49 yiyu@afra shell → source s.sh
03:17:58 yiyu@afra shell → pwd
/Users/yiyu/dev/tmp/
(目錄被切換了)

() 與 {}

這兩種包裹指令的方式可以讓指令一個接著一個執行下去,差異在於前者是在子 shell 中執行,後者是在當前 shell 中執行。

  • ():產生新的 Shell 環境來執行
1
2
3
4
5
03:27:54 yiyu@afra shell → pwd
/Users/yiyu/dev/tmp/shell
03:27:58 yiyu@afra shell → (cd ..)
03:28:28 yiyu@afra shell → pwd
/Users/yiyu/dev/tmp/shell
  • {}:使用當前環境執行。切記,指令前後必須要空格,最後一個指令後面必須要有分號
1
2
3
4
03:28:29 yiyu@afra shell → pwd
/Users/yiyu/dev/tmp/shell
03:29:23 yiyu@afra shell → { cd ..; pwd; }
/Users/yiyu/dev/tmp

再論參數

字串格式

1
${#string}

可以用來計算字串,非常實用

  • $0 可以用來顯示當前的程式名稱,時常被用來顯示當前 Shell 為何

樣式配對結構

1
2
3
4
5
6
7
8
9
var=hel___lo~
echo ${var#*l} # 刪除左側最短配對 # ___lo~
echo ${var##*l} # 刪除左側最短長配對 # o~

# 如果想要改為刪除右側,將 # 替換為 %
path=/Users/yy/dev
basename path # Unix 的 basename 指令。輸出:dev
# 我們可以用樣式配對結構來改寫
echo ${path##*/} # 刪除從左邊配對至最後一個 / ,即可以達成 basename 指令之輸出

後記

熟悉 Shell 的一些知識與概念之後,寫 script 時不但能用較為優雅的方式來撰寫,也能增加 script 的靈活度。對於現在 CI/CD 流行的時代,Shell 幾乎是不可或缺的技術之一。這篇文章除了提供自己在日後可以快速方便的查閱以外,也提供有使用過 Shell 但是一直沒有深入了解過的各位朋友們!