天天看點

MIT 計算機操作環境導論Missing Semester Lesson 4 資料整理課後練習

您是否曾經有過這樣的需求,将某種格式存儲的資料轉換成另外一種格式? 肯定有過,對吧! 這也正是我們這節課所要講授的主要内容。具體來講,我們需要不斷地對資料進行處理,直到得到我們想要的最終結果。

在之前的課程中,其實我們已經接觸到了一些資料整理的基本技術。可以這麼說,每當您使用管道運算符的時候,其實就是在進行某種形式的資料整理。

例如這樣一條指令 

journalctl | grep -i intel

,它會找到所有包含intel(區分大小寫)的系統日志。您可能并不認為是資料整理,但是它确實将某種形式的資料(全部系統日志)轉換成了另外一種形式的資料(僅包含intel的日志)。大多數情況下,資料整理需要您能夠明确哪些工具可以被用來達成特定資料整理的目的,并且明白如何組合使用這些工具。

讓我們從頭講起。既然需學習資料整理,那有兩樣東西自然是必不可少的:用來整理的資料以及相關的應用場景。日志處理通常是一個比較典型的使用場景,因為我們經常需要在日志中查找某些資訊,這種情況下通讀日志是不現實的。現在,讓我們研究一下系統日志,看看哪些使用者曾經嘗試過登入我們的伺服器:

ssh myserver journalctl
           

内容太多了。現在讓我們把涉及 sshd 的資訊過濾出來:

ssh myserver journalctl | grep sshd
           

注意,這裡我們使用管道将一個遠端伺服器上的檔案傳遞給本機的 

grep

 程式! 

ssh

 太牛了,下一節課我們會講授指令行環境,屆時我們會詳細讨論 

ssh

 的相關内容。此時我們列印出的内容,仍然比我們需要的要多得多,讀起來也非常費勁。我們來改進一下:

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

多出來的引号是什麼作用呢?這麼說吧,我們的日志是一個非常大的檔案,把這麼大的檔案流直接傳輸到我們本地的電腦上再進行過濾是對流量的一種浪費。是以我們采取另外一種方式,我們先在遠端機器上過濾文本内容,然後再将結果傳輸到本機。 

less

 為我們建立來一個檔案分頁器,使我們可以通過翻頁的方式浏覽較長的文本。為了進一步節省流量,我們甚至可以将目前過濾出的日志儲存到檔案中,這樣後續就不需要再次通過網絡通路該檔案了:

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

過濾結果中仍然包含不少沒用的資料。我們有很多辦法可以删除這些無用的資料,但是讓我們先研究一下 

sed

 這個非常強大的工具。

sed

 是一個基于文本編輯器

ed

建構的”流編輯器” 。在 

sed

 中,您基本上是利用一些簡短的指令來修改檔案,而不是直接操作檔案的内容(盡管您也可以選擇這樣做)。相關的指令行非常多,但是最常用的是 

s

,即替換指令,例如我們可以這樣寫:

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

上面這段指令中,我們使用了一段簡單的正規表達式。正規表達式是一種非常強大工具,可以讓我們基于某種模式來對字元串進行比對。

s

 指令的文法如下:

s/REGEX/SUBSTITUTION/

, 其中 

REGEX

 部分是我們需要使用的正規表達式,而 

SUBSTITUTION

 是用于替換比對結果的文本。

正規表達式

正規表達式非常常見也非常有用,值得您花些時間去了解它。讓我們從這一句正規表達式開始學習: 

/.*Disconnected from /

。正規表達式通常以(盡管并不總是) 

/

開始和結束。大多數的 ASCII 字元都表示它們本來的含義,但是有一些字元确實具有表示比對行為的“特殊”含義。不同字元所表示的含義,根據正規表達式的實作方式不同,也會有所變化,這一點确實令人沮喪。常見的模式有:

  • .

     除空格之外的”任意單個字元”
  • *

     比對前面字元零次或多次
  • +

     比對前面字元一次或多次
  • [abc]

     比對 

    a

    b

     和 

    c

     中的任意一個
  • (RX1|RX2)

     任何能夠比對

    RX1

     或 

    RX2

    的結果
  • ^

     行首
  • $

     行尾

sed

 的正規表達式有些時候是比較奇怪的,它需要你在這些模式前添加

\

才能使其具有特殊含義。或者,您也可以添加

-E

選項來支援這些比對。

回過頭我們再看

/.*Disconnected from /

,我們會發現這個正規表達式可以比對任何以若幹任意字元開頭,并接着包含”Disconnected from “的字元串。這也正式我們所希望的。但是請注意,正規表達式并不容易寫對。如果有人将 “Disconnected from” 作為自己的使用者名會怎樣呢?

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

