日拱一卒,麻省理工教你CS基礎,那些酷炫無比的命令列工具

語言: CN / TW / HK

大家好,我是樑唐。

今天和大家繼續聊聊麻省理工的missing semester,消失的學期,講解那些不會在課上提及的工具和技術。

這一節課主要講的的是一些shell中進階工具的使用,雖然在日常工作當中我也經常使用shell命令,有時候也會編寫shell指令碼,這節課聽完仍然讓我眼界大開,原來還有這麼多神奇的工具很用法。也讓我感慨,MIT到底是MIT,不愧是全球頂尖的院校,基礎內容都能講出花來。

從內容上來說,我是非常推薦大家都能看一下講課視訊的。這節課B站有up主做了精校的中英雙語字幕,不過可惜只更新了四節課。如果大家想看的話,我建議可以看一下雙語精校版的

B站課程連結

但這節課換了老師,新老師Jose是西班牙人,所以口音比較重。我也是看了這個視訊才知道,原來歐洲人說英語也可以有這麼重的口音,甚至彈幕裡還有人在為了老師到底是俄羅斯人還是印度人在吵架的……後來看了簡介才發現是西班牙人,如果你注意聽的話,當老師說到result這個單詞的時候,可以聽到經典的顫音,很有意思。

口音比較重的結果就是聽起來比較吃力,其實老師講得很好,只不過有時候很容易忽略一些細節。

這節課的筆記非常好,還有很多課程沒有來得及提到的內容。但由於篇幅限制,一些命令的演示和說明可能比較簡單,感興趣的同學最好做一下深入的研究。

課程筆記

好了,廢話不多說,讓我們日拱一卒,開啟新的旅程吧。

在這門課上,我們將會演示一些shell工具以及bash指令碼語言的基礎用法。這些內容基本上能夠覆蓋大多數命令列的使用場景。

Shell Scripting

目前我們已經演示瞭如何在shell裡執行程式,以及使用管道命令。

然而,在許多場景當中,我們希望能夠執行一系列命令並且使用一些控制流命令,比如條件語句、迴圈等等。

Shell指令碼的複雜度會提升一些,大多數Shell擁有它們專屬的指令碼語言,涵蓋變數、控制流以及特有的語法。和其他指令碼語言不同的是,shell指令碼是專門為了執行shell相關的任務而優化過的。比如建立命令管道,將執行的結果儲存在檔案裡,或者是從標準輸入讀入資料,都是shell指令碼的基礎操作,這也使得它比一些通用的指令碼語言更加易用。這節課我們將會聚焦在bash指令碼,因為它更加普遍。

在bash建立變數,使用語法foo=bar,將會建立一個變數$foo。需要注意foo = bar不會生效,因為它會將foo當成是要執行的程式,而=bar當成是foo的引數。因為shell指令碼是按照空格分隔引數的。這個特性在剛開始使用的時候會覺得很彆扭,所以記得經常檢查。

string可以使用單引號或雙引號來表示,但它們不是等價的。以單引號分隔的字串是純字元,當中的變數不會被取值。而雙引號的字串可以。

和大多數程式語言一樣,bash也支援控制流語法,比如if, case, whilefor。同樣,bash也有可以接收引數的函式,並且可以執行。下面是一個函式建立一個資料夾並且cd進入的例子。

這裡的$1指的是指令碼的第一個引數,和其他指令碼語言不同,bash使用許多特殊的變數來代表引數、error程式碼和其他相關的變數。接下來列舉其中常用的一些:

  • $0- 指令碼的名稱
  • $1 to $9 - 指令碼的引數,$1是第一個引數,以此類推
  • $@- 所有的引數
  • $#- 引數的數量
  • $?- 上一個命令的返回結果
  • $$- 當前指令碼的執行PID(程序id)
  • !!- 上一個執行的命令,包括引數,比如我們執行某命令提示沒有許可權失敗,我們想重試可以直接簡寫sudo !!
  • $_- 上一條命令的最後一個引數,如果你是在互動式的shell終端使用,你也可以使用快捷鍵Esc加上.或者是Alt+.

命令通常使用STDOUT返回,錯誤通過STDERR,並且一個返回碼提示錯誤是指令碼友好的常用做法。返回碼或者是退出時的狀態是指令碼/命令用來互動執行結果的一種方式。0通常意味著一切OK,除了0以外的值通常代表著出現了一些錯誤。

