Shell工具與腳本語言
在本課中,我們會展示基於bash的腳本語言的一些基本操作,以及一些常用的 shell 工具。它們將解決我們在使用命令列時遇到的常見問題。
Shell腳本
截至目前我們已經學習了如何在 shell 中執行指令, 並且用管道組合它們。 但是,在許多場景下我們希望執行一系列指令,並且使用條件或迴圈等流程控制。
Shell 腳本是更加複雜的解決辦法。 大部分 shell 都有自己的腳本語言,包括變數,流程控制與專屬自己的語法。 shell 腳本與其他腳本語言不同的是,它針對有關 shell 的任務進行最佳化。 因此,創建指令流程,將結果存儲至檔案,從標準輸入中獲取訊息都是 shell 腳本的內建能力, 這使得它比普通腳本語言更加容易使用。 此節我們會專注於bash腳本,因爲它最為普遍,
在bash中,使用 foo=bar
爲變數賦值,使用 $foo
來取得變數的值。
注意 foo = bar
並不會正常執行,因爲它將被解釋爲使用參數 =
和 bar
執行 foo
。
總之,在shell腳本中空格會分隔參數。初次使用時可能會造成混淆,請務必多加檢查。
Bash 中的字串通過 '
與 "
定界字元來定義,它們含義並不相同。
以 '
定界的字串中的變數僅代表其字元本身,而 "
中的變數會作爲變數對待。
foo=bar
echo "$foo"
# prints bar
echo '$foo'
# prints $foo
如同大多數程式語言,bash支持包含if
, case
, while
與 for
等的流程控制。
類似地,bash
也支援函式,其可以獲取參數並執行。以下例子會一個建立新文件夾並 cd
進入該文件夾:
mcd () {
mkdir -p "$1"
cd "$1"
}
此處 $1
代表腳本或函式獲取的第一個參數。
與其他腳本語言不同,bash使用了大量特殊變數來表示參數,錯誤碼與其他相關變數。
此處列舉了部分例子,更完整的列表可以參照於此.
$0
- 腳本名稱$1
to$9
- 腳本參數。$1
是第一個參數,以此類推。$@
- 全部參數$#
- 參數數量$?
- 前一條指令的傳回值$$
- 目前腳本的行程ID (Process identification number, PID)!!
- 完整的前一條指令,含有參數。一個常見情況是因爲權限錯誤導致指令失敗,此時可以使用sudo !!
再嘗試一次。$_
- 前一條指令的最後一個參數。如果你使用的是互動式shell,按下Esc
後輸入.
可以獲取它。
指令通常會使用 STDOUT
來返回輸出, 使用 STDERR
返回錯誤, 與一個傳回值(Return Code)來更加友好地表示錯誤訊息。
腳本或單獨指令利用傳回值與退出狀態(exit status)的形式互相溝通。
通常,傳回值爲 0 表示一切正常,非0的傳回值表示發生了某種錯誤。
退出碼可以與 &&
(and operator) 與 ||
(or operator)共同使用,它們都是短路求值 運算子。
同一行內的多個指令可以使用 ;
分隔。
程式 true
的傳回值永遠是0,false
的傳回值永遠是1.
以下是一些例子:
false || echo "Oops, fail"
# Oops, fail
true || echo "Will not be printed"
#
true && echo "Things went well"
# Things went well
false && echo "Will not be printed"
#
true ; echo "This will always run"
# This will always run
false ; echo "This will always run"
# This will always run
另一種常見的模式是以變數的形式獲取一個指令的輸出。我們可以透過 指令替換(command substitution) 來做這件事。
當在腳本中使用 $( CMD )
時, CMD
會被執行,然後用它的執行結果替換掉 $( CMD )
。
例如,如果你執行 for file in $(ls)
,shell會首先執行 ls
然後遍歷其傳回值。
另一個不太常見的特性是 行程替代 (process substitution), <( CMD )
將會執行 CMD
並且將結果存入臨時檔案中,並將 <()
替換成臨時檔案名稱。
這在我們希望傳回值通過檔案而非 STDIN 傳送時非常有用。
例如, diff <(ls foo) <(ls bar)
將會顯示文件夾 foo
與 bar
中的內容.
既然我們已經講了這麼多,是時候看看這些特性的範例了。
這個範例將會使用 grep
搜尋字串 foobar
,如果沒有找到,就將其作爲註釋加入到檔案中。
#!/bin/bash
echo "Starting program at $(date)" # date將會被替代為日期與時間
echo "Running program $0 with $# arguments with pid $$"
for file in "$@"; do
grep foobar "$file" > /dev/null 2> /dev/null
# 當字串沒有被找到,grep將會退出並返回狀態碼 1
# 我們將標準輸出流(STDOUT)和標準錯誤流(STDERR)重新導向到Null,因為我們並不關心這些訊息
if [[ $? -ne 0 ]]; then
echo "File $file does not have any foobar, adding one"
echo "# foobar" >> "$file"
fi
done
在條件語句中,我們比較 $?
是否等於 0。
Bash實現了許多類似的比較操作,您可以查看 test 手冊
。
在bash中進行比較時,盡量使用雙方括號 [[ ]]
而不是單方括號 [ ]
,這樣會降低出錯的機率,儘管這樣並不能相容於 sh
。更詳細的說明參見這裡。
當執行腳本時,我們經常需要提供形式類似的參數。 bash使我們可以輕鬆的實現這一操作,它可以根據文件擴展名展開表達式。這一技術被稱為shell的 通配( globbing)
- 萬用字元 - 當你想要利用萬用字元進行匹配時,你可以分別使用
?
和*
來匹配一個或任意個字符。例如,對於文件foo
,foo1
,foo2
,foo10
和bar
,rm foo?
這條命令會刪除foo1
和foo2
,而rm foo*
則會刪除除了bar
之外的所有文件。 - 大括號 - 當你有一系列的指令,其中包含一段共同子字串時,可以用大括號來自動展開這些命令。這在批量移動或轉換文件時非常方便。
convert image.{png,jpg}
# 會展開為
convert image.png image.jpg
cp /path/to/project/{foo,bar,baz}.sh /newpath
# 會展開為
cp /path/to/project/foo.sh /path/to/project/bar.sh /path/to/project/baz.sh /newpath
# 也可以結合通配使用
mv *{.py,.sh} folder
# 會移動所有 *.py 和 *.sh 文件
mkdir foo bar
# 下面命令會建立foo/a, foo/b, ... foo/h, bar/a, bar/b, ... bar/h這些文件
touch {foo,bar}/{a..h}
touch foo/x bar/y
# 顯示foo和bar文件的不同
diff <(ls foo) <(ls bar)
# 輸出
# < x
# ---
# > y
編寫 bash
腳本有時候會很彆扭和反直覺。例如 shellcheck這樣的工具可以幫助你找到sh/bash腳本中的錯誤。
請注意,腳本並不一定只有用bash寫才能在終端裡調用。比如說,這是一段Python腳本,作用是將輸入的參數倒序輸出:
#!/usr/local/bin/python
import sys
for arg in reversed(sys.argv[1:]):
print(arg)
shell知道去用python直譯器而不是shell命令來運行這段腳本,是因為腳本的開頭第一行的shebang。
在shebang行中使用env
命令是一種慣例,它會利用環境變數中的程式來解析該腳本,這樣就提高來您的腳本的可移植性。 env
會利用我們第一節講座中介紹過的PATH
環境變數來進行定位。
例如,使用了env
的shebang看上去時這樣的#!/usr/bin/env python
。
我們應該注意shell函數和腳本有以下的差異:
- 函數只能用與shell使用相同的語言,腳本可以使用任意語言。因此在腳本中包含
shebang
是很重要的。 - 函數僅在定義時被載入,腳本會在每次被執行時載入。這讓函數的載入比腳本略快一些,但每次修改函數定義,都要重新載入一次。
- 函數會在當前的shell環境中執行,腳本會在獨立的行程中執行。因此,函數可以對環境變數進行更改,比如改變當前工作目錄,腳本則不行。腳本需要使用
export
將環境變數導出,並將值傳遞給環境變數。 - 與其他程式語言一樣,函數可以模組化程式碼、提高程式碼複用性與可讀性。 shell腳本中往往也會包含它們自己的函數定義。
Shell 工具
查看命令如何使用
看到這裡,您可能會有疑問,我們應該如何找到該命令的旗標(flag)呢?例如 ls -l
, mv -i
和 mkdir -p
。
或者問說,給予一個指令,我們如何查詢他的使用方法與可設置的選項?
您可能會先去Google答案,但是,UNIX 可比 StackOverflow 出現的早,我們的系統裡其實早就內建方法可以查到相關訊息。
在上一節中我們介紹過,最常用的方法是為對應的命令行添加-h
或 --help
旗標。另外一個更詳細的方法則是使用man
命令。 man
命令是手冊(manual)的縮寫,它提供了命令的用戶手冊。
例如,man rm
會輸出命令 rm
的說明,同時還有其標記列表,包括之前我們介紹過的-i
。
事實上,目前我們給出的所有命令的說明鏈接,都是網頁版的Linux命令手冊。即使是您安裝的第三方命令,前提是開發者編寫了手冊並將其包含在了安裝包中。在交互式的、基於字符處理的終端窗口中,一般也可以通過 :help
命令或輸入 ?
來獲取幫助。
有時候手冊內容太過詳實,讓我們難以在其中查詢哪些是最常用的旗標和語法。
TLDR pages 是一個很不錯的替代品,它提供了一些案例,可以幫助您快速找到正確的選項。 例如,自己就常常在tldr上搜尋tar
和 ffmpeg
的用法。
查詢文件
程式設計師們面對的最常見的重複任務就是查詢文件或目錄。所有的類UNIX系統都包含一個名為find
的工具,它是shell上用於查詢文件的絕佳工具。 find
命令會遞歸地搜尋符合條件的文件,例如:
# 查詢所有名稱為src的文件夾
find . -name src -type d
# 查詢所有文件夾路徑中包含test的python文件
find . -path '*/test/*.py' -type f
# 查詢前一天修改的所有文件
find . -mtime -1
# 查詢所有大小在500k至10M的tar.gz文件
find . -size +500k -size -10M -name '*.tar.gz'
除了列出所尋找的文件之外,find還能對所有查詢到的文件進行操作。這能大大簡化一些單調的任務。
# 刪除所有副檔名為 .tmp 的文件
find . -name '*.tmp' -exec rm {} \;
# 轉換所有 PNG 檔為 JPG 檔
find . -name '*.png' -exec convert {} {}.jpg \;
儘管 find
用途廣泛,它的語法卻比較難以記憶。
例如,為了查詢滿足的模式 PATTERN
的文件,您需要執行 find -name '*PATTERN*'
(如果您希望模式匹配時是區分大小寫,可以使用-iname
選項)
您當然可以使用alias設置別名來簡化上述操作,但shell的哲學之一便是尋找(更好用的)替代方案。
記住,shell最好的特性就是您只是在呼叫程式,因此您只要找到合適的替代程式即可(甚至自己編寫)。
例如, fd
就是一個更簡單、更快速、更友好的程式,它可以用來作為find
的替代品。
它有很多不錯的默認設置,例如輸出著色、默認支持正則匹配、支持unicode並且我認為它的語法更符合直覺。以模式PATTERN
搜尋的語法是 fd PATTERN
。
大多數人都認為find
和fd
已經很好用了,但是有的人可能想知道,我們有沒有更有效的方法,例如不要每次都搜尋文件而是通過編譯索引或建立數據庫的方式來實現更加快速地搜尋。 這就要靠 locate
了。 locate
使用一個由updatedb
負責更新的數據庫,在大多數係統中updatedb
都會通過cron
每日更新。這便需要我們在速度和時效性之間做出權衡。而且,find
和類似的工具可以通過別的屬性比如文件大小、修改時間或是權限來查詢文件,locate
則只能通過文件名。 這裡有一個更詳細的對比。
查詢代碼
查詢文件是很有用的技能,但是很多時候您的目標其實是查看文件的內容。
一個最常見的場景是您希望查詢具有某種模式的全部文件,並找它們的位置。 為了實現這一點,很多類UNIX的系統都提供了grep
命令,它是用於對輸入文本進行匹配的通用工具。它是一個非常重要的shell工具,我們會在後續的資料預處理課程中深入的探討它。
grep
有很多選項,這也使它成為一個非常全能的工具。
其中我經常使用的有 -C
:獲取查詢結果的上下文(Context);-v
將對結果進行反選(Invert),也就是輸出不匹配的結果。舉例來說, grep -C 5
會輸出匹配結果前後五行。
當需要搜索大量文件的時候,使用 -R
會遞歸地(Recursively)進入子目錄並蒐索所有匹配的文件。
但是,我們有很多辦法可以對 grep -R
進行改進,例如使其忽略.git
文件夾,使用多CPU等等。
因此也出現了很多它的替代品,包括ack, ag 和rg。
它們都非常好用,但是功能也都差不多,我比較常用的是 ripgrep (rg
) ,因為它速度快,而且用法非常符合直覺。例子如下:
# 查詢所有使用了 requests 函式庫的文件
rg -t py 'import requests'
# 查詢所有沒有寫 shebang 的文件(包含隱藏文件)
rg -u --files-without-match "^#!"
# 查詢所有的foo字串,並印出其之後的5行
rg foo -A 5
# 印出匹配的統計信息(匹配的行和文件的數量)
rg --stats PATTERN
與 find
/fd
一樣,重要的是你要知道有些問題使用合適的工具就會迎刃而解,而具體選擇哪個工具則不是那麼重要。
查詢 shell 命令
目前為止,我們已經學習瞭如何查找文件和代碼,但隨著你使用shell的時間越來越久,您可能想要找到之前輸入過的某條命令。 首先,按向上的方向鍵會顯示你使用過的上一條命令,繼續按上鍵則會遍歷整個歷史記錄。
history
命令允許我們查找過去shell中輸入的歷史命令。這個命令會在標準輸出中印出shell中的命令。
如果我們要搜索歷史記錄,則可以利用管道將輸出結果傳遞給 grep
進行模式搜索。 history | grep find
會打印包含find子串的命令。
對於大多數的shell來說,您可以使用 Ctrl+R
對命令歷史記錄進行回溯搜索。
按下 Ctrl+R
後您可以輸入子串來進行匹配,查找歷史命令行。重複按下就會在所有搜索結果中循環。
在 zsh中,使用方向鍵上或下也可以完成這項工作。
Ctrl+R
可以配合 fzf 使用。
fzf
是一個通用的模糊查找工具,它可以和很多命令一起使用。這裡我們可以對歷史命令進行模糊查找並將結果以賞心悅目的格式輸出。
另外一個和歷史命令相關的技巧我喜歡稱之為基於歷史的自動補全。 這一特性最初是由 fish shell 創建的,它可以根據您最近使用過的開頭相同的命令,動態地對當前對shell命令進行補全。 這一功能在 zsh 中也可以使用,它可以極大對提高用戶體驗。
最後,有一點值得注意,輸入命令時,如果您在命令的開頭加上一個空格,它就不會被加進shell記錄中。當你輸入包含密碼或是其他敏感尋訊息的命令時會用到這一特性。
如果你不小心忘了在前面加空格,可以通過編輯 bash_history
或 .zhistory
來手動地從歷史記錄中移除那一項。
文件夾切換
目前的所有操作我們都假設一個前提,也就是我們已經位於想要執行命令的目錄下,但是如何才能高效地在目錄間隨意切換呢? 有很多簡便的方法可以做到,比如設置alias,使用 ln -s創建符號連接等。而開發者們已經想到了很多更為巧妙的解決方案。
對於本課程的主題來說,我們希望對常用的情況進行最佳化。
使用fasd
可以查找最常用和/或最近使用的文件和目錄。
Fasd 根據 frecency對文件和文件排序,也就是說它會同時針對頻率(frequency )和時效( recency)進行排序。 最直接對用法是自動跳轉 (autojump),對於經常訪問的目錄,在目錄名子串前加入一個命令 z
就可以快速切換命令到該目錄。例如, 如果您經常訪問/home/user/files/cool_project
目錄,那麼可以使用 z cool
直接進入該目錄。
還有一些更複雜的工具可以用來概覽目錄結構,例如tree
, broot
或更加完整對文件管理器,例如nnn
或ranger
。
課後練習
-
參考
man ls
並撰寫ls
指令來完成以下操作:- 所有文件(包括隱藏文件)
- 文件打印以人類可以理解的格式輸出 (例如,使用 454M 而不是 454279954)
- 文件以最近訪問順序排序
- 以彩色文本顯示輸出結果
典型輸出如下:
-rw-r--r-- 1 user group 1.1M Jan 14 09:53 baz drwxr-xr-x 5 user group 160 Jan 14 09:53 . -rw-r--r-- 1 user group 514 Jan 14 06:42 bar -rw-r--r-- 1 user group 106M Jan 13 12:12 foo drwx------+ 47 user group 1.5K Jan 12 18:08 ..
-
編寫兩個bash函數
marco
和polo
執行下面的操作。 每當你執行marco
時,當前的工作目錄應當以某種形式保存,當執行polo
時,無論現在處在什麼目錄下,都應當cd
回到當時執行marco
的目錄。 為了方便debug,你可以把代碼寫在單獨的文件marco.sh
中,並通過source marco.sh
命令,重新載入函數。 -
假設您有一個命令,它很少出錯。因此為了在出錯時能夠對其進行調試,需要花費大量的時間重現錯誤並捕獲輸出。 編寫一段bash腳本,運行如下的腳本直到它出錯,將它的標準輸出和標準錯誤流記錄到文件,並在最後輸出所有內容。 Bonus: 報告腳本在失敗前共運行了多少次。
#!/usr/bin/env bash n=$(( RANDOM % 100 )) if [[ n -eq 42 ]]; then echo "Something went wrong" >&2 echo "The error was using magic numbers" exit 1 fi echo "Everything went according to plan"
-
本節課我們講解了
find
命令的-exec
參數非常強大,它可以對我們查找對文件進行操作。 但是,如果我們要對所有文件進行操作呢?例如創建一個zip壓縮文件? 我們已經知道,命令行可以從參數或標準輸入接受輸入。 在用管道連接命令時,我們將標準輸出和標準輸入連接起來,但是有些命令,例如tar
則需要從參數接受輸入。這裡我們可以使用xargs
命令,它可以使用標準輸入中的內容作為參數。 例如ls | xargs rm
會刪除當前目錄中的所有文件。您的任務是編寫一個命令,它可以遞歸地查找文件夾中所有的HTML文件,並將它們壓縮成zip文件。注意,即使文件名中包含空格,您的命令也應該能夠正確執行(提示:查看
xargs
的參數-d
) -
(進階) 編寫一個命令或腳本遞歸的查找文件夾中最近使用的文件。更通用的做法,你可以按照最近的使用時間列出文件嗎?
Licensed under CC BY-NC-SA.