正規表達式會如何比對?

*

 和 

+

 在預設情況下是貪婪模式,也就是說,它們會盡可能多的比對文本。是以對上述字元串的比對結果如下:

46.97.239.16 port 55920 [preauth]
           

這可不上我們想要的結果。對于某些正規表達式的實作來說,您可以給 

*

 或 

+

 增加一個

?

 字尾使其變成非貪婪模式,但是很可惜 

sed

 并不支援該字尾。不過,我們可以切換到 perl 的指令行模式,該模式支援編寫這樣的正規表達式:

perl -pe 's/.*?Disconnected from //'
           

讓我們回到 

sed

 指令并使用它完成後續的任務,畢竟對于這一類任務,

sed

是最常見的工具。

sed

 還可以非常友善的做一些事情,例如列印比對後的内容,一次調用中進行多次替換搜尋等。但是這些内容我們并不會在此進行介紹。

sed

 本身是一個非常全能的工具,但是在具體功能上往往能找到更好的工具作為替代品。

好的,我們還需要去掉使用者名後面的字尾,應該如何操作呢?

想要比對使用者名後面的文本,尤其是當這裡的使用者名可以包含空格時,這個問題變得非常棘手!這裡我們需要做的是比對一整行:

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

讓我們借助正規表達式線上調試工具regex debugger 來了解這段表達式。OK,開始的部分和以前是一樣的,随後,我們比對兩種類型的“user”(在日志中基于兩種字首區分)。再然後我們比對屬于使用者名的所有字元。接着,再比對任意一個單詞(

[^ ]+

 會比對任意非空切不包含空格的序列)。緊接着後面比對單“port”和它後面的一串數字,以及可能存在的字尾

[preauth]

,最後再比對行尾。

注意,這樣做的話,即使使用者名是“Disconnected from”,對比對結果也不會有任何影響,您知道這是為什麼嗎?

問題還沒有完全解決,日志的内容全部被替換成了空字元串,整個日志的内容是以都被删除了。我們實際上希望能夠将使用者名保留下來。對此,我們可以使用“捕獲組(capture groups)”來完成。被圓括号内的正規表達式比對到的文本,都會被存入一系列以編号區分的捕獲組中。捕獲組的内容可以在替換字元串時使用(有些正規表達式的引擎甚至支援替換表達式本身),例如

\1

、 

\2

\3

等等,是以可以使用如下指令:

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

想必您已經意識到了,為了完成某種比對,我們最終可能會寫出非常複雜的正規表達式。例如,這裡有一篇關于如何比對電子郵箱位址的文章e-mail address,比對電子郵箱可一點也不簡單。網絡上還有很多關于如何比對電子郵箱位址的讨論。人們還為其編寫了測試用例及 測試矩陣。您甚至可以編寫一個用于判斷一個數是否為質數的正規表達式。

正規表達式是出了名的難以寫對,但是它仍然會是您強大的常備工具之一。

回到資料整理

OK,現在我們有如下表達式:

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

!

現在,我們已經得到了一個包含使用者名的清單,清單中的使用者都曾經嘗試過登陸我們的系統。但這還不夠,讓我們過濾出那些最常出現的使用者:

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

 會把連續出現的行折疊為一行并使用出現次數作為字首。我們希望按照出現次數排序,過濾出最常登陸的使用者:

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

 部分表示“僅排序到第n個部分”,預設情況是到行尾。就本例來說,針對整個行進行排序也沒有任何問題,我們這裡主要是為了學習這一用法!

如果我們希望得到登陸次數最少的使用者,我們可以使用 

head

 來代替

tail

。或者使用

sort -r

來進行倒序排序。

相當不錯。但我們隻想擷取使用者名,而且不要一行一個地顯示。

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,
           

我們可以利用 

paste

指令來合并行(

-s

),并指定一個分隔符進行分割 (

-d

),那

awk

的作用又是什麼呢?

awk – 另外一種編輯器

awk

 其實是一種程式設計語言,隻不過它碰巧非常善于處理文本。關于 

awk

 可以介紹的内容太多了,限于篇幅,這裡我們僅介紹一些基礎知識。

首先, 

{print $2}

 的作用是什麼? 

awk

 程式接受一個模式串(可選),以及一個代碼塊,指定當模式比對時應該做何種操作。預設當模式串即比對所有行(上面指令中當用法)。 在代碼塊中,

$0

 表示正行的内容,

$1

 到 

$n

 為一行中的 n 個區域,區域的分割基于 

awk

 的域分隔符(預設是空格,可以通過

-F

來修改)。在這個例子中,我們的代碼意思是:對于每一行文本,列印其第二個部分,也就是使用者名。