返回碼可以被用在條件語句當中,使用&&||,兩者都是短路運算子。命令之間也可以使用分號;進行分隔,true命令永遠返回0,false命令永遠返回1。讓我們來看一些例子:

另外一個常用的語句是將一個命令的結果作為變數,這可以通過命令替換來實現。當你輸入$( CMD )它會先執行CMD命令,獲取命令的輸出之後,將它立即當做是變數。

舉個例子,如果你運行了for file in $(ls),shell會首先呼叫ls,然後遍歷它的結果。一個比較小眾的做法是process substitution(沒想到好的翻譯),<( CMD )將會執行CMD然後將它的結果存放在一個臨時的檔案中,並且將<()替換成臨時檔案的名稱。這在一些接收檔案而不是STDIN的命令當中可能會非常好用。

舉個例子,diff <(ls foo) <(ls bar)將會展示foo資料夾和bar資料夾下檔案的差異。讓我們再來看另外一個例子,程式會遍歷我們所有傳入的引數,使用grep命令篩選字串foobar,如果沒有找到就將foobar新增在檔案末尾。

這一段程式碼可能有些難懂,儘可能解釋一下。grep foobar "$file" > /dev/null 2> /dev/null這行程式碼當中細節有些多,展開來說一說。

首先是grep語句,這是過濾語句,意思是從$file檔案當中過濾包含foobar的文字。正常grep找到之後的結果會輸出到stdout,這裡我們給它重定向到了/dev/null,這是Linux系統中的一個特殊檔案,輸入的資料都會丟棄。

如果grep語句沒有找到一條吻合的文字,那麼會生成一個錯誤碼。為了不讓錯誤碼影響程式的執行,我們把錯誤碼也重定向到了/etc/null,錯誤碼重定向使用的是2>

在比較的時候,我們判斷了$?是否等於0,也就是判斷grep的執行結果是否有報錯,如果報錯了,就說明沒有找到我們指定的字串foobar

Bash當中實現了許多這樣的比較方式,你可以使用man命令來檢視test的細節。在bash中進行比較的時候,使用雙方括號[[ ]]而非單括號[]。這樣會降低犯錯的機率,雖然它對於sh來說不是很便攜。大家可以查閱一下這兩者的區別。

當進入bash指令碼之後,你可能會希望輸入一些命令近似的指令碼。Bash提供一些方法可以拓展檔名,這種技術通常被稱為shell global(全域性匹配)。

  • 萬用字元- 當你想要匹配任意字元時,你可以使用?或者*來代替一個或任意多個字元。比如我們有foo, foo1, foo2, foo10, bar這幾個檔案。命令rm foo?將會刪除foo1, foo2rm foo*將會刪除除了bar之外所有的
  • 花括號{} - 當你的命令擁有一系列共同的單詞時,你可以使用花括號來擴充套件。尤其是移動或者是轉變檔案的時候。

```bash convert image.{png,jpg}

Will expand to

convert image.png image.jpg

cp /path/to/project/{foo,bar,baz}.sh /newpath

Will expand to

cp /path/to/project/foo.sh /path/to/project/bar.sh /path/to/project/baz.sh /newpath

Globbing techniques can also be combined

mv *{.py,.sh} folder

Will move all .py and .sh files

mkdir foo bar

This creates files foo/a, foo/b, ... foo/h, bar/a, bar/b, ... bar/h

touch {foo,bar}/{a..h} touch foo/x bar/y

Show differences between files in foo and bar

diff <(ls foo) <(ls bar)

Outputs

< x

---

> y

```

編寫shell指令碼有時候是奇妙並且違反直覺的,有一些類似shellcheck的工具可以幫助你檢查你的sh/bash指令碼中的錯誤。

注意,指令碼並不是一定需要寫在bash中才能被終端呼叫。比如說,你這裡有一個簡單的Python指令碼,可以反向輸出它得到的引數:

核心知道這是一個Python指令碼,而不是shell命令,是因為我們在指令碼頭部引入了shebang。在腳本當中寫入shebang是一個很好的習慣,當你不確定命令呼叫的程式在什麼位置時,可以使用env命令,可以增加你的指令碼的可移植性。

