日拱一卒,麻省理工CS入門課,命令列這樣用也太帥了

語言: CN / TW / HK

大家好,我是梁唐。

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

這次老師講課的內容依然是關於命令列,不過和之前對一些簡單的命令進行科普不同。這一堂課主要是針對一些資料處理的特殊場景,講解一些比較fancy的命令和工具的使用。基於這些命令和工具,我們可以非常簡單,甚至只用一行程式碼就完成一些看起來比較複雜的資料處理。

我個人感覺還挺有意思的,哪怕只是死記硬背幾個,用到的時候耍個酷也很好玩。

這節課和第一節課是同一個老師,口音很不錯,語速也不會太快,我個人覺得還蠻適合來練習聽力的。提供一下關於這節課的一些資料資訊。

B站影片連結

這個中英文精校的up主只更新到了第四節課,換句話說以後就沒有熟肉影片看了。不得不說還是挺可惜的,尤其是下節課的又是西班牙老師,怎麼說呢,也不能吐槽,只能怪我們自己英語不夠好吧。且行且珍惜,且聽且成長吧。

官方筆記

這節課其實老師上課演示的時候講的內容不算非常多,如果比較趕時間的同學也可以只看筆記或者是本文,本文是基於老師上課內容以及筆記的一個翻譯整理版本。因為本人各方面水平有限,所以有些錯誤在所難免,希望大家多多包涵。

然後再號召一下,如果有一隻追更的小夥伴不妨在下方評論區打個卡,讓我知道我不是一個人在戰鬥。

好了,廢話不多說了,讓我們開始今天的課程吧。

前言

你有沒有過這樣的需求:將某種資料從一種格式轉換成另外一種格式?

當然你有了!這節課會介紹一些這個問題下一些比較常規的做法。尤其是對資料進行一些整理,無論是文字格式還是二進位制格式,直到它變成你最終想要的結果。

我們在之前的課程當中已經見過了一些基礎的資料處理的case,尤其是當你使用管道命令 | 的時候,其實某種程度上你就是在做資料整理。

假設現在我們有這麼一條命令journalctl | grep -i intel。它會找到系統當中所有提到intel的log。你可能不會覺得這也能算是資料處理,但它確實從一種形式(你的系統log)轉換成了另外一種更加好用的格式(intel日誌條目)。

大多數資料整理是關於熟悉哪些工具你可以使用,以及如何將它們組合起來。

讓我們從頭開始,我們需要兩樣東西來做資料處理:待處理的資料,以及一些處理資料的工具。日誌是一個常用的好例子,因為我們總是需要用到它,並且閱讀完整的日誌總是很麻煩。讓我們通過伺服器日誌來看看,誰經常登入我的伺服器:

這會返回非常大量的資料,讓我們通過ssh來做一點限制:

注意,我們在一個遠端的檔案流中使用了管道命令,將它傳輸到了本地的命令grep上。ssh命令非常神奇,我們將會在之後的課程上講述更多關於它的使用方式。即使我們做了過濾,資料也依然非常多,很難讀,讓我們繼續優化:

bash ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' | less

為什麼要加上引號呢?因為我們的日誌數量太大了,如果全部拉到本地再進行過濾非常浪費時間。所以我們可以在遠端伺服器上進行過濾,只獲取過濾之後的結果。less命令將會給我們一個分頁器,允許我們在一個很長的輸出結果當中上下翻頁。

為了節約時間,我們還可以把當前獲取到的過濾之後的結果存入檔案當中,這樣我們就不用每次都聯網獲取資料了:

bash $ ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' > ssh.log $ less ssh.log

資料當中仍然有許多噪音,我們有很多種方法來解決它。我們可以使用一種非常強大的工具:sed

sed是一個流編輯器,它基於非常古老的ed編輯器。我們可以使用很短的命令來修改檔案,而不是對整個內容直接編輯。關於sed有非常多的命令,其中最常用的是s代表替換,比如,我們可以這樣寫:

bash ssh myserver journalctl | grep sshd | grep "Disconnected from" | sed 's/.*Disconnected from //'

我們只是編寫了簡單的正則表示式,正則表示式是一個強大的文字匹配的結構。s命令的結構是s/REGEX/SUBSTITUTION/REGEX是你想要搜尋匹配的正則表示式,SUBSTITUTION是你想要替換的內容。