讓我們康康,還有什麼炫酷的操作可以做。讓我們統計一下所有以

c

 開頭,以 

e

 結尾,并且僅嘗試過一次登陸的使用者。

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

讓我們好好分析一下。首先,注意這次我們為 

awk

指定了一個比對模式串(也就是

{...}

前面的那部分内容)。該比對要求文本的第一部分需要等于1(這部分剛好是

uniq -c

得到的計數值),然後其第二部分必須滿足給定的一個正規表達式。代碼快中的内容則表示列印使用者名。然後我們使用 

wc -l

 統計輸出結果的行數。

不過,既然 

awk

 是一種程式設計語言,那麼則可以這樣:

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

BEGIN

 也是一種模式,它會比對輸入的開頭( 

END

 則比對結尾)。然後,對每一行第一個部分進行累加,最後将結果輸出。事實上,我們完全可以抛棄 

grep

 和 

sed

 ,因為 

awk

 就可以解決所有問題。至于怎麼做,就留給讀者們做課後練習吧。

分析資料

想做數學計算也是可以的!例如這樣,您可以将每行的數字加起來:

| paste -sd+ | bc -l
           

下面這種更加複雜的表達式也可以:

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

您可以通過多種方式擷取統計資料。如果已經安裝了R語言,

st

是個不錯的選擇:

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 --slave -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'
           

R 也是一種程式設計語言,它非常适合被用來進行資料分析和繪制圖表。這裡我們不會講的特别詳細, 您隻需要知道

summary

 可以列印統計結果。我們通過輸入的資訊計算出一個矩陣,然後R語言就可以得到我們想要的統計資料。

如果您希望繪制一些簡單的圖表, 

gnuplot

 可以幫助到您:

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'
           

利用資料整理來确定參數

有時候您要利用資料整理技術從一長串清單裡找出你所需要安裝或移除的東西。我們之前讨論的相關技術配合 

xargs

 即可實作:

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

整理二進制資料

雖然到目前為止我們的讨論都是基于文本資料,但對于二進制檔案其實同樣有用。例如我們可以用 ffmpeg 從相機中捕獲一張圖檔,将其轉換成灰階圖後通過SSH将壓縮後的檔案發送到遠端伺服器,并在那裡解壓、存檔并顯示。

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 -'
           

課後練習

  1. 學習一下這篇簡短的 互動式正規表達式教程.
  2. 統計words檔案 (

    /usr/share/dict/words

    ) 中包含至少三個

    a

     且不以

    's

     結尾的單詞個數。這些單詞中,出現頻率最高的末尾兩個字母是什麼? 

    sed

    的 

    y

    指令,或者 

    tr

     程式也許可以幫你解決大小寫的問題。共存在多少種詞尾兩字母組合?還有一個很 有挑戰性的問題:哪個組合從未出現過?
  3. 進行原地替換聽上去很有誘惑力,例如: 

    sed s/REGEX/SUBSTITUTION/ input.txt > input.txt

    。但是這并不是一個明知的做法,為什麼呢?還是說隻有 

    sed

    是這樣的? 檢視 

    man sed

     來完成這個問題
  4. 找出您最近十次開機的開機時間平均數、中位數和最長時間。在Linux上需要用到 

    journalctl

     ,而在 macOS 上使用 

    log show

    。找到每次起到開始和結束時的時間戳。在Linux上類似這樣操作:
    Logs begin at ...
               
    systemd[577]: Startup finished in ...
               
    在 macOS 上, 查找:
    === system boot:
               
    Previous shutdown cause: 5
               
  5. 檢視之前三次重新開機啟動資訊中不同的部分 (參見 

    journalctl

    -b

     選項)。将這一任務分為幾個步驟,首先擷取之前三次啟動的啟動日志,也許擷取啟動日志的指令就有合适的選項可以幫助您提取前三次啟動的日志,亦或者您可以使用

    sed '0,/STRING/d'

     來删除 

    STRING

    比對到的字元串前面的全部内容。然後,過濾掉每次都不相同的部分,例如時間戳。下一步,重複記錄輸入行并對其計數(可以使用

    uniq

     )。最後,删除所有出現過3次的内容(因為這些内容上三次啟動日志中的重複部分)。
  6. 在網上找一個類似 這個 或者 這個的資料集。或者從 這裡找一些。使用 

    curl

     擷取資料集并提取其中兩列資料,如果您想要擷取的是HTML資料,那麼

    pup

    可能會更有幫助。對于JSON類型的資料,可以試試

    jq

    。請使用一條指令來找出其中一列的最大值和最小值,用另外一條指令計算兩列之間差的總和。

繼續閱讀