env將會使用我們上節課提到的PATH環境變數來尋找合適的程式。舉個例子:#!/usr/bin/env python

shell函式和指令碼存在一些差異,你需要牢牢記住:

  • shell中函式需要是相同語言編寫的,而指令碼可以是任何語言寫的。這也是我們為指令碼引入shebang的原因
  • 函式的定義只會被載入一次,而指令碼每次執行的時候都會被載入一次。這就使得函式匯入的時候會稍微快一點,不過每次它被修改的時候,你都需要重新匯入
  • 函式時在當前shell環境當中執行的,而指令碼則會在它們獨自的程序當中執行。所以函式可以修改環境變數,比如修改你的當前路徑,而指令碼不行。指令碼可以通過環境變數獲得一些被export關鍵字匯出的值
  • 就和其他語言一樣,函式是一個很有用的結構,它可以提升程式的富有性、shell程式碼的簡潔性以及模組化。通常,shell指令碼會匯入它們的函式定義

Shell Tools

尋找如何使用命令

現在,你可能想要知道,怎麼樣知道命令中那些flag的用法,比如ls -l, mv -i, mkdir -p。準確地說,給定一個命令之後,我們要如何找到它的使用說明,知道各個選項的用法?你當然可以使用谷歌,但Unix早於Stack Overflow,它有內建的方法可以獲取資訊。

就和我們上節課看到的一樣,最先考慮的方法是使用使用引數-h--help。更細節的話,是使用man命令。man命令是manual的縮寫,它為這個命令提供一個人工說明頁面(叫做manpage)。比如,man rm會輸出rm命令和它所有的flag的用法。包括我們之前展示過的-i

除了原生的命令之外,通過其他渠道安裝的一些命令一樣可以使用man來檢視manpage。只要它的開發者為它編寫了對應的manpage,並且將它加入了安裝過程。

對於一些互動式的工具來說,幫助資訊通常可以通過在程式中輸入:help或者?來檢視。

有時候manpage提供的資訊太多太細節,使人難以解讀應該使用什麼flag。這個時候我們可以使用TLDR命令,它會提供命令的一些具體例子,這樣我們可以快速找到我們需要的選項。比如我就常常參考tarffmpeg命令的tldr頁面,而不是manpage。

尋找檔案

一個所有程式設計師都經常會遇到的問題是尋找檔案或資料夾。所有類Unix系統都提供了find命令,它是一個查詢檔案的非常好用的工具。find將會遞迴式地根據一些標準查詢匹配的檔案,一些例子:

除了列出檔案之外,find命令還可以根據你的查詢執行一些操作。對於一些單調的重複性操作會非常有幫助。

比如批量刪除所有.tmp的臨時檔案,或者是批量將.png檔案轉換成.jpg

雖然find工具很好用, 但有時候它的語法很難記住。比如你想要根據某個模式PATTERN找到匹配的檔案,你需要執行find -name '*PATTERN*',如果你要忽略大小寫,則需要使用-iname

針對這個場景,你可以建立一些別名,但shell哲學中,你還可以探索替代品。記住,shell中最好的一個屬性是,它僅僅是呼叫程式的,所以你可以找到或者是乾脆自己針對某一個問題寫一個替代品。

比如說fd是一個簡單快速,並且好用的find替代品。它提供許多預設的功能,比如說彩色輸出、正則表示式匹配以及支援unicode。它擁有一個我個人認為更直觀的語法,比如說當你想要找到一個模式PATTERN,你可以僅僅輸入fd PATTERN

大多數人都同意findfd非常好,但你們可能也會好奇,這樣搜尋檔案和使用一些編譯語言或者資料庫進行快速搜尋的方法相比如何。這就是locate命令的原理,.locate使用一個被updatedb更新的資料庫。在大多數系統當中,updatedb會被定時排程,以天為單位進行更新。

所以這兩種方法在資料的時效性和效能上有一個權衡,另外,find和類似的工具可以根據其他的一些特性比如檔案大小、修改時間、許可權等進行查詢。而locate只能使用檔名。大家感興趣可以進一步調研這兩者的差別。

查詢程式碼

通過檔名查詢檔案非常方便,但也經常會希望根據檔案中的內容進行查詢。