相信你已經注意到了,這裡的搜尋和替換的命令和vim中的命令非常相似。實際上它們的確擁有非常相似的語法,在你學習新的工具時,一些舊知識可以幫你觸類旁通學得更快。

Regular expressions

正則表示式是一個通用且非常好用的工具,它值得我們花費一點時間去學習它的原理。我們先從我們剛剛使用到的命令/.*Disconnected from /開始。

正則表示式通常被/包裹,大多數ASCII字元代表它們原本的含義,但也有一些特殊字元擁有特殊的含義。正因此,在不同的表示式中,同樣的字元可能表示不同的含義,這也是很多人被勸退的原因。常用的匹配模式有:

  • .表示匹配任意單個字元
  • *匹配0個或任意多個它之前的字元
  • +匹配一個或多個它之前的字元
  • [abc]匹配括號內的任一字元,a或b或c
  • (RX1 | RX2)表示匹配RX1RX2
  • ^匹配一行開頭
  • $匹配一行結束

sed使用的正則表示式有一些奇怪,它需要在特殊符號之前加上\,或者你可以傳入引數-E。我們回顧一下剛剛用到的/.*Disconnected from /,我們可以看到它在開頭匹配任何文字,接著匹配Disconnected from這也是我們希望的。

但有的時候,正則表示式會有trick,如果我們拿到的日誌裡的使用者名稱叫做Disconnected from會怎樣?我們將會獲得:

plain Jan 17 03:13:00 thesquareplanet.com sshd[2631]: Disconnected from invalid user Disconnected from 46.97.239.16 port 55920 [preauth]

我們最終將會得到什麼結果?

由於*, +在預設情況下是貪婪匹配的,即它們會盡可能多地匹配文字。因此在上面的例子當中,我們將會得到這樣的結果:

這可能不是我們想要的,因為我們想要的使用者名稱沒有了。在一些正則表示式當中,你可以使用字尾*+或者?來讓它不再是貪婪的。但很遺憾的是,sed不支援這種語法。我們可以切換到perl的命令列模式,它支援這種結構:

在接下來的工作當中,我們將繼續使用sedsed可以做其他一些方便的事情,比如列印匹配的行,每次呼叫做多次替換,搜尋一些結果等等。但我們這裡不會講解太多,sed是一個非常完整的話題,但我們嚐嚐有更好的工具。

好了,我們現在仍然有一些字尾是我們不想要的,我們要怎麼做呢?

僅僅匹配username後面的內容有一些棘手,尤其是使用者名稱當中可能還會有空格之類的情況下。我們需要做的是匹配整行:

bash sed -E 's/.*Disconnected from (invalid |authenticating )?user .* [^ ]+ port [0-9]+( \[preauth\])?$//'

讓我們在regex_debugger網站(http://regex101.com/r/qqbZqh/2)當中看一下這個命令:

開頭和之前一樣,然後我們匹配了"user"的變體(日誌當中有兩個字首)。接著我們匹配了使用者名稱對應的所有字元。接著我們匹配了一個單個字元[^ ]+;表示非空格字元組成的非空串。再然後是單詞"port",之後是一系列數字。

最後是字尾[preauth],再是行尾。

可以注意到,Disconnected from這樣的使用者名稱不會再困擾我們了,你能看出來原因嗎?

但這仍然有一個問題,就是我們整個日誌會變成空的。然而我們希望的是保留使用者名稱。為此,我們可以使用capture groups。任何文字匹配了被括號包圍的表示式語句都會被存如capture group當中。這可以在替換的時候被用到:\1, \2, \3

bash sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'

就像你看到的一樣,我們寫出了非常非常複雜的正則表示式。

這其實是正常現象,比如有一篇文章講的是寫出一個匹配email格式的正則表示式,它是這樣的:

所以正則表示式並不是一個簡單的事情,關於它有非常多的討論。人們寫了很多測試樣例,你甚至可以通過正則表示式來判斷一個數是否是質數。

正則表示式是出了名的難搞,但把它放進你的工具箱,也能幫到你很多。這一段如果看不懂可以去看下影片,老師在課上講得很好,因為有演示所以很容易理解,只看文章會比較困難。

Back to data wrangling

回到資料處理,現在我們有了命令:

bash ssh myserver journalctl | grep sshd | grep "Disconnected from" | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'

sed還能做很多其他有意思的事情,比如說插入文字(使用i命令),顯示列印資料(使用p命令),通過下標選擇行,以及其他很多內容。通過man sed來檢視!

現在,我們過濾出了嘗試登入我伺服器的使用者名稱單。但這依然很多,所以我們來看看最常出現的那些:

bash ssh myserver journalctl | grep sshd | grep "Disconnected from" | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/' | sort | uniq -c

sort將會將它的讀入進行排序,uniq -c將相同的行聚合到一起,先輸出該行出現的次數,再輸出對應的內容。我們希望排序之後保留最長出現的使用者名稱:

bash ssh myserver journalctl | grep sshd | grep "Disconnected from" | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/' | sort | uniq -c | sort -nk1,1 | tail -n10

sort -n將會按照數字大小來排序,而不是按照字典序。-k1,1表示按照空格分隔之後僅僅使用第一列的結果進行排序。接著,n表示排序至第幾個欄位,預設是行末。

在這個case當中,我們排序到行末也沒有影響,這裡只是為了學習這個特性。

如果我們想要最少出現的那些,我們可以使用head而不是tail,我們也可以使用sort -r按照降序排序。

但如果我們僅僅想要使用者名稱,並且將這些使用者名稱按照逗號分割寫進一行,應該怎麼辦呢?

bash ssh myserver journalctl | grep sshd | grep "Disconnected from" | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/' | sort | uniq -c | sort -nk1,1 | tail -n10 | awk '{print $2}' | paste -sd,

如果你使用的是Mac系統,該命令可能無法生效。因為paste命令在macOS下沒有。

這裡的paste命令讓你能以給定的分隔符(-d)合併多行(-s),但這裡的awk是幹嘛的呢?

awk – another editor

awk是一個非常擅長處理文字流的程式語言,如果你想要仔細學習它,篇幅可能非常大。所以這裡只會介紹最基本的用法。

首先,{print $2}做了什麼?awk程式的模式是給定一個可選的模式再加上一個花括號包裹的程式碼塊來說明如果該模式與給定的行匹配該怎麼做。預設的模式(我們剛才用的)是匹配所有行。在程式碼塊當中,$0表示整行文字,$1$n表示akw分隔分出的第n個欄位(預設是空格,可以通過-F修改)。

在這個case當中,我們針對每一行都列印它的第二個欄位,而這個欄位就是我們要的username。

讓我們來看看能不能做一些更復雜的事情,比如說找出只登入了一次,並且以c開頭以e結尾的使用者名稱:

bash awk '$1 == 1 && $2 ~ /^c[^ ]*e$/ { print $2 }' | wc -l

這裡有很多需要解釋的,首先可以注意到我們有一個模式,這個模式要求第一個欄位等於1,也就是uniq -c輸出的數量,然而第二個欄位必須要匹配正則表示式:/^c[^ ]*e$

最後的程式碼塊表示我們輸出username,最後我們統計一下一共有多少行滿足條件被輸出了:wc -l

然而,我們說過awk是一個程式語言,還記得嗎?

bash BEGIN { rows = 0 } $1 == 1 && $2 ~ /^c[^ ]*e$/ { rows += $1 } END { print rows }

BEGIN是一個模式,它匹配input的開頭,END匹配輸入的結尾。

現在,每一行塊會將rows變數加上$1即第一個欄位的值,在這裡它永遠等於1,表示多了一個匹配。最後輸出統計結果。

實際上,我們也可以不用使用grepsed因為awk完全可以搞定這些事。關於這個問題我們將留給讀者去解決。

Analyzing data

通過使用bc你可以直接在你的shell裡做數學運算,bc是一個從STDIN讀入資料的計算器。比如,我們可以把每一行的數字通過+號連在一起:

bash paste -sd+ | bc -l

或者一些更精細的表達:

bash echo "2*($(data | paste -sd+))" | bc -l

你也有很多方法可以對資料進行統計,st是一個很好的辦法,如果你已經學過R語言,還可以這麼寫:

bash ssh myserver journalctl | grep sshd | grep "Disconnected from" | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/' | sort | uniq -c | awk '{print $1}' | R --no-echo -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'

R是另外一門奇怪的程式語言,非常擅長資料分析和畫圖。我們不會過多深入,簡單來說,summary將會列印資料的一個彙總資訊,我們建立了一個包含input流的向量,R根據這個向量給了我們想要的結果。

如果你想要簡單的圖示,gnuplot會很好用。

bash ssh myserver journalctl | grep sshd | grep "Disconnected from" | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/' | sort | uniq -c | sort -nk1,1 | tail -n10 | gnuplot -p -e 'set boxwidth 0.5; plot "-" using 1:xtic(2) with boxes'

Data wrangling to make arguments

有時候你想要用資料清理來批量安裝或寫在,我們剛才談論的技術加上xargs工具會是一個很有效的組合。

比如像是課上我展示的一樣,我可以使用接下來的命令來批量提取舊版本的nightly名稱從而來解除安裝它們。這需要使用資料清洗加上xargs命令:

bash rustup toolchain list | grep nightly | grep -vE "nightly-x86" | sed 's/-x86.*//' | xargs rustup toolchain uninstall

Wrangling binary data

目前為止,我們已經談論了清洗文字資料,但管道對於二進位制資料一樣有效。比如,我們可以使用ffmpeg來從攝像頭捕獲一張圖片,將它轉成灰度資料壓縮再通過ssh將它傳到我們遠端的伺服器中解壓拷貝再展示出來:

bash ffmpeg -loglevel panic -i /dev/video0 -frames 1 -f image2 - | convert - -colorspace gray - | gzip | ssh mymachine 'gzip -d | tee copy.jpg | env DISPLAY=:0 feh -'

這個環節上課也做了展示,看影片會更加直觀。

Exercises

  1. 完成互動式的正則表示式的教程:http://regexone.com/
  2. 找出在/usr/share/dict/words中至少包含3個a又不是以a結尾的單詞的數量。給出3個這些單詞最頻繁的最後兩個字母,sed y命令或者是tr命令可以幫助你解決大小寫敏感的問題。這些字母組合一共有多少個?以及最難的挑戰,哪一個組合沒有出現過?
  3. 對於文字做原地替換看起來很誘人,比如sed s/REGEX/SUBSTITUTION/ input.txt > input.txt,然而這並不是一個好主意,為什麼?sed是特例嗎?請閱讀man sed來找出這種情況的解決方案
  4. 找出你係統平均、中位數以及最大的開機時間,對於Linux系統可以使用journalctl,對於macOS可以使用log show。以及找出每次開機記錄的開始和結束的時間戳。在Linux上,它看起來是這樣的:

在macOS上,看起來是這樣的:

  1. 尋找啟動資訊中,過去三次重啟不共享的資訊。將這個任務拆分成多個步驟。首先找到過去三次重啟的日誌。你使用提取開機日誌的命令當中應該有一個flag可以完成,或者你可以使用sed '0,/STRING/d'來移除match STRING的所有行。接著,移除行中每次都變化的值,比如時間戳。接著,對輸入行進行去重,對每一個部分進行計數(uniq可以用)。最後,取出數量不等於3的行
  2. 找一些網上的資料集,比如http://stats.wikimedia.org/EN/TablesWikipediaZZ.htm,http://ucr.fbi.gov/crime-in-the-u.s/2016/crime-in-the-u.s.-2016/topic-pages/tables/table-1或者從這裡找一個:http://www.springboard.com/blog/data-science/free-public-data-sets-data-science-project/。使用curl命令來獲取它,並且提取出是數字的兩列。如果你獲取HTML資料,pup會很好用。對於JSON資料來說,試試jq。使用一行命令找到一列的最小和最大值,在另外一條命令中算出兩列之和的差值

答案

  1. 第一題是給大家自己練習的,雖然是英文的,但並不難懂。如果實在是覺得吃力,配合翻譯軟體基本上沒什麼太大的問題。

我做了一下,連基礎帶提高,一共有23篇練習。快的話,一個小時不到就可以刷完。刷完之後,熟悉正則表示式的基本用法問題不大。質量很不錯,非常建議。比看枯燥無聊的教程說明好多了。

  1. 第二題有點難,算是一個比較綜合性的應用。

首先,我們要先對單詞進行大小寫轉換,我沒查到sed y命令的語法,所以只能使用tr進行轉換:

bash cat words | tr "[:upper:]" "[:lower:]"

其次,我們要找出其中包含三個a,並且又不是以a結尾的單詞。這需要我們使用正則表示式來完成,首先是3個a。這3個a可以連續也可以不連續,我們可以寫成:(.*a){3}表示若干個字母帶上a的組合,出現3次。又說不能以a結尾,我們寫成[^a]$。中間再加上.*作為銜接,再管道接上wc -l計算數量整個命令就是:

bash cat words | tr "[:upper:]" "[:lower:]" | grep -E "^(.*a){3}.*[^a]$" | wc -l

不同的電腦上跑出來結果不同,我的Mac的結果是5471

接下來我們要統計最後兩個字母出現的頻次,找出其中出現次數最多的3個。我們可以使用sed命令,利用正則表示式過濾出這部分。這裡的正則很簡單,我們只需要捕獲最後的兩個字母,其餘的全用.*匹配即可。

接著是sort, uniq再sort,最後tail取出最後3個即可

bash cat words | tr "[:upper:]" "[:lower:]" | grep -E "^(.*a){3}.*[^a]$" | sed -E "s/^.*([a-z]{2})$/\1/" | sort | uniq -c | sort | tail -n3

接著我們要找出所有沒有出現過的字母組合,這部分說實話有點麻煩。首先,我們要找出所有出現的字母組合,這部分很簡單,我們只需要稍微改一下上面的命令,把統計的數字去掉,只保留字元組合,然後再排序即可。

bash cat words| tr "[:upper:]" "[:lower:]" | grep -E "^(.*a){3}.*[^a]$" | sed -E "s/^.*([a-z]{2})$/\1/" | sort | uniq | sort > occur.txt

其次我們要列舉所有的組合,這裡我們可以用指令碼來做:

```bash

!/bin/bash

for i in {a..z}; do for j in {a..z}; do echo ${i}${j} done done ./enum.sh > all.txt ```

最後,我們用diff命令檢視一下兩個檔案的差別,再統計行數:

bash diff --unchanged-group-format='' occur.txt all.txt | wc -l

  1. 不能使用sed s/REGEX/SUBSTITUTION/ input.txt > input.txt的操作,因為會先執行> input.txt將後者清空。我在Stack Overflow上查到可以使用sed -i.bak操作為原檔案建立一個備份。但我在man sed當中沒有找到類似的用法

  2. 由於我Mac很少關機,所以這題用了我的樹莓派。

這裡有一個坑點,journalctl預設只會儲存最近一次啟動的日誌。需要我們手動修改設定才可以讓它儲存更多日誌

bash sudo vim /etc/systemd/journald.conf

將其中的Storate設定成persistent:

之後我們多重啟幾次,蒐集啟動日誌。

首先我們使用journalctl以及grep篩選出系統重啟的日誌:

img

觀察一下日誌會發現,每次啟動的時候都會輸出兩條。一條200多毫秒,一條20多秒。看起來20多秒的那個才是真正的啟動時間。所以我們再加上grep \[1\]進行過濾:

最後我們使用sed命令以正則表示式選出啟動的時間。使用sed的正則非常蛋疼,因為它當中很多語法不支援……基本上只支援最基本的那些,連\d,\w這些符號都不支援

bash journalctl | grep "Startup" | grep "\[1\]" | sed -E "s/.*=\ (.*)s\.$/\1/"

篩選出了時間之後,我們可以仿照老師上課的例子,用R來做統計:

bash journalctl | grep "Startup" | grep "\[1\]" | sed -E "s/.*=\ (.*)s\.$/\1/" | R --no-echo -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'

  1. 這題有些繁瑣,得分成好幾個步驟執行。首先我們需要把前3次重啟時的日誌儲存下來。

首先我們需要使用我們使用journalctl -b命令將對應的啟動日誌寫入檔案,這樣我們就不用每次都通過journalctl獲取日誌了,可以直接從檔案中讀取。

由於啟動日誌裡內容非常多,所以我們需要篩選出剛好是重啟這部分的日誌。使用題目中提示的sed命令來搞定:journalctl -b -4 | sed '0,/Startup finished/d'

這個時候還不夠,日誌的開頭都是時間戳,這部分需要去掉。

我們可以使用捕獲組,從raspberrypi後面開始捕獲。

cat log.txt | sed -E "s/.*raspberrypi\ (.*)$/\1/" | head -n10

最後,我們進行老一套操作,排序、計數、排序、過濾,篩選出結果:

shell cat log.txt | sed -E "s/.*raspberrypi\ (.*)$/\1/" | sort | uniq -c | sort | awk '$1<3 { print }'

這一次的作業難度比之前大了很多,而且涉及的知識點也很雜,除了各種命令列工具之外,還包含了很多其他的知識點和內容。我做的時候也查閱了大量的資料,踩了不少的坑,但做完之後好處也是很明顯的,就是對於命令列工具的使用明顯比之前更加熟練了。

因此,推薦有需要的同學也能親自動手嘗試嘗試。