比如我們可能會希望搜尋所有包含了某個特定pattern的檔案,以及這些pattern出現的位置。為了實現這一點,大多數類Unix系統提供了grep工具,它可以從輸入文字中進行模式匹配。grep在shell工具當中非常重要,在之後的內容當中我們還會詳細闡述。

現在,我們知道grep有許多flag,讓它變得非常通用。我個人經常使用-c來獲取匹配行的上下文,以及-v來翻轉過濾,比如說打印出所有沒有匹配上的內容。

grep -C 5將會在匹配之後輸出5行上下文內容,如果你希望快速搜尋很多檔案,你可以使用-R,這樣它會遞迴式地進入資料夾檢索檔案。

grep -R也有很多改進的地方,比如說忽略.git資料夾,使用多核CPU等等。有很多grep的替代工具,比如說ack, agrg。這些工具都非常好用,並且功能非常接近。我個人目前使用ripgrep(rg),它執行非常快速,並且很直觀。

注意,和find/fd一樣,重要的是你得知道這些問題怎麼樣快速通過合適的工具解決,而這些工具本身並沒有那麼重要。

查詢shell命令

現在我們已經研究了怎麼查詢檔案和程式碼,當你在shell中花費更多時間的時候,你可能會希望你能在某些時刻找到一些特定的命令。最快速的方法就是通過上箭頭往上翻你之前執行的命令,但如果你一直用這種方式翻命令,顯然是非常緩慢的。

history命令可以讓你看到你shell中歷史上所有的命令,它會通過標準輸出來展示所有的記錄。如果我們想要搜尋一些特定的命令,可以使用grep來查詢特定的模式。history | grep find將會輸出包括find關鍵字的命令。

在大多數shell當中,你可以使用Ctrl + R來搜尋你的歷史記錄。在按下Ctrl + R之後,你可以輸入你想要搜尋的命令的關鍵字。當你持續按下Ctrl + R,它將會在匹配的多條記錄中迴圈查詢。這也可以在zsh中設定成使用上下箭頭。

我們也可以將Ctrl + R的結果和fzf繫結,fzf是一個通用的模糊查詢器,它可以和許多命令一起使用。在這裡,它將可以在你的歷史記錄中進行模糊匹配,並且以一種方便和舒服的方式進行展示。

另外一個我很喜歡的關於歷史記錄的工具是自動提示功能,最早被fish shell使用。這個特性可以自動地根據你當前輸入的內容用字首匹配的方式展示最近一次命令的匹配結果。它也可以在zsh中啟用,這是shell的一個非常重要的技巧。

你可以修改你的歷史行為,比如防止命令以空格開頭。當你輸入帶密碼或者是其它位元敏感的資訊時,將會非常好用。你需要在你的.bashrc加入HISTCONTROL=ignorespace,或者在你的.zshrc中加入HIST_IGNORE_SPACE

如果你之前不小心有過輸入了前導空格的命令,你可以在.bash_history或者.zhistory中手動刪除它們。

路徑導航

現在,我們已經假設你已經熟悉了上面這些操作。

但你怎樣快速地導航到路徑呢?關於這一點,有很多簡單的方法。比如說可以在shell裡建立別名,或者是使用ln - s建立軟連線。實際上,開發者已經想出了相當聰明和好用的方案。

就像是這門課的主題一樣,你需要經常對一些通用問題進行優化。可以使用fasdautojump找到頻繁使用或最近使用的檔案或路徑,fasd對檔案和路徑按照使用頻率和最近使用時間進行排序。fasd預設帶上z命令使得你可以通過一個子串快速cd到一個最近訪問過的路徑。

比如,你經常去/home/user/files/cool_project,你可以使用z cool來跳轉過去。使用autojump可以通過j cool命令實現類似的功能。

除此之外還有一些更加複雜的工具,比如說tree, broot甚至是完全成熟的檔案管理器,例如nnnranger

練習

  1. 閱讀man ls並且寫一個ls命令,使得它完成以下格式:

  2. 包括所有檔案,包括隱藏檔案

  3. 將檔案大小以人們可閱讀的形式展示比如(454M 而不是 454279954)
  4. 檔案按照最近訪問時間排序
  5. 輸出彩色結果

一個參考輸出應該是這樣的:

  1. 寫一個bash函式macropolo。當你執行macro時,你當前工作的路徑應當以某種方式被儲存。當你執行polo時,無論你處在什麼路徑下,polo都會cd回你之前執行macro的地方。為了方便debug,你可以將程式碼寫在macro.sh中,通過source macro.sh載入程式碼
  2. 假設你有一個命令很少失敗,為了debug,你需要捕獲它的輸出,但可能會花很多時間才能重現失敗。寫一個bash函式,它會重複執行下列指令碼,直到失敗,並且捕獲它的標準輸出以及錯誤流寫入檔案,並在結束時打印出來。如果你還能彙報一共執行了多少次可以獲得額外分數獎勵

```bash #!/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" ```

  1. 我們上課的時候說過find命令的exec引數非常強大,可以批量搜尋處理檔案。然而,如果我們想要對所有檔案做一些操作,比如說建立一個zip檔案,我們該怎麼操作呢?就像你看到的一樣,命令從引數和STDIN接收輸入,當使用管道時,我們將STDOUTSTDIN結合起來。但一些命令,比如tar從引數獲取資料。為了打通這兩者之間的資訊溝通,有一個叫做xargs的命令,可以使用STDIN當做引數來執行命令。比如ls | xargs rm將會刪除當前路徑下所有檔案。你的任務是寫一個命令,它能夠遞迴查詢當前路徑下所有HTML檔案,並且給它們建立zip壓縮包。注意:即使檔名中包含空格,你的命令也依然需要生效。(提示,檢視xargs``-dflag)。如果你是macOS,需要注意,findGNU coreutils中的不同。你可以使用find -print0以及xargs中的-0flag。作為一個mac使用者,你也需要意識到,mac安裝命令列工具的方法和GNU不同,你可以使用brew安裝GNU版本
  2. (進階)寫一個命令或指令碼來遞迴式地查詢當前路徑下最經常訪問的檔案。另外,你可以根據最近訪問時間列出所有的檔案嗎?

答案

不知道大家有沒有感覺到,這一次練習的難度明顯提升了很多。

第一題

相對比較簡單,英語不錯的同學讀一下man ls中的說明就可以找到答案,如果不願意逐行讀大段的英文,也可以通過搜尋引擎很快找到答案。

bash ls --laht --color

-l命令是輸出完整資訊,包括許可權以及檔案大小,-a包含隱藏路徑,-h將檔案大小以閱讀友好的方式展示,-t將檔案按照建立時間排序,--colorls命令的顯示結果變成彩色

第二題

邏輯不難想到,當我們執行macro時,我們需要儲存下當前路徑。由於當函式執行結束,函式中的變數即銷燬,所以我們要把它export成全域性變數。

polo函式當中,直接cd到匯出的全域性變數即可。

```bash macro() { export cachepath=$(pwd) }

polo() { cd $cachepath } ```

第三題

這題也不難,筆記當中有一個近似的例子。核心在於使用2>符號將錯誤流改寫到檔案中。再使用$?捕獲上一次命令執行的結果,通過返回值判斷有沒有錯誤發生。

整體的邏輯不復雜,只不過shell的語法不熟悉,剛接觸可能需要查一下。

bash function func() { cnt=1 ./random.sh > output.txt 2> error.txt while [[ $? -eq 0 ]] do (( cnt++ )) ./random.sh > output.txt 2> error.txt done cat error.txt echo "$cnt" }

第四題

這題題目比較長,看著有點唬人,我們需要冷靜下來好好分析。

首先我們使用find -exec命令時,是針對每一個檔案進行的,而我們希望針對所有檔案建立一個壓縮包。這就需要我們把所有find出來的檔案作為壓縮命令的輸入。但find命令都是直接輸出的,我們需要使用-print0這個flag,將它的輸出改寫到stdout

之後我們需要將之前的輸出作為命令的引數,需要用到xargs命令。使用man xargs檢視一下用法, 不難寫出程式碼:

bash find . -path "*.html" -type f -print0 | xargs -0 zip archieve.zip

第五題

第五題和第四題類似的套路,首先我們需要找到當前資料夾下所有的檔案,這可以使用find命令完成:find . -type f。接著我們需要使用ls命令對它進行排序。這裡我們一樣使用-print0xargs -0兩個命令來進行銜接,加上ls命令的顯示更多資訊的引數,以及排序即可。

bash find . -type f -print0 | xargs -0 ls -